Merge "Fix doc type of Installer::addInstallStep"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 14 Jun 2019 00:08:43 +0000 (00:08 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 14 Jun 2019 00:08:43 +0000 (00:08 +0000)
125 files changed:
.travis.yml
RELEASE-NOTES-1.34
autoload.php
docs/extension.schema.v2.json
includes/AutoLoader.php
includes/DefaultSettings.php
includes/DevelopmentSettings.php
includes/Rest/CopyableStreamInterface.php [new file with mode: 0644]
includes/Rest/EntryPoint.php [new file with mode: 0644]
includes/Rest/Handler.php [new file with mode: 0644]
includes/Rest/Handler/HelloHandler.php [new file with mode: 0644]
includes/Rest/HeaderContainer.php [new file with mode: 0644]
includes/Rest/HttpException.php [new file with mode: 0644]
includes/Rest/JsonEncodingException.php [new file with mode: 0644]
includes/Rest/PathTemplateMatcher/PathConflict.php [new file with mode: 0644]
includes/Rest/PathTemplateMatcher/PathMatcher.php [new file with mode: 0644]
includes/Rest/RequestBase.php [new file with mode: 0644]
includes/Rest/RequestData.php [new file with mode: 0644]
includes/Rest/RequestFromGlobals.php [new file with mode: 0644]
includes/Rest/RequestInterface.php [new file with mode: 0644]
includes/Rest/Response.php [new file with mode: 0644]
includes/Rest/ResponseFactory.php [new file with mode: 0644]
includes/Rest/ResponseInterface.php [new file with mode: 0644]
includes/Rest/Router.php [new file with mode: 0644]
includes/Rest/SimpleHandler.php [new file with mode: 0644]
includes/Rest/Stream.php [new file with mode: 0644]
includes/Rest/StringStream.php [new file with mode: 0644]
includes/Rest/coreRoutes.json [new file with mode: 0644]
includes/Setup.php
includes/Storage/BlobStoreFactory.php
includes/Storage/DerivedPageDataUpdater.php
includes/Storage/PageEditStash.php
includes/Storage/PageUpdater.php
includes/Storage/SqlBlobStore.php
includes/api/i18n/fa.json
includes/api/i18n/ko.json
includes/block/BlockManager.php
includes/block/CompositeBlock.php [new file with mode: 0644]
includes/block/DatabaseBlock.php
includes/externalstore/ExternalStore.php
includes/externalstore/ExternalStoreDB.php
includes/externalstore/ExternalStoreMwstore.php
includes/installer/CliInstaller.php
includes/installer/i18n/ar.json
includes/installer/i18n/be-tarask.json
includes/installer/i18n/cs.json
includes/installer/i18n/fr.json
includes/installer/i18n/it.json
includes/installer/i18n/pl.json
includes/installer/i18n/pt-br.json
includes/libs/objectcache/APCBagOStuff.php
includes/libs/objectcache/APCUBagOStuff.php
includes/libs/objectcache/BagOStuff.php
includes/libs/objectcache/EmptyBagOStuff.php
includes/libs/objectcache/HashBagOStuff.php
includes/libs/objectcache/MemcachedBagOStuff.php
includes/libs/objectcache/MemcachedClient.php
includes/libs/objectcache/MemcachedPeclBagOStuff.php
includes/libs/objectcache/MemcachedPhpBagOStuff.php
includes/libs/objectcache/MultiWriteBagOStuff.php
includes/libs/objectcache/RESTBagOStuff.php
includes/libs/objectcache/RedisBagOStuff.php
includes/libs/objectcache/ReplicatedBagOStuff.php
includes/libs/objectcache/WANObjectCache.php
includes/libs/objectcache/WinCacheBagOStuff.php
includes/libs/objectcache/serialized/SerializedValueContainer.php [new file with mode: 0644]
includes/libs/rdbms/database/Database.php
includes/libs/rdbms/database/DatabaseMssql.php
includes/libs/rdbms/database/DatabaseMysqlBase.php
includes/libs/rdbms/database/DatabaseSqlite.php
includes/libs/rdbms/database/IDatabase.php
includes/libs/rdbms/lbfactory/LBFactory.php
includes/libs/rdbms/loadbalancer/ILoadBalancer.php
includes/libs/rdbms/loadbalancer/LoadBalancer.php
includes/libs/stats/BufferingStatsdDataFactory.php
includes/logging/DeleteLogFormatter.php
includes/objectcache/SqlBagOStuff.php
includes/page/WikiFilePage.php
includes/registration/ExtensionProcessor.php
includes/resourceloader/ResourceLoader.php
includes/resourceloader/ResourceLoaderContext.php
includes/resourceloader/ResourceLoaderModule.php
includes/resourceloader/ResourceLoaderStartUpModule.php
includes/resourceloader/ResourceLoaderWikiModule.php
includes/specials/SpecialEmailUser.php
includes/specials/SpecialMovepage.php
includes/user/User.php
languages/i18n/arz.json
languages/i18n/ast.json
languages/i18n/bcc.json
languages/i18n/be-tarask.json
languages/i18n/bg.json
languages/i18n/ckb.json
languages/i18n/cs.json
languages/i18n/fa.json
languages/i18n/fr.json
languages/i18n/fy.json
languages/i18n/hu.json
languages/i18n/ko.json
languages/i18n/lki.json
languages/i18n/lrc.json
languages/i18n/luz.json
languages/i18n/lv.json
languages/i18n/my.json
languages/i18n/nqo.json
languages/i18n/ru.json
languages/i18n/sdc.json
languages/i18n/sr-ec.json
maintenance/migrateArchiveText.php
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/OutputPageTest.php
tests/phpunit/includes/Rest/ResponseFactoryTest.php [new file with mode: 0644]
tests/phpunit/includes/api/ApiStashEditTest.php
tests/phpunit/includes/block/CompositeBlockTest.php [new file with mode: 0644]
tests/phpunit/includes/db/LoadBalancerTest.php
tests/phpunit/includes/libs/objectcache/BagOStuffTest.php
tests/phpunit/includes/libs/rdbms/database/DatabaseSQLTest.php
tests/phpunit/includes/logging/DeleteLogFormatterTest.php
tests/phpunit/includes/objectcache/MemcachedBagOStuffTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderClientHtmlTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php
tests/phpunit/includes/title/NamespaceInfoTest.php
tests/phpunit/includes/user/PasswordResetTest.php
tests/phpunit/includes/user/UserTest.php
tests/selenium/specs/rollback.js

index ada60e4..ebe1631 100644 (file)
@@ -24,17 +24,9 @@ cache:
 matrix:
   fast_finish: true
   include:
-    # On Trusty, mysql user 'travis' doesn't have create database rights
-    # Postgres has no user called 'root'.
-    - env: dbtype=mysql dbuser=root
       php: 7.3
-    - env: dbtype=mysql dbuser=root
       php: 7.2
-    - env: dbtype=mysql dbuser=root
       php: 7.1
-    - env: dbtype=postgres dbuser=travis
-      php: 7.1
-    - env: dbtype=mysql dbuser=root
       php: 7
   allow_failures:
     - php: 7.3
@@ -60,13 +52,13 @@ addons:
 before_script:
   - echo 'opcache.enable_cli = 1' >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini
   - composer install --prefer-source --quiet --no-interaction
-  - if [ "$dbtype" = postgres ]; then psql -c "CREATE DATABASE traviswiki WITH OWNER travis;" -U postgres; fi
+  # At Travis CI, the mysql user 'travis' doesn't have create database rights, use 'root' instead.
   - >
       php maintenance/install.php traviswiki admin
       --pass travis
-      --dbtype "$dbtype"
+      --dbtype "mysql"
       --dbname traviswiki
-      --dbuser "$dbuser"
+      --dbuser "root"
       --dbpass ""
       --scriptpath "/w"
   - echo -en "\n\nrequire_once __DIR__ . '/includes/DevelopmentSettings.php';\n" >> ./LocalSettings.php
index 33d060d..12c0382 100644 (file)
@@ -205,6 +205,9 @@ because of Phabricator reports.
 * jquery.ui.effect-bounce, jquery.ui.effect-explode, jquery.ui.effect-fold
   jquery.ui.effect-pulsate, jquery.ui.effect-slide, jquery.ui.effect-transfer,
   which are no longer used, have now been removed.
+* SpecialEmailUser::validateTarget(), ::getTarget() without a sender/user
+  specified, deprecated in 1.30, have been removed.
+* BufferingStatsdDataFactory::getBuffer(), deprecated in 1.30, has been removed.
 * …
 
 === Deprecations in 1.34 ===
@@ -258,6 +261,9 @@ because of Phabricator reports.
 * DatabaseBlock::setCookie, DatabaseBlock::getCookieValue,
   DatabaseBlock::getIdFromCookieValue and AbstractBlock::shouldTrackWithCookie
   are moved to internal helper methods for BlockManager::trackBlockWithCookie.
+* ResourceLoaderContext::getConfig and ResourceLoaderContext::getLogger have
+  been deprecated. Inside ResourceLoaderModule subclasses, use the local methods
+  instead. Elsewhere, use the methods from the ResourceLoader class.
 
 === Other changes in 1.34 ===
 * …
index b26549e..ae044f4 100644 (file)
@@ -1324,6 +1324,7 @@ $wgAutoloadLocalClasses = [
        'SearchUpdate' => __DIR__ . '/includes/deferred/SearchUpdate.php',
        'SectionProfileCallback' => __DIR__ . '/includes/profiler/SectionProfileCallback.php',
        'SectionProfiler' => __DIR__ . '/includes/profiler/SectionProfiler.php',
+       'SerializedValueContainer' => __DIR__ . '/includes/libs/objectcache/serialized/SerializedValueContainer.php',
        'SevenZipStream' => __DIR__ . '/maintenance/includes/SevenZipStream.php',
        'ShiConverter' => __DIR__ . '/languages/classes/LanguageShi.php',
        'ShortPagesPage' => __DIR__ . '/includes/specials/SpecialShortpages.php',
index 6076581..c1db2b6 100644 (file)
                        "type": "array",
                        "description": "List of service wiring files to be loaded by the default instance of MediaWikiServices"
                },
+               "RestRoutes": {
+                       "type": "array",
+                       "description": "List of route specifications to be added to the REST API",
+                       "items": {
+                               "type": "object",
+                               "properties": {
+                                       "method": {
+                                               "oneOf": [
+                                                       {
+                                                               "type": "string",
+                                                               "description": "The HTTP method name"
+                                                       },
+                                                       {
+                                                               "type": "array",
+                                                               "items": {
+                                                                       "type": "string",
+                                                                       "description": "An acceptable HTTP method name"
+                                                               }
+                                                       }
+                                               ]
+                                       },
+                                       "path": {
+                                               "type": "string",
+                                               "description": "The path template. This should start with an initial slash, designating the root of the REST API. Path parameters are enclosed in braces, for example /endpoint/{param}."
+                                       },
+                                       "factory": {
+                                               "type": ["string", "array"],
+                                               "description": "A factory function to be called to create the handler for this route"
+                                       },
+                                       "class": {
+                                               "type": "string",
+                                               "description": "The fully-qualified class name of the handler. This should be omitted if a factory is specified."
+                                       },
+                                       "args": {
+                                               "type": "array",
+                                               "description": "The arguments passed to the handler constructor or factory"
+                                       }
+                               }
+                       }
+               },
                "attributes": {
                        "description":"Registration information for other extensions",
                        "type": "object",
index fa11bcb..57e4341 100644 (file)
@@ -136,6 +136,7 @@ class AutoLoader {
                        'MediaWiki\\Linker\\' => __DIR__ . '/linker/',
                        'MediaWiki\\Permissions\\' => __DIR__ . '/Permissions/',
                        'MediaWiki\\Preferences\\' => __DIR__ . '/preferences/',
+                       'MediaWiki\\Rest\\' => __DIR__ . '/Rest/',
                        'MediaWiki\\Revision\\' => __DIR__ . '/Revision/',
                        'MediaWiki\\Session\\' => __DIR__ . '/session/',
                        'MediaWiki\\Shell\\' => __DIR__ . '/shell/',
index 73d05ff..1be573d 100644 (file)
@@ -193,6 +193,13 @@ $wgScript = false;
  */
 $wgLoadScript = false;
 
+/**
+ * The URL path to the REST API
+ * Defaults to "{$wgScriptPath}/rest.php"
+ * @since 1.34
+ */
+$wgRestPath = false;
+
 /**
  * The URL path of the skins directory.
  * Defaults to "{$wgResourceBasePath}/skins".
@@ -8086,10 +8093,10 @@ $wgExemptFromUserRobotsControl = null;
 /** @} */ # End robot policy }
 
 /************************************************************************//**
- * @name   AJAX and API
+ * @name   AJAX, Action API and REST API
  * Note: The AJAX entry point which this section refers to is gradually being
- * replaced by the API entry point, api.php. They are essentially equivalent.
- * Both of them are used for dynamic client-side features, via XHR.
+ * replaced by the Action API entry point, api.php. They are essentially
+ * equivalent. Both of them are used for dynamic client-side features, via XHR.
  * @{
  */
 
index 882047b..c558aee 100644 (file)
@@ -14,7 +14,7 @@
  */
 
 /**
- * Debugging: PHP
+ * Debugging for PHP
  */
 
 // Enable showing of errors
@@ -22,7 +22,7 @@ error_reporting( -1 );
 ini_set( 'display_errors', 1 );
 
 /**
- * Debugging: MediaWiki
+ * Debugging for MediaWiki
  */
 global $wgDevelopmentWarnings, $wgShowExceptionDetails, $wgShowHostnames,
        $wgDebugRawPage, $wgSQLMode, $wgCommandLineMode, $wgDebugLogFile,
@@ -53,9 +53,3 @@ if ( $logDir ) {
        $wgDebugLogGroups['error'] = "$logDir/mw-error.log";
 }
 unset( $logDir );
-// Make caching faster
-$wgMainCacheType = CACHE_ACCEL;
-$wgMessageCacheType = CACHE_ACCEL;
-$wgParserCacheType = CACHE_ACCEL;
-$wgSessionCacheType = CACHE_ACCEL;
-$wgLanguageConverterCacheType = CACHE_ACCEL;
diff --git a/includes/Rest/CopyableStreamInterface.php b/includes/Rest/CopyableStreamInterface.php
new file mode 100644 (file)
index 0000000..d271db3
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * An interface for a stream with a copyToStream() function.
+ */
+interface CopyableStreamInterface extends \Psr\Http\Message\StreamInterface {
+       /**
+        * Copy this stream to a specified stream resource. For some streams,
+        * this can be implemented without a tight loop in PHP code.
+        *
+        * Note that $stream is not a StreamInterface object.
+        *
+        * @param resource $stream Destination
+        */
+       function copyToStream( $stream );
+}
diff --git a/includes/Rest/EntryPoint.php b/includes/Rest/EntryPoint.php
new file mode 100644 (file)
index 0000000..d5924f0
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use ExtensionRegistry;
+use MediaWiki\MediaWikiServices;
+use RequestContext;
+use Title;
+
+class EntryPoint {
+       public static function main() {
+               // URL safety checks
+               global $wgRequest;
+               if ( !$wgRequest->checkUrlExtension() ) {
+                       return;
+               }
+
+               // Set $wgTitle and the title in RequestContext, as in api.php
+               global $wgTitle;
+               $wgTitle = Title::makeTitle( NS_SPECIAL, 'Badtitle/rest.php' );
+               RequestContext::getMain()->setTitle( $wgTitle );
+
+               $services = MediaWikiServices::getInstance();
+
+               $conf = $services->getMainConfig();
+               $request = new RequestFromGlobals( [
+                       'cookiePrefix' => $conf->get( 'CookiePrefix' )
+               ] );
+
+               global $IP;
+               $router = new Router(
+                       [ "$IP/includes/Rest/coreRoutes.json" ],
+                       ExtensionRegistry::getInstance()->getAttribute( 'RestRoutes' ),
+                       $conf->get( 'RestPath' ),
+                       $services->getLocalServerObjectCache(),
+                       new ResponseFactory
+               );
+
+               $response = $router->execute( $request );
+
+               $webResponse = $wgRequest->response();
+               $webResponse->header(
+                       'HTTP/' . $response->getProtocolVersion() . ' ' .
+                       $response->getStatusCode() . ' ' .
+                       $response->getReasonPhrase() );
+
+               foreach ( $response->getRawHeaderLines() as $line ) {
+                       $webResponse->header( $line );
+               }
+
+               foreach ( $response->getCookies() as $cookie ) {
+                       $webResponse->setCookie(
+                               $cookie['name'],
+                               $cookie['value'],
+                               $cookie['expiry'],
+                               $cookie['options'] );
+               }
+
+               $stream = $response->getBody();
+               $stream->rewind();
+               if ( $stream instanceof CopyableStreamInterface ) {
+                       $stream->copyToStream( fopen( 'php://output', 'w' ) );
+               } else {
+                       while ( true ) {
+                               $buffer = $stream->read( 65536 );
+                               if ( $buffer === '' ) {
+                                       break;
+                               }
+                               echo $buffer;
+                       }
+               }
+       }
+}
diff --git a/includes/Rest/Handler.php b/includes/Rest/Handler.php
new file mode 100644 (file)
index 0000000..472e1cc
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+abstract class Handler {
+       /** @var RequestInterface */
+       private $request;
+
+       /** @var array */
+       private $config;
+
+       /** @var ResponseFactory */
+       private $responseFactory;
+
+       /**
+        * Initialise with dependencies from the Router. This is called after construction.
+        */
+       public function init( RequestInterface $request, array $config,
+               ResponseFactory $responseFactory
+       ) {
+               $this->request = $request;
+               $this->config = $config;
+               $this->responseFactory = $responseFactory;
+       }
+
+       /**
+        * Get the current request. The return type declaration causes it to raise
+        * a fatal error if init() has not yet been called.
+        *
+        * @return RequestInterface
+        */
+       public function getRequest(): RequestInterface {
+               return $this->request;
+       }
+
+       /**
+        * Get the configuration array for the current route. The return type
+        * declaration causes it to raise a fatal error if init() has not
+        * been called.
+        *
+        * @return array
+        */
+       public function getConfig(): array {
+               return $this->config;
+       }
+
+       /**
+        * Get the ResponseFactory which can be used to generate Response objects.
+        * This will raise a fatal error if init() has not been
+        * called.
+        *
+        * @return ResponseFactory
+        */
+       public function getResponseFactory(): ResponseFactory {
+               return $this->responseFactory;
+       }
+
+       /**
+        * The subclass should override this to provide the maximum last modified
+        * timestamp for the current request. This is called before execute() in
+        * order to decide whether to send a 304.
+        *
+        * The timestamp can be in any format accepted by ConvertibleTimestamp, or
+        * null to indicate that the timestamp is unknown.
+        *
+        * @return bool|string|int|float|\DateTime|null
+        */
+       protected function getLastModified() {
+               return null;
+       }
+
+       /**
+        * The subclass should override this to provide an ETag for the current
+        * request. This is called before execute() in order to decide whether to
+        * send a 304.
+        *
+        * See RFC 7232 § 2.3 for semantics.
+        *
+        * @return string|null
+        */
+       protected function getETag() {
+               return null;
+       }
+
+       /**
+        * Execute the handler. This is called after parameter validation. The
+        * return value can either be a Response or any type accepted by
+        * ResponseFactory::createFromReturnValue().
+        *
+        * To automatically construct an error response, execute() should throw a
+        * RestException. Such exceptions will not be logged like a normal exception.
+        *
+        * If execute() throws any other kind of exception, the exception will be
+        * logged and a generic 500 error page will be shown.
+        *
+        * @return mixed
+        */
+       abstract public function execute();
+}
diff --git a/includes/Rest/Handler/HelloHandler.php b/includes/Rest/Handler/HelloHandler.php
new file mode 100644 (file)
index 0000000..6e119dd
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+namespace MediaWiki\Rest\Handler;
+
+use MediaWiki\Rest\SimpleHandler;
+
+/**
+ * Example handler
+ * @unstable
+ */
+class HelloHandler extends SimpleHandler {
+       public function run( $name ) {
+               return [ 'message' => "Hello, $name!" ];
+       }
+}
diff --git a/includes/Rest/HeaderContainer.php b/includes/Rest/HeaderContainer.php
new file mode 100644 (file)
index 0000000..50f4355
--- /dev/null
@@ -0,0 +1,202 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * This is a container for storing headers. The header names are case-insensitive,
+ * but the case is preserved for methods that return headers in bulk. The
+ * header values are a comma-separated list, or equivalently, an array of strings.
+ *
+ * Unlike PSR-7, the container is mutable.
+ */
+class HeaderContainer {
+       private $headerLists;
+       private $headerLines;
+       private $headerNames;
+
+       /**
+        * Erase any existing headers and replace them with the specified
+        * header arrays or values.
+        *
+        * @param array $headers
+        */
+       public function resetHeaders( $headers = [] ) {
+               $this->headerLines = [];
+               $this->headerLists = [];
+               $this->headerNames = [];
+               foreach ( $headers as $name => $value ) {
+                       $this->headerNames[ strtolower( $name ) ] = $name;
+                       list( $valueParts, $valueLine ) = $this->convertToListAndString( $value );
+                       $this->headerLines[$name] = $valueLine;
+                       $this->headerLists[$name] = $valueParts;
+               }
+       }
+
+       /**
+        * Take an input header value, which may either be a string or an array,
+        * and convert it to an array of header values and a header line.
+        *
+        * The return value is an array where element 0 has the array of header
+        * values, and element 1 has the header line.
+        *
+        * Theoretically, if the input is a string, this could parse the string
+        * and split it on commas. Doing this is complicated, because some headers
+        * can contain double-quoted strings containing commas. The User-Agent
+        * header allows commas in comments delimited by parentheses. So it is not
+        * just explode(",", $value), we would need to parse a grammar defined by
+        * RFC 7231 appendix D which depends on header name.
+        *
+        * It's unclear how much it would help handlers to have fully spec-aware
+        * HTTP header handling just to split on commas. They would probably be
+        * better served by an HTTP header parsing library which provides the full
+        * parse tree.
+        *
+        * @param string $name The header name
+        * @param string|string[] $value The input header value
+        * @return array
+        */
+       private function convertToListAndString( $value ) {
+               if ( is_array( $value ) ) {
+                       return [ array_values( $value ), implode( ', ', $value ) ];
+               } else {
+                       return [ [ $value ], $value ];
+               }
+       }
+
+       /**
+        * Set or replace a header
+        *
+        * @param string $name
+        * @param string|string[] $value
+        */
+       public function setHeader( $name, $value ) {
+               list( $valueParts, $valueLine ) = $this->convertToListAndString( $value );
+               $lowerName = strtolower( $name );
+               $origName = $this->headerNames[$lowerName] ?? null;
+               if ( $origName !== null ) {
+                       unset( $this->headerLines[$origName] );
+                       unset( $this->headerLists[$origName] );
+               }
+               $this->headerNames[$lowerName] = $name;
+               $this->headerLines[$name] = $valueLine;
+               $this->headerLists[$name] = $valueParts;
+       }
+
+       /**
+        * Set a header or append to an existing header
+        *
+        * @param string $name
+        * @param string|string[] $value
+        */
+       public function addHeader( $name, $value ) {
+               list( $valueParts, $valueLine ) = $this->convertToListAndString( $value );
+               $lowerName = strtolower( $name );
+               $origName = $this->headerNames[$lowerName] ?? null;
+               if ( $origName === null ) {
+                       $origName = $name;
+                       $this->headerNames[$lowerName] = $origName;
+                       $this->headerLines[$origName] = $valueLine;
+                       $this->headerLists[$origName] = $valueParts;
+               } else {
+                       $this->headerLines[$origName] .= ', ' . $valueLine;
+                       $this->headerLists[$origName] = array_merge( $this->headerLists[$origName],
+                               $valueParts );
+               }
+       }
+
+       /**
+        * Remove a header
+        *
+        * @param string $name
+        */
+       public function removeHeader( $name ) {
+               $lowerName = strtolower( $name );
+               $origName = $this->headerNames[$lowerName] ?? null;
+               if ( $origName !== null ) {
+                       unset( $this->headerNames[$lowerName] );
+                       unset( $this->headerLines[$origName] );
+                       unset( $this->headerLists[$origName] );
+               }
+       }
+
+       /**
+        * Get header arrays indexed by original name
+        *
+        * @return string[][]
+        */
+       public function getHeaders() {
+               return $this->headerLists;
+       }
+
+       /**
+        * Get the header with a particular name, or an empty array if there is no
+        * such header.
+        *
+        * @param string $name
+        * @return string[]
+        */
+       public function getHeader( $name ) {
+               $headerName = $this->headerNames[ strtolower( $name ) ] ?? null;
+               if ( $headerName === null ) {
+                       return [];
+               }
+               return $this->headerLists[$headerName];
+       }
+
+       /**
+        * Return true if the header exists, false otherwise
+        * @param string $name
+        * @return bool
+        */
+       public function hasHeader( $name ) {
+               return isset( $this->headerNames[ strtolower( $name ) ] );
+       }
+
+       /**
+        * Get the specified header concatenated into a comma-separated string.
+        * If the header does not exist, an empty string is returned.
+        *
+        * @param string $name
+        * @return string
+        */
+       public function getHeaderLine( $name ) {
+               $headerName = $this->headerNames[ strtolower( $name ) ] ?? null;
+               if ( $headerName === null ) {
+                       return '';
+               }
+               return $this->headerLines[$headerName];
+       }
+
+       /**
+        * Get all header lines
+        *
+        * @return string[]
+        */
+       public function getHeaderLines() {
+               return $this->headerLines;
+       }
+
+       /**
+        * Get an array of strings of the form "Name: Value", suitable for passing
+        * directly to header() to set response headers. The PHP manual describes
+        * these strings as "raw HTTP headers", so we adopt that terminology.
+        *
+        * @return string[] Header list (integer indexed)
+        */
+       public function getRawHeaderLines() {
+               $lines = [];
+               foreach ( $this->headerNames as $lowerName => $name ) {
+                       if ( $lowerName === 'set-cookie' ) {
+                               // As noted by RFC 7230 section 3.2.2, Set-Cookie is the only
+                               // header for which multiple values cannot be concatenated into
+                               // a single comma-separated line.
+                               foreach ( $this->headerLists[$name] as $value ) {
+                                       $lines[] = "$name: $value";
+                               }
+                       } else {
+                               $lines[] = "$name: " . $this->headerLines[$name];
+                       }
+               }
+               return $lines;
+       }
+}
diff --git a/includes/Rest/HttpException.php b/includes/Rest/HttpException.php
new file mode 100644 (file)
index 0000000..ae6dde2
--- /dev/null
@@ -0,0 +1,14 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * This is the base exception class for non-fatal exceptions thrown from REST
+ * handlers. The exception is not logged, it is merely converted to an
+ * error response.
+ */
+class HttpException extends \Exception {
+       public function __construct( $message, $code = 500 ) {
+               parent::__construct( $message, $code );
+       }
+}
diff --git a/includes/Rest/JsonEncodingException.php b/includes/Rest/JsonEncodingException.php
new file mode 100644 (file)
index 0000000..e731ac3
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+class JsonEncodingException extends \RuntimeException {
+       public function __construct( $message, $code ) {
+               parent::__construct( "JSON encoding error: $message", $code );
+       }
+}
diff --git a/includes/Rest/PathTemplateMatcher/PathConflict.php b/includes/Rest/PathTemplateMatcher/PathConflict.php
new file mode 100644 (file)
index 0000000..dd9f34a
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+
+namespace MediaWiki\Rest\PathTemplateMatcher;
+
+use Exception;
+
+class PathConflict extends Exception {
+       public $newTemplate;
+       public $newUserData;
+       public $existingTemplate;
+       public $existingUserData;
+
+       public function __construct( $template, $userData, $existingNode ) {
+               $this->newTemplate = $template;
+               $this->newUserData = $userData;
+               $this->existingTemplate = $existingNode['template'];
+               $this->existingUserData = $existingNode['userData'];
+               parent::__construct( "Unable to add path template \"$template\" since it conflicts " .
+                       "with the existing template \"{$this->existingTemplate}\"" );
+       }
+}
diff --git a/includes/Rest/PathTemplateMatcher/PathMatcher.php b/includes/Rest/PathTemplateMatcher/PathMatcher.php
new file mode 100644 (file)
index 0000000..69987e0
--- /dev/null
@@ -0,0 +1,221 @@
+<?php
+
+namespace MediaWiki\Rest\PathTemplateMatcher;
+
+/**
+ * A tree-based path routing algorithm.
+ *
+ * This container builds defined routing templates into a tree, allowing
+ * paths to be efficiently matched against all templates. The match time is
+ * independent of the number of registered path templates.
+ *
+ * Efficient matching comes at the cost of a potentially significant setup time.
+ * We measured ~10ms for 1000 templates. Using getCacheData() and
+ * newFromCache(), this setup time may be amortized over multiple requests.
+ */
+class PathMatcher {
+       /**
+        * An array of trees indexed by the number of path components in the input.
+        *
+        * A tree node consists of an associative array in which the key is a match
+        * specifier string, and the value is another node. A leaf node, which is
+        * identifiable by its fixed depth in the tree, consists of an associative
+        * array with the following keys:
+        *   - template: The path template string
+        *   - paramNames: A list of parameter names extracted from the template
+        *   - userData: The user data supplied to add()
+        *
+        * A match specifier string may be either "*", which matches any path
+        * component, or a literal string prefixed with "=", which matches the
+        * specified deprefixed string literal.
+        *
+        * @var array
+        */
+       private $treesByLength = [];
+
+       /**
+        * Create a PathMatcher from cache data
+        *
+        * @param array $data The data array previously returned by getCacheData()
+        * @return PathMatcher
+        */
+       public static function newFromCache( $data ) {
+               $matcher = new self;
+               $matcher->treesByLength = $data;
+               return $matcher;
+       }
+
+       /**
+        * Get a data array for later use by newFromCache().
+        *
+        * The internal format is private to PathMatcher, but note that it includes
+        * any data passed as $userData to add(). The array returned will be
+        * serializable as long as all $userData values are serializable.
+        *
+        * @return array
+        */
+       public function getCacheData() {
+               return $this->treesByLength;
+       }
+
+       /**
+        * Determine whether a path template component is a parameter
+        *
+        * @param string $part
+        * @return bool
+        */
+       private function isParam( $part ) {
+               $partLength = strlen( $part );
+               return $partLength > 2 && $part[0] === '{' && $part[$partLength - 1] === '}';
+       }
+
+       /**
+        * If a path template component is a parameter, return the parameter name.
+        * Otherwise, return false.
+        *
+        * @param string $part
+        * @return string|false
+        */
+       private function getParamName( $part ) {
+               if ( $this->isParam( $part ) ) {
+                       return substr( $part, 1, -1 );
+               } else {
+                       return false;
+               }
+       }
+
+       /**
+        * Recursively search the match tree, checking whether the proposed path
+        * template, passed as an array of component parts, can be added to the
+        * matcher without ambiguity.
+        *
+        * Ambiguity means that a path exists which matches multiple templates.
+        *
+        * The function calls itself recursively, incrementing $index so as to
+        * ignore a prefix of the input, in order to check deeper parts of the
+        * match tree.
+        *
+        * If a conflict is discovered, the conflicting leaf node is returned.
+        * Otherwise, false is returned.
+        *
+        * @param array $node The tree node to check against
+        * @param string[] $parts The array of path template parts
+        * @param int $index The current index into $parts
+        * @return array|false
+        */
+       private function findConflict( $node, $parts, $index = 0 ) {
+               if ( $index >= count( $parts ) ) {
+                       // If we reached the leaf node then a conflict is detected
+                       return $node;
+               }
+               $part = $parts[$index];
+               $result = false;
+               if ( $this->isParam( $part ) ) {
+                       foreach ( $node as $key => $childNode ) {
+                               $result = $this->findConflict( $childNode, $parts, $index + 1 );
+                               if ( $result !== false ) {
+                                       break;
+                               }
+                       }
+               } else {
+                       if ( isset( $node["=$part"] ) ) {
+                               $result = $this->findConflict( $node["=$part"], $parts, $index + 1 );
+                       }
+                       if ( $result === false && isset( $node['*'] ) ) {
+                               $result = $this->findConflict( $node['*'], $parts, $index + 1 );
+                       }
+               }
+               return $result;
+       }
+
+       /**
+        * Add a template to the matcher.
+        *
+        * The path template consists of components separated by "/". Each component
+        * may be either a parameter of the form {paramName}, or a literal string.
+        * A parameter matches any input path component, whereas a literal string
+        * matches itself.
+        *
+        * Path templates must not conflict with each other, that is, any input
+        * path must match at most one path template. If a path template conflicts
+        * with another already registered, this function throws a PathConflict
+        * exception.
+        *
+        * @param string $template The path template
+        * @param mixed $userData User data used to identify the matched route to
+        *   the caller of match()
+        * @throws PathConflict
+        */
+       public function add( $template, $userData ) {
+               $parts = explode( '/', $template );
+               $length = count( $parts );
+               if ( !isset( $this->treesByLength[$length] ) ) {
+                       $this->treesByLength[$length] = [];
+               }
+               $tree =& $this->treesByLength[$length];
+               $conflict = $this->findConflict( $tree, $parts );
+               if ( $conflict !== false ) {
+                       throw new PathConflict( $template, $userData, $conflict );
+               }
+
+               $params = [];
+               foreach ( $parts as $index => $part ) {
+                       $paramName = $this->getParamName( $part );
+                       if ( $paramName !== false ) {
+                               $params[] = $paramName;
+                               $key = '*';
+                       } else {
+                               $key = "=$part";
+                       }
+                       if ( $index === $length - 1 ) {
+                               $tree[$key] = [
+                                       'template' => $template,
+                                       'paramNames' => $params,
+                                       'userData' => $userData
+                               ];
+                       } elseif ( !isset( $tree[$key] ) ) {
+                               $tree[$key] = [];
+                       }
+                       $tree =& $tree[$key];
+               }
+       }
+
+       /**
+        * Match a path against the current match trees.
+        *
+        * If the path matches a previously added path template, an array will be
+        * returned with the following keys:
+        *   - params: An array mapping parameter names to their detected values
+        *   - userData: The user data passed to add(), which identifies the route
+        *
+        * If the path does not match any template, false is returned.
+        *
+        * @param string $path
+        * @return array|false
+        */
+       public function match( $path ) {
+               $parts = explode( '/', $path );
+               $length = count( $parts );
+               if ( !isset( $this->treesByLength[$length] ) ) {
+                       return false;
+               }
+               $node = $this->treesByLength[$length];
+
+               $paramValues = [];
+               foreach ( $parts as $part ) {
+                       if ( isset( $node["=$part"] ) ) {
+                               $node = $node["=$part"];
+                       } elseif ( isset( $node['*'] ) ) {
+                               $node = $node['*'];
+                               $paramValues[] = $part;
+                       } else {
+                               return false;
+                       }
+               }
+
+               return [
+                       'params' => array_combine( $node['paramNames'], $paramValues ),
+                       'userData' => $node['userData']
+               ];
+       }
+}
diff --git a/includes/Rest/RequestBase.php b/includes/Rest/RequestBase.php
new file mode 100644 (file)
index 0000000..cacef62
--- /dev/null
@@ -0,0 +1,115 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * Shared code between RequestData and RequestFromGlobals
+ */
+abstract class RequestBase implements RequestInterface {
+       /**
+        * @var HeaderContainer|null
+        */
+       private $headerCollection;
+
+       /** @var array */
+       private $attributes = [];
+
+       /** @var string */
+       private $cookiePrefix;
+
+       /**
+        * @internal
+        * @param string $cookiePrefix
+        */
+       protected function __construct( $cookiePrefix ) {
+               $this->cookiePrefix = $cookiePrefix;
+       }
+
+       /**
+        * Override this in the implementation class if lazy initialisation of
+        * header values is desired. It should call setHeaders().
+        *
+        * @internal
+        */
+       protected function initHeaders() {
+       }
+
+       public function __clone() {
+               if ( $this->headerCollection !== null ) {
+                       $this->headerCollection = clone $this->headerCollection;
+               }
+       }
+
+       /**
+        * Erase any existing headers and replace them with the specified header
+        * lines.
+        *
+        * Call this either from the constructor or from initHeaders() of the
+        * implementing class.
+        *
+        * @internal
+        * @param string[] $headers The header lines
+        */
+       protected function setHeaders( $headers ) {
+               $this->headerCollection = new HeaderContainer;
+               $this->headerCollection->resetHeaders( $headers );
+       }
+
+       public function getHeaders() {
+               if ( $this->headerCollection === null ) {
+                       $this->initHeaders();
+               }
+               return $this->headerCollection->getHeaders();
+       }
+
+       public function getHeader( $name ) {
+               if ( $this->headerCollection === null ) {
+                       $this->initHeaders();
+               }
+               return $this->headerCollection->getHeader( $name );
+       }
+
+       public function hasHeader( $name ) {
+               if ( $this->headerCollection === null ) {
+                       $this->initHeaders();
+               }
+               return $this->headerCollection->hasHeader( $name );
+       }
+
+       public function getHeaderLine( $name ) {
+               if ( $this->headerCollection === null ) {
+                       $this->initHeaders();
+               }
+               return $this->headerCollection->getHeaderLine( $name );
+       }
+
+       public function setAttributes( $attributes ) {
+               $this->attributes = $attributes;
+       }
+
+       public function getAttributes() {
+               return $this->attributes;
+       }
+
+       public function getAttribute( $name, $default = null ) {
+               if ( array_key_exists( $name, $this->attributes ) ) {
+                       return $this->attributes[$name];
+               } else {
+                       return $default;
+               }
+       }
+
+       public function getCookiePrefix() {
+               return $this->cookiePrefix;
+       }
+
+       public function getCookie( $name, $default = null ) {
+               $cookies = $this->getCookieParams();
+               $prefixedName = $this->getCookiePrefix() . $name;
+               if ( array_key_exists( $prefixedName, $cookies ) ) {
+                       return $cookies[$prefixedName];
+               } else {
+                       return $default;
+               }
+       }
+}
diff --git a/includes/Rest/RequestData.php b/includes/Rest/RequestData.php
new file mode 100644 (file)
index 0000000..1522c6b
--- /dev/null
@@ -0,0 +1,104 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use GuzzleHttp\Psr7\Uri;
+use Psr\Http\Message\StreamInterface;
+use Psr\Http\Message\UploadedFileInterface;
+use Psr\Http\Message\UriInterface;
+
+/**
+ * This is a Request class that allows data to be injected, for the purposes
+ * of testing or internal requests.
+ */
+class RequestData extends RequestBase {
+       private $method;
+
+       /** @var UriInterface */
+       private $uri;
+
+       private $protocolVersion;
+
+       /** @var StreamInterface */
+       private $body;
+
+       private $serverParams;
+
+       private $cookieParams;
+
+       private $queryParams;
+
+       /** @var UploadedFileInterface[] */
+       private $uploadedFiles;
+
+       private $postParams;
+
+       /**
+        * Construct a RequestData from an array of parameters.
+        *
+        * @param array $params An associative array of parameters. All parameters
+        *   have defaults. Parameters are:
+        *     - method: The HTTP method
+        *     - uri: The URI
+        *     - protocolVersion: The HTTP protocol version number
+        *     - bodyContents: A string giving the request body
+        *     - serverParams: Equivalent to $_SERVER
+        *     - cookieParams: Equivalent to $_COOKIE
+        *     - queryParams: Equivalent to $_GET
+        *     - uploadedFiles: An array of objects implementing UploadedFileInterface
+        *     - postParams: Equivalent to $_POST
+        *     - attributes: The attributes, usually from path template parameters
+        *     - headers: An array with the the key being the header name
+        *     - cookiePrefix: A prefix to add to cookie names in getCookie()
+        */
+       public function __construct( $params = [] ) {
+               $this->method = $params['method'] ?? 'GET';
+               $this->uri = $params['uri'] ?? new Uri;
+               $this->protocolVersion = $params['protocolVersion'] ?? '1.1';
+               $this->body = new StringStream( $params['bodyContents'] ?? '' );
+               $this->serverParams = $params['serverParams'] ?? [];
+               $this->cookieParams = $params['cookieParams'] ?? [];
+               $this->queryParams = $params['queryParams'] ?? [];
+               $this->uploadedFiles = $params['uploadedFiles'] ?? [];
+               $this->postParams = $params['postParams'] ?? [];
+               $this->setAttributes( $params['attributes'] ?? [] );
+               $this->setHeaders( $params['headers'] ?? [] );
+               parent::__construct( $params['cookiePrefix'] ?? '' );
+       }
+
+       public function getMethod() {
+               return $this->method;
+       }
+
+       public function getUri() {
+               return $this->uri;
+       }
+
+       public function getProtocolVersion() {
+               return $this->protocolVersion;
+       }
+
+       public function getBody() {
+               return $this->body;
+       }
+
+       public function getServerParams() {
+               return $this->serverParams;
+       }
+
+       public function getCookieParams() {
+               return $this->cookieParams;
+       }
+
+       public function getQueryParams() {
+               return $this->queryParams;
+       }
+
+       public function getUploadedFiles() {
+               return $this->uploadedFiles;
+       }
+
+       public function getPostParams() {
+               return $this->postParams;
+       }
+}
diff --git a/includes/Rest/RequestFromGlobals.php b/includes/Rest/RequestFromGlobals.php
new file mode 100644 (file)
index 0000000..c73427b
--- /dev/null
@@ -0,0 +1,101 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use GuzzleHttp\Psr7\LazyOpenStream;
+use GuzzleHttp\Psr7\ServerRequest;
+use GuzzleHttp\Psr7\Uri;
+
+// phpcs:disable MediaWiki.Usage.SuperGlobalsUsage.SuperGlobals
+
+/**
+ * This is a request class that gets data directly from the superglobals and
+ * other global PHP state, notably php://input.
+ */
+class RequestFromGlobals extends RequestBase {
+       private $uri;
+       private $protocol;
+       private $uploadedFiles;
+
+       /**
+        * @param array $params Associative array of parameters:
+        *   - cookiePrefix: The prefix for cookie names used by getCookie()
+        */
+       public function __construct( $params = [] ) {
+               parent::__construct( $params['cookiePrefix'] ?? '' );
+       }
+
+       // RequestInterface
+
+       public function getMethod() {
+               return $_SERVER['REQUEST_METHOD'] ?? 'GET';
+       }
+
+       public function getUri() {
+               if ( $this->uri === null ) {
+                       $this->uri = new Uri( \WebRequest::getGlobalRequestURL() );
+               }
+               return $this->uri;
+       }
+
+       // MessageInterface
+
+       public function getProtocolVersion() {
+               if ( $this->protocol === null ) {
+                       $serverProtocol = $_SERVER['SERVER_PROTOCOL'] ?? '';
+                       $prefixLength = strlen( 'HTTP/' );
+                       if ( strncmp( $serverProtocol, 'HTTP/', $prefixLength ) === 0 ) {
+                               $this->protocol = substr( $serverProtocol, $prefixLength );
+                       } else {
+                               $this->protocol = '1.1';
+                       }
+               }
+               return $this->protocol;
+       }
+
+       protected function initHeaders() {
+               if ( function_exists( 'apache_request_headers' ) ) {
+                       $this->setHeaders( apache_request_headers() );
+               } else {
+                       $headers = [];
+                       foreach ( $_SERVER as $name => $value ) {
+                               if ( substr( $name, 0, 5 ) === 'HTTP_' ) {
+                                       $name = strtolower( str_replace( '_', '-', substr( $name, 5 ) ) );
+                                       $headers[$name] = $value;
+                               } elseif ( $name === 'CONTENT_LENGTH' ) {
+                                       $headers['content-length'] = $value;
+                               }
+                       }
+                       $this->setHeaders( $headers );
+               }
+       }
+
+       public function getBody() {
+               return new LazyOpenStream( 'php://input', 'r' );
+       }
+
+       // ServerRequestInterface
+
+       public function getServerParams() {
+               return $_SERVER;
+       }
+
+       public function getCookieParams() {
+               return $_COOKIE;
+       }
+
+       public function getQueryParams() {
+               return $_GET;
+       }
+
+       public function getUploadedFiles() {
+               if ( $this->uploadedFiles === null ) {
+                       $this->uploadedFiles = ServerRequest::normalizeFiles( $_FILES );
+               }
+               return $this->uploadedFiles;
+       }
+
+       public function getPostParams() {
+               return $_POST;
+       }
+}
diff --git a/includes/Rest/RequestInterface.php b/includes/Rest/RequestInterface.php
new file mode 100644 (file)
index 0000000..65c72f6
--- /dev/null
@@ -0,0 +1,276 @@
+<?php
+
+/**
+ * Copyright (c) 2019 Wikimedia Foundation.
+ *
+ * This file is partly derived from PSR-7, which requires the following copyright notice:
+ *
+ * Copyright (c) 2014 PHP Framework Interoperability Group
+ *
+ * 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:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @file
+ */
+
+namespace MediaWiki\Rest;
+
+use Psr\Http\Message\StreamInterface;
+use Psr\Http\Message\UriInterface;
+
+/**
+ * A request interface similar to PSR-7's ServerRequestInterface
+ */
+interface RequestInterface {
+       // RequestInterface
+
+       /**
+        * Retrieves the HTTP method of the request.
+        *
+        * @return string Returns the request method.
+        */
+       function getMethod();
+
+       /**
+        * Retrieves the URI instance.
+        *
+        * This method MUST return a UriInterface instance.
+        *
+        * @link http://tools.ietf.org/html/rfc3986#section-4.3
+        * @return UriInterface Returns a UriInterface instance
+        *     representing the URI of the request.
+        */
+       function getUri();
+
+       // MessageInterface
+
+       /**
+        * Retrieves the HTTP protocol version as a string.
+        *
+        * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
+        *
+        * @return string HTTP protocol version.
+        */
+       function getProtocolVersion();
+
+       /**
+        * Retrieves all message header values.
+        *
+        * The keys represent the header name as it will be sent over the wire, and
+        * each value is an array of strings associated with the header.
+        *
+        *     // Represent the headers as a string
+        *     foreach ($message->getHeaders() as $name => $values) {
+        *         echo $name . ": " . implode(", ", $values);
+        *     }
+        *
+        *     // Emit headers iteratively:
+        *     foreach ($message->getHeaders() as $name => $values) {
+        *         foreach ($values as $value) {
+        *             header(sprintf('%s: %s', $name, $value), false);
+        *         }
+        *     }
+        *
+        * While header names are not case-sensitive, getHeaders() will preserve the
+        * exact case in which headers were originally specified.
+        *
+        * A single header value may be a string containing a comma-separated list.
+        * Lists will not necessarily be split into arrays. See the comment on
+        * HeaderContainer::convertToListAndString().
+        *
+        * @return string[][] Returns an associative array of the message's headers. Each
+        *     key MUST be a header name, and each value MUST be an array of strings
+        *     for that header.
+        */
+       function getHeaders();
+
+       /**
+        * Retrieves a message header value by the given case-insensitive name.
+        *
+        * This method returns an array of all the header values of the given
+        * case-insensitive header name.
+        *
+        * If the header does not appear in the message, this method MUST return an
+        * empty array.
+        *
+        * A single header value may be a string containing a comma-separated list.
+        * Lists will not necessarily be split into arrays. See the comment on
+        * HeaderContainer::convertToListAndString().
+        *
+        * @param string $name Case-insensitive header field name.
+        * @return string[] An array of string values as provided for the given
+        *    header. If the header does not appear in the message, this method MUST
+        *    return an empty array.
+        */
+       function getHeader( $name );
+
+       /**
+        * Checks if a header exists by the given case-insensitive name.
+        *
+        * @param string $name Case-insensitive header field name.
+        * @return bool Returns true if any header names match the given header
+        *     name using a case-insensitive string comparison. Returns false if
+        *     no matching header name is found in the message.
+        */
+       function hasHeader( $name );
+
+       /**
+        * Retrieves a comma-separated string of the values for a single header.
+        *
+        * This method returns all of the header values of the given
+        * case-insensitive header name as a string concatenated together using
+        * a comma.
+        *
+        * NOTE: Not all header values may be appropriately represented using
+        * comma concatenation. For such headers, use getHeader() instead
+        * and supply your own delimiter when concatenating.
+        *
+        * If the header does not appear in the message, this method MUST return
+        * an empty string.
+        *
+        * @param string $name Case-insensitive header field name.
+        * @return string A string of values as provided for the given header
+        *    concatenated together using a comma. If the header does not appear in
+        *    the message, this method MUST return an empty string.
+        */
+       function getHeaderLine( $name );
+
+       /**
+        * Gets the body of the message.
+        *
+        * @return StreamInterface Returns the body as a stream.
+        */
+       function getBody();
+
+       // ServerRequestInterface
+
+       /**
+        * Retrieve server parameters.
+        *
+        * Retrieves data related to the incoming request environment,
+        * typically derived from PHP's $_SERVER superglobal. The data IS NOT
+        * REQUIRED to originate from $_SERVER.
+        *
+        * @return array
+        */
+       function getServerParams();
+
+       /**
+        * Retrieve cookies.
+        *
+        * Retrieves cookies sent by the client to the server.
+        *
+        * The data MUST be compatible with the structure of the $_COOKIE
+        * superglobal.
+        *
+        * @return array
+        */
+       function getCookieParams();
+
+       /**
+        * Retrieve query string arguments.
+        *
+        * Retrieves the deserialized query string arguments, if any.
+        *
+        * Note: the query params might not be in sync with the URI or server
+        * params. If you need to ensure you are only getting the original
+        * values, you may need to parse the query string from `getUri()->getQuery()`
+        * or from the `QUERY_STRING` server param.
+        *
+        * @return array
+        */
+       function getQueryParams();
+
+       /**
+        * Retrieve normalized file upload data.
+        *
+        * This method returns upload metadata in a normalized tree, with each leaf
+        * an instance of Psr\Http\Message\UploadedFileInterface.
+        *
+        * @return array An array tree of UploadedFileInterface instances; an empty
+        *     array MUST be returned if no data is present.
+        */
+       function getUploadedFiles();
+
+       /**
+        * Retrieve attributes derived from the request.
+        *
+        * The request "attributes" may be used to allow injection of any
+        * parameters derived from the request: e.g., the results of path
+        * match operations; the results of decrypting cookies; the results of
+        * deserializing non-form-encoded message bodies; etc. Attributes
+        * will be application and request specific, and CAN be mutable.
+        *
+        * @return array Attributes derived from the request.
+        */
+       function getAttributes();
+
+       /**
+        * Retrieve a single derived request attribute.
+        *
+        * Retrieves a single derived request attribute as described in
+        * getAttributes(). If the attribute has not been previously set, returns
+        * the default value as provided.
+        *
+        * This method obviates the need for a hasAttribute() method, as it allows
+        * specifying a default value to return if the attribute is not found.
+        *
+        * @see getAttributes()
+        * @param string $name The attribute name.
+        * @param mixed|null $default Default value to return if the attribute does not exist.
+        * @return mixed
+        */
+       function getAttribute( $name, $default = null );
+
+       // MediaWiki extensions to PSR-7
+
+       /**
+        * Erase all attributes from the object and set the attribute array to the
+        * specified value
+        *
+        * @param mixed[] $attributes
+        */
+       function setAttributes( $attributes );
+
+       /**
+        * Get the current cookie prefix
+        *
+        * @return string
+        */
+       function getCookiePrefix();
+
+       /**
+        * Add the cookie prefix to a specified cookie name and get the value of
+        * the resulting prefixed cookie. If the cookie does not exist, $default
+        * is returned.
+        *
+        * @param string $name
+        * @param mixed|null $default
+        * @return mixed The cookie value as a string, or $default
+        */
+       function getCookie( $name, $default = null );
+
+       /**
+        * Retrieve POST form parameters.
+        *
+        * This will return an array of parameters in the format of $_POST.
+        *
+        * @return array The deserialized POST parameters
+        */
+       function getPostParams();
+}
diff --git a/includes/Rest/Response.php b/includes/Rest/Response.php
new file mode 100644 (file)
index 0000000..3b01028
--- /dev/null
@@ -0,0 +1,112 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use HttpStatus;
+use Psr\Http\Message\StreamInterface;
+
+class Response implements ResponseInterface {
+       /** @var int */
+       private $statusCode = 200;
+
+       /** @var string */
+       private $reasonPhrase = 'OK';
+
+       /** @var string */
+       private $protocolVersion = '1.1';
+
+       /** @var StreamInterface */
+       private $body;
+
+       /** @var HeaderContainer */
+       private $headerContainer;
+
+       /** @var array */
+       private $cookies = [];
+
+       /**
+        * @internal Use ResponseFactory
+        * @param string $bodyContents
+        */
+       public function __construct( $bodyContents = '' ) {
+               $this->body = new StringStream( $bodyContents );
+               $this->headerContainer = new HeaderContainer;
+       }
+
+       public function getStatusCode() {
+               return $this->statusCode;
+       }
+
+       public function getReasonPhrase() {
+               return $this->reasonPhrase;
+       }
+
+       public function setStatus( $code, $reasonPhrase = '' ) {
+               $this->statusCode = $code;
+               if ( $reasonPhrase === '' ) {
+                       $reasonPhrase = HttpStatus::getMessage( $code ) ?? '';
+               }
+               $this->reasonPhrase = $reasonPhrase;
+       }
+
+       public function getProtocolVersion() {
+               return $this->protocolVersion;
+       }
+
+       public function getHeaders() {
+               return $this->headerContainer->getHeaders();
+       }
+
+       public function hasHeader( $name ) {
+               return $this->headerContainer->hasHeader( $name );
+       }
+
+       public function getHeader( $name ) {
+               return $this->headerContainer->getHeader( $name );
+       }
+
+       public function getHeaderLine( $name ) {
+               return $this->headerContainer->getHeaderLine( $name );
+       }
+
+       public function getBody() {
+               return $this->body;
+       }
+
+       public function setProtocolVersion( $version ) {
+               $this->protocolVersion = $version;
+       }
+
+       public function setHeader( $name, $value ) {
+               $this->headerContainer->setHeader( $name, $value );
+       }
+
+       public function addHeader( $name, $value ) {
+               $this->headerContainer->addHeader( $name, $value );
+       }
+
+       public function removeHeader( $name ) {
+               $this->headerContainer->removeHeader( $name );
+       }
+
+       public function setBody( StreamInterface $body ) {
+               $this->body = $body;
+       }
+
+       public function getRawHeaderLines() {
+               return $this->headerContainer->getRawHeaderLines();
+       }
+
+       public function setCookie( $name, $value, $expire = 0, $options = [] ) {
+               $this->cookies[] = [
+                       'name' => $name,
+                       'value' => $value,
+                       'expire' => $expire,
+                       'options' => $options
+               ];
+       }
+
+       public function getCookies() {
+               return $this->cookies;
+       }
+}
diff --git a/includes/Rest/ResponseFactory.php b/includes/Rest/ResponseFactory.php
new file mode 100644 (file)
index 0000000..7ccb612
--- /dev/null
@@ -0,0 +1,222 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use Exception;
+use HttpStatus;
+use InvalidArgumentException;
+use MWExceptionHandler;
+use stdClass;
+use Throwable;
+
+/**
+ * Generates standardized response objects.
+ */
+class ResponseFactory {
+
+       const CT_PLAIN = 'text/plain; charset=utf-8';
+       const CT_HTML = 'text/html; charset=utf-8';
+       const CT_JSON = 'application/json';
+
+       /**
+        * Encode a stdClass object or array to a JSON string
+        *
+        * @param array|stdClass $value
+        * @return string
+        * @throws JsonEncodingException
+        */
+       public function encodeJson( $value ) {
+               $json = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
+               if ( $json === false ) {
+                       throw new JsonEncodingException( json_last_error_msg(), json_last_error() );
+               }
+               return $json;
+       }
+
+       /**
+        * Create an unspecified response. It is the caller's responsibility to set specifics
+        * like response code, content type etc.
+        * @return Response
+        */
+       public function create() {
+               return new Response();
+       }
+
+       /**
+        * Create a successful JSON response.
+        * @param array|stdClass $value JSON value
+        * @param string|null $contentType HTTP content type (should be 'application/json+...')
+        *   or null for plain 'application/json'
+        * @return Response
+        */
+       public function createJson( $value, $contentType = null ) {
+               $contentType = $contentType ?? self::CT_JSON;
+               $response = new Response( $this->encodeJson( $value ) );
+               $response->setHeader( 'Content-Type', $contentType );
+               return $response;
+       }
+
+       /**
+        * Create a 204 (No Content) response, used to indicate that an operation which does
+        * not return anything (e.g. a PUT request) was successful.
+        *
+        * Headers are generally interpreted to refer to the target of the operation. E.g. if
+        * this was a PUT request, the caller of this method might want to add an ETag header
+        * describing the created resource.
+        *
+        * @return Response
+        */
+       public function createNoContent() {
+               $response = new Response();
+               $response->setStatus( 204 );
+               return $response;
+       }
+
+       /**
+        * Creates a permanent (301) redirect.
+        * This indicates that the caller of the API should update their indexes and call
+        * the new URL in the future. 301 redirects tend to get cached and are hard to undo.
+        * Client behavior for methods other than GET/HEAD is not well-defined and this type
+        * of response should be avoided in such cases.
+        * @param string $target Redirect URL (can be relative)
+        * @return Response
+        */
+       public function createPermanentRedirect( $target ) {
+               $response = $this->createRedirectBase( $target );
+               $response->setStatus( 301 );
+               return $response;
+       }
+
+       /**
+        * Creates a temporary (307) redirect.
+        * This indicates that the operation the client was trying to perform can temporarily
+        * be achieved by using a different URL. Clients will preserve the request method when
+        * retrying the request with the new URL.
+        * @param string $target Redirect URL (can be relative)
+        * @return Response
+        */
+       public function createTemporaryRedirect( $target ) {
+               $response = $this->createRedirectBase( $target );
+               $response->setStatus( 307 );
+               return $response;
+       }
+
+       /**
+        * Creates a See Other (303) redirect.
+        * This indicates that the target resource might be of interest to the client, without
+        * necessarily implying that it is the same resource. The client will always use GET
+        * (or HEAD) when following the redirection. Useful for GET-after-POST.
+        * @param string $target Redirect URL (can be relative)
+        * @return Response
+        */
+       public function createSeeOther( $target ) {
+               $response = $this->createRedirectBase( $target );
+               $response->setStatus( 303 );
+               return $response;
+       }
+
+       /**
+        * Create a 304 (Not Modified) response, used when the client has an up-to-date cached response.
+        *
+        * Per RFC 7232 the response should contain all Cache-Control, Content-Location, Date,
+        * ETag, Expires, and Vary headers that would have been sent with the 200 OK answer
+        * if the requesting client did not have a valid cached response. This is the responsibility
+        * of the caller of this method.
+        *
+        * @return Response
+        */
+       public function createNotModified() {
+               $response = new Response();
+               $response->setStatus( 304 );
+               return $response;
+       }
+
+       /**
+        * Create a HTTP 4xx or 5xx response.
+        * @param int $errorCode HTTP error code
+        * @param array $bodyData An array of data to be included in the JSON response
+        * @return Response
+        * @throws InvalidArgumentException
+        */
+       public function createHttpError( $errorCode, array $bodyData = [] ) {
+               if ( $errorCode < 400 || $errorCode >= 600 ) {
+                       throw new InvalidArgumentException( 'error code must be 4xx or 5xx' );
+               }
+               $response = $this->createJson( $bodyData + [
+                       'httpCode' => $errorCode,
+                       'httpReason' => HttpStatus::getMessage( $errorCode )
+               ] );
+               // TODO add link to error code documentation
+               $response->setStatus( $errorCode );
+               return $response;
+       }
+
+       /**
+        * Turn an exception into a JSON error response.
+        * @param Exception|Throwable $exception
+        * @return Response
+        */
+       public function createFromException( $exception ) {
+               if ( $exception instanceof HttpException ) {
+                       // FIXME can HttpException represent 2xx or 3xx responses?
+                       $response = $this->createHttpError( $exception->getCode(),
+                               [ 'message' => $exception->getMessage() ] );
+               } else {
+                       $response = $this->createHttpError( 500, [
+                               'message' => 'Error: exception of type ' . get_class( $exception ),
+                               'exception' => MWExceptionHandler::getStructuredExceptionData( $exception )
+                       ] );
+                       // FIXME should we try to do something useful with ILocalizedException?
+                       // FIXME should we try to do something useful with common MediaWiki errors like ReadOnlyError?
+               }
+               return $response;
+       }
+
+       /**
+        * Create a JSON response from an arbitrary value.
+        * This is a fallback; it's preferable to use createJson() instead.
+        * @param mixed $value A structure containing only scalars, arrays and stdClass objects
+        * @return Response
+        * @throws InvalidArgumentException When $value cannot be reasonably represented as JSON
+        */
+       public function createFromReturnValue( $value ) {
+               $originalValue = $value;
+               if ( is_scalar( $value ) ) {
+                       $data = [ 'value' => $value ];
+               } elseif ( is_array( $value ) || $value instanceof stdClass ) {
+                       $data = $value;
+               } else {
+                       $type = gettype( $originalValue );
+                       if ( $type === 'object' ) {
+                               $type = get_class( $originalValue );
+                       }
+                       throw new InvalidArgumentException( __METHOD__ . ": Invalid return value type $type" );
+               }
+               $response = $this->createJson( $data );
+               return $response;
+       }
+
+       /**
+        * Create a redirect response with type / response code unspecified.
+        * @param string $target Redirect target (an absolute URL)
+        * @return Response
+        */
+       protected function createRedirectBase( $target ) {
+               $response = new Response( $this->getHyperLink( $target ) );
+               $response->setHeader( 'Content-Type', self::CT_HTML );
+               $response->setHeader( 'Location', $target );
+               return $response;
+       }
+
+       /**
+        * Returns a minimal HTML document that links to the given URL, as suggested by
+        * RFC 7231 for 3xx responses.
+        * @param string $url An absolute URL
+        * @return string
+        */
+       protected function getHyperLink( $url ) {
+               $url = htmlspecialchars( $url );
+               return "<!doctype html><title>Redirect</title><a href=\"$url\">$url</a>";
+       }
+
+}
diff --git a/includes/Rest/ResponseInterface.php b/includes/Rest/ResponseInterface.php
new file mode 100644 (file)
index 0000000..797b96f
--- /dev/null
@@ -0,0 +1,277 @@
+<?php
+
+/**
+ * Copyright (c) 2019 Wikimedia Foundation.
+ *
+ * This file is partly derived from PSR-7, which requires the following copyright notice:
+ *
+ * Copyright (c) 2014 PHP Framework Interoperability Group
+ *
+ * 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:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ *
+ * @file
+ */
+
+namespace MediaWiki\Rest;
+
+use Psr\Http\Message\StreamInterface;
+
+/**
+ * An interface similar to PSR-7's ResponseInterface, the primary difference
+ * being that it is mutable.
+ */
+interface ResponseInterface {
+       // ResponseInterface
+
+       /**
+        * Gets the response status code.
+        *
+        * The status code is a 3-digit integer result code of the server's attempt
+        * to understand and satisfy the request.
+        *
+        * @return int Status code.
+        */
+       function getStatusCode();
+
+       /**
+        * Gets the response reason phrase associated with the status code.
+        *
+        * Because a reason phrase is not a required element in a response
+        * status line, the reason phrase value MAY be empty. Implementations MAY
+        * choose to return the default RFC 7231 recommended reason phrase (or those
+        * listed in the IANA HTTP Status Code Registry) for the response's
+        * status code.
+        *
+        * @see http://tools.ietf.org/html/rfc7231#section-6
+        * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
+        * @return string Reason phrase; must return an empty string if none present.
+        */
+       function getReasonPhrase();
+
+       // ResponseInterface mutation
+
+       /**
+        * Set the status code and, optionally, reason phrase.
+        *
+        * If no reason phrase is specified, implementations MAY choose to default
+        * to the RFC 7231 or IANA recommended reason phrase for the response's
+        * status code.
+        *
+        * @see http://tools.ietf.org/html/rfc7231#section-6
+        * @see http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
+        * @param int $code The 3-digit integer result code to set.
+        * @param string $reasonPhrase The reason phrase to use with the
+        *     provided status code; if none is provided, implementations MAY
+        *     use the defaults as suggested in the HTTP specification.
+        * @throws \InvalidArgumentException For invalid status code arguments.
+        */
+       function setStatus( $code, $reasonPhrase = '' );
+
+       // MessageInterface
+
+       /**
+        * Retrieves the HTTP protocol version as a string.
+        *
+        * The string MUST contain only the HTTP version number (e.g., "1.1", "1.0").
+        *
+        * @return string HTTP protocol version.
+        */
+       function getProtocolVersion();
+
+       /**
+        * Retrieves all message header values.
+        *
+        * The keys represent the header name as it will be sent over the wire, and
+        * each value is an array of strings associated with the header.
+        *
+        *     // Represent the headers as a string
+        *     foreach ($message->getHeaders() as $name => $values) {
+        *         echo $name . ': ' . implode(', ', $values);
+        *     }
+        *
+        *     // Emit headers iteratively:
+        *     foreach ($message->getHeaders() as $name => $values) {
+        *         foreach ($values as $value) {
+        *             header(sprintf('%s: %s', $name, $value), false);
+        *         }
+        *     }
+        *
+        * While header names are not case-sensitive, getHeaders() will preserve the
+        * exact case in which headers were originally specified.
+        *
+        * @return string[][] Returns an associative array of the message's headers.
+        *     Each key MUST be a header name, and each value MUST be an array of
+        *     strings for that header.
+        */
+       function getHeaders();
+
+       /**
+        * Checks if a header exists by the given case-insensitive name.
+        *
+        * @param string $name Case-insensitive header field name.
+        * @return bool Returns true if any header names match the given header
+        *     name using a case-insensitive string comparison. Returns false if
+        *     no matching header name is found in the message.
+        */
+       function hasHeader( $name );
+
+       /**
+        * Retrieves a message header value by the given case-insensitive name.
+        *
+        * This method returns an array of all the header values of the given
+        * case-insensitive header name.
+        *
+        * If the header does not appear in the message, this method MUST return an
+        * empty array.
+        *
+        * @param string $name Case-insensitive header field name.
+        * @return string[] An array of string values as provided for the given
+        *    header. If the header does not appear in the message, this method MUST
+        *    return an empty array.
+        */
+       function getHeader( $name );
+
+       /**
+        * Retrieves a comma-separated string of the values for a single header.
+        *
+        * This method returns all of the header values of the given
+        * case-insensitive header name as a string concatenated together using
+        * a comma.
+        *
+        * NOTE: Not all header values may be appropriately represented using
+        * comma concatenation. For such headers, use getHeader() instead
+        * and supply your own delimiter when concatenating.
+        *
+        * If the header does not appear in the message, this method MUST return
+        * an empty string.
+        *
+        * @param string $name Case-insensitive header field name.
+        * @return string A string of values as provided for the given header
+        *    concatenated together using a comma. If the header does not appear in
+        *    the message, this method MUST return an empty string.
+        */
+       function getHeaderLine( $name );
+
+       /**
+        * Gets the body of the message.
+        *
+        * @return StreamInterface Returns the body as a stream.
+        */
+       function getBody();
+
+       // MessageInterface mutation
+
+       /**
+        * Set the HTTP protocol version.
+        *
+        * The version string MUST contain only the HTTP version number (e.g.,
+        * "1.1", "1.0").
+        *
+        * @param string $version HTTP protocol version
+        */
+       function setProtocolVersion( $version );
+
+       /**
+        * Set or replace the specified header.
+        *
+        * While header names are case-insensitive, the casing of the header will
+        * be preserved by this function, and returned from getHeaders().
+        *
+        * @param string $name Case-insensitive header field name.
+        * @param string|string[] $value Header value(s).
+        * @throws \InvalidArgumentException for invalid header names or values.
+        */
+       function setHeader( $name, $value );
+
+       /**
+        * Append the given value to the specified header.
+        *
+        * Existing values for the specified header will be maintained. The new
+        * value(s) will be appended to the existing list. If the header did not
+        * exist previously, it will be added.
+        *
+        * @param string $name Case-insensitive header field name to add.
+        * @param string|string[] $value Header value(s).
+        * @throws \InvalidArgumentException for invalid header names.
+        * @throws \InvalidArgumentException for invalid header values.
+        */
+       function addHeader( $name, $value );
+
+       /**
+        * Remove the specified header.
+        *
+        * Header resolution MUST be done without case-sensitivity.
+        *
+        * @param string $name Case-insensitive header field name to remove.
+        */
+       function removeHeader( $name );
+
+       /**
+        * Set the message body
+        *
+        * The body MUST be a StreamInterface object.
+        *
+        * @param StreamInterface $body Body.
+        * @throws \InvalidArgumentException When the body is not valid.
+        */
+       function setBody( StreamInterface $body );
+
+       // MediaWiki extensions to PSR-7
+
+       /**
+        * Get the full header lines including colon-separated name and value, for
+        * passing directly to header(). Not including the status line.
+        *
+        * @return string[]
+        */
+       function getRawHeaderLines();
+
+       /**
+        * Set a cookie
+        *
+        * The name will have the cookie prefix added to it before it is sent over
+        * the network.
+        *
+        * @param string $name The name of the cookie, not including prefix.
+        * @param string $value The value to be stored in the cookie.
+        * @param int|null $expire Unix timestamp (in seconds) when the cookie should expire.
+        *        0 (the default) causes it to expire $wgCookieExpiration seconds from now.
+        *        null causes it to be a session cookie.
+        * @param array $options Assoc of additional cookie options:
+        *     prefix: string, name prefix ($wgCookiePrefix)
+        *     domain: string, cookie domain ($wgCookieDomain)
+        *     path: string, cookie path ($wgCookiePath)
+        *     secure: bool, secure attribute ($wgCookieSecure)
+        *     httpOnly: bool, httpOnly attribute ($wgCookieHttpOnly)
+        */
+       public function setCookie( $name, $value, $expire = 0, $options = [] );
+
+       /**
+        * Get all previously set cookies as a list of associative arrays with
+        * the following keys:
+        *
+        *  - name: The cookie name
+        *  - value: The cookie value
+        *  - expire: The requested expiry time
+        *  - options: An associative array of further options
+        *
+        * @return array
+        */
+       public function getCookies();
+}
diff --git a/includes/Rest/Router.php b/includes/Rest/Router.php
new file mode 100644 (file)
index 0000000..ab54439
--- /dev/null
@@ -0,0 +1,231 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use AppendIterator;
+use BagOStuff;
+use MediaWiki\Rest\PathTemplateMatcher\PathMatcher;
+use Wikimedia\ObjectFactory;
+
+/**
+ * The REST router is responsible for gathering handler configuration, matching
+ * an input path and HTTP method against the defined routes, and constructing
+ * and executing the relevant handler for a request.
+ */
+class Router {
+       /** @var string[] */
+       private $routeFiles;
+
+       /** @var array */
+       private $extraRoutes;
+
+       /** @var array|null */
+       private $routesFromFiles;
+
+       /** @var int[]|null */
+       private $routeFileTimestamps;
+
+       /** @var string */
+       private $rootPath;
+
+       /** @var \BagOStuff */
+       private $cacheBag;
+
+       /** @var PathMatcher[]|null Path matchers by method */
+       private $matchers;
+
+       /** @var string|null */
+       private $configHash;
+
+       /** @var ResponseFactory */
+       private $responseFactory;
+
+       /**
+        * @param string[] $routeFiles List of names of JSON files containing routes
+        * @param array $extraRoutes Extension route array
+        * @param string $rootPath The base URL path
+        * @param BagOStuff $cacheBag A cache in which to store the matcher trees
+        * @param ResponseFactory $responseFactory
+        */
+       public function __construct( $routeFiles, $extraRoutes, $rootPath,
+               BagOStuff $cacheBag, ResponseFactory $responseFactory
+       ) {
+               $this->routeFiles = $routeFiles;
+               $this->extraRoutes = $extraRoutes;
+               $this->rootPath = $rootPath;
+               $this->cacheBag = $cacheBag;
+               $this->responseFactory = $responseFactory;
+       }
+
+       /**
+        * Get the cache data, or false if it is missing or invalid
+        *
+        * @return bool|array
+        */
+       private function fetchCacheData() {
+               $cacheData = $this->cacheBag->get( $this->getCacheKey() );
+               if ( $cacheData && $cacheData['CONFIG-HASH'] === $this->getConfigHash() ) {
+                       unset( $cacheData['CONFIG-HASH'] );
+                       return $cacheData;
+               } else {
+                       return false;
+               }
+       }
+
+       /**
+        * @return string The cache key
+        */
+       private function getCacheKey() {
+               return $this->cacheBag->makeKey( __CLASS__, '1' );
+       }
+
+       /**
+        * Get a config version hash for cache invalidation
+        *
+        * @return string
+        */
+       private function getConfigHash() {
+               if ( $this->configHash === null ) {
+                       $this->configHash = md5( json_encode( [
+                               $this->extraRoutes,
+                               $this->getRouteFileTimestamps()
+                       ] ) );
+               }
+               return $this->configHash;
+       }
+
+       /**
+        * Load the defined JSON files and return the merged routes
+        *
+        * @return array
+        */
+       private function getRoutesFromFiles() {
+               if ( $this->routesFromFiles === null ) {
+                       $this->routeFileTimestamps = [];
+                       foreach ( $this->routeFiles as $fileName ) {
+                               $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
+                               $routes = json_decode( file_get_contents( $fileName ), true );
+                               if ( $this->routesFromFiles === null ) {
+                                       $this->routesFromFiles = $routes;
+                               } else {
+                                       $this->routesFromFiles = array_merge( $this->routesFromFiles, $routes );
+                               }
+                       }
+               }
+               return $this->routesFromFiles;
+       }
+
+       /**
+        * Get an array of last modification times of the defined route files.
+        *
+        * @return int[] Last modification times
+        */
+       private function getRouteFileTimestamps() {
+               if ( $this->routeFileTimestamps === null ) {
+                       $this->routeFileTimestamps = [];
+                       foreach ( $this->routeFiles as $fileName ) {
+                               $this->routeFileTimestamps[$fileName] = filemtime( $fileName );
+                       }
+               }
+               return $this->routeFileTimestamps;
+       }
+
+       /**
+        * Get an iterator for all defined routes, including loading the routes from
+        * the JSON files.
+        *
+        * @return AppendIterator
+        */
+       private function getAllRoutes() {
+               $iterator = new AppendIterator;
+               $iterator->append( new \ArrayIterator( $this->getRoutesFromFiles() ) );
+               $iterator->append( new \ArrayIterator( $this->extraRoutes ) );
+               return $iterator;
+       }
+
+       /**
+        * Get an array of PathMatcher objects indexed by HTTP method
+        *
+        * @return PathMatcher[]
+        */
+       private function getMatchers() {
+               if ( $this->matchers === null ) {
+                       $cacheData = $this->fetchCacheData();
+                       $matchers = [];
+                       if ( $cacheData ) {
+                               foreach ( $cacheData as $method => $data ) {
+                                       $matchers[$method] = PathMatcher::newFromCache( $data );
+                               }
+                       } else {
+                               foreach ( $this->getAllRoutes() as $spec ) {
+                                       $methods = $spec['method'] ?? [ 'GET' ];
+                                       if ( !is_array( $methods ) ) {
+                                               $methods = [ $methods ];
+                                       }
+                                       foreach ( $methods as $method ) {
+                                               if ( !isset( $matchers[$method] ) ) {
+                                                       $matchers[$method] = new PathMatcher;
+                                               }
+                                               $matchers[$method]->add( $spec['path'], $spec );
+                                       }
+                               }
+
+                               $cacheData = [ 'CONFIG-HASH' => $this->getConfigHash() ];
+                               foreach ( $matchers as $method => $matcher ) {
+                                       $cacheData[$method] = $matcher->getCacheData();
+                               }
+                               $this->cacheBag->set( $this->getCacheKey(), $cacheData );
+                       }
+                       $this->matchers = $matchers;
+               }
+               return $this->matchers;
+       }
+
+       /**
+        * Find the handler for a request and execute it
+        *
+        * @param RequestInterface $request
+        * @return ResponseInterface
+        */
+       public function execute( RequestInterface $request ) {
+               $matchers = $this->getMatchers();
+               $matcher = $matchers[$request->getMethod()] ?? null;
+               if ( $matcher === null ) {
+                       return $this->responseFactory->createHttpError( 404 );
+               }
+               $path = $request->getUri()->getPath();
+               if ( substr_compare( $path, $this->rootPath, 0, strlen( $this->rootPath ) ) !== 0 ) {
+                       return $this->responseFactory->createHttpError( 404 );
+               }
+               $relPath = substr( $path, strlen( $this->rootPath ) );
+               $match = $matcher->match( $relPath );
+               if ( !$match ) {
+                       return $this->responseFactory->createHttpError( 404 );
+               }
+               $request->setAttributes( $match['params'] );
+               $spec = $match['userData'];
+               $objectFactorySpec = array_intersect_key( $spec,
+                       [ 'factory' => true, 'class' => true, 'args' => true ] );
+               $handler = ObjectFactory::getObjectFromSpec( $objectFactorySpec );
+               $handler->init( $request, $spec, $this->responseFactory );
+
+               try {
+                       return $this->executeHandler( $handler );
+               } catch ( HttpException $e ) {
+                       return $this->responseFactory->createFromException( $e );
+               }
+       }
+
+       /**
+        * Execute a fully-constructed handler
+        * @param Handler $handler
+        * @return ResponseInterface
+        */
+       private function executeHandler( $handler ): ResponseInterface {
+               $response = $handler->execute();
+               if ( !( $response instanceof ResponseInterface ) ) {
+                       $response = $this->responseFactory->createFromReturnValue( $response );
+               }
+               return $response;
+       }
+}
diff --git a/includes/Rest/SimpleHandler.php b/includes/Rest/SimpleHandler.php
new file mode 100644 (file)
index 0000000..65bc0f5
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * A handler base class which unpacks attributes from the path template and
+ * passes them as formal parameters to run().
+ *
+ * run() must be declared in the subclass. It cannot be declared as abstract
+ * here because it has a variable parameter list.
+ *
+ * @package MediaWiki\Rest
+ */
+class SimpleHandler extends Handler {
+       public function execute() {
+               $params = array_values( $this->getRequest()->getAttributes() );
+               return $this->run( ...$params );
+       }
+}
diff --git a/includes/Rest/Stream.php b/includes/Rest/Stream.php
new file mode 100644 (file)
index 0000000..1169875
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use GuzzleHttp\Psr7;
+
+class Stream extends Psr7\Stream implements CopyableStreamInterface {
+       private $stream;
+
+       public function __construct( $stream, $options = [] ) {
+               $this->stream = $stream;
+               parent::__construct( $stream, $options );
+       }
+
+       public function copyToStream( $target ) {
+               stream_copy_to_stream( $this->stream, $target );
+       }
+}
diff --git a/includes/Rest/StringStream.php b/includes/Rest/StringStream.php
new file mode 100644 (file)
index 0000000..18fb6b1
--- /dev/null
@@ -0,0 +1,139 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * A stream class which uses a string as the underlying storage. Surprisingly,
+ * Guzzle does not appear to have one of these. BufferStream does not do what
+ * we want.
+ *
+ * The normal use of this class should be to first write to the stream, then
+ * rewind, then read back the whole buffer with getContents().
+ *
+ * Seeking is supported, however seeking past the end of the string does not
+ * fill with null bytes as in a real file, it throws an exception instead.
+ */
+class StringStream implements CopyableStreamInterface {
+       private $contents = '';
+       private $offset = 0;
+
+       /**
+        * Construct a StringStream with the given contents.
+        *
+        * The offset will start at 0, ready for reading. If appending to the
+        * given string is desired, you should first seek to the end.
+        *
+        * @param string $contents
+        */
+       public function __construct( $contents = '' ) {
+               $this->contents = $contents;
+       }
+
+       public function copyToStream( $stream ) {
+               if ( $this->offset !== 0 ) {
+                       $block = substr( $this->contents, $this->offset );
+               } else {
+                       $block = $this->contents;
+               }
+               fwrite( $stream, $block );
+       }
+
+       public function __toString() {
+               return $this->contents;
+       }
+
+       public function close() {
+       }
+
+       public function detach() {
+               return null;
+       }
+
+       public function getSize() {
+               return strlen( $this->contents );
+       }
+
+       public function tell() {
+               return $this->offset;
+       }
+
+       public function eof() {
+               return $this->offset >= strlen( $this->contents );
+       }
+
+       public function isSeekable() {
+               return true;
+       }
+
+       public function seek( $offset, $whence = SEEK_SET ) {
+               switch ( $whence ) {
+                       case SEEK_SET:
+                               $this->offset = $offset;
+                               break;
+
+                       case SEEK_CUR:
+                               $this->offset += $offset;
+                               break;
+
+                       case SEEK_END:
+                               $this->offset = strlen( $this->contents ) + $offset;
+                               break;
+
+                       default:
+                               throw new \InvalidArgumentException( "Invalid value for \$whence" );
+               }
+               if ( $this->offset > strlen( $this->contents ) ) {
+                       throw new \InvalidArgumentException( "Cannot seek beyond the end of a StringStream" );
+               }
+               if ( $this->offset < 0 ) {
+                       throw new \InvalidArgumentException( "Cannot seek before the start of a StringStream" );
+               }
+       }
+
+       public function rewind() {
+               $this->offset = 0;
+       }
+
+       public function isWritable() {
+               return true;
+       }
+
+       public function write( $string ) {
+               if ( $this->offset === strlen( $this->contents ) ) {
+                       $this->contents .= $string;
+               } else {
+                       $this->contents = substr_replace( $this->contents, $string,
+                               $this->offset, strlen( $string ) );
+               }
+               $this->offset += strlen( $string );
+               return strlen( $string );
+       }
+
+       public function isReadable() {
+               return true;
+       }
+
+       public function read( $length ) {
+               if ( $this->offset === 0 && $length >= strlen( $this->contents ) ) {
+                       $ret = $this->contents;
+               } else {
+                       $ret = substr( $this->contents, $this->offset, $length );
+               }
+               $this->offset += strlen( $ret );
+               return $ret;
+       }
+
+       public function getContents() {
+               if ( $this->offset === 0 ) {
+                       $ret = $this->contents;
+               } else {
+                       $ret = substr( $this->contents, $this->offset );
+               }
+               $this->offset = strlen( $this->contents );
+               return $ret;
+       }
+
+       public function getMetadata( $key = null ) {
+               return null;
+       }
+}
diff --git a/includes/Rest/coreRoutes.json b/includes/Rest/coreRoutes.json
new file mode 100644 (file)
index 0000000..6b440f7
--- /dev/null
@@ -0,0 +1,6 @@
+[
+       {
+               "path": "/user/{name}/hello",
+               "class": "MediaWiki\\Rest\\Handler\\HelloHandler"
+       }
+]
index f367fc2..54e6795 100644 (file)
@@ -143,6 +143,9 @@ if ( $wgScript === false ) {
 if ( $wgLoadScript === false ) {
        $wgLoadScript = "$wgScriptPath/load.php";
 }
+if ( $wgRestPath === false ) {
+       $wgRestPath = "$wgScriptPath/rest.php";
+}
 
 if ( $wgArticlePath === false ) {
        if ( $wgUsePathInfo ) {
index 5e99454..368ca48 100644 (file)
@@ -23,7 +23,7 @@ namespace MediaWiki\Storage;
 use Language;
 use MediaWiki\Config\ServiceOptions;
 use WANObjectCache;
-use Wikimedia\Rdbms\LBFactory;
+use Wikimedia\Rdbms\ILBFactory;
 
 /**
  * Service for instantiating BlobStores
@@ -35,7 +35,7 @@ use Wikimedia\Rdbms\LBFactory;
 class BlobStoreFactory {
 
        /**
-        * @var LBFactory
+        * @var ILBFactory
         */
        private $lbFactory;
 
@@ -68,7 +68,7 @@ class BlobStoreFactory {
        ];
 
        public function __construct(
-               LBFactory $lbFactory,
+               ILBFactory $lbFactory,
                WANObjectCache $cache,
                ServiceOptions $options,
                Language $contLang
index 53fe615..0008ef7 100644 (file)
@@ -60,7 +60,7 @@ use SiteStatsUpdate;
 use Title;
 use User;
 use Wikimedia\Assert\Assert;
-use Wikimedia\Rdbms\LBFactory;
+use Wikimedia\Rdbms\ILBFactory;
 use WikiPage;
 
 /**
@@ -132,7 +132,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        private $messageCache;
 
        /**
-        * @var LBFactory
+        * @var ILBFactory
         */
        private $loadbalancerFactory;
 
@@ -268,7 +268,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         * @param JobQueueGroup $jobQueueGroup
         * @param MessageCache $messageCache
         * @param Language $contLang
-        * @param LBFactory $loadbalancerFactory
+        * @param ILBFactory $loadbalancerFactory
         */
        public function __construct(
                WikiPage $wikiPage,
@@ -279,7 +279,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                JobQueueGroup $jobQueueGroup,
                MessageCache $messageCache,
                Language $contLang,
-               LBFactory $loadbalancerFactory
+               ILBFactory $loadbalancerFactory
        ) {
                $this->wikiPage = $wikiPage;
 
index 46f957f..9c2b3e7 100644 (file)
@@ -338,7 +338,12 @@ class PageEditStash {
        public function stashInputText( $text, $textHash ) {
                $textKey = $this->cache->makeKey( 'stashedit', 'text', $textHash );
 
-               return $this->cache->set( $textKey, $text, self::MAX_CACHE_TTL );
+               return $this->cache->set(
+                       $textKey,
+                       $text,
+                       self::MAX_CACHE_TTL,
+                       BagOStuff::WRITE_ALLOW_SEGMENTS
+               );
        }
 
        /**
@@ -388,7 +393,7 @@ class PageEditStash {
         */
        private function getStashKey( Title $title, $contentHash, User $user ) {
                return $this->cache->makeKey(
-                       'stashed-edit-info',
+                       'stashedit-info-v1',
                        md5( $title->getPrefixedDBkey() ),
                        // Account for the edit model/text
                        $contentHash,
@@ -397,29 +402,13 @@ class PageEditStash {
                );
        }
 
-       /**
-        * @param string $hash
-        * @return string
-        */
-       private function getStashParserOutputKey( $hash ) {
-               return $this->cache->makeKey( 'stashed-edit-output', $hash );
-       }
-
        /**
         * @param string $key
         * @return stdClass|bool Object map (pstContent,output,outputID,timestamp,edits) or false
         */
        private function getStashValue( $key ) {
                $stashInfo = $this->cache->get( $key );
-               if ( !is_object( $stashInfo ) ) {
-                       return false;
-               }
-
-               $parserOutputKey = $this->getStashParserOutputKey( $stashInfo->outputID );
-               $parserOutput = $this->cache->get( $parserOutputKey );
-               if ( $parserOutput instanceof ParserOutput ) {
-                       $stashInfo->output = $parserOutput;
-
+               if ( is_object( $stashInfo ) && $stashInfo->output instanceof ParserOutput ) {
                        return $stashInfo;
                }
 
@@ -459,23 +448,14 @@ class PageEditStash {
                }
 
                // Store what is actually needed and split the output into another key (T204742)
-               $parserOutputID = md5( $key );
                $stashInfo = (object)[
                        'pstContent' => $pstContent,
-                       'outputID'   => $parserOutputID,
+                       'output'     => $parserOutput,
                        'timestamp'  => $timestamp,
                        'edits'      => $user->getEditCount()
                ];
 
-               $ok = $this->cache->set( $key, $stashInfo, $ttl );
-               if ( $ok ) {
-                       $ok = $this->cache->set(
-                               $this->getStashParserOutputKey( $parserOutputID ),
-                               $parserOutput,
-                               $ttl
-                       );
-               }
-
+               $ok = $this->cache->set( $key, $stashInfo, $ttl, BagOStuff::WRITE_ALLOW_SEGMENTS );
                if ( $ok ) {
                        // These blobs can waste slots in low cardinality memcached slabs
                        $this->pruneExcessStashedEntries( $user, $key );
@@ -494,7 +474,7 @@ class PageEditStash {
                $keyList = $this->cache->get( $key ) ?: [];
                if ( count( $keyList ) >= self::MAX_CACHE_RECENT ) {
                        $oldestKey = array_shift( $keyList );
-                       $this->cache->delete( $oldestKey );
+                       $this->cache->delete( $oldestKey, BagOStuff::WRITE_PRUNE_SEGMENTS );
                }
 
                $keyList[] = $newKey;
index e25f0f0..7246238 100644 (file)
@@ -51,7 +51,7 @@ use Wikimedia\Assert\Assert;
 use Wikimedia\Rdbms\DBConnRef;
 use Wikimedia\Rdbms\DBUnexpectedError;
 use Wikimedia\Rdbms\IDatabase;
-use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\ILoadBalancer;
 use WikiPage;
 
 /**
@@ -87,7 +87,7 @@ class PageUpdater {
        private $derivedDataUpdater;
 
        /**
-        * @var LoadBalancer
+        * @var ILoadBalancer
         */
        private $loadBalancer;
 
@@ -151,7 +151,7 @@ class PageUpdater {
         * @param User $user
         * @param WikiPage $wikiPage
         * @param DerivedPageDataUpdater $derivedDataUpdater
-        * @param LoadBalancer $loadBalancer
+        * @param ILoadBalancer $loadBalancer
         * @param RevisionStore $revisionStore
         * @param SlotRoleRegistry $slotRoleRegistry
         */
@@ -159,7 +159,7 @@ class PageUpdater {
                User $user,
                WikiPage $wikiPage,
                DerivedPageDataUpdater $derivedDataUpdater,
-               LoadBalancer $loadBalancer,
+               ILoadBalancer $loadBalancer,
                RevisionStore $revisionStore,
                SlotRoleRegistry $slotRoleRegistry
        ) {
index 82410cc..e0e14b0 100644 (file)
@@ -36,7 +36,7 @@ use MWException;
 use WANObjectCache;
 use Wikimedia\Assert\Assert;
 use Wikimedia\Rdbms\IDatabase;
-use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\ILoadBalancer;
 
 /**
  * Service for storing and loading Content objects.
@@ -52,7 +52,7 @@ class SqlBlobStore implements IDBAccessObject, BlobStore {
        const TEXT_CACHE_GROUP = 'revisiontext:10';
 
        /**
-        * @var LoadBalancer
+        * @var ILoadBalancer
         */
        private $dbLoadBalancer;
 
@@ -92,7 +92,7 @@ class SqlBlobStore implements IDBAccessObject, BlobStore {
        private $useExternalStore = false;
 
        /**
-        * @param LoadBalancer $dbLoadBalancer A load balancer for acquiring database connections
+        * @param ILoadBalancer $dbLoadBalancer A load balancer for acquiring database connections
         * @param WANObjectCache $cache A cache manager for caching blobs. This can be the local
         *        wiki's default instance even if $wikiId refers to a different wiki, since
         *        makeGlobalKey() is used to constructed a key that allows cached blobs from the
@@ -102,7 +102,7 @@ class SqlBlobStore implements IDBAccessObject, BlobStore {
         * @param bool|string $wikiId The ID of the target wiki database. Use false for the local wiki.
         */
        public function __construct(
-               LoadBalancer $dbLoadBalancer,
+               ILoadBalancer $dbLoadBalancer,
                WANObjectCache $cache,
                $wikiId = false
        ) {
@@ -186,7 +186,7 @@ class SqlBlobStore implements IDBAccessObject, BlobStore {
        }
 
        /**
-        * @return LoadBalancer
+        * @return ILoadBalancer
         */
        private function getDBLoadBalancer() {
                return $this->dbLoadBalancer;
@@ -220,9 +220,6 @@ class SqlBlobStore implements IDBAccessObject, BlobStore {
                        if ( $this->useExternalStore ) {
                                // Store and get the URL
                                $data = ExternalStore::insertToDefault( $data );
-                               if ( !$data ) {
-                                       throw new BlobAccessException( "Failed to store text to external storage" );
-                               }
                                if ( $flags ) {
                                        $flags .= ',';
                                }
index 993e75c..d33b8b8 100644 (file)
@@ -14,7 +14,8 @@
                        "Huji",
                        "Ladsgroup",
                        "Freshman404",
-                       "Alifakoor"
+                       "Alifakoor",
+                       "FarsiNevis"
                ]
        },
        "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:API:Main_page|مستندات]]\n* [[mw:API:FAQ|پرسش‌های متداول]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api فهرست پست الکترونیکی]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce اعلانات رابط برنامه‌نویسی کاربردی]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R ایرادها و درخواست‌ها]\n</div>\n\n<strong>وضعیت:</strong> تمام ویژگی‌هایی که در این صفحه نمایش یافته‌اند باید کار بکنند، ولی رابط برنامه‌نویسی کاربردی کماکان در حال توسعه است، و ممکن است در هر زمان تغییر بکند. به عضویت [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ فهرست پست الکترونیکی mediawiki-api-announce] در بیایید تا از تغییرات باخبر شوید.\n\n<strong>درخواست‌های معیوب:</strong> وقتی درخواست‌های معیوب به رابط برنامه‌نویسی کاربردی فرستاده شوند، یک سرایند اچ‌تی‌تی‌پی با کلید «MediaWiki-API-Erorr» فرستاده می‌شود و بعد هم مقدار سرایند و هم کد خطای بازگردانده شده  هر دو به یک مقدار نسبت داده می‌شوند. برای اطلاعات بیشتر [[mw:API:Errors_and_warnings|API: Errors and warnings]] را ببینید.\n\n<strong>آزمایش:</strong> برای انجام درخواست‌های API آزمایشی [[Special:ApiSandbox]] را ببینید.",
@@ -52,6 +53,8 @@
        "apihelp-compare-param-fromtitle": "عنوان اول برای مقایسه.",
        "apihelp-compare-param-fromid": "شناسه صفحه اول برای مقایسه.",
        "apihelp-compare-param-fromrev": "نسخه اول برای مقایسه.",
+       "apihelp-compare-param-fromcontentmodel": "<kbd>fromslots=main</kbd> را تعیین کنید و در عوض، <var>fromcontentmodel-main</var> را به کار ببر.",
+       "apihelp-compare-param-fromcontentformat": "<kbd>fromslots=main</kbd> را تعیین کن و در عوض، <var>fromcontentformat-main</var> را به کار ببر.",
        "apihelp-compare-param-totitle": "عنوان دوم برای مقایسه.",
        "apihelp-compare-param-toid": "شناسه صفحه دوم برای مقایسه.",
        "apihelp-compare-param-torev": "نسخه دوم برای مقایسه.",
@@ -75,9 +78,9 @@
        "apihelp-edit-param-sectiontitle": "عنوان برای بخش جدید.",
        "apihelp-edit-param-text": "محتوای صفحه.",
        "apihelp-edit-param-summary": "خلاصه را ویرایش کنید. همچنین عنوان بخش را زمانی که $1section=تازه و $1sectiontitle تنظیم نشده‌است.",
-       "apihelp-edit-param-minor": "ویرایش جزئی.",
+       "apihelp-edit-param-minor": "این ویرایش را به‌عنوان «ویرایش جزئی» نشانه‌گذاری کن.",
        "apihelp-edit-param-notminor": "ویرایش غیر جزئی.",
-       "apihelp-edit-param-bot": "عÙ\84اÙ\85ت Ø²Ø¯Ù\86 Ø§Û\8cÙ\86 Ù\88Û\8cراÛ\8cØ´ Ø¨Ù\87 Ø¹Ù\86Ù\88اÙ\86 Ù\88Û\8cراÛ\8cØ´ Ø±Ø¨Ø§Øª.",
+       "apihelp-edit-param-bot": "اÛ\8cÙ\86 Ù\88Û\8cراÛ\8cØ´ Ø±Ø§ Ø¨Ù\87â\80\8cعÙ\86Ù\88اÙ\86 Â«Ù\88Û\8cراÛ\8cØ´ Ø±Ø¨Ø§ØªÂ» Ù\86شاÙ\86Ù\87â\80\8cگذارÛ\8c Ú©Ù\86.",
        "apihelp-edit-param-createonly": "اگر صفحه موجود بود، ویرایش نکن.",
        "apihelp-edit-param-nocreate": "رها کردن خطا در صورتی که صفحه وجود ندارد.",
        "apihelp-edit-param-watch": "افزودن صفحه به فهرست پی‌گیری شما",
        "apihelp-feedcontributions-param-deletedonly": "فقط مشارکت‌های حذف شده نمایش داده شود.",
        "apihelp-feedcontributions-param-toponly": "فقط ویرایش‌هایی که آخرین نسخه‌اند نمایش داده شود.",
        "apihelp-feedcontributions-param-newonly": "فقط نمایش ویرایش‌هایی که تولید‌های صفحه هستند.",
+       "apihelp-feedcontributions-param-hideminor": "ویرایش‌های جزئی را پنهان کن.",
        "apihelp-feedcontributions-param-showsizediff": "نمایش تفاوت حجم تغییرات بین نسخه‌ها.",
        "apihelp-feedcontributions-example-simple": "مشارکت‌های [[کاربر:نمونه]] را برگردان",
        "apihelp-feedrecentchanges-summary": "خوراک تغییرات اخیر را برمی‌گرداند.",
        "apihelp-logout-summary": "خروج به همراه پاک نمودن اطلاعات این نشست",
        "apihelp-logout-example-logout": "خروج کاربر فعلی",
        "apihelp-mergehistory-summary": "ادغام تاریخچه صفحات",
+       "apihelp-mergehistory-param-reason": "علت ادغام تاریخچه",
+       "apihelp-mergehistory-example-merge": "کلّ تاریخچهٔ <kbd>Oldpage</kbd> را در <kbd>Newpage</kbd> ادغام کن.",
        "apihelp-move-summary": "انتقال صفحه",
        "apihelp-move-param-to": "عنوانی که قصد دارید صفحه را به آن نام تغییر دهید.",
        "apihelp-move-param-reason": "دلیل انتقال",
        "apihelp-options-param-reset": "ترجیحات را به مقادیر پیش فرض سایت بازمی گرداند.",
        "apihelp-options-example-reset": "بازنشانی همه تنظیمات.",
        "apihelp-paraminfo-param-helpformat": "ساختار راهنمای رشته‌ها",
+       "apihelp-parse-param-disablepp": "به جایش از <var>$1disablelimitreport</var> استفاده کن.",
        "apihelp-parse-example-page": "تجزیه یک صفحه.",
        "apihelp-parse-example-text": "تجزیه متن ویکی.",
        "apihelp-parse-example-summary": "تجزیه خلاصه.",
        "apihelp-protect-example-protect": "محافظت از صفحه",
        "apihelp-protect-example-unprotect": "خارج ساختن صفحه از حفاظت با تغییر سطح حفاظتی به <kbd>all</kbd>.",
        "apihelp-protect-example-unprotect2": "خارج ساختن صفحه از حفاظت با قراردادن هیچ‌گونه محدودیت‌حفاظتی",
-       "apihelp-purge-param-forcelinkupdate": "بÙ\87â\80\8cرÙ\88زرساÙ\86Û\8c Ø¬Ø¯Ø§Ù\88Ù\84 پیوندها.",
+       "apihelp-purge-param-forcelinkupdate": "رÙ\88زاÙ\85دسازÛ\8c Ø¬Ø¯Ù\88Ù\84â\80\8cÙ\87اÛ\8c پیوندها.",
        "apihelp-purge-param-forcerecursivelinkupdate": "جدول پیوندها را به‌روز رسانی کنید، و جدول‌های پیوندهای هر صفحه‌ای را که از این صفحه به عنوان الگو استفاده می‌کند به‌روز رسانی کنید.",
        "apihelp-query-param-list": "کدام فهرست‌ها دریافت شود.",
        "apihelp-query-param-meta": "کدام فراداده‌ها دریافت شود.",
        "apihelp-query+allcategories-param-prefix": "عنوان همهٔ رده‌ها را که با این مقدار آغاز می‌شود جستجو کنید.",
        "apihelp-query+allcategories-param-limit": "میزان رده‌ها برای بازگرداندن.",
        "apihelp-query+alldeletedrevisions-paraminfo-nonuseronly": "نمی‌تواند همراه <var>$3user</var> به کار رود.",
+       "apihelp-query+allfileusages-paramvalue-prop-title": "عنوان پرونده را درج می‌کند.",
        "apihelp-query+allfileusages-param-limit": "تعداد آیتم‌ها برای بازگرداندن.",
        "apihelp-query+allfileusages-param-dir": "جهتی که باید فهرست شود.",
        "apihelp-query+allfileusages-example-unique": "فهرست پرونده‌های با عنوان یکتا",
index 0bc67bc..c029636 100644 (file)
        "apihelp-delete-param-oldimage": "[[Special:ApiHelp/query+imageinfo|action=query&prop=imageinfo&iiprop=archivename]]에 지정된 바대로 삭제할 오래된 그림의 이름입니다.",
        "apihelp-delete-example-simple": "<kbd>Main Page</kbd>를 삭제합니다.",
        "apihelp-delete-example-reason": "<kbd>Preparing for move</kbd> 라는 이유로 <kbd>Main Page</kbd>를 삭제하기.",
-       "apihelp-disabled-summary": "이 모듈은 해제되었습니다.",
+       "apihelp-disabled-summary": "이 모듈은 비활성화되었습니다.",
        "apihelp-edit-summary": "문서를 만들고 편집합니다.",
        "apihelp-edit-param-title": "편집할 문서의 제목. <var>$1pageid</var>과 같이 사용할 수 없습니다.",
        "apihelp-edit-param-pageid": "편집할 문서의 문서 ID입니다. <var>$1title</var>과 함께 사용할 수 없습니다.",
index be240ca..60ae2f8 100644 (file)
@@ -110,7 +110,9 @@ class BlockManager {
        }
 
        /**
-        * Get the blocks that apply to a user and return the most relevant one.
+        * Get the blocks that apply to a user. If there is only one, return that, otherwise
+        * return a composite block that combines the strictest features of the applicable
+        * blocks.
         *
         * TODO: $user should be UserIdentity instead of User
         *
@@ -143,29 +145,28 @@ class BlockManager {
                }
 
                // User/IP blocking
+               // After this, $blocks is an array of blocks or an empty array
                // TODO: remove dependency on DatabaseBlock
-               $block = DatabaseBlock::newFromTarget( $user, $ip, !$fromReplica );
+               $blocks = DatabaseBlock::newListFromTarget( $user, $ip, !$fromReplica );
 
                // Cookie blocking
-               if ( !$block instanceof AbstractBlock ) {
-                       $block = $this->getBlockFromCookieValue( $user, $request );
+               $cookieBlock = $this->getBlockFromCookieValue( $user, $request );
+               if ( $cookieBlock instanceof AbstractBlock ) {
+                       $blocks[] = $cookieBlock;
                }
 
                // Proxy blocking
-               if ( !$block instanceof AbstractBlock
-                       && $ip !== null
-                       && !in_array( $ip, $this->proxyWhitelist )
-               ) {
+               if ( $ip !== null && !in_array( $ip, $this->proxyWhitelist ) ) {
                        // Local list
                        if ( $this->isLocallyBlockedProxy( $ip ) ) {
-                               $block = new SystemBlock( [
+                               $blocks[] = new SystemBlock( [
                                        'byText' => wfMessage( 'proxyblocker' )->text(),
                                        'reason' => wfMessage( 'proxyblockreason' )->plain(),
                                        'address' => $ip,
                                        'systemBlock' => 'proxy',
                                ] );
                        } elseif ( $isAnon && $this->isDnsBlacklisted( $ip ) ) {
-                               $block = new SystemBlock( [
+                               $blocks[] = new SystemBlock( [
                                        'byText' => wfMessage( 'sorbs' )->text(),
                                        'reason' => wfMessage( 'sorbsreason' )->plain(),
                                        'address' => $ip,
@@ -175,8 +176,7 @@ class BlockManager {
                }
 
                // (T25343) Apply IP blocks to the contents of XFF headers, if enabled
-               if ( !$block instanceof AbstractBlock
-                       && $this->applyIpBlocksToXff
+               if ( $this->applyIpBlocksToXff
                        && $ip !== null
                        && !in_array( $ip, $this->proxyWhitelist )
                ) {
@@ -185,21 +185,15 @@ class BlockManager {
                        $xff = array_diff( $xff, [ $ip ] );
                        // TODO: remove dependency on DatabaseBlock
                        $xffblocks = DatabaseBlock::getBlocksForIPList( $xff, $isAnon, !$fromReplica );
-                       // TODO: remove dependency on DatabaseBlock
-                       $block = DatabaseBlock::chooseBlock( $xffblocks, $xff );
-                       if ( $block instanceof AbstractBlock ) {
-                               # Mangle the reason to alert the user that the block
-                               # originated from matching the X-Forwarded-For header.
-                               $block->setReason( wfMessage( 'xffblockreason', $block->getReason() )->plain() );
-                       }
+                       $blocks = array_merge( $blocks, $xffblocks );
                }
 
-               if ( !$block instanceof AbstractBlock
-                       && $ip !== null
+               // Soft blocking
+               if ( $ip !== null
                        && $isAnon
                        && IP::isInRanges( $ip, $this->softBlockRanges )
                ) {
-                       $block = new SystemBlock( [
+                       $blocks[] = new SystemBlock( [
                                'address' => $ip,
                                'byText' => 'MediaWiki default',
                                'reason' => wfMessage( 'softblockrangesreason', $ip )->plain(),
@@ -208,7 +202,19 @@ class BlockManager {
                        ] );
                }
 
-               return $block;
+               if ( count( $blocks ) > 0 ) {
+                       if ( count( $blocks ) === 1 ) {
+                               $block = $blocks[ 0 ];
+                       } else {
+                               $block = new CompositeBlock( [
+                                       'address' => $ip,
+                                       'originalBlocks' => $blocks,
+                               ] );
+                       }
+                       return $block;
+               }
+
+               return null;
        }
 
        /**
@@ -393,13 +399,23 @@ class BlockManager {
        public function trackBlockWithCookie( User $user ) {
                $block = $user->getBlock();
                $request = $user->getRequest();
-
-               if (
-                       $block &&
-                       $request->getCookie( 'BlockID' ) === null &&
-                       $this->shouldTrackBlockWithCookie( $block, $user->isAnon() )
-               ) {
-                       $this->setBlockCookie( $block, $request->response() );
+               $response = $request->response();
+               $isAnon = $user->isAnon();
+
+               if ( $block && $request->getCookie( 'BlockID' ) === null ) {
+                       if ( $block instanceof CompositeBlock ) {
+                               // TODO: Improve on simply tracking the first trackable block (T225654)
+                               foreach ( $block->getOriginalBlocks() as $originalBlock ) {
+                                       if ( $this->shouldTrackBlockWithCookie( $originalBlock, $isAnon ) ) {
+                                               $this->setBlockCookie( $originalBlock, $response );
+                                               return;
+                                       }
+                               }
+                       } else {
+                               if ( $this->shouldTrackBlockWithCookie( $block, $isAnon ) ) {
+                                       $this->setBlockCookie( $block, $response );
+                               }
+                       }
                }
        }
 
diff --git a/includes/block/CompositeBlock.php b/includes/block/CompositeBlock.php
new file mode 100644 (file)
index 0000000..fda1505
--- /dev/null
@@ -0,0 +1,164 @@
+<?php
+/**
+ * Class for blocks composed from multiple blocks.
+ *
+ * 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
+ */
+
+namespace MediaWiki\Block;
+
+use IContextSource;
+use Title;
+
+/**
+ * Multiple Block class.
+ *
+ * Multiple blocks exist to enforce restrictions from more than one block, if several
+ * blocks apply to a user/IP. Multiple blocks are created temporarily on enforcement.
+ *
+ * @since 1.34
+ */
+class CompositeBlock extends AbstractBlock {
+       /** @var AbstractBlock[] */
+       private $originalBlocks;
+
+       /**
+        * Create a new block with specified parameters on a user, IP or IP range.
+        *
+        * @param array $options Parameters of the block:
+        *     originalBlocks Block[] Blocks that this block is composed from
+        */
+       function __construct( $options = [] ) {
+               parent::__construct( $options );
+
+               $defaults = [
+                       'originalBlocks' => [],
+               ];
+
+               $options += $defaults;
+
+               $this->originalBlocks = $options[ 'originalBlocks' ];
+
+               $this->setHideName( $this->propHasValue( 'mHideName', true ) );
+               $this->isSitewide( $this->propHasValue( 'isSitewide', true ) );
+               $this->isEmailBlocked( $this->propHasValue( 'mBlockEmail', true ) );
+               $this->isCreateAccountBlocked( $this->propHasValue( 'blockCreateAccount', true ) );
+               $this->isUsertalkEditAllowed( !$this->propHasValue( 'allowUsertalk', false ) );
+       }
+
+       /**
+        * Determine whether any original blocks have a particular property set to a
+        * particular value.
+        *
+        * @param string $prop
+        * @param mixed $value
+        * @return bool At least one block has the property set to the value
+        */
+       private function propHasValue( $prop, $value ) {
+               foreach ( $this->originalBlocks as $block ) {
+                       if ( $block->$prop == $value ) {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
+       /**
+        * Determine whether any original blocks have a particular method returning a
+        * particular value.
+        *
+        * @param string $method
+        * @param mixed $value
+        * @param mixed ...$params
+        * @return bool At least one block has the method returning the value
+        */
+       private function methodReturnsValue( $method, $value, ...$params ) {
+               foreach ( $this->originalBlocks as $block ) {
+                       if ( $block->$method( ...$params ) == $value ) {
+                               return true;
+                       }
+               }
+               return false;
+       }
+
+       /**
+        * Get the original blocks from which this block is composed
+        *
+        * @since 1.34
+        * @return AbstractBlock[]
+        */
+       public function getOriginalBlocks() {
+               return $this->originalBlocks;
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function getPermissionsError( IContextSource $context ) {
+               $params = $this->getBlockErrorParams( $context );
+
+               $msg = $this->isSitewide() ? 'blockedtext' : 'blockedtext-partial';
+
+               array_unshift( $params, $msg );
+
+               return $params;
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function appliesToRight( $right ) {
+               return $this->methodReturnsValue( __FUNCTION__, true, $right );
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function appliesToUsertalk( Title $usertalk = null ) {
+               return $this->methodReturnsValue( __FUNCTION__, true, $usertalk );
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function appliesToTitle( Title $title ) {
+               return $this->methodReturnsValue( __FUNCTION__, true, $title );
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function appliesToNamespace( $ns ) {
+               return $this->methodReturnsValue( __FUNCTION__, true, $ns );
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function appliesToPage( $pageId ) {
+               return $this->methodReturnsValue( __FUNCTION__, true, $pageId );
+       }
+
+       /**
+        * @inheritDoc
+        */
+       public function appliesToPasswordReset() {
+               return $this->methodReturnsValue( __FUNCTION__, true );
+       }
+
+}
index 876a81f..ba08d54 100644 (file)
@@ -1159,26 +1159,40 @@ class DatabaseBlock extends AbstractBlock {
         *     not be the same as the target you gave if you used $vagueTarget!
         */
        public static function newFromTarget( $specificTarget, $vagueTarget = null, $fromMaster = false ) {
+               $blocks = self::newListFromTarget( $specificTarget, $vagueTarget, $fromMaster );
+               return self::chooseMostSpecificBlock( $blocks );
+       }
+
+       /**
+        * This is similar to DatabaseBlock::newFromTarget, but it returns all the relevant blocks.
+        *
+        * @since 1.34
+        * @param string|User|int|null $specificTarget
+        * @param string|User|int|null $vagueTarget
+        * @param bool $fromMaster
+        * @return DatabaseBlock[] Any relevant blocks
+        */
+       public static function newListFromTarget(
+               $specificTarget,
+               $vagueTarget = null,
+               $fromMaster = false
+       ) {
                list( $target, $type ) = self::parseTarget( $specificTarget );
                if ( $type == self::TYPE_ID || $type == self::TYPE_AUTO ) {
-                       return self::newFromID( $target );
-
+                       $block = self::newFromID( $target );
+                       return $block ? [ $block ] : [];
                } elseif ( $target === null && $vagueTarget == '' ) {
                        # We're not going to find anything useful here
                        # Be aware that the == '' check is explicit, since empty values will be
                        # passed by some callers (T31116)
-                       return null;
-
+                       return [];
                } elseif ( in_array(
                        $type,
                        [ self::TYPE_USER, self::TYPE_IP, self::TYPE_RANGE, null ] )
                ) {
-                       $blocks = self::newLoad( $target, $type, $fromMaster, $vagueTarget );
-                       if ( !empty( $blocks ) ) {
-                               return self::chooseMostSpecificBlock( $blocks );
-                       }
+                       return self::newLoad( $target, $type, $fromMaster, $vagueTarget );
                }
-               return null;
+               return [];
        }
 
        /**
index 9cf8e15..76f20f0 100644 (file)
@@ -159,7 +159,7 @@ class ExternalStore {
         *
         * @param string $data
         * @param array $params Map of ExternalStoreMedium::__construct context parameters
-        * @return string|bool The URL of the stored data item, or false on error
+        * @return string The URL of the stored data item
         * @throws MWException
         */
        public static function insertToDefault( $data, array $params = [] ) {
@@ -177,7 +177,7 @@ class ExternalStore {
         * @param array $tryStores Refer to $wgDefaultExternalStore
         * @param string $data
         * @param array $params Map of ExternalStoreMedium::__construct context parameters
-        * @return string|bool The URL of the stored data item, or false on error
+        * @return string The URL of the stored data item
         * @throws MWException
         */
        public static function insertWithFallback( array $tryStores, $data, array $params = [] ) {
@@ -245,7 +245,7 @@ class ExternalStore {
        /**
         * @param string $data
         * @param string $wiki
-        * @return string|bool The URL of the stored data item, or false on error
+        * @return string The URL of the stored data item
         * @throws MWException
         */
        public static function insertToForeignDefault( $data, $wiki ) {
index cac16b6..15bc3e0 100644 (file)
@@ -92,6 +92,9 @@ class ExternalStoreDB extends ExternalStoreMedium {
                return $ret;
        }
 
+       /**
+        * @inheritDoc
+        */
        public function store( $location, $data ) {
                $dbw = $this->getMaster( $location );
                $dbw->insert( $this->getTable( $dbw ),
@@ -105,6 +108,9 @@ class ExternalStoreDB extends ExternalStoreMedium {
                return "DB://$location/$id";
        }
 
+       /**
+        * @inheritDoc
+        */
        public function isReadOnly( $location ) {
                $lb = $this->getLoadBalancer( $location );
                $domainId = $this->getDomainId( $lb->getServerInfo( $lb->getWriterIndex() ) );
index 30c742d..7414f23 100644 (file)
@@ -73,29 +73,32 @@ class ExternalStoreMwstore extends ExternalStoreMedium {
                return $blobs;
        }
 
+       /**
+        * @inheritDoc
+        */
        public function store( $backend, $data ) {
                $be = FileBackendGroup::singleton()->get( $backend );
-               if ( $be instanceof FileBackend ) {
-                       // Get three random base 36 characters to act as shard directories
-                       $rand = Wikimedia\base_convert( mt_rand( 0, 46655 ), 10, 36, 3 );
-                       // Make sure ID is roughly lexicographically increasing for performance
-                       $id = str_pad( UIDGenerator::newTimestampedUID128( 32 ), 26, '0', STR_PAD_LEFT );
-                       // Segregate items by wiki ID for the sake of bookkeeping
-                       // @FIXME: this does not include the domain for b/c but it ideally should
-                       $wiki = $this->params['wiki'] ?? wfWikiID();
+               // Get three random base 36 characters to act as shard directories
+               $rand = Wikimedia\base_convert( mt_rand( 0, 46655 ), 10, 36, 3 );
+               // Make sure ID is roughly lexicographically increasing for performance
+               $id = str_pad( UIDGenerator::newTimestampedUID128( 32 ), 26, '0', STR_PAD_LEFT );
+               // Segregate items by wiki ID for the sake of bookkeeping
+               // @FIXME: this does not include the domain for b/c but it ideally should
+               $wiki = $this->params['wiki'] ?? wfWikiID();
 
-                       $url = $be->getContainerStoragePath( 'data' ) . '/' . rawurlencode( $wiki );
-                       $url .= ( $be instanceof FSFileBackend )
-                               ? "/{$rand[0]}/{$rand[1]}/{$rand[2]}/{$id}" // keep directories small
-                               : "/{$rand[0]}/{$rand[1]}/{$id}"; // container sharding is only 2-levels
+               $url = $be->getContainerStoragePath( 'data' ) . '/' . rawurlencode( $wiki );
+               $url .= ( $be instanceof FSFileBackend )
+                       ? "/{$rand[0]}/{$rand[1]}/{$rand[2]}/{$id}" // keep directories small
+                       : "/{$rand[0]}/{$rand[1]}/{$id}"; // container sharding is only 2-levels
 
-                       $be->prepare( [ 'dir' => dirname( $url ), 'noAccess' => 1, 'noListing' => 1 ] );
-                       if ( $be->create( [ 'dst' => $url, 'content' => $data ] )->isOK() ) {
-                               return $url;
-                       }
-               }
+               $be->prepare( [ 'dir' => dirname( $url ), 'noAccess' => 1, 'noListing' => 1 ] );
+               $status = $be->create( [ 'dst' => $url, 'content' => $data ] );
 
-               return false;
+               if ( $status->isOK() ) {
+                       return $url;
+               } else {
+                       throw new MWException( __METHOD__ . ": operation failed: $status" );
+               }
        }
 
        public function isReadOnly( $backend ) {
index c008333..567fb10 100644 (file)
@@ -165,6 +165,15 @@ class CliInstaller extends Installer {
         * Main entry point.
         */
        public function execute() {
+               // If APC is available, use that as the MainCacheType, instead of nothing.
+               // This is hacky and should be consolidated with WebInstallerOptions.
+               // This is here instead of in __construct(), because it should run run after
+               // doEnvironmentChecks(), which populates '_Caches'.
+               if ( count( $this->getVar( '_Caches' ) ) ) {
+                       // We detected a CACHE_ACCEL implementation, use it.
+                       $this->setVar( '_MainCacheType', 'accel' );
+               }
+
                $vars = Installer::getExistingLocalSettings();
                if ( $vars ) {
                        $this->showStatusMessage(
index d676a04..0f78c62 100644 (file)
@@ -57,8 +57,8 @@
        "config-env-bad": "جرى التحقق من البيئة. لا يمكنك تنصيب ميدياويكي.",
        "config-env-php": "بي إتش بي $1 مثبت.",
        "config-env-hhvm": "نصبت HHVM $1.",
-       "config-unicode-using-intl": "باستخدام [https://pecl.php.net/intl امتداد intl PECL] لتسوية يونيكود.",
-       "config-unicode-pure-php-warning": "<strong>تحذير:</strong> لا يتوفر [https://pecl.php.net/intl امتداد intl PECL] للتعامل مع تطبيع يونيكود; حيث يتراجع لإبطاء تنفيذ Pure-Pure;\nإذا كنت تدير موقعا عالي الزيارات، فتجب عليك القراءة قليلا في [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations تطبيع يونيكود].",
+       "config-unicode-using-intl": "باستخدام [https://php.net/manual/en/book.intl.php امتداد PHP intl] لتسوية يونيكود.",
+       "config-unicode-pure-php-warning": "<strong>تحذير:</strong> لا يتوفر [https://php.net/manual/en/book.intl.php امتداد PHP intl] للتعامل مع تطبيع يونيكود; حيث يتراجع لإبطاء تنفيذ Pure-Pure;\nإذا كنت تدير موقعا عالي الزيارات، فتجب عليك القراءة قليلا في [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations تطبيع يونيكود].",
        "config-unicode-update-warning": "<strong>تحذير:</strong> يستخدم الإصدار المثبت من برنامج تطبيع نظام يونيكود إصدارًا قديما من مكتبة [http://site.icu-project.org/ مشروع ICU];\nتجب عليك [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations الترقية] إذا كنت مهتما باستخدام يونيكود.",
        "config-no-db": "لا يمكن العثور على مشغل قاعدة بيانات مناسب! تحتاج إلى تثبيت مشغل قاعدة بيانات PHP، \n{{PLURAL:$2|نوع قاعدة البيانات التالي مدعوم|أنواع قاعدة البيانات التالية مدعومة}} البيانات التالية مدعومة: $1.\n\nإذا قمت بتجميع PHP بنفسك، فقم بتكوينها مع تمكين عميل قاعدة البيانات، على سبيل المثال، باستخدام <code>./configure --with-mysqli</code>.\nإذا قمت بتثبيت PHP من حزمة Debian أو Ubuntu، فستحتاج أيضا إلى تثبيت، على سبيل المثال، حزمة <code>php-mysql</code>.",
        "config-outdated-sqlite": "<strong>تحذير:</strong> لديك SQLite $2، وهو أقل من الحد الأدنى المطلوب للنسخة $1، SQLite سوف يكون غير متوفر.",
index 80e063e..4146ce4 100644 (file)
@@ -53,8 +53,8 @@
        "config-env-bad": "Асяродзьдзе было праверанае.\nУсталяваньне MediaWiki немагчымае.",
        "config-env-php": "Усталяваны PHP $1.",
        "config-env-hhvm": "HHVM $1 усталяваная.",
-       "config-unicode-using-intl": "Выкарыстоўваецца [https://pecl.php.net/intl intl пашырэньне з PECL] для Unicode-нармалізацыі",
-       "config-unicode-pure-php-warning": "'''Папярэджаньне''': [https://pecl.php.net/intl Пашырэньне intl з PECL] — ня слушнае для Unicode-нармалізацыі, цяпер выкарыстоўваецца марудная PHP-рэалізацыя.\nКалі ў Вас сайт з высокай наведвальнасьцю, раім пачытаць пра [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-нармалізацыю].",
+       "config-unicode-using-intl": "Выкарыстоўваецца [https://php.net/manual/en/book.intl.php PHP-пашырэньне intl] для Unicode-нармалізацыі.",
+       "config-unicode-pure-php-warning": "<strong>Папярэджаньне</strong>: [https://php.net/manual/en/book.intl.php PHP-пашырэньне intl] — ня слушнае для Unicode-нармалізацыі, цяпер выкарыстоўваецца марудная PHP-рэалізацыя.\nКалі ў вас сайт з высокай наведвальнасьцю, раім пачытаць пра [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations Unicode-нармалізацыю].",
        "config-unicode-update-warning": "'''Папярэджаньне''': усталяваная вэрсія бібліятэкі для Unicode-нармалізацыі выкарыстоўвае састарэлую вэрсію бібліятэкі з [http://site.icu-project.org/ праекту ICU].\nРаім [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations абнавіць], калі ваш сайт будзе працаваць з Unicode.",
        "config-no-db": "Немагчыма знайсьці адпаведны драйвэр базы зьвестак. Вам неабходна ўсталяваць драйвэр базы зьвестак для PHP.\n{{PLURAL:$2|Падтрымліваецца наступны тып базы|Падтрымліваюцца наступныя тыпы базаў}} зьвестак: $1.\n\nКалі вы скампілявалі PHP самастойна, зьмяніце канфігурацыю, каб уключыць кліента базы зьвестак, напрыклад, з дапамогай <code>./configure --with-mysqli</code>.\nКалі вы ўсталявалі PHP з пакунку Debian або Ubuntu, тады вам трэба дадаткова ўсталяваць, напрыклад, пакунак <code>php-mysql</code>.",
        "config-outdated-sqlite": "<strong>Папярэджаньне</strong>: усталяваны SQLite $2, у той час, калі мінімальная сумяшчальная вэрсія — $1. SQLite ня будзе даступны.",
index 8f62c4f..62f5eb5 100644 (file)
@@ -12,7 +12,8 @@
                        "Seb35",
                        "Ilimanaq29",
                        "Dvorapa",
-                       "Patriccck"
+                       "Patriccck",
+                       "Tchoř"
                ]
        },
        "config-desc": "Instalační program pro MediaWiki",
@@ -58,8 +59,8 @@
        "config-env-bad": "Prostředí bylo zkontrolováno.\nMediaWiki nelze nainstalovat.",
        "config-env-php": "Je nainstalováno PHP $1.",
        "config-env-hhvm": "Je nainstalováno HHVM $1.",
-       "config-unicode-using-intl": "Pro normalizaci Unicode se používá [https://pecl.php.net/intl PECL rozšíření intl].",
-       "config-unicode-pure-php-warning": "<strong>Upozornění:</strong> Není dostupné [https://pecl.php.net/intl PECL rozšíření intl] pro normalizaci Unicode, bude se využívat pomalá implementace v čistém PHP.\nPokud provozujete wiki s velkou návštěvností, měli byste si přečíst něco o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizaci Unicode].",
+       "config-unicode-using-intl": "Pro normalizaci Unicode se používá [https://php.net/manual/en/book.intl.php rozšíření PHP intl].",
+       "config-unicode-pure-php-warning": "<strong>Upozornění:</strong> Není dostupné [https://php.net/manual/en/book.intl.php rozšíření PHP intl] pro normalizaci Unicode, bude se využívat pomalá implementace v čistém PHP.\nPokud provozujete wiki s velkou návštěvností, měli byste si přečíst o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizaci Unicode].",
        "config-unicode-update-warning": "<strong>Upozornění:</strong> Nainstalovaná verze vrstvy pro normalizaci Unicode používá starší verzi knihovny [http://site.icu-project.org/ projektu ICU].\nPokud vám aspoň trochu záleží na používání Unicode, měli byste [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ji aktualizovat].",
        "config-no-db": "Nepodařilo se nalézt vhodný databázový ovladač! Musíte nainstalovat databázový ovladač pro PHP.\n{{PLURAL:$2|Je podporován následující typ databáze|Jsou podporovány následující typy databází}}: $1.\n\nPokud jste si PHP přeložili sami, překonfigurujte ho se zapnutým databázovým klientem, například pomocí <code>./configure --with-mysqli</code>.\nPokud jste PHP nainstalovali z balíčku Debian či Ubuntu, potřebujete nainstalovat také modul <code>php-mysql</code>.",
        "config-outdated-sqlite": "<strong>Upozornění:</strong> Máte SQLite $2, které je starší než minimálně vyžadovaná verze $1. SQLite nebude dostupné.",
        "config-profile-no-anon": "Vyžadována registrace uživatelů",
        "config-profile-fishbowl": "Editace jen pro vybrané",
        "config-profile-private": "Soukromá wiki",
-       "config-profile-help": "Wiki fungují nejlépe, když je necháte editovat co největším možným počtem lidí.\nV MediaWiki můžete snadno kontrolovat poslední změny a vracet zpět libovolnou škodu způsobenou hloupými nebo zlými uživateli.\n\nMnoho lidí však zjistilo, že je MediaWiki užitečné v širokém spektru rolí a někdy není snadné všechny přesvědčit o výhodách wikizvyklostí.\nTakže si můžete vybrat.\n\nModel '''{{int:config-profile-wiki}}''' dovoluje editovat všem, aniž by se museli přihlašovat.\nNa wiki, kde je '''{{int:config-profile-no-anon}}''', se lépe řídí zodpovědnost, ale může to odradit náhodné přispěvatele.\n\nProfil '''{{int:config-profile-fishbowl}}''' umožňuje schváleným uživatelům editovat, ale veřejnost si může stránky prohlížet včetně jejich historie.\n'''{{int:config-profile-private}}''' dovoluje stránky prohlížet jen schváleným uživatelům, kteří je i mohou editovat.\n\nPo instalaci je možná komplexní konfigurace uživatelských práv; viz [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights odpovídající stránku příručky].",
+       "config-profile-help": "Wiki fungují nejlépe, když je necháte editovat co největším možným počtem lidí.\nV MediaWiki můžete snadno kontrolovat poslední změny a vracet zpět libovolnou škodu způsobenou hloupými nebo zlými uživateli.\n\nMnoho lidí však zjistilo, že je MediaWiki užitečné v širokém spektru rolí a někdy není snadné všechny přesvědčit o výhodách wikizvyklostí.\nTakže si můžete vybrat.\n\nModel '''{{int:config-profile-wiki}}''' dovoluje editovat všem, aniž by se museli přihlašovat.\nNa wiki, kde je '''{{int:config-profile-no-anon}}''', se lépe řídí zodpovědnost, ale může to odradit náhodné přispěvatele.\n\nProfil '''{{int:config-profile-fishbowl}}''' umožňuje schváleným uživatelům editovat, ale veřejnost si může stránky prohlížet včetně jejich historie.\n'''{{int:config-profile-private}}''' dovoluje stránky prohlížet jen schváleným uživatelům, kteří je i mohou editovat.\n\nPo instalaci je možná komplexní konfigurace uživatelských práv; vizte [https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:User_rights odpovídající stránku příručky].",
        "config-license": "Autorská práva a licence:",
        "config-license-none": "Bez patičky s licencí",
        "config-license-cc-by-sa": "Creative Commons Uveďte autora-Zachovejte licenci",
index 623e624..1c69e65 100644 (file)
@@ -77,8 +77,8 @@
        "config-env-bad": "L’environnement a été vérifié.\nVous ne pouvez pas installer MediaWiki.",
        "config-env-php": "PHP $1 est installé.",
        "config-env-hhvm": "HHVM $1 est installé.",
-       "config-unicode-using-intl": "Utilisation de [https://pecl.php.net/intl l’extension PECL intl] pour la normalisation Unicode.",
-       "config-unicode-pure-php-warning": "<strong>Attention :</strong> L’[https://pecl.php.net/intl extension PECL intl] n’est pas disponible pour la normalisation d’Unicode, retour à la version lente implémentée en PHP seulement.\nSi votre site web sera très fréquenté, vous devriez lire ceci : [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ''Unicode normalization''] (en anglais).",
+       "config-unicode-using-intl": "Utilisation de [https://php.net/manual/en/book.intl.php extension intl de PHP] pour la normalisation Unicode.",
+       "config-unicode-pure-php-warning": "<strong>Attention :</strong> L’[https://php.net/manual/en/book.intl.php extension intl de PHP] n’est pas disponible pour la normalisation d’Unicode, retour à la version lente implémentée en PHP seulement.\nSi votre site web sera très fréquenté, vous devriez lire ceci : [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations ''Unicode normalization''] (en anglais).",
        "config-unicode-update-warning": "<strong>Attention :</strong> la version installée du normalisateur Unicode utilise une ancienne version de la bibliothèque logicielle du [http://site.icu-project.org/ ''Projet ICU''].\nVous devriez faire une [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations mise à jour] si vous êtes concerné par l’usage d’Unicode.",
        "config-no-db": "Impossible de trouver un pilote de base de données approprié ! Vous devez installer un pilote de base de données pour PHP. {{PLURAL:$2|Le type suivant|Les types suivants}} de bases de données {{PLURAL:$2|est reconnu|sont reconnus}} : $1.\n\nSi vous avez compilé PHP vous-même, reconfigurez-le avec un client de base de données activé, par exemple en utilisant <code>./configure --with-mysqli</code>.  \nSi vous avez installé PHP depuis un paquet Debian ou Ubuntu, alors vous devrez aussi installer, par exemple, le paquet <code>php-mysql</code>.",
        "config-outdated-sqlite": "<strong>Attention :</strong> vous avez SQLite $2, qui est inférieur à la version minimale requise $1. SQLite sera indisponible.",
index c878979..dd9c2ec 100644 (file)
@@ -68,8 +68,8 @@
        "config-env-bad": "L'ambiente è stato controllato.\nNon è possibile installare MediaWiki.",
        "config-env-php": "PHP $1 è installato.",
        "config-env-hhvm": "HHVM $1 è installato.",
-       "config-unicode-using-intl": "Usa [https://pecl.php.net/intl l'estensione PECL intl] per la normalizzazione Unicode.",
-       "config-unicode-pure-php-warning": "'''Attenzione:''' [https://pecl.php.net/intl l'estensione PECL intl] non è disponibile per gestire la normalizzazione Unicode, quindi si torna alla lenta implementazione in PHP puro.\nSe esegui un sito ad alto traffico, dovresti leggere alcune considerazioni sulla [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizzazione Unicode].",
+       "config-unicode-using-intl": "Usa [https://php.net/manual/en/book.intl.php l'estensione PHP intl] per la normalizzazione Unicode.",
+       "config-unicode-pure-php-warning": "<strong>Attenzione:</strong> [https://php.net/manual/en/book.intl.php l'estensione PHP intl] non è disponibile per gestire la normalizzazione Unicode, quindi si torna alla lenta implementazione in PHP puro.\nSe esegui un sito ad alto traffico, dovresti leggere [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizzazione Unicode].",
        "config-unicode-update-warning": "'''Attenzione:''' la versione installata del gestore per la normalizzazione Unicode usa una vecchia versione della libreria [http://site.icu-project.org/ del progetto ICU].\nDovresti [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations aggiornare] se vuoi usare l'Unicode.",
        "config-no-db": "Impossibile trovare un driver adatto per il database! È necessario installare un driver per PHP.\n{{PLURAL:$2|Il seguente formato di database è supportato|I seguenti formati di database sono supportati}}: $1.\n\nSe compili PHP autonomamente, riconfiguralo attivando un client database, per esempio utilizzando <code>./configure --with-mysqli</code>.\nQualora avessi installato PHP per mezzo di un pacchetto Debian o Ubuntu, allora devi installare anche il pacchetto <code>php-mysql</code>.",
        "config-outdated-sqlite": "<strong>Attenzione</strong>: è presente SQLite $2 mentre è richiesta la versione $1, SQLite non sarà disponibile.",
index e8f9078..18dc924 100644 (file)
@@ -70,8 +70,8 @@
        "config-env-bad": "Środowisko oprogramowania zostało sprawdzone.\nNie możesz zainstalować MediaWiki.",
        "config-env-php": "Zainstalowane jest PHP w wersji $1.",
        "config-env-hhvm": "Zainstalowany jest HHVM $1.",
-       "config-unicode-using-intl": "Korzystanie z [https://pecl.php.net/intl rozszerzenia intl PECL] do normalizacji Unicode.",
-       "config-unicode-pure-php-warning": "<strong>Uwaga:<strong> [https://pecl.php.net/intl Rozszerzenie intl PECL] do obsługi normalizacji Unicode nie jest dostępne. Użyta zostanie mało wydajna zwykła implementacja w PHP.\nJeśli prowadzisz stronę o dużym natężeniu ruchu, powinieneś zapoznać się z informacjami o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizacji Unicode].",
+       "config-unicode-using-intl": "Korzystanie z [https://php.net/manual/en/book.intl.php rozszerzenia PHP intl] do normalizacji Unicode.",
+       "config-unicode-pure-php-warning": "<strong>Uwaga:<strong> [https://php.net/manual/en/book.intl.php rozszerzenie PHP intl] do obsługi normalizacji Unicode nie jest dostępne. Użyta zostanie mało wydajna zwykła implementacja w PHP.\nJeśli prowadzisz stronę o dużym natężeniu ruchu, powinieneś zapoznać się z informacjami o [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalizacji Unicode].",
        "config-unicode-update-warning": "<strong>Uwaga:</strong> zainstalowana wersja normalizacji Unicode korzysta z nieaktualnej biblioteki [http://site.icu-project.org/ projektu ICU].\nPowinieneś [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations wykonać aktualizację], jeśli chcesz korzystać w pełni z Unicode.",
        "config-no-db": "Nie można odnaleźć właściwego sterownika bazy danych! Musisz zainstalować sterownik bazy danych dla PHP.\nMożna użyć {{PLURAL:$2|następującego typu bazy|następujących typów baz}} danych: $1.\n\nJeśli skompilowałeś PHP samodzielnie, skonfiguruj go ponownie z włączonym klientem bazy danych, na przykład za pomocą polecenia <code>./configure --with-mysqli</code>.\nJeśli zainstalowałeś PHP jako pakiet Debiana lub Ubuntu, musisz również zainstalować np. moduł <code>php-mysql</code>.",
        "config-outdated-sqlite": "<strong>Ostrzeżenie</strong>: masz SQLite  $2, która jest niższa od minimalnej wymaganej wersji  $1 . SQLite będzie niedostępne.",
index a17ca69..e9bb22b 100644 (file)
@@ -69,8 +69,8 @@
        "config-env-bad": "O ambiente foi verificado.\nVocê não pode instalar o MediaWiki.",
        "config-env-php": "O PHP $1 está instalado.",
        "config-env-hhvm": "O HHVM $1 está instalado.",
-       "config-unicode-using-intl": "Usando a [https://pecl.php.net/intl extensão intl PECL] para a normalização Unicode.",
-       "config-unicode-pure-php-warning": "<strong>Aviso</strong>: A [https://pecl.php.net/intl extensão intl PECL] não está disponível para efetuar a normalização Unicode, abortando e passando para a lenta implementação de PHP puro.\nSe o seu site tem um alto volume de tráfego, informe-se sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalização Unicode].",
+       "config-unicode-using-intl": "Usando a [https://www.php.net/manual/pt_BR/book.intl.php extensão intl PHP] para a normalização Unicode.",
+       "config-unicode-pure-php-warning": "<strong>Aviso</strong>: A [https://www.php.net/manual/pt_BR/book.intl.php extensão intl PHP] não está disponível para efetuar a normalização Unicode, abortando e passando para a lenta implementação de PHP puro.\nSe o seu site tem um alto volume de tráfego, informe-se sobre a [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations normalização Unicode].",
        "config-unicode-update-warning": "<strong>Aviso:</strong> A versão instalada do wrapper de normalização Unicode usa uma versão mais antiga da biblioteca do [http://www.site.icu-project.org/projeto ICU].\nVocê deve [https://www.mediawiki.org/wiki/Special:MyLanguage/Unicode_normalization_considerations atualizar] se você tem quaisquer preocupações com o uso do Unicode.",
        "config-no-db": "Não foi possível encontrar um driver apropriado para a banco de dados! Você precisa instalar um driver de banco de dados para PHP. {{PLURAL:$2|É aceito o seguinte tipo|São aceitos os seguintes tipos}} de banco de dados: $1.\n\nSe você compilou o PHP, reconfigure-o com um cliente de banco de dados ativado, por exemplo, usando <code>./configure --with-mysqli</code>.\nSe instalou o PHP a partir de um pacote Debian ou Ubuntu, então também precisa instalar, por exemplo, o pacote <code>php-mysql</code>.",
        "config-outdated-sqlite": "<strong>Aviso:</strong> você tem o SQLite versão $2, que é menor do que a versão mínima necessária $1. O SQLite não estará disponível.",
index 5a36c65..465fe82 100644 (file)
@@ -45,6 +45,7 @@ class APCBagOStuff extends BagOStuff {
        const KEY_SUFFIX = ':4';
 
        public function __construct( array $params = [] ) {
+               $params['segmentationSize'] = $params['segmentationSize'] ?? INF;
                parent::__construct( $params );
                // The extension serializer is still buggy, unlike "php" and "igbinary"
                $this->nativeSerialize = ( ini_get( 'apc.serializer' ) !== 'default' );
@@ -62,7 +63,7 @@ class APCBagOStuff extends BagOStuff {
                return $value;
        }
 
-       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
                apc_store(
                        $key . self::KEY_SUFFIX,
                        $this->nativeSerialize ? $value : $this->serialize( $value ),
@@ -80,7 +81,7 @@ class APCBagOStuff extends BagOStuff {
                );
        }
 
-       public function delete( $key, $flags = 0 ) {
+       protected function doDelete( $key, $flags = 0 ) {
                apc_delete( $key . self::KEY_SUFFIX );
 
                return true;
@@ -93,12 +94,4 @@ class APCBagOStuff extends BagOStuff {
        public function decr( $key, $value = 1 ) {
                return apc_dec( $key . self::KEY_SUFFIX, $value );
        }
-
-       protected function serialize( $value ) {
-               return $this->isInteger( $value ) ? (int)$value : serialize( $value );
-       }
-
-       protected function unserialize( $value ) {
-               return $this->isInteger( $value ) ? (int)$value : unserialize( $value );
-       }
 }
index 0d9822a..b14ac7c 100644 (file)
@@ -45,6 +45,7 @@ class APCUBagOStuff extends BagOStuff {
        const KEY_SUFFIX = ':4';
 
        public function __construct( array $params = [] ) {
+               $params['segmentationSize'] = $params['segmentationSize'] ?? INF;
                parent::__construct( $params );
                // The extension serializer is still buggy, unlike "php" and "igbinary"
                $this->nativeSerialize = ( ini_get( 'apc.serializer' ) !== 'default' );
@@ -54,7 +55,7 @@ class APCUBagOStuff extends BagOStuff {
                $casToken = null;
 
                $blob = apcu_fetch( $key . self::KEY_SUFFIX );
-               $value = $this->unserialize( $blob );
+               $value = $this->nativeSerialize ? $blob : $this->unserialize( $blob );
                if ( $value !== false ) {
                        $casToken = $blob; // don't bother hashing this
                }
@@ -62,10 +63,10 @@ class APCUBagOStuff extends BagOStuff {
                return $value;
        }
 
-       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
                return apcu_store(
                        $key . self::KEY_SUFFIX,
-                       $this->serialize( $value ),
+                       $this->nativeSerialize ? $value : $this->serialize( $value ),
                        $exptime
                );
        }
@@ -73,12 +74,12 @@ class APCUBagOStuff extends BagOStuff {
        public function add( $key, $value, $exptime = 0, $flags = 0 ) {
                return apcu_add(
                        $key . self::KEY_SUFFIX,
-                       $this->serialize( $value ),
+                       $this->nativeSerialize ? $value : $this->serialize( $value ),
                        $exptime
                );
        }
 
-       public function delete( $key, $flags = 0 ) {
+       protected function doDelete( $key, $flags = 0 ) {
                apcu_delete( $key . self::KEY_SUFFIX );
 
                return true;
@@ -101,20 +102,4 @@ class APCUBagOStuff extends BagOStuff {
                        return false;
                }
        }
-
-       protected function serialize( $value ) {
-               if ( $this->nativeSerialize ) {
-                       return $value;
-               }
-
-               return $this->isInteger( $value ) ? (int)$value : serialize( $value );
-       }
-
-       protected function unserialize( $value ) {
-               if ( $this->nativeSerialize ) {
-                       return $value;
-               }
-
-               return $this->isInteger( $value ) ? (int)$value : unserialize( $value );
-       }
 }
index 321476b..50441c5 100644 (file)
@@ -50,8 +50,14 @@ use Wikimedia\WaitConditionLoop;
  * For any given instance, methods like lock(), unlock(), merge(), and set() with WRITE_SYNC
  * should semantically operate over its entire access scope; any nodes/threads in that scope
  * should serialize appropriately when using them. Likewise, a call to get() with READ_LATEST
- * from one node in its access scope should reflect the prior changes of any other node its access
- * scope. Any get() should reflect the changes of any prior set() with WRITE_SYNC.
+ * from one node in its access scope should reflect the prior changes of any other node its
+ * access scope. Any get() should reflect the changes of any prior set() with WRITE_SYNC.
+ *
+ * Subclasses should override the default "segmentationSize" field with an appropriate value.
+ * The value should not be larger than what the storage backend (by default) supports. It also
+ * should be roughly informed by common performance bottlenecks (e.g. values over a certain size
+ * having poor scalability). The same goes for the "segmentedValueMaxSize" member, which limits
+ * the maximum size and chunk count (indirectly) of values.
  *
  * @ingroup Cache
  */
@@ -68,6 +74,10 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
        protected $asyncHandler;
        /** @var int Seconds */
        protected $syncTimeout;
+       /** @var int Bytes; chunk size of segmented cache values */
+       protected $segmentationSize;
+       /** @var int Bytes; maximum total size of a segmented cache value */
+       protected $segmentedValueMaxSize;
 
        /** @var bool */
        private $debugMode = false;
@@ -93,6 +103,11 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
        /** Bitfield constants for set()/merge() */
        const WRITE_SYNC = 4; // synchronously write to all locations for replicated stores
        const WRITE_CACHE_ONLY = 8; // Only change state of the in-memory cache
+       const WRITE_ALLOW_SEGMENTS = 16; // Allow partitioning of the value if it is large
+       const WRITE_PRUNE_SEGMENTS = 32; // Delete all partition segments of the value
+
+       /** @var string Component to use for key construction of blob segment keys */
+       const SEGMENT_COMPONENT = 'segment';
 
        /**
         * $params include:
@@ -103,6 +118,12 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
         *   - reportDupes: Whether to emit warning log messages for all keys that were
         *      requested more than once (requires an asyncHandler).
         *   - syncTimeout: How long to wait with WRITE_SYNC in seconds.
+        *   - segmentationSize: The chunk size, in bytes, of segmented values. The value should
+        *      not exceed the maximum size of values in the storage backend, as configured by
+        *      the site administrator.
+        *   - segmentedValueMaxSize: The maximum total size, in bytes, of segmented values.
+        *      This should be configured to a reasonable size give the site traffic and the
+        *      amount of I/O between application and cache servers that the network can handle.
         * @param array $params
         */
        public function __construct( array $params = [] ) {
@@ -119,6 +140,8 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
                }
 
                $this->syncTimeout = $params['syncTimeout'] ?? 3;
+               $this->segmentationSize = $params['segmentationSize'] ?? 8388608; // 8MiB
+               $this->segmentedValueMaxSize = $params['segmentedValueMaxSize'] ?? 67108864; // 64MiB
        }
 
        /**
@@ -180,7 +203,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
        public function get( $key, $flags = 0 ) {
                $this->trackDuplicateKeys( $key );
 
-               return $this->doGet( $key, $flags );
+               return $this->resolveSegments( $key, $this->doGet( $key, $flags ) );
        }
 
        /**
@@ -233,16 +256,112 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         * @return bool Success
         */
-       abstract public function set( $key, $value, $exptime = 0, $flags = 0 );
+       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+               if (
+                       ( $flags & self::WRITE_ALLOW_SEGMENTS ) != self::WRITE_ALLOW_SEGMENTS ||
+                       is_infinite( $this->segmentationSize )
+               ) {
+                       return $this->doSet( $key, $value, $exptime, $flags );
+               }
+
+               $serialized = $this->serialize( $value );
+               $segmentSize = $this->getSegmentationSize();
+               $maxTotalSize = $this->getSegmentedValueMaxSize();
+
+               $size = strlen( $serialized );
+               if ( $size <= $segmentSize ) {
+                       // Since the work of serializing it was already done, just use it inline
+                       return $this->doSet(
+                               $key,
+                               SerializedValueContainer::newUnified( $serialized ),
+                               $exptime,
+                               $flags
+                       );
+               } elseif ( $size > $maxTotalSize ) {
+                       $this->setLastError( "Key $key exceeded $maxTotalSize bytes." );
+
+                       return false;
+               }
+
+               $chunksByKey = [];
+               $segmentHashes = [];
+               $count = intdiv( $size, $segmentSize ) + ( ( $size % $segmentSize ) ? 1 : 0 );
+               for ( $i = 0; $i < $count; ++$i ) {
+                       $segment = substr( $serialized, $i * $segmentSize, $segmentSize );
+                       $hash = sha1( $segment );
+                       $chunkKey = $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $hash );
+                       $chunksByKey[$chunkKey] = $segment;
+                       $segmentHashes[] = $hash;
+               }
+
+               $ok = $this->setMulti( $chunksByKey, $exptime, $flags );
+               if ( $ok ) {
+                       // Only when all segments are stored should the main key be changed
+                       $ok = $this->doSet(
+                               $key,
+                               SerializedValueContainer::newSegmented( $segmentHashes ),
+                               $exptime,
+                               $flags
+                       );
+               }
+
+               return $ok;
+       }
+
+       /**
+        * Set an item
+        *
+        * @param string $key
+        * @param mixed $value
+        * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        * @return bool Success
+        */
+       abstract protected function doSet( $key, $value, $exptime = 0, $flags = 0 );
 
        /**
         * Delete an item
         *
+        * For large values written using WRITE_ALLOW_SEGMENTS, this only deletes the main
+        * segment list key unless WRITE_PRUNE_SEGMENTS is in the flags. While deleting the segment
+        * list key has the effect of functionally deleting the key, it leaves unused blobs in cache.
+        *
         * @param string $key
         * @return bool True if the item was deleted or not found, false on failure
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         */
-       abstract public function delete( $key, $flags = 0 );
+       public function delete( $key, $flags = 0 ) {
+               if ( ( $flags & self::WRITE_PRUNE_SEGMENTS ) != self::WRITE_PRUNE_SEGMENTS ) {
+                       return $this->doDelete( $key, $flags );
+               }
+
+               $mainValue = $this->doGet( $key, self::READ_LATEST );
+               if ( !$this->doDelete( $key, $flags ) ) {
+                       return false;
+               }
+
+               if ( !SerializedValueContainer::isSegmented( $mainValue ) ) {
+                       return true; // no segments to delete
+               }
+
+               $orderedKeys = array_map(
+                       function ( $segmentHash ) use ( $key ) {
+                               return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash );
+                       },
+                       $mainValue->{SerializedValueContainer::SEGMENTED_HASHES}
+               );
+
+               return $this->deleteMulti( $orderedKeys, $flags );
+       }
+
+       /**
+        * Delete an item
+        *
+        * @param string $key
+        * @return bool True if the item was deleted or not found, false on failure
+        * @param int $flags Bitfield of BagOStuff::WRITE_* constants
+        */
+       abstract protected function doDelete( $key, $flags = 0 );
 
        /**
         * Insert an item if it does not already exist
@@ -291,7 +410,10 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
                        $casToken = null; // passed by reference
                        // Get the old value and CAS token from cache
                        $this->clearLastError();
-                       $currentValue = $this->doGet( $key, self::READ_LATEST, $casToken );
+                       $currentValue = $this->resolveSegments(
+                               $key,
+                               $this->doGet( $key, self::READ_LATEST, $casToken )
+                       );
                        if ( $this->getLastError() ) {
                                $this->logger->warning(
                                        __METHOD__ . ' failed due to I/O error on get() for {key}.',
@@ -324,6 +446,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
 
                                return false; // IO error; don't spam retries
                        }
+
                } while ( !$success && --$attempts );
 
                return $success;
@@ -338,7 +461,6 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
         * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         * @return bool Success
-        * @throws Exception
         */
        protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
                if ( !$this->lock( $key, 0 ) ) {
@@ -368,28 +490,40 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
         *
         * If an expiry in the past is given then the key will immediately be expired
         *
+        * For large values written using WRITE_ALLOW_SEGMENTS, this only changes the TTL of the
+        * main segment list key. While lowering the TTL of the segment list key has the effect of
+        * functionally lowering the TTL of the key, it might leave unused blobs in cache for longer.
+        * Raising the TTL of such keys is not effective, since the expiration of a single segment
+        * key effectively expires the entire value.
+        *
         * @param string $key
-        * @param int $expiry TTL or UNIX timestamp
+        * @param int $exptime TTL or UNIX timestamp
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
         * @return bool Success Returns false on failure or if the item does not exist
         * @since 1.28
         */
-       public function changeTTL( $key, $expiry = 0, $flags = 0 ) {
-               $found = false;
+       public function changeTTL( $key, $exptime = 0, $flags = 0 ) {
+               $expiry = $this->convertToExpiry( $exptime );
+               $delete = ( $expiry != 0 && $expiry < $this->getCurrentTime() );
 
-               $ok = $this->merge(
-                       $key,
-                       function ( $cache, $ttl, $currentValue ) use ( &$found ) {
-                               $found = ( $currentValue !== false );
+               if ( !$this->lock( $key, 0 ) ) {
+                       return false;
+               }
+               // Use doGet() to avoid having to trigger resolveSegments()
+               $blob = $this->doGet( $key, self::READ_LATEST );
+               if ( $blob ) {
+                       if ( $delete ) {
+                               $ok = $this->doDelete( $key, $flags );
+                       } else {
+                               $ok = $this->doSet( $key, $blob, $exptime, $flags );
+                       }
+               } else {
+                       $ok = false;
+               }
 
-                               return $currentValue; // nothing is written if this is false
-                       },
-                       $expiry,
-                       1, // 1 attempt
-                       $flags
-               );
+               $this->unlock( $key );
 
-               return ( $ok && $found );
+               return $ok;
        }
 
        /**
@@ -459,7 +593,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
                if ( isset( $this->locks[$key] ) && --$this->locks[$key]['depth'] <= 0 ) {
                        unset( $this->locks[$key] );
 
-                       $ok = $this->delete( "{$key}:lock" );
+                       $ok = $this->doDelete( "{$key}:lock" );
                        if ( !$ok ) {
                                $this->logger->warning(
                                        __METHOD__ . ' failed to release lock for {key}.',
@@ -533,9 +667,25 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
         * @return array
         */
        public function getMulti( array $keys, $flags = 0 ) {
+               $valuesBykey = $this->doGetMulti( $keys, $flags );
+               foreach ( $valuesBykey as $key => $value ) {
+                       // Resolve one blob at a time (avoids too much I/O at once)
+                       $valuesBykey[$key] = $this->resolveSegments( $key, $value );
+               }
+
+               return $valuesBykey;
+       }
+
+       /**
+        * Get an associative array containing the item for each of the keys that have items.
+        * @param string[] $keys List of keys
+        * @param int $flags Bitfield; supports READ_LATEST [optional]
+        * @return array
+        */
+       protected function doGetMulti( array $keys, $flags = 0 ) {
                $res = [];
                foreach ( $keys as $key ) {
-                       $val = $this->get( $key, $flags );
+                       $val = $this->doGet( $key, $flags );
                        if ( $val !== false ) {
                                $res[$key] = $val;
                        }
@@ -546,6 +696,9 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
 
        /**
         * Batch insertion/replace
+        *
+        * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
+        *
         * @param mixed[] $data Map of (key => value)
         * @param int $exptime Either an interval in seconds or a unix timestamp for expiry
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants (since 1.33)
@@ -553,11 +706,13 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
         * @since 1.24
         */
        public function setMulti( array $data, $exptime = 0, $flags = 0 ) {
+               if ( ( $flags & self::WRITE_ALLOW_SEGMENTS ) === self::WRITE_ALLOW_SEGMENTS ) {
+                       throw new InvalidArgumentException( __METHOD__ . ' got WRITE_ALLOW_SEGMENTS' );
+               }
+
                $res = true;
                foreach ( $data as $key => $value ) {
-                       if ( !$this->set( $key, $value, $exptime, $flags ) ) {
-                               $res = false;
-                       }
+                       $res = $this->doSet( $key, $value, $exptime, $flags ) && $res;
                }
 
                return $res;
@@ -565,6 +720,9 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
 
        /**
         * Batch deletion
+        *
+        * This does not support WRITE_ALLOW_SEGMENTS to avoid excessive read I/O
+        *
         * @param string[] $keys List of keys
         * @param int $flags Bitfield of BagOStuff::WRITE_* constants
         * @return bool Success
@@ -573,7 +731,7 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
        public function deleteMulti( array $keys, $flags = 0 ) {
                $res = true;
                foreach ( $keys as $key ) {
-                       $res = $this->delete( $key, $flags ) && $res;
+                       $res = $this->doDelete( $key, $flags ) && $res;
                }
 
                return $res;
@@ -624,6 +782,43 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
                return $newValue;
        }
 
+       /**
+        * Get and reassemble the chunks of blob at the given key
+        *
+        * @param string $key
+        * @param mixed $mainValue
+        * @return string|null|bool The combined string, false if missing, null on error
+        */
+       protected function resolveSegments( $key, $mainValue ) {
+               if ( SerializedValueContainer::isUnified( $mainValue ) ) {
+                       return $this->unserialize( $mainValue->{SerializedValueContainer::UNIFIED_DATA} );
+               }
+
+               if ( SerializedValueContainer::isSegmented( $mainValue ) ) {
+                       $orderedKeys = array_map(
+                               function ( $segmentHash ) use ( $key ) {
+                                       return $this->makeGlobalKey( self::SEGMENT_COMPONENT, $key, $segmentHash );
+                               },
+                               $mainValue->{SerializedValueContainer::SEGMENTED_HASHES}
+                       );
+
+                       $segmentsByKey = $this->doGetMulti( $orderedKeys );
+
+                       $parts = [];
+                       foreach ( $orderedKeys as $segmentKey ) {
+                               if ( isset( $segmentsByKey[$segmentKey] ) ) {
+                                       $parts[] = $segmentsByKey[$segmentKey];
+                               } else {
+                                       return false; // missing segment
+                               }
+                       }
+
+                       return $this->unserialize( implode( '', $parts ) );
+               }
+
+               return $mainValue;
+       }
+
        /**
         * Get the "last error" registered; clearLastError() should be called manually
         * @return int ERR_* constant for the "last error" registry
@@ -732,7 +927,15 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
         * @return bool
         */
        protected function isInteger( $value ) {
-               return ( is_int( $value ) || ctype_digit( $value ) );
+               if ( is_int( $value ) ) {
+                       return true;
+               } elseif ( !is_string( $value ) ) {
+                       return false;
+               }
+
+               $integer = (int)$value;
+
+               return ( $value === (string)$integer );
        }
 
        /**
@@ -784,6 +987,22 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
                return $this->attrMap[$flag] ?? self::QOS_UNKNOWN;
        }
 
+       /**
+        * @return int|float The chunk size, in bytes, of segmented objects (INF for no limit)
+        * @since 1.34
+        */
+       public function getSegmentationSize() {
+               return $this->segmentationSize;
+       }
+
+       /**
+        * @return int|float Maximum total segmented object size in bytes (INF for no limit)
+        * @since 1.34
+        */
+       public function getSegmentedValueMaxSize() {
+               return $this->segmentedValueMaxSize;
+       }
+
        /**
         * Merge the flag maps of one or more BagOStuff objects into a "lowest common denominator" map
         *
@@ -822,4 +1041,22 @@ abstract class BagOStuff implements IExpiringStore, LoggerAwareInterface {
        public function setMockTime( &$time ) {
                $this->wallClockOverride =& $time;
        }
+
+       /**
+        * @param mixed $value
+        * @return string|int String/integer representation
+        * @note Special handling is usually needed for integers so incr()/decr() work
+        */
+       protected function serialize( $value ) {
+               return is_int( $value ) ? $value : serialize( $value );
+       }
+
+       /**
+        * @param string|int $value
+        * @return mixed Original value or false on error
+        * @note Special handling is usually needed for integers so incr()/decr() work
+        */
+       protected function unserialize( $value ) {
+               return $this->isInteger( $value ) ? (int)$value : unserialize( $value );
+       }
 }
index ffe3a4c..575bc58 100644 (file)
@@ -33,15 +33,15 @@ class EmptyBagOStuff extends BagOStuff {
                return false;
        }
 
-       public function add( $key, $value, $exp = 0, $flags = 0 ) {
+       protected function doSet( $key, $value, $exp = 0, $flags = 0 ) {
                return true;
        }
 
-       public function set( $key, $value, $exp = 0, $flags = 0 ) {
+       protected function doDelete( $key, $flags = 0 ) {
                return true;
        }
 
-       public function delete( $key, $flags = 0 ) {
+       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
                return true;
        }
 
@@ -49,6 +49,10 @@ class EmptyBagOStuff extends BagOStuff {
                return false;
        }
 
+       public function incrWithInit( $key, $ttl, $value = 1, $init = 1 ) {
+               return false; // faster
+       }
+
        public function merge( $key, callable $callback, $exptime = 0, $attempts = 10, $flags = 0 ) {
                return true; // faster
        }
index d24f408..016bdfe 100644 (file)
@@ -49,6 +49,7 @@ class HashBagOStuff extends BagOStuff {
         *   - maxKeys : only allow this many keys (using oldest-first eviction)
         */
        function __construct( $params = [] ) {
+               $params['segmentationSize'] = $params['segmentationSize'] ?? INF;
                parent::__construct( $params );
 
                $this->token = microtime( true ) . ':' . mt_rand();
@@ -75,7 +76,7 @@ class HashBagOStuff extends BagOStuff {
                return $this->bag[$key][self::KEY_VAL];
        }
 
-       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
                // Refresh key position for maxCacheKeys eviction
                unset( $this->bag[$key] );
                $this->bag[$key] = [
@@ -94,14 +95,14 @@ class HashBagOStuff extends BagOStuff {
        }
 
        public function add( $key, $value, $exptime = 0, $flags = 0 ) {
-               if ( $this->get( $key ) === false ) {
-                       return $this->set( $key, $value, $exptime, $flags );
+               if ( $this->hasKey( $key ) && !$this->expire( $key ) ) {
+                       return false; // key already set
                }
 
-               return false; // key already set
+               return $this->doSet( $key, $value, $exptime, $flags );
        }
 
-       public function delete( $key, $flags = 0 ) {
+       protected function doDelete( $key, $flags = 0 ) {
                unset( $this->bag[$key] );
 
                return true;
@@ -136,7 +137,7 @@ class HashBagOStuff extends BagOStuff {
                        return false;
                }
 
-               $this->delete( $key );
+               $this->doDelete( $key );
 
                return true;
        }
index 3d6bd16..cfbf2b3 100644 (file)
  *
  * @ingroup Cache
  */
-class MemcachedBagOStuff extends BagOStuff {
-       /** @var MemcachedClient|Memcached */
-       protected $client;
-
+abstract class MemcachedBagOStuff extends BagOStuff {
        function __construct( array $params ) {
                parent::__construct( $params );
 
                $this->attrMap[self::ATTR_SYNCWRITES] = self::QOS_SYNCWRITES_BE; // unreliable
+               $this->segmentationSize = $params['maxPreferedKeySize'] ?? 917504; // < 1MiB
        }
 
        /**
@@ -50,55 +48,6 @@ class MemcachedBagOStuff extends BagOStuff {
                ];
        }
 
-       protected function doGet( $key, $flags = 0, &$casToken = null ) {
-               return $this->client->get( $this->validateKeyEncoding( $key ), $casToken );
-       }
-
-       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
-               return $this->client->set( $this->validateKeyEncoding( $key ), $value,
-                       $this->fixExpiry( $exptime ) );
-       }
-
-       protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
-               return $this->client->cas( $casToken, $this->validateKeyEncoding( $key ),
-                       $value, $this->fixExpiry( $exptime ) );
-       }
-
-       public function delete( $key, $flags = 0 ) {
-               return $this->client->delete( $this->validateKeyEncoding( $key ) );
-       }
-
-       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
-               return $this->client->add( $this->validateKeyEncoding( $key ), $value,
-                       $this->fixExpiry( $exptime ) );
-       }
-
-       public function incr( $key, $value = 1 ) {
-               $n = $this->client->incr( $this->validateKeyEncoding( $key ), $value );
-
-               return ( $n !== false && $n !== null ) ? $n : false;
-       }
-
-       public function decr( $key, $value = 1 ) {
-               $n = $this->client->decr( $this->validateKeyEncoding( $key ), $value );
-
-               return ( $n !== false && $n !== null ) ? $n : false;
-       }
-
-       public function changeTTL( $key, $exptime = 0, $flags = 0 ) {
-               return $this->client->touch( $this->validateKeyEncoding( $key ),
-                       $this->fixExpiry( $exptime ) );
-       }
-
-       /**
-        * Get the underlying client object. This is provided for debugging
-        * purposes.
-        * @return MemcachedClient|Memcached
-        */
-       public function getClient() {
-               return $this->client;
-       }
-
        /**
         * Construct a cache key.
         *
index 937ca55..eecf7ec 100644 (file)
@@ -278,6 +278,23 @@ class MemcachedClient {
        }
 
        // }}}
+
+       /**
+        * @param mixed $value
+        * @return string|integer
+        */
+       public function serialize( $value ) {
+               return serialize( $value );
+       }
+
+       /**
+        * @param string $value
+        * @return mixed
+        */
+       public function unserialize( $value ) {
+               return unserialize( $value );
+       }
+
        // {{{ add()
 
        /**
@@ -503,7 +520,8 @@ class MemcachedClient {
 
                if ( $this->_debug ) {
                        foreach ( $val as $k => $v ) {
-                               $this->_debugprint( sprintf( "MemCache: sock %s got %s", serialize( $sock ), $k ) );
+                               $this->_debugprint(
+                                       sprintf( "MemCache: sock %s got %s", $this->serialize( $sock ), $k ) );
                        }
                }
 
@@ -1018,7 +1036,7 @@ class MemcachedClient {
                                         * yet read "END"), these 2 calls would collide.
                                         */
                                        if ( $flags & self::SERIALIZED ) {
-                                               $ret[$rkey] = unserialize( $ret[$rkey] );
+                                               $ret[$rkey] = $this->unserialize( $ret[$rkey] );
                                        } elseif ( $flags & self::INTVAL ) {
                                                $ret[$rkey] = intval( $ret[$rkey] );
                                        }
@@ -1072,7 +1090,7 @@ class MemcachedClient {
                if ( is_int( $val ) ) {
                        $flags |= self::INTVAL;
                } elseif ( !is_scalar( $val ) ) {
-                       $val = serialize( $val );
+                       $val = $this->serialize( $val );
                        $flags |= self::SERIALIZED;
                        if ( $this->_debug ) {
                                $this->_debugprint( sprintf( "client: serializing data as it is not scalar" ) );
index db94503..43cebd3 100644 (file)
@@ -27,6 +27,8 @@
  * @ingroup Cache
  */
 class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
+       /** @var Memcached */
+       protected $client;
 
        /**
         * Available parameters are:
@@ -93,24 +95,22 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                $this->client->setOption( Memcached::OPT_LIBKETAMA_COMPATIBLE, true );
 
                // Set the serializer
-               switch ( $params['serializer'] ) {
-                       case 'php':
-                               $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP );
-                               break;
-                       case 'igbinary':
-                               if ( !Memcached::HAVE_IGBINARY ) {
-                                       throw new InvalidArgumentException(
-                                               __CLASS__ . ': the igbinary extension is not available ' .
-                                               'but igbinary serialization was requested.'
-                                       );
-                               }
-                               $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY );
-                               break;
-                       default:
+               $ok = false;
+               if ( $params['serializer'] === 'php' ) {
+                       $ok = $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_PHP );
+               } elseif ( $params['serializer'] === 'igbinary' ) {
+                       if ( !Memcached::HAVE_IGBINARY ) {
                                throw new InvalidArgumentException(
-                                       __CLASS__ . ': invalid value for serializer parameter'
+                                       __CLASS__ . ': the igbinary extension is not available ' .
+                                       'but igbinary serialization was requested.'
                                );
+                       }
+                       $ok = $this->client->setOption( Memcached::OPT_SERIALIZER, Memcached::SERIALIZER_IGBINARY );
+               }
+               if ( !$ok ) {
+                       throw new InvalidArgumentException( __CLASS__ . ': invalid serializer parameter' );
                }
+
                $servers = [];
                foreach ( $params['servers'] as $host ) {
                        if ( preg_match( '/^\[(.+)\]:(\d+)$/', $host, $m ) ) {
@@ -138,9 +138,6 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                return $params;
        }
 
-       /**
-        * @suppress PhanTypeNonVarPassByRef
-        */
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
                $this->debug( "get($key)" );
                if ( defined( Memcached::class . '::GET_EXTENDED' ) ) { // v3.0.0
@@ -160,9 +157,13 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                return $result;
        }
 
-       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "set($key)" );
-               $result = parent::set( $key, $value, $exptime, $flags = 0 );
+               $result = $this->client->set(
+                       $this->validateKeyEncoding( $key ),
+                       $value,
+                       $this->fixExpiry( $exptime )
+               );
                if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTSTORED ) {
                        // "Not stored" is always used as the mcrouter response with AllAsyncRoute
                        return true;
@@ -172,12 +173,14 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
        protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "cas($key)" );
-               return $this->checkResult( $key, parent::cas( $casToken, $key, $value, $exptime, $flags ) );
+               $result = $this->client->cas( $casToken, $this->validateKeyEncoding( $key ),
+                       $value, $this->fixExpiry( $exptime ) );
+               return $this->checkResult( $key, $result );
        }
 
-       public function delete( $key, $flags = 0 ) {
+       protected function doDelete( $key, $flags = 0 ) {
                $this->debug( "delete($key)" );
-               $result = parent::delete( $key );
+               $result = $this->client->delete( $this->validateKeyEncoding( $key ) );
                if ( $result === false && $this->client->getResultCode() === Memcached::RES_NOTFOUND ) {
                        // "Not found" is counted as success in our interface
                        return true;
@@ -187,7 +190,12 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
 
        public function add( $key, $value, $exptime = 0, $flags = 0 ) {
                $this->debug( "add($key)" );
-               return $this->checkResult( $key, parent::add( $key, $value, $exptime ) );
+               $result = $this->client->add(
+                       $this->validateKeyEncoding( $key ),
+                       $value,
+                       $this->fixExpiry( $exptime )
+               );
+               return $this->checkResult( $key, $result );
        }
 
        public function incr( $key, $value = 1 ) {
@@ -242,7 +250,7 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                return $result;
        }
 
-       public function getMulti( array $keys, $flags = 0 ) {
+       public function doGetMulti( array $keys, $flags = 0 ) {
                $this->debug( 'getMulti(' . implode( ', ', $keys ) . ')' );
                foreach ( $keys as $key ) {
                        $this->validateKeyEncoding( $key );
@@ -260,9 +268,55 @@ class MemcachedPeclBagOStuff extends MemcachedBagOStuff {
                return $this->checkResult( false, $result );
        }
 
-       public function changeTTL( $key, $expiry = 0, $flags = 0 ) {
+       public function deleteMulti( array $keys, $flags = 0 ) {
+               $this->debug( 'deleteMulti(' . implode( ', ', $keys ) . ')' );
+               foreach ( $keys as $key ) {
+                       $this->validateKeyEncoding( $key );
+               }
+               $result = $this->client->deleteMulti( $keys ) ?: [];
+               $ok = true;
+               foreach ( $result as $code ) {
+                       if ( !in_array( $code, [ true, Memcached::RES_NOTFOUND ], true ) ) {
+                               // "Not found" is counted as success in our interface
+                               $ok = false;
+                       }
+               }
+               return $this->checkResult( false, $ok );
+       }
+
+       public function changeTTL( $key, $exptime = 0, $flags = 0 ) {
                $this->debug( "touch($key)" );
-               $result = $this->client->touch( $key, $expiry );
+               $result = $this->client->touch( $key, $exptime );
                return $this->checkResult( $key, $result );
        }
+
+       protected function serialize( $value ) {
+               if ( is_int( $value ) ) {
+                       return $value;
+               }
+
+               $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
+               if ( $serializer === Memcached::SERIALIZER_PHP ) {
+                       return serialize( $value );
+               } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
+                       return igbinary_serialize( $value );
+               }
+
+               throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." );
+       }
+
+       protected function unserialize( $value ) {
+               if ( $this->isInteger( $value ) ) {
+                       return (int)$value;
+               }
+
+               $serializer = $this->client->getOption( Memcached::OPT_SERIALIZER );
+               if ( $serializer === Memcached::SERIALIZER_PHP ) {
+                       return unserialize( $value );
+               } elseif ( $serializer === Memcached::SERIALIZER_IGBINARY ) {
+                       return igbinary_unserialize( $value );
+               }
+
+               throw new UnexpectedValueException( __METHOD__ . ": got serializer '$serializer'." );
+       }
 }
index 8f190c3..ea73cba 100644 (file)
@@ -27,6 +27,9 @@
  * @ingroup Cache
  */
 class MemcachedPhpBagOStuff extends MemcachedBagOStuff {
+       /** @var MemcachedClient */
+       protected $client;
+
        /**
         * Available parameters are:
         *   - servers:             The list of IP:port combinations holding the memcached servers.
@@ -51,11 +54,73 @@ class MemcachedPhpBagOStuff extends MemcachedBagOStuff {
                $this->client->set_debug( $debug );
        }
 
-       public function getMulti( array $keys, $flags = 0 ) {
+       protected function doGet( $key, $flags = 0, &$casToken = null ) {
+               $casToken = null;
+
+               return $this->client->get( $this->validateKeyEncoding( $key ), $casToken );
+       }
+
+       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
+               return $this->client->set(
+                       $this->validateKeyEncoding( $key ),
+                       $value,
+                       $this->fixExpiry( $exptime )
+               );
+       }
+
+       protected function doDelete( $key, $flags = 0 ) {
+               return $this->client->delete( $this->validateKeyEncoding( $key ) );
+       }
+
+       public function add( $key, $value, $exptime = 0, $flags = 0 ) {
+               return $this->client->add(
+                       $this->validateKeyEncoding( $key ),
+                       $value,
+                       $this->fixExpiry( $exptime )
+               );
+       }
+
+       protected function cas( $casToken, $key, $value, $exptime = 0, $flags = 0 ) {
+               return $this->client->cas(
+                       $casToken,
+                       $this->validateKeyEncoding( $key ),
+                       $value,
+                       $this->fixExpiry( $exptime )
+               );
+       }
+
+       public function incr( $key, $value = 1 ) {
+               $n = $this->client->incr( $this->validateKeyEncoding( $key ), $value );
+
+               return ( $n !== false && $n !== null ) ? $n : false;
+       }
+
+       public function decr( $key, $value = 1 ) {
+               $n = $this->client->decr( $this->validateKeyEncoding( $key ), $value );
+
+               return ( $n !== false && $n !== null ) ? $n : false;
+       }
+
+       public function changeTTL( $key, $exptime = 0, $flags = 0 ) {
+               return $this->client->touch(
+                       $this->validateKeyEncoding( $key ),
+                       $this->fixExpiry( $exptime )
+               );
+       }
+
+       public function doGetMulti( array $keys, $flags = 0 ) {
                foreach ( $keys as $key ) {
                        $this->validateKeyEncoding( $key );
                }
 
                return $this->client->get_multi( $keys );
        }
+
+       protected function serialize( $value ) {
+               return is_int( $value ) ? $value : $this->client->serialize( $value );
+       }
+
+       protected function unserialize( $value ) {
+               return $this->isInteger( $value ) ? (int)$value : $this->client->unserialize( $value );
+       }
 }
index 1ed91ea..0503382 100644 (file)
@@ -130,6 +130,7 @@ class MultiWriteBagOStuff extends BagOStuff {
                                $missIndexes,
                                $this->asyncWrites,
                                'set',
+                               // @TODO: consider using self::WRITE_ALLOW_SEGMENTS here?
                                [ $key, $value, self::UPGRADE_TTL ]
                        );
                }
@@ -356,4 +357,24 @@ class MultiWriteBagOStuff extends BagOStuff {
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
                throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
        }
+
+       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
+
+       protected function doDelete( $key, $flags = 0 ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
+
+       protected function doGetMulti( array $keys, $flags = 0 ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
+
+       protected function serialize( $value ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
+
+       protected function unserialize( $value ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
 }
index a10d1a4..2a12689 100644 (file)
@@ -79,6 +79,7 @@ class RESTBagOStuff extends BagOStuff {
        private $extendedErrorBodyFields;
 
        public function __construct( $params ) {
+               $params['segmentationSize'] = $params['segmentationSize'] ?? INF;
                if ( empty( $params['url'] ) ) {
                        throw new InvalidArgumentException( 'URL parameter is required' );
                }
@@ -146,7 +147,7 @@ class RESTBagOStuff extends BagOStuff {
                return false;
        }
 
-       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
+       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
                // @TODO: respect WRITE_SYNC (e.g. EACH_QUORUM)
                // @TODO: respect $exptime
                $req = [
@@ -172,7 +173,7 @@ class RESTBagOStuff extends BagOStuff {
                return false; // key already set
        }
 
-       public function delete( $key, $flags = 0 ) {
+       protected function doDelete( $key, $flags = 0 ) {
                // @TODO: respect WRITE_SYNC (e.g. EACH_QUORUM)
                $req = [
                        'method' => 'DELETE',
index 2c74d45..0ba9c3f 100644 (file)
@@ -106,7 +106,7 @@ class RedisBagOStuff extends BagOStuff {
                return $result;
        }
 
-       public function set( $key, $value, $expiry = 0, $flags = 0 ) {
+       protected function doSet( $key, $value, $expiry = 0, $flags = 0 ) {
                list( $server, $conn ) = $this->getConnection( $key );
                if ( !$conn ) {
                        return false;
@@ -128,7 +128,7 @@ class RedisBagOStuff extends BagOStuff {
                return $result;
        }
 
-       public function delete( $key, $flags = 0 ) {
+       protected function doDelete( $key, $flags = 0 ) {
                list( $server, $conn ) = $this->getConnection( $key );
                if ( !$conn ) {
                        return false;
@@ -146,7 +146,7 @@ class RedisBagOStuff extends BagOStuff {
                return $result;
        }
 
-       public function getMulti( array $keys, $flags = 0 ) {
+       public function doGetMulti( array $keys, $flags = 0 ) {
                $batches = [];
                $conns = [];
                foreach ( $keys as $key ) {
@@ -351,25 +351,6 @@ class RedisBagOStuff extends BagOStuff {
                return $result;
        }
 
-       /**
-        * @param mixed $data
-        * @return string
-        */
-       protected function serialize( $data ) {
-               // Serialize anything but integers so INCR/DECR work
-               // Do not store integer-like strings as integers to avoid type confusion (T62563)
-               return is_int( $data ) ? $data : serialize( $data );
-       }
-
-       /**
-        * @param string $data
-        * @return mixed
-        */
-       protected function unserialize( $data ) {
-               $int = intval( $data );
-               return $data === (string)$int ? $int : unserialize( $data );
-       }
-
        /**
         * Get a Redis object with a connection suitable for fetching the specified key
         * @param string $key
index 70f9096..f79c1ff 100644 (file)
@@ -75,7 +75,7 @@ class ReplicatedBagOStuff extends BagOStuff {
        }
 
        public function get( $key, $flags = 0 ) {
-               return ( $flags & self::READ_LATEST )
+               return ( ( $flags & self::READ_LATEST ) == self::READ_LATEST )
                        ? $this->writeStore->get( $key, $flags )
                        : $this->readStore->get( $key, $flags );
        }
@@ -164,4 +164,24 @@ class ReplicatedBagOStuff extends BagOStuff {
        protected function doGet( $key, $flags = 0, &$casToken = null ) {
                throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
        }
+
+       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
+
+       protected function doDelete( $key, $flags = 0 ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
+
+       protected function doGetMulti( array $keys, $flags = 0 ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
+
+       protected function serialize( $value ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
+
+       protected function unserialize( $blob ) {
+               throw new LogicException( __METHOD__ . ': proxy class does not need this method.' );
+       }
 }
index dac3421..1d8662a 100644 (file)
@@ -1464,7 +1464,7 @@ class WANObjectCache implements IExpiringStore, LoggerAwareInterface {
         * @param string $kClass
         * @param float $elapsed Seconds spent regenerating the value
         * @param float $lockTSE
-        * @param $hasLock bool
+        * @param bool $hasLock
         * @return bool Whether it is OK to proceed with a key set operation
         */
        private function checkAndSetCooloff( $key, $kClass, $elapsed, $lockTSE, $hasLock ) {
index 8c419b2..9d7e143 100644 (file)
@@ -36,7 +36,7 @@ class WinCacheBagOStuff extends BagOStuff {
                        return false;
                }
 
-               $value = unserialize( $blob );
+               $value = $this->unserialize( $blob );
                if ( $value !== false ) {
                        $casToken = (string)$blob; // don't bother hashing this
                }
@@ -67,8 +67,8 @@ class WinCacheBagOStuff extends BagOStuff {
                return $success;
        }
 
-       public function set( $key, $value, $expire = 0, $flags = 0 ) {
-               $result = wincache_ucache_set( $key, serialize( $value ), $expire );
+       protected function doSet( $key, $value, $expire = 0, $flags = 0 ) {
+               $result = wincache_ucache_set( $key, $this->serialize( $value ), $expire );
 
                // false positive, wincache_ucache_set returns an empty array
                // in some circumstances.
@@ -77,7 +77,11 @@ class WinCacheBagOStuff extends BagOStuff {
        }
 
        public function add( $key, $value, $exptime = 0, $flags = 0 ) {
-               $result = wincache_ucache_add( $key, serialize( $value ), $exptime );
+               if ( wincache_ucache_exists( $key ) ) {
+                       return false; // avoid warnings
+               }
+
+               $result = wincache_ucache_add( $key, $this->serialize( $value ), $exptime );
 
                // false positive, wincache_ucache_add returns an empty array
                // in some circumstances.
@@ -85,7 +89,7 @@ class WinCacheBagOStuff extends BagOStuff {
                return ( $result === [] || $result === true );
        }
 
-       public function delete( $key, $flags = 0 ) {
+       protected function doDelete( $key, $flags = 0 ) {
                wincache_ucache_delete( $key );
 
                return true;
diff --git a/includes/libs/objectcache/serialized/SerializedValueContainer.php b/includes/libs/objectcache/serialized/SerializedValueContainer.php
new file mode 100644 (file)
index 0000000..7c7d8aa
--- /dev/null
@@ -0,0 +1,66 @@
+<?php
+
+/**
+ * Helper class for segmenting large cache values without relying on serializing classes
+ *
+ * @since 1.34
+ */
+class SerializedValueContainer {
+       const SCHEMA = '__svc_schema__';
+       const SCHEMA_UNIFIED = 'DAAIDgoKAQw'; // 64 bit UID
+       const SCHEMA_SEGMENTED = 'CAYCDAgCDw4'; // 64 bit UID
+
+       const UNIFIED_DATA = '__data__';
+       const SEGMENTED_HASHES = '__hashes__';
+
+       /**
+        * @param string $serialized
+        * @return stdClass
+        */
+       public static function newUnified( $serialized ) {
+               return (object)[
+                       self::SCHEMA => self::SCHEMA_UNIFIED,
+                       self::UNIFIED_DATA => $serialized
+               ];
+       }
+
+       /**
+        * @param string[] $segmentHashList Ordered list of hashes for each segment
+        * @return stdClass
+        */
+       public static function newSegmented( array $segmentHashList ) {
+               return (object)[
+                       self::SCHEMA => self::SCHEMA_SEGMENTED,
+                       self::SEGMENTED_HASHES => $segmentHashList
+               ];
+       }
+
+       /**
+        * @param mixed $value
+        * @return bool
+        */
+       public static function isUnified( $value ) {
+               return self::instanceOf( $value, self::SCHEMA_UNIFIED );
+       }
+
+       /**
+        * @param mixed $value
+        * @return bool
+        */
+       public static function isSegmented( $value ) {
+               return self::instanceOf( $value, self::SCHEMA_SEGMENTED );
+       }
+
+       /**
+        * @param mixed $value
+        * @param string $schema SCHEMA_* class constant
+        * @return bool
+        */
+       private static function instanceOf( $value, $schema ) {
+               return (
+                       $value instanceof stdClass &&
+                       property_exists( $value, self::SCHEMA ) &&
+                       $value->{self::SCHEMA} === $schema
+               );
+       }
+}
index fe23a38..c6b1662 100644 (file)
@@ -46,38 +46,6 @@ use RuntimeException;
  * @since 1.28
  */
 abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAwareInterface {
-       /** Number of times to re-try an operation in case of deadlock */
-       const DEADLOCK_TRIES = 4;
-       /** Minimum time to wait before retry, in microseconds */
-       const DEADLOCK_DELAY_MIN = 500000;
-       /** Maximum time to wait before retry */
-       const DEADLOCK_DELAY_MAX = 1500000;
-
-       /** How long before it is worth doing a dummy query to test the connection */
-       const PING_TTL = 1.0;
-       const PING_QUERY = 'SELECT 1 AS ping';
-
-       const TINY_WRITE_SEC = 0.010;
-       const SLOW_WRITE_SEC = 0.500;
-       const SMALL_WRITE_ROWS = 100;
-
-       /** @var string Lock granularity is on the level of the entire database */
-       const ATTR_DB_LEVEL_LOCKING = 'db-level-locking';
-       /** @var string The SCHEMA keyword refers to a grouping of tables in a database */
-       const ATTR_SCHEMAS_AS_TABLE_GROUPS = 'supports-schemas';
-
-       /** @var int New Database instance will not be connected yet when returned */
-       const NEW_UNCONNECTED = 0;
-       /** @var int New Database instance will already be connected when returned */
-       const NEW_CONNECTED = 1;
-
-       /** @var string The last SQL query attempted */
-       private $lastQuery = '';
-       /** @var float|bool UNIX timestamp of last write query */
-       private $lastWriteTime = false;
-       /** @var string|bool */
-       private $lastPhpError = false;
-
        /** @var string Server that this instance is currently connected to */
        protected $server;
        /** @var string User that this instance is currently connected under the name of */
@@ -92,8 +60,23 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        protected $cliMode;
        /** @var string Agent name for query profiling */
        protected $agent;
+       /** @var int Bitfield of class DBO_* constants */
+       protected $flags;
+       /** @var array LoadBalancer tracking information */
+       protected $lbInfo = [];
+       /** @var array|bool Variables use for schema element placeholders */
+       protected $schemaVars = false;
        /** @var array Parameters used by initConnection() to establish a connection */
        protected $connectionParams = [];
+       /** @var array SQL variables values to use for all new connections */
+       protected $connectionVariables = [];
+       /** @var string Current SQL query delimiter */
+       protected $delimiter = ';';
+       /** @var string|bool|null Stashed value of html_errors INI setting */
+       protected $htmlErrors;
+       /** @var int */
+       protected $nonNativeInsertSelectBatchSize = 10000;
+
        /** @var BagOStuff APC cache */
        protected $srvCache;
        /** @var LoggerInterface */
@@ -104,177 +87,100 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        protected $errorLogger;
        /** @var callable Deprecation logging callback */
        protected $deprecationLogger;
+       /** @var callable|null */
+       protected $profiler;
+       /** @var TransactionProfiler */
+       protected $trxProfiler;
+       /** @var DatabaseDomain */
+       protected $currentDomain;
+       /** @var IDatabase|null Lazy handle to the master DB this server replicates from */
+       private $lazyMasterHandle;
 
        /** @var object|resource|null Database connection */
        protected $conn = null;
-       /** @var bool */
+       /** @var bool Whether a connection handle is open (connection itself might be dead) */
        protected $opened = false;
 
-       /** @var array[] List of (callable, method name, atomic section id) */
-       protected $trxIdleCallbacks = [];
-       /** @var array[] List of (callable, method name, atomic section id) */
-       protected $trxPreCommitCallbacks = [];
-       /** @var array[] List of (callable, method name, atomic section id) */
-       protected $trxEndCallbacks = [];
-       /** @var callable[] Map of (name => callable) */
-       protected $trxRecurringCallbacks = [];
-       /** @var bool Whether to suppress triggering of transaction end callbacks */
-       protected $trxEndCallbacksSuppressed = false;
-
-       /** @var int */
-       protected $flags;
-       /** @var array */
-       protected $lbInfo = [];
-       /** @var array|bool */
-       protected $schemaVars = false;
-       /** @var array */
-       protected $sessionVars = [];
-       /** @var array|null */
-       protected $preparedArgs;
-       /** @var string|bool|null Stashed value of html_errors INI setting */
-       protected $htmlErrors;
-       /** @var string */
-       protected $delimiter = ';';
-       /** @var DatabaseDomain */
-       protected $currentDomain;
-       /** @var integer|null Rows affected by the last query to query() or its CRUD wrappers */
-       protected $affectedRowCount;
+       /** @var array Map of (name => 1) for locks obtained via lock() */
+       protected $sessionNamedLocks = [];
+       /** @var array Map of (table name => 1) for TEMPORARY tables */
+       protected $sessionTempTables = [];
 
-       /**
-        * @var int Transaction status
-        */
-       protected $trxStatus = self::STATUS_TRX_NONE;
-       /**
-        * @var Exception|null The last error that caused the status to become STATUS_TRX_ERROR
-        */
-       protected $trxStatusCause;
-       /**
-        * @var array|null If wasKnownStatementRollbackError() prevented trxStatus from being set,
-        *  the relevant details are stored here.
-        */
-       protected $trxStatusIgnoredCause;
-       /**
-        * Either 1 if a transaction is active or 0 otherwise.
-        * The other Trx fields may not be meaningfull if this is 0.
-        *
-        * @var int
-        */
+       /** @var int Whether there is an active transaction (1 or 0) */
        protected $trxLevel = 0;
-       /**
-        * Either a short hexidecimal string if a transaction is active or ""
-        *
-        * @var string
-        * @see Database::trxLevel
-        */
+       /** @var string Hexidecimal string if a transaction is active or empty string otherwise */
        protected $trxShortId = '';
-       /**
-        * The UNIX time that the transaction started. Callers can assume that if
-        * snapshot isolation is used, then the data is *at least* up to date to that
-        * point (possibly more up-to-date since the first SELECT defines the snapshot).
-        *
-        * @var float|null
-        * @see Database::trxLevel
-        */
+       /** @var int Transaction status */
+       protected $trxStatus = self::STATUS_TRX_NONE;
+       /** @var Exception|null The last error that caused the status to become STATUS_TRX_ERROR */
+       protected $trxStatusCause;
+       /** @var array|null Error details of the last statement-only rollback */
+       private $trxStatusIgnoredCause;
+       /** @var float|null UNIX timestamp at the time of BEGIN for the last transaction */
        private $trxTimestamp = null;
-       /** @var float Lag estimate at the time of BEGIN */
+       /** @var float Replication lag estimate at the time of BEGIN for the last transaction */
        private $trxReplicaLag = null;
-       /**
-        * Remembers the function name given for starting the most recent transaction via begin().
-        * Used to provide additional context for error reporting.
-        *
-        * @var string
-        * @see Database::trxLevel
-        */
+       /** @var string Name of the function that start the last transaction */
        private $trxFname = null;
-       /**
-        * Record if possible write queries were done in the last transaction started
-        *
-        * @var bool
-        * @see Database::trxLevel
-        */
+       /** @var bool Whether possible write queries were done in the last transaction started */
        private $trxDoneWrites = false;
-       /**
-        * Record if the current transaction was started implicitly due to DBO_TRX being set.
-        *
-        * @var bool
-        * @see Database::trxLevel
-        */
+       /** @var bool Whether the current transaction was started implicitly due to DBO_TRX */
        private $trxAutomatic = false;
-       /**
-        * Counter for atomic savepoint identifiers. Reset when a new transaction begins.
-        *
-        * @var int
-        */
+       /** @var int Counter for atomic savepoint identifiers (reset with each transaction) */
        private $trxAtomicCounter = 0;
-       /**
-        * Array of levels of atomicity within transactions
-        *
-        * @var array List of (name, unique ID, savepoint ID)
-        */
+       /** @var array List of (name, unique ID, savepoint ID) for each active atomic section level */
        private $trxAtomicLevels = [];
-       /**
-        * Record if the current transaction was started implicitly by Database::startAtomic
-        *
-        * @var bool
-        */
+       /** @var bool Whether the current transaction was started implicitly by startAtomic() */
        private $trxAutomaticAtomic = false;
-       /**
-        * Track the write query callers of the current transaction
-        *
-        * @var string[]
-        */
+       /** @var string[] Write query callers of the current transaction */
        private $trxWriteCallers = [];
-       /**
-        * @var float Seconds spent in write queries for the current transaction
-        */
+       /** @var float Seconds spent in write queries for the current transaction */
        private $trxWriteDuration = 0.0;
-       /**
-        * @var int Number of write queries for the current transaction
-        */
+       /** @var int Number of write queries for the current transaction */
        private $trxWriteQueryCount = 0;
-       /**
-        * @var int Number of rows affected by write queries for the current transaction
-        */
+       /** @var int Number of rows affected by write queries for the current transaction */
        private $trxWriteAffectedRows = 0;
-       /**
-        * @var float Like trxWriteQueryCount but excludes lock-bound, easy to replicate, queries
-        */
+       /** @var float Like trxWriteQueryCount but excludes lock-bound, easy to replicate, queries */
        private $trxWriteAdjDuration = 0.0;
-       /**
-        * @var int Number of write queries counted in trxWriteAdjDuration
-        */
+       /** @var int Number of write queries counted in trxWriteAdjDuration */
        private $trxWriteAdjQueryCount = 0;
-       /**
-        * @var float RTT time estimate
-        */
-       private $rttEstimate = 0.0;
-
-       /** @var array Map of (name => 1) for locks obtained via lock() */
-       private $namedLocksHeld = [];
-       /** @var array Map of (table name => 1) for TEMPORARY tables */
-       protected $sessionTempTables = [];
-
-       /** @var IDatabase|null Lazy handle to the master DB this server replicates from */
-       private $lazyMasterHandle;
-
-       /** @var float UNIX timestamp */
-       protected $lastPing = 0.0;
+       /** @var array[] List of (callable, method name, atomic section id) */
+       private $trxIdleCallbacks = [];
+       /** @var array[] List of (callable, method name, atomic section id) */
+       private $trxPreCommitCallbacks = [];
+       /** @var array[] List of (callable, method name, atomic section id) */
+       private $trxEndCallbacks = [];
+       /** @var callable[] Map of (name => callable) */
+       private $trxRecurringCallbacks = [];
+       /** @var bool Whether to suppress triggering of transaction end callbacks */
+       private $trxEndCallbacksSuppressed = false;
 
        /** @var int[] Prior flags member variable values */
        private $priorFlags = [];
 
-       /** @var callable|null */
-       protected $profiler;
-       /** @var TransactionProfiler */
-       protected $trxProfiler;
+       /** @var integer|null Rows affected by the last query to query() or its CRUD wrappers */
+       protected $affectedRowCount;
 
-       /** @var int */
-       protected $nonNativeInsertSelectBatchSize = 10000;
+       /** @var float UNIX timestamp */
+       private $lastPing = 0.0;
+       /** @var string The last SQL query attempted */
+       private $lastQuery = '';
+       /** @var float|bool UNIX timestamp of last write query */
+       private $lastWriteTime = false;
+       /** @var string|bool */
+       private $lastPhpError = false;
+       /** @var float Query rount trip time estimate */
+       private $lastRoundTripEstimate = 0.0;
 
-       /** @var string Idiom used when a cancelable atomic section started the transaction */
-       private static $NOT_APPLICABLE = 'n/a';
-       /** @var string Prefix to the atomic section counter used to make savepoint IDs */
-       private static $SAVEPOINT_PREFIX = 'wikimedia_rdbms_atomic';
+       /** @var string Lock granularity is on the level of the entire database */
+       const ATTR_DB_LEVEL_LOCKING = 'db-level-locking';
+       /** @var string The SCHEMA keyword refers to a grouping of tables in a database */
+       const ATTR_SCHEMAS_AS_TABLE_GROUPS = 'supports-schemas';
+
+       /** @var int New Database instance will not be connected yet when returned */
+       const NEW_UNCONNECTED = 0;
+       /** @var int New Database instance will already be connected when returned */
+       const NEW_CONNECTED = 1;
 
        /** @var int Transaction is in a error state requiring a full or savepoint rollback */
        const STATUS_TRX_ERROR = 1;
@@ -283,10 +189,30 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /** @var int No transaction is active */
        const STATUS_TRX_NONE = 3;
 
+       /** @var string Idiom used when a cancelable atomic section started the transaction */
+       private static $NOT_APPLICABLE = 'n/a';
+       /** @var string Prefix to the atomic section counter used to make savepoint IDs */
+       private static $SAVEPOINT_PREFIX = 'wikimedia_rdbms_atomic';
+
        /** @var int Writes to this temporary table do not affect lastDoneWrites() */
-       const TEMP_NORMAL = 1;
+       private static $TEMP_NORMAL = 1;
        /** @var int Writes to this temporary table effect lastDoneWrites() */
-       const TEMP_PSEUDO_PERMANENT = 2;
+       private static $TEMP_PSEUDO_PERMANENT = 2;
+
+       /** Number of times to re-try an operation in case of deadlock */
+       private static $DEADLOCK_TRIES = 4;
+       /** Minimum time to wait before retry, in microseconds */
+       private static $DEADLOCK_DELAY_MIN = 500000;
+       /** Maximum time to wait before retry */
+       private static $DEADLOCK_DELAY_MAX = 1500000;
+
+       /** How long before it is worth doing a dummy query to test the connection */
+       private static $PING_TTL = 1.0;
+       private static $PING_QUERY = 'SELECT 1 AS ping';
+
+       private static $TINY_WRITE_SEC = 0.010;
+       private static $SLOW_WRITE_SEC = 0.500;
+       private static $SMALL_WRITE_ROWS = 100;
 
        /**
         * @note exceptions for missing libraries/drivers should be thrown in initConnection()
@@ -312,7 +238,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                // Disregard deprecated DBO_IGNORE flag (T189999)
                $this->flags &= ~self::DBO_IGNORE;
 
-               $this->sessionVars = $params['variables'];
+               $this->connectionVariables = $params['variables'];
 
                $this->srvCache = $params['srvCache'] ?? new HashBagOStuff();
 
@@ -749,7 +675,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $applyTime = max( $this->trxWriteAdjDuration - $rttAdjTotal, 0 );
                // For omitted queries, make them count as something at least
                $omitted = $this->trxWriteQueryCount - $this->trxWriteAdjQueryCount;
-               $applyTime += self::TINY_WRITE_SEC * $omitted;
+               $applyTime += self::$TINY_WRITE_SEC * $omitted;
 
                return $applyTime;
        }
@@ -1020,7 +946,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         *
         * @throws DBUnexpectedError
         */
-       protected function assertHasConnectionHandle() {
+       final protected function assertHasConnectionHandle() {
                if ( !$this->isOpen() ) {
                        throw new DBUnexpectedError( $this, "DB connection was already closed." );
                }
@@ -1029,7 +955,8 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /**
         * Make sure that this server is not marked as a replica nor read-only as a sanity check
         *
-        * @throws DBUnexpectedError
+        * @throws DBReadOnlyRoleError
+        * @throws DBReadOnlyError
         */
        protected function assertIsWritableMaster() {
                if ( $this->getLBInfo( 'replica' ) === true ) {
@@ -1064,6 +991,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /**
         * Run a query and return a DBMS-dependent wrapper or boolean
         *
+        * This is meant to handle the basic command of actually sending a query to the
+        * server via the driver. No implicit transaction, reconnection, nor retry logic
+        * should happen here. The higher level query() method is designed to handle those
+        * sorts of concerns. This method should not trigger such higher level methods.
+        *
+        * The lastError() and lastErrno() methods should meaningfully reflect what error,
+        * if any, occured during the last call to this method. Methods like executeQuery(),
+        * query(), select(), insert(), update(), delete(), and upsert() implement their calls
+        * to doQuery() such that an immediately subsequent call to lastError()/lastErrno()
+        * meaningfully reflects any error that occured during that public query method call.
+        *
         * For SELECT queries, this returns either:
         *   - a) A driver-specific value/resource, only on success. This can be iterated
         *        over by calling fetchObject()/fetchRow() until there are no more rows.
@@ -1108,11 +1046,11 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                // for all queries within a request. Use cases:
                // - Treating these as writes would trigger ChronologyProtector (see method doc).
                // - We use this method to reject writes to replicas, but we need to allow
-               //   use of transactions on replicas for read snapshots. This fine given
+               //   use of transactions on replicas for read snapshots. This is fine given
                //   that transactions by themselves don't make changes, only actual writes
                //   within the transaction matter, which we still detect.
                return !preg_match(
-                       '/^(?:SELECT|BEGIN|ROLLBACK|COMMIT|SAVEPOINT|RELEASE|SET|SHOW|EXPLAIN|\(SELECT)\b/i',
+                       '/^(?:SELECT|BEGIN|ROLLBACK|COMMIT|SAVEPOINT|RELEASE|SET|SHOW|EXPLAIN|USE|\(SELECT)\b/i',
                        $sql
                );
        }
@@ -1141,7 +1079,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        protected function isTransactableQuery( $sql ) {
                return !in_array(
                        $this->getQueryVerb( $sql ),
-                       [ 'BEGIN', 'ROLLBACK', 'COMMIT', 'SET', 'SHOW', 'CREATE', 'ALTER' ],
+                       [ 'BEGIN', 'ROLLBACK', 'COMMIT', 'SET', 'SHOW', 'CREATE', 'ALTER', 'USE' ],
                        true
                );
        }
@@ -1159,7 +1097,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        $sql,
                        $matches
                ) ) {
-                       $type = $pseudoPermanent ? self::TEMP_PSEUDO_PERMANENT : self::TEMP_NORMAL;
+                       $type = $pseudoPermanent ? self::$TEMP_PSEUDO_PERMANENT : self::$TEMP_NORMAL;
                        $this->sessionTempTables[$matches[1]] = $type;
 
                        return $type;
@@ -1190,108 +1128,132 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        }
 
        public function query( $sql, $fname = __METHOD__, $flags = 0 ) {
-               $this->assertTransactionStatus( $sql, $fname );
-               $this->assertHasConnectionHandle();
-
                $flags = (int)$flags; // b/c; this field used to be a bool
-               $ignoreErrors = $this->hasFlags( $flags, self::QUERY_SILENCE_ERRORS );
+               // Sanity check that the SQL query is appropriate in the current context and is
+               // allowed for an outside caller (e.g. does not break transaction/session tracking).
+               $this->assertQueryIsCurrentlyAllowed( $sql, $fname );
+
+               // Send the query to the server and fetch any corresponding errors
+               list( $ret, $err, $errno, $unignorable ) = $this->executeQuery( $sql, $fname, $flags );
+               if ( $ret === false ) {
+                       $ignoreErrors = $this->hasFlags( $flags, self::QUERY_SILENCE_ERRORS );
+                       // Throw an error unless both the ignore flag was set and a rollback is not needed
+                       $this->reportQueryError( $err, $errno, $sql, $fname, $ignoreErrors && !$unignorable );
+               }
+
+               return $this->resultObject( $ret );
+       }
+
+       /**
+        * Execute a query, retrying it if there is a recoverable connection loss
+        *
+        * This is similar to query() except:
+        *   - It does not prevent all non-ROLLBACK queries if there is a corrupted transaction
+        *   - It does not disallow raw queries that are supposed to use dedicated IDatabase methods
+        *   - It does not throw exceptions for common error cases
+        *
+        * This is meant for internal use with Database subclasses.
+        *
+        * @param string $sql Original SQL query
+        * @param string $fname Name of the calling function
+        * @param int $flags Bitfield of class QUERY_* constants
+        * @return array An n-tuple of:
+        *   - mixed|bool: An object, resource, or true on success; false on failure
+        *   - string: The result of calling lastError()
+        *   - int: The result of calling lastErrno()
+        *   - bool: Whether a rollback is needed to allow future non-rollback queries
+        * @throws DBUnexpectedError
+        */
+       final protected function executeQuery( $sql, $fname, $flags ) {
+               $this->assertHasConnectionHandle();
 
                $priorTransaction = $this->trxLevel;
-               $priorWritesPending = $this->writesOrCallbacksPending();
 
                if ( $this->isWriteQuery( $sql ) ) {
                        # In theory, non-persistent writes are allowed in read-only mode, but due to things
                        # like https://bugs.mysql.com/bug.php?id=33669 that might not work anyway...
                        $this->assertIsWritableMaster();
-                       # Do not treat temporary table writes as "meaningful writes" that need committing.
-                       # Profile them as reads. Integration tests can override this behavior via $flags.
+                       # Do not treat temporary table writes as "meaningful writes" since they are only
+                       # visible to one session and are not permanent. Profile them as reads. Integration
+                       # tests can override this behavior via $flags.
                        $pseudoPermanent = $this->hasFlags( $flags, self::QUERY_PSEUDO_PERMANENT );
                        $tableType = $this->registerTempTableWrite( $sql, $pseudoPermanent );
-                       $isEffectiveWrite = ( $tableType !== self::TEMP_NORMAL );
+                       $isPermWrite = ( $tableType !== self::$TEMP_NORMAL );
                        # DBConnRef uses QUERY_REPLICA_ROLE to enforce the replica role for raw SQL queries
-                       if ( $isEffectiveWrite && $this->hasFlags( $flags, self::QUERY_REPLICA_ROLE ) ) {
+                       if ( $isPermWrite && $this->hasFlags( $flags, self::QUERY_REPLICA_ROLE ) ) {
                                throw new DBReadOnlyRoleError( $this, "Cannot write; target role is DB_REPLICA" );
                        }
                } else {
-                       $isEffectiveWrite = false;
+                       $isPermWrite = false;
                }
 
-               # Add trace comment to the begin of the sql string, right after the operator.
-               # Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (T44598)
+               // Add trace comment to the begin of the sql string, right after the operator.
+               // Or, for one-word queries (like "BEGIN" or COMMIT") add it to the end (T44598)
                $commentedSql = preg_replace( '/\s|$/', " /* $fname {$this->agent} */ ", $sql, 1 );
 
-               # Send the query to the server and fetch any corresponding errors
-               $ret = $this->attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname );
-               $lastError = $this->lastError();
-               $lastErrno = $this->lastErrno();
-
-               $recoverableSR = false; // recoverable statement rollback?
-               $recoverableCL = false; // recoverable connection loss?
-
-               if ( $ret === false && $this->wasConnectionLoss() ) {
-                       # Check if no meaningful session state was lost
-                       $recoverableCL = $this->canRecoverFromDisconnect( $sql, $priorWritesPending );
-                       # Update session state tracking and try to restore the connection
-                       $reconnected = $this->replaceLostConnection( __METHOD__ );
-                       # Silently resend the query to the server if it is safe and possible
-                       if ( $recoverableCL && $reconnected ) {
-                               $ret = $this->attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname );
-                               $lastError = $this->lastError();
-                               $lastErrno = $this->lastErrno();
-
-                               if ( $ret === false && $this->wasConnectionLoss() ) {
-                                       # Query probably causes disconnects; reconnect and do not re-run it
-                                       $this->replaceLostConnection( __METHOD__ );
-                               } else {
-                                       $recoverableCL = false; // connection does not need recovering
-                                       $recoverableSR = $this->wasKnownStatementRollbackError();
-                               }
-                       }
-               } else {
-                       $recoverableSR = $this->wasKnownStatementRollbackError();
+               // Send the query to the server and fetch any corresponding errors
+               list( $ret, $err, $errno, $recoverableSR, $recoverableCL, $reconnected ) =
+                       $this->executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags );
+               // Check if the query failed due to a recoverable connection loss
+               if ( $ret === false && $recoverableCL && $reconnected ) {
+                       // Silently resend the query to the server since it is safe and possible
+                       list( $ret, $err, $errno, $recoverableSR, $recoverableCL ) =
+                               $this->executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags );
                }
 
+               $corruptedTrx = false;
+
                if ( $ret === false ) {
                        if ( $priorTransaction ) {
                                if ( $recoverableSR ) {
                                        # We're ignoring an error that caused just the current query to be aborted.
                                        # But log the cause so we can log a deprecation notice if a caller actually
                                        # does ignore it.
-                                       $this->trxStatusIgnoredCause = [ $lastError, $lastErrno, $fname ];
+                                       $this->trxStatusIgnoredCause = [ $err, $errno, $fname ];
                                } elseif ( !$recoverableCL ) {
                                        # Either the query was aborted or all queries after BEGIN where aborted.
                                        # In the first case, the only options going forward are (a) ROLLBACK, or
                                        # (b) ROLLBACK TO SAVEPOINT (if one was set). If the later case, the only
                                        # option is ROLLBACK, since the snapshots would have been released.
+                                       $corruptedTrx = true; // cannot recover
                                        $this->trxStatus = self::STATUS_TRX_ERROR;
                                        $this->trxStatusCause =
-                                               $this->getQueryExceptionAndLog( $lastError, $lastErrno, $sql, $fname );
-                                       $ignoreErrors = false; // cannot recover
+                                               $this->getQueryExceptionAndLog( $err, $errno, $sql, $fname );
                                        $this->trxStatusIgnoredCause = null;
                                }
                        }
-
-                       $this->reportQueryError( $lastError, $lastErrno, $sql, $fname, $ignoreErrors );
                }
 
-               return $this->resultObject( $ret );
+               return [ $ret, $err, $errno, $corruptedTrx ];
        }
 
        /**
-        * Wrapper for query() that also handles profiling, logging, and affected row count updates
+        * Wrapper for doQuery() that handles DBO_TRX, profiling, logging, affected row count
+        * tracking, and reconnects (without retry) on query failure due to connection loss
         *
         * @param string $sql Original SQL query
         * @param string $commentedSql SQL query with debugging/trace comment
-        * @param bool $isEffectiveWrite Whether the query is a (non-temporary table) write
+        * @param bool $isPermWrite Whether the query is a (non-temporary table) write
         * @param string $fname Name of the calling function
-        * @return bool|IResultWrapper True for a successful write query, ResultWrapper
-        *     object for a successful read query, or false on failure
+        * @param int $flags Bitfield of class QUERY_* constants
+        * @return array An n-tuple of:
+        *   - mixed|bool: An object, resource, or true on success; false on failure
+        *   - string: The result of calling lastError()
+        *   - int: The result of calling lastErrno()
+        *       - bool: Whether a statement rollback error occured
+        *   - bool: Whether a disconnect *both* happened *and* was recoverable
+        *   - bool: Whether a reconnection attempt was *both* made *and* succeeded
+        * @throws DBUnexpectedError
         */
-       private function attemptQuery( $sql, $commentedSql, $isEffectiveWrite, $fname ) {
-               $this->beginIfImplied( $sql, $fname );
+       private function executeQueryAttempt( $sql, $commentedSql, $isPermWrite, $fname, $flags ) {
+               $priorWritesPending = $this->writesOrCallbacksPending();
+
+               if ( ( $flags & self::QUERY_IGNORE_DBO_TRX ) == 0 ) {
+                       $this->beginIfImplied( $sql, $fname );
+               }
 
                // Keep track of whether the transaction has write queries pending
-               if ( $isEffectiveWrite ) {
+               if ( $isPermWrite ) {
                        $this->lastWriteTime = microtime( true );
                        if ( $this->trxLevel && !$this->trxDoneWrites ) {
                                $this->trxDoneWrites = true;
@@ -1310,27 +1272,42 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->affectedRowCount = null;
                $this->lastQuery = $sql;
                $ret = $this->doQuery( $commentedSql );
+               $lastError = $this->lastError();
+               $lastErrno = $this->lastErrno();
+
                $this->affectedRowCount = $this->affectedRows();
                unset( $ps ); // profile out (if set)
                $queryRuntime = max( microtime( true ) - $startTime, 0.0 );
 
+               $recoverableSR = false; // recoverable statement rollback?
+               $recoverableCL = false; // recoverable connection loss?
+               $reconnected = false; // reconnection both attempted and succeeded?
+
                if ( $ret !== false ) {
                        $this->lastPing = $startTime;
-                       if ( $isEffectiveWrite && $this->trxLevel ) {
+                       if ( $isPermWrite && $this->trxLevel ) {
                                $this->updateTrxWriteQueryTime( $sql, $queryRuntime, $this->affectedRows() );
                                $this->trxWriteCallers[] = $fname;
                        }
+               } elseif ( $this->wasConnectionError( $lastErrno ) ) {
+                       # Check if no meaningful session state was lost
+                       $recoverableCL = $this->canRecoverFromDisconnect( $sql, $priorWritesPending );
+                       # Update session state tracking and try to restore the connection
+                       $reconnected = $this->replaceLostConnection( __METHOD__ );
+               } else {
+                       # Check if only the last query was rolled back
+                       $recoverableSR = $this->wasKnownStatementRollbackError();
                }
 
-               if ( $sql === self::PING_QUERY ) {
-                       $this->rttEstimate = $queryRuntime;
+               if ( $sql === self::$PING_QUERY ) {
+                       $this->lastRoundTripEstimate = $queryRuntime;
                }
 
                $this->trxProfiler->recordQueryCompletion(
                        $generalizedSql,
                        $startTime,
-                       $isEffectiveWrite,
-                       $isEffectiveWrite ? $this->affectedRows() : $this->numRows( $ret )
+                       $isPermWrite,
+                       $isPermWrite ? $this->affectedRows() : $this->numRows( $ret )
                );
 
                // Avoid the overhead of logging calls unless debug mode is enabled
@@ -1346,7 +1323,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        );
                }
 
-               return $ret;
+               return [ $ret, $lastError, $lastErrno, $recoverableSR, $recoverableCL, $reconnected ];
        }
 
        /**
@@ -1381,13 +1358,13 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        private function updateTrxWriteQueryTime( $sql, $runtime, $affected ) {
                // Whether this is indicative of replica DB runtime (except for RBR or ws_repl)
                $indicativeOfReplicaRuntime = true;
-               if ( $runtime > self::SLOW_WRITE_SEC ) {
+               if ( $runtime > self::$SLOW_WRITE_SEC ) {
                        $verb = $this->getQueryVerb( $sql );
                        // insert(), upsert(), replace() are fast unless bulky in size or blocked on locks
                        if ( $verb === 'INSERT' ) {
-                               $indicativeOfReplicaRuntime = $this->affectedRows() > self::SMALL_WRITE_ROWS;
+                               $indicativeOfReplicaRuntime = $this->affectedRows() > self::$SMALL_WRITE_ROWS;
                        } elseif ( $verb === 'REPLACE' ) {
-                               $indicativeOfReplicaRuntime = $this->affectedRows() > self::SMALL_WRITE_ROWS / 2;
+                               $indicativeOfReplicaRuntime = $this->affectedRows() > self::$SMALL_WRITE_ROWS / 2;
                        }
                }
 
@@ -1407,7 +1384,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         * @param string $fname
         * @throws DBTransactionStateError
         */
-       private function assertTransactionStatus( $sql, $fname ) {
+       private function assertQueryIsCurrentlyAllowed( $sql, $fname ) {
                $verb = $this->getQueryVerb( $sql );
                if ( $verb === 'USE' ) {
                        throw new DBUnexpectedError( $this, "Got USE query; use selectDomain() instead." );
@@ -1458,7 +1435,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                # Dropped connections also mean that named locks are automatically released.
                # Only allow error suppression in autocommit mode or when the lost transaction
                # didn't matter anyway (aside from DBO_TRX snapshot loss).
-               if ( $this->namedLocksHeld ) {
+               if ( $this->sessionNamedLocks ) {
                        return false; // possible critical section violation
                } elseif ( $this->sessionTempTables ) {
                        return false; // tables might be queried latter
@@ -1485,7 +1462,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                $this->sessionTempTables = [];
                // https://dev.mysql.com/doc/refman/5.7/en/miscellaneous-functions.html#function_get-lock
                // https://www.postgresql.org/docs/9.4/static/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
-               $this->namedLocksHeld = [];
+               $this->sessionNamedLocks = [];
                // Session loss implies transaction loss
                $this->trxLevel = 0;
                $this->trxAtomicCounter = 0;
@@ -2963,7 +2940,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
        public function textFieldSize( $table, $field ) {
                $table = $this->tableName( $table );
-               $sql = "SHOW COLUMNS FROM $table LIKE \"$field\";";
+               $sql = "SHOW COLUMNS FROM $table LIKE \"$field\"";
                $res = $this->query( $sql, __METHOD__ );
                $row = $this->fetchObject( $res );
 
@@ -3313,7 +3290,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        public function deadlockLoop() {
                $args = func_get_args();
                $function = array_shift( $args );
-               $tries = self::DEADLOCK_TRIES;
+               $tries = self::$DEADLOCK_TRIES;
 
                $this->begin( __METHOD__ );
 
@@ -3327,7 +3304,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                        } catch ( DBQueryError $e ) {
                                if ( $this->wasDeadlock() ) {
                                        // Retry after a randomized delay
-                                       usleep( mt_rand( self::DEADLOCK_DELAY_MIN, self::DEADLOCK_DELAY_MAX ) );
+                                       usleep( mt_rand( self::$DEADLOCK_DELAY_MIN, self::$DEADLOCK_DELAY_MAX ) );
                                } else {
                                        // Throw the error back up
                                        throw $e;
@@ -3976,7 +3953,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
        }
 
-       final public function rollback( $fname = __METHOD__, $flush = '' ) {
+       final public function rollback( $fname = __METHOD__, $flush = self::FLUSHING_ONE ) {
                $trxActive = $this->trxLevel;
 
                if ( $flush !== self::FLUSHING_INTERNAL
@@ -4130,20 +4107,20 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
 
        public function ping( &$rtt = null ) {
                // Avoid hitting the server if it was hit recently
-               if ( $this->isOpen() && ( microtime( true ) - $this->lastPing ) < self::PING_TTL ) {
-                       if ( !func_num_args() || $this->rttEstimate > 0 ) {
-                               $rtt = $this->rttEstimate;
+               if ( $this->isOpen() && ( microtime( true ) - $this->lastPing ) < self::$PING_TTL ) {
+                       if ( !func_num_args() || $this->lastRoundTripEstimate > 0 ) {
+                               $rtt = $this->lastRoundTripEstimate;
                                return true; // don't care about $rtt
                        }
                }
 
                // This will reconnect if possible or return false if not
                $this->clearFlag( self::DBO_TRX, self::REMEMBER_PRIOR );
-               $ok = ( $this->query( self::PING_QUERY, __METHOD__, true ) !== false );
+               $ok = ( $this->query( self::$PING_QUERY, __METHOD__, true ) !== false );
                $this->restoreFlags( self::RESTORE_PRIOR );
 
                if ( $ok ) {
-                       $rtt = $this->rttEstimate;
+                       $rtt = $this->lastRoundTripEstimate;
                }
 
                return $ok;
@@ -4497,17 +4474,17 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                // RDBMs methods for checking named locks may or may not count this thread itself.
                // In MySQL, IS_FREE_LOCK() returns 0 if the thread already has the lock. This is
                // the behavior choosen by the interface for this method.
-               return !isset( $this->namedLocksHeld[$lockName] );
+               return !isset( $this->sessionNamedLocks[$lockName] );
        }
 
        public function lock( $lockName, $method, $timeout = 5 ) {
-               $this->namedLocksHeld[$lockName] = 1;
+               $this->sessionNamedLocks[$lockName] = 1;
 
                return true;
        }
 
        public function unlock( $lockName, $method ) {
-               unset( $this->namedLocksHeld[$lockName] );
+               unset( $this->sessionNamedLocks[$lockName] );
 
                return true;
        }
index a532ec2..5632027 100644 (file)
@@ -1167,10 +1167,13 @@ class DatabaseMssql extends Database {
 
                $database = $domain->getDatabase();
                if ( $database !== $this->getDBname() ) {
-                       $encDatabase = $this->addIdentifierQuotes( $database );
-                       $res = $this->doQuery( "USE $encDatabase" );
-                       if ( !$res ) {
-                               throw new DBExpectedError( $this, "Could not select database '$database'." );
+                       $sql = 'USE ' . $this->addIdentifierQuotes( $database );
+                       list( $res, $err, $errno ) =
+                               $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX );
+
+                       if ( $res === false ) {
+                               $this->reportQueryError( $err, $errno, $sql, __METHOD__ );
+                               return false; // unreachable
                        }
                }
                // Update that domain fields on success (no exception thrown)
index 6d28717..e871ab9 100644 (file)
@@ -182,7 +182,7 @@ abstract class DatabaseMysqlBase extends Database {
                }
                // Set any custom settings defined by site config
                // (e.g. https://dev.mysql.com/doc/refman/4.1/en/innodb-parameters.html)
-               foreach ( $this->sessionVars as $var => $val ) {
+               foreach ( $this->connectionVariables as $var => $val ) {
                        // Escape strings but not numbers to avoid MySQL complaining
                        if ( !is_int( $val ) && !is_float( $val ) ) {
                                $val = $this->addQuotes( $val );
@@ -244,11 +244,12 @@ abstract class DatabaseMysqlBase extends Database {
 
                if ( $database !== $this->getDBname() ) {
                        $sql = 'USE ' . $this->addIdentifierQuotes( $database );
-                       $ret = $this->doQuery( $sql );
-                       if ( $ret === false ) {
-                               $error = $this->lastError();
-                               $errno = $this->lastErrno();
-                               $this->reportQueryError( $error, $errno, $sql, __METHOD__ );
+                       list( $res, $err, $errno ) =
+                               $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX );
+
+                       if ( $res === false ) {
+                               $this->reportQueryError( $err, $errno, $sql, __METHOD__ );
+                               return false; // unreachable
                        }
                }
 
@@ -952,21 +953,22 @@ abstract class DatabaseMysqlBase extends Database {
                        $gtidArg = $this->addQuotes( implode( ',', $gtidsWait ) );
                        if ( strpos( $gtidArg, ':' ) !== false ) {
                                // MySQL GTIDs, e.g "source_id:transaction_id"
-                               $res = $this->doQuery( "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)" );
+                               $sql = "SELECT WAIT_FOR_EXECUTED_GTID_SET($gtidArg, $timeout)";
                        } else {
                                // MariaDB GTIDs, e.g."domain:server:sequence"
-                               $res = $this->doQuery( "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)" );
+                               $sql = "SELECT MASTER_GTID_WAIT($gtidArg, $timeout)";
                        }
                } else {
                        // Wait on the binlog coordinates
                        $encFile = $this->addQuotes( $pos->getLogFile() );
                        $encPos = intval( $pos->getLogPosition()[$pos::CORD_EVENT] );
-                       $res = $this->doQuery( "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)" );
+                       $sql = "SELECT MASTER_POS_WAIT($encFile, $encPos, $timeout)";
                }
 
+               list( $res, $err ) = $this->executeQuery( $sql, __METHOD__, self::QUERY_IGNORE_DBO_TRX );
                $row = $res ? $this->fetchRow( $res ) : false;
                if ( !$row ) {
-                       throw new DBExpectedError( $this, "Replication wait failed: {$this->lastError()}" );
+                       throw new DBExpectedError( $this, "Replication wait failed: {$err}" );
                }
 
                // Result can be NULL (error), -1 (timeout), or 0+ per the MySQL manual
index 8e1b06d..3722ef4 100644 (file)
@@ -216,7 +216,7 @@ class DatabaseSqlite extends Database {
                        # Enforce LIKE to be case sensitive, just like MySQL
                        $this->query( 'PRAGMA case_sensitive_like = 1' );
 
-                       $sync = $this->sessionVars['synchronous'] ?? null;
+                       $sync = $this->connectionVariables['synchronous'] ?? null;
                        if ( in_array( $sync, [ 'EXTRA', 'FULL', 'NORMAL' ], true ) ) {
                                $this->query( "PRAGMA synchronous = $sync" );
                        }
index 90e30fa..89a66e8 100644 (file)
@@ -115,6 +115,8 @@ interface IDatabase {
        const QUERY_PSEUDO_PERMANENT = 2;
        /** @var int Enforce that a query does not make effective writes */
        const QUERY_REPLICA_ROLE = 4;
+       /** @var int Ignore the current presence of any DBO_TRX flag */
+       const QUERY_IGNORE_DBO_TRX = 8;
 
        /** @var bool Parameter to unionQueries() for UNION ALL */
        const UNION_ALL = true;
@@ -165,8 +167,10 @@ interface IDatabase {
        /**
         * Get the UNIX timestamp of the time that the transaction was established
         *
-        * This can be used to reason about the staleness of SELECT data
-        * in REPEATABLE-READ transaction isolation level.
+        * This can be used to reason about the staleness of SELECT data in REPEATABLE-READ
+        * transaction isolation level. Callers can assume that if a view-snapshot isolation
+        * is used, then the data read by SQL queries is *at least* up to date to that point
+        * (possibly more up-to-date since the first SELECT defines the snapshot).
         *
         * @return float|null Returns null if there is not active transaction
         * @since 1.25
index 8608a7d..f48487a 100644 (file)
@@ -85,7 +85,9 @@ abstract class LBFactory implements ILBFactory {
        /** @var callable[] */
        private $replicationWaitCallbacks = [];
 
-       /** @var mixed */
+       /** var int An identifier for this class instance */
+       private $id;
+       /** @var int|null Ticket used to delegate transaction ownership */
        private $ticket;
        /** @var string|bool String if a requested DBO_TRX transaction round is active */
        private $trxRoundId = false;
@@ -153,6 +155,7 @@ abstract class LBFactory implements ILBFactory {
                $this->defaultGroup = $conf['defaultGroup'] ?? null;
                $this->secret = $conf['secret'] ?? '';
 
+               $this->id = mt_rand();
                $this->ticket = mt_rand();
        }
 
@@ -251,7 +254,7 @@ abstract class LBFactory implements ILBFactory {
                }
                $this->trxRoundId = $fname;
                // Set DBO_TRX flags on all appropriate DBs
-               $this->forEachLBCallMethod( 'beginMasterChanges', [ $fname ] );
+               $this->forEachLBCallMethod( 'beginMasterChanges', [ $fname, $this->id ] );
                $this->trxRoundStage = self::ROUND_CURSORY;
        }
 
@@ -269,17 +272,17 @@ abstract class LBFactory implements ILBFactory {
                // Run pre-commit callbacks and suppress post-commit callbacks, aborting on failure
                do {
                        $count = 0; // number of callbacks executed this iteration
-                       $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$count ) {
-                               $count += $lb->finalizeMasterChanges();
+                       $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$count, $fname ) {
+                               $count += $lb->finalizeMasterChanges( $fname, $this->id );
                        } );
                } while ( $count > 0 );
                $this->trxRoundId = false;
                // Perform pre-commit checks, aborting on failure
-               $this->forEachLBCallMethod( 'approveMasterChanges', [ $options ] );
+               $this->forEachLBCallMethod( 'approveMasterChanges', [ $options, $fname, $this->id ] );
                // Log the DBs and methods involved in multi-DB transactions
                $this->logIfMultiDbTransaction();
                // Actually perform the commit on all master DB connections and revert DBO_TRX
-               $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname ] );
+               $this->forEachLBCallMethod( 'commitMasterChanges', [ $fname, $this->id ] );
                // Run all post-commit callbacks in a separate step
                $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS;
                $e = $this->executePostTransactionCallbacks();
@@ -294,7 +297,7 @@ abstract class LBFactory implements ILBFactory {
                $this->trxRoundStage = self::ROUND_ROLLING_BACK;
                $this->trxRoundId = false;
                // Actually perform the rollback on all master DB connections and revert DBO_TRX
-               $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname ] );
+               $this->forEachLBCallMethod( 'rollbackMasterChanges', [ $fname, $this->id ] );
                // Run all post-commit callbacks in a separate step
                $this->trxRoundStage = self::ROUND_ROLLBACK_CALLBACKS;
                $this->executePostTransactionCallbacks();
@@ -305,17 +308,18 @@ abstract class LBFactory implements ILBFactory {
         * @return Exception|null
         */
        private function executePostTransactionCallbacks() {
+               $fname = __METHOD__;
                // Run all post-commit callbacks until new ones stop getting added
                $e = null; // first callback exception
                do {
-                       $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) {
-                               $ex = $lb->runMasterTransactionIdleCallbacks();
+                       $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e, $fname ) {
+                               $ex = $lb->runMasterTransactionIdleCallbacks( $fname, $this->id );
                                $e = $e ?: $ex;
                        } );
                } while ( $this->hasMasterChanges() );
                // Run all listener callbacks once
-               $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e ) {
-                       $ex = $lb->runMasterTransactionListenerCallbacks();
+               $this->forEachLB( function ( ILoadBalancer $lb ) use ( &$e, $fname ) {
+                       $ex = $lb->runMasterTransactionListenerCallbacks( $fname, $this->id );
                        $e = $e ?: $ex;
                } );
 
@@ -605,7 +609,8 @@ abstract class LBFactory implements ILBFactory {
                                // being called later (but before the first connection attempt) (T192611)
                                $this->getChronologyProtector()->applySessionReplicationPosition( $lb );
                        },
-                       'roundStage' => $initStage
+                       'roundStage' => $initStage,
+                       'ownerId' => $this->id
                ];
        }
 
@@ -614,7 +619,7 @@ abstract class LBFactory implements ILBFactory {
         */
        protected function initLoadBalancer( ILoadBalancer $lb ) {
                if ( $this->trxRoundId !== false ) {
-                       $lb->beginMasterChanges( $this->trxRoundId ); // set DBO_TRX
+                       $lb->beginMasterChanges( $this->trxRoundId, $this->id ); // set DBO_TRX
                }
 
                $lb->setTableAliases( $this->tableAliases );
index faa9654..0258878 100644 (file)
@@ -116,6 +116,7 @@ interface ILoadBalancer {
         *  - errorLogger : Callback that takes an Exception and logs it. [optional]
         *  - deprecationLogger: Callback to log a deprecation warning. [optional]
         *  - roundStage: STAGE_POSTCOMMIT_* class constant; for internal use [optional]
+        *  - ownerId: integer ID of an LBFactory instance that manages this instance [optional]
         * @throws InvalidArgumentException
         */
        public function __construct( array $params );
@@ -414,18 +415,21 @@ interface ILoadBalancer {
        /**
         * Commit transactions on all open connections
         * @param string $fname Caller name
+        * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID)
         * @throws DBExpectedError
         */
-       public function commitAll( $fname = __METHOD__ );
+       public function commitAll( $fname = __METHOD__, $owner = null );
 
        /**
         * Run pre-commit callbacks and defer execution of post-commit callbacks
         *
         * Use this only for mutli-database commits
         *
+        * @param string $fname Caller name
+        * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID)
         * @return int Number of pre-commit callbacks run (since 1.32)
         */
-       public function finalizeMasterChanges();
+       public function finalizeMasterChanges( $fname = __METHOD__, $owner = null );
 
        /**
         * Perform all pre-commit checks for things like replication safety
@@ -434,9 +438,11 @@ interface ILoadBalancer {
         *
         * @param array $options Includes:
         *   - maxWriteDuration : max write query duration time in seconds
+        * @param string $fname Caller name
+        * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID)
         * @throws DBTransactionError
         */
-       public function approveMasterChanges( array $options );
+       public function approveMasterChanges( array $options, $fname, $owner = null );
 
        /**
         * Flush any master transaction snapshots and set DBO_TRX (if DBO_DEFAULT is set)
@@ -447,38 +453,45 @@ interface ILoadBalancer {
         *   - commitAll()
         * This allows for custom transaction rounds from any outer transaction scope.
         *
-        * @param string $fname
+        * @param string $fname Caller name
+        * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID)
         * @throws DBExpectedError
         */
-       public function beginMasterChanges( $fname = __METHOD__ );
+       public function beginMasterChanges( $fname = __METHOD__, $owner = null );
 
        /**
         * Issue COMMIT on all open master connections to flush changes and view snapshots
         * @param string $fname Caller name
+        * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID)
         * @throws DBExpectedError
         */
-       public function commitMasterChanges( $fname = __METHOD__ );
+       public function commitMasterChanges( $fname = __METHOD__, $owner = null );
 
        /**
         * Consume and run all pending post-COMMIT/ROLLBACK callbacks and commit dangling transactions
         *
+        * @param string $fname Caller name
+        * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID)
         * @return Exception|null The first exception or null if there were none
         */
-       public function runMasterTransactionIdleCallbacks();
+       public function runMasterTransactionIdleCallbacks( $fname = __METHOD__, $owner = null );
 
        /**
         * Run all recurring post-COMMIT/ROLLBACK listener callbacks
         *
+        * @param string $fname Caller name
+        * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID)
         * @return Exception|null The first exception or null if there were none
         */
-       public function runMasterTransactionListenerCallbacks();
+       public function runMasterTransactionListenerCallbacks( $fname = __METHOD__, $owner = null );
 
        /**
         * Issue ROLLBACK only on master, only if queries were done on connection
         * @param string $fname Caller name
+        * @param int|null $owner ID of the calling instance (e.g. the LBFactory ID)
         * @throws DBExpectedError
         */
-       public function rollbackMasterChanges( $fname = __METHOD__ );
+       public function rollbackMasterChanges( $fname = __METHOD__, $owner = null );
 
        /**
         * Commit all replica DB transactions so as to flush any REPEATABLE-READ or SSI snapshots
index 51fda52..ffb7a34 100644 (file)
@@ -105,6 +105,8 @@ class LoadBalancer implements ILoadBalancer {
        private $errorConnection;
        /** @var int The generic (not query grouped) replica DB index */
        private $genericReadIndex = -1;
+       /** @var int[] The group replica DB indexes keyed by group */
+       private $readIndexByGroup = [];
        /** @var bool|DBMasterPos False if not set */
        private $waitForPos;
        /** @var bool Whether the generic reader fell back to a lagged replica DB */
@@ -122,6 +124,8 @@ class LoadBalancer implements ILoadBalancer {
        /** @var bool Whether any connection has been attempted yet */
        private $connectionAttempted = false;
 
+       /** @var int|null An integer ID of the managing LBFactory instance or null */
+       private $ownerId;
        /** @var string|bool String if a requested DBO_TRX transaction round is active */
        private $trxRoundId = false;
        /** @var string Stage of the current transaction round in the transaction round life-cycle */
@@ -249,6 +253,7 @@ class LoadBalancer implements ILoadBalancer {
                }
 
                $this->defaultGroup = $params['defaultGroup'] ?? null;
+               $this->ownerId = $params['ownerId'] ?? null;
        }
 
        public function getLocalDomainID() {
@@ -395,9 +400,12 @@ class LoadBalancer implements ILoadBalancer {
                if ( count( $this->servers ) == 1 ) {
                        // Skip the load balancing if there's only one server
                        return $this->getWriterIndex();
-               } elseif ( $group === false && $this->genericReadIndex >= 0 ) {
-                       // A generic reader index was already selected and "waitForPos" was handled
-                       return $this->genericReadIndex;
+               }
+
+               $index = $this->getExistingReaderIndex( $group );
+               if ( $index >= 0 ) {
+                       // A reader index was already selected and "waitForPos" was handled
+                       return $index;
                }
 
                if ( $group !== false ) {
@@ -432,11 +440,11 @@ class LoadBalancer implements ILoadBalancer {
                        $laggedReplicaMode = true;
                }
 
-               if ( $this->genericReadIndex < 0 && $this->genericLoads[$i] > 0 && $group === false ) {
-                       // Cache the generic (ungrouped) reader index for future DB_REPLICA handles
-                       $this->genericReadIndex = $i;
-                       // Record if the generic reader index is in "lagged replica DB" mode
-                       $this->laggedReplicaMode = ( $laggedReplicaMode || $this->laggedReplicaMode );
+               // Cache the reader index for future DB_REPLICA handles
+               $this->setExistingReaderIndex( $group, $i );
+               // Record whether the generic reader index is in "lagged replica DB" mode
+               if ( $group === false && $laggedReplicaMode ) {
+                       $this->laggedReplicaMode = true;
                }
 
                $serverName = $this->getServerName( $i );
@@ -445,6 +453,40 @@ class LoadBalancer implements ILoadBalancer {
                return $i;
        }
 
+       /**
+        * Get the server index chosen by the load balancer for use with the given query group
+        *
+        * @param string|bool $group Query group; use false for the generic group
+        * @return int Server index or -1 if none was chosen
+        */
+       protected function getExistingReaderIndex( $group ) {
+               if ( $group === false ) {
+                       $index = $this->genericReadIndex;
+               } else {
+                       $index = $this->readIndexByGroup[$group] ?? -1;
+               }
+
+               return $index;
+       }
+
+       /**
+        * Set the server index chosen by the load balancer for use with the given query group
+        *
+        * @param string|bool $group Query group; use false for the generic group
+        * @param int $index The index of a specific server
+        */
+       private function setExistingReaderIndex( $group, $index ) {
+               if ( $index < 0 ) {
+                       throw new UnexpectedValueException( "Cannot set a negative read server index" );
+               }
+
+               if ( $group === false ) {
+                       $this->genericReadIndex = $index;
+               } else {
+                       $this->readIndexByGroup[$group] = $index;
+               }
+       }
+
        /**
         * @param array $loads List of server weights
         * @param string|bool $domain
@@ -1328,13 +1370,14 @@ class LoadBalancer implements ILoadBalancer {
                $conn->close();
        }
 
-       public function commitAll( $fname = __METHOD__ ) {
-               $this->commitMasterChanges( $fname );
+       public function commitAll( $fname = __METHOD__, $owner = null ) {
+               $this->commitMasterChanges( $fname, $owner );
                $this->flushMasterSnapshots( $fname );
                $this->flushReplicaSnapshots( $fname );
        }
 
-       public function finalizeMasterChanges() {
+       public function finalizeMasterChanges( $fname = __METHOD__, $owner = null ) {
+               $this->assertOwnership( $fname, $owner );
                $this->assertTransactionRoundStage( [ self::ROUND_CURSORY, self::ROUND_FINALIZED ] );
 
                $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise
@@ -1358,7 +1401,8 @@ class LoadBalancer implements ILoadBalancer {
                return $total;
        }
 
-       public function approveMasterChanges( array $options ) {
+       public function approveMasterChanges( array $options, $fname = __METHOD__, $owner = null ) {
+               $this->assertOwnership( $fname, $owner );
                $this->assertTransactionRoundStage( self::ROUND_FINALIZED );
 
                $limit = $options['maxWriteDuration'] ?? 0;
@@ -1391,7 +1435,8 @@ class LoadBalancer implements ILoadBalancer {
                $this->trxRoundStage = self::ROUND_APPROVED;
        }
 
-       public function beginMasterChanges( $fname = __METHOD__ ) {
+       public function beginMasterChanges( $fname = __METHOD__, $owner = null ) {
+               $this->assertOwnership( $fname, $owner );
                if ( $this->trxRoundId !== false ) {
                        throw new DBTransactionError(
                                null,
@@ -1415,7 +1460,8 @@ class LoadBalancer implements ILoadBalancer {
                $this->trxRoundStage = self::ROUND_CURSORY;
        }
 
-       public function commitMasterChanges( $fname = __METHOD__ ) {
+       public function commitMasterChanges( $fname = __METHOD__, $owner = null ) {
+               $this->assertOwnership( $fname, $owner );
                $this->assertTransactionRoundStage( self::ROUND_APPROVED );
 
                $failures = [];
@@ -1453,7 +1499,8 @@ class LoadBalancer implements ILoadBalancer {
                $this->trxRoundStage = self::ROUND_COMMIT_CALLBACKS;
        }
 
-       public function runMasterTransactionIdleCallbacks() {
+       public function runMasterTransactionIdleCallbacks( $fname = __METHOD__, $owner = null ) {
+               $this->assertOwnership( $fname, $owner );
                if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) {
                        $type = IDatabase::TRIGGER_COMMIT;
                } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) {
@@ -1522,7 +1569,8 @@ class LoadBalancer implements ILoadBalancer {
                return $e;
        }
 
-       public function runMasterTransactionListenerCallbacks() {
+       public function runMasterTransactionListenerCallbacks( $fname = __METHOD__, $owner = null ) {
+               $this->assertOwnership( $fname, $owner );
                if ( $this->trxRoundStage === self::ROUND_COMMIT_CALLBACKS ) {
                        $type = IDatabase::TRIGGER_COMMIT;
                } elseif ( $this->trxRoundStage === self::ROUND_ROLLBACK_CALLBACKS ) {
@@ -1549,7 +1597,9 @@ class LoadBalancer implements ILoadBalancer {
                return $e;
        }
 
-       public function rollbackMasterChanges( $fname = __METHOD__ ) {
+       public function rollbackMasterChanges( $fname = __METHOD__, $owner = null ) {
+               $this->assertOwnership( $fname, $owner );
+
                $restore = ( $this->trxRoundId !== false );
                $this->trxRoundId = false;
                $this->trxRoundStage = self::ROUND_ERROR; // "failed" until proven otherwise
@@ -1567,6 +1617,7 @@ class LoadBalancer implements ILoadBalancer {
 
        /**
         * @param string|string[] $stage
+        * @throws DBTransactionError
         */
        private function assertTransactionRoundStage( $stage ) {
                $stages = (array)$stage;
@@ -1585,6 +1636,20 @@ class LoadBalancer implements ILoadBalancer {
                }
        }
 
+       /**
+        * @param string $fname
+        * @param int|null $owner Owner ID of the caller
+        * @throws DBTransactionError
+        */
+       private function assertOwnership( $fname, $owner ) {
+               if ( $this->ownerId !== null && $owner !== $this->ownerId ) {
+                       throw new DBTransactionError(
+                               null,
+                               "$fname: LoadBalancer is owned by LBFactory #{$this->ownerId} (got '$owner')."
+                       );
+               }
+       }
+
        /**
         * Make all DB servers with DBO_DEFAULT/DBO_TRX set join the transaction round
         *
index 5ef0135..679b1c3 100644 (file)
@@ -91,15 +91,6 @@ class BufferingStatsdDataFactory extends StatsdDataFactory implements IBuffering
                return $entity;
        }
 
-       /**
-        * @deprecated since 1.30 Use getData() instead
-        * @return StatsdData[]
-        */
-       public function getBuffer() {
-               wfDeprecated( __METHOD__, '1.30' );
-               return $this->buffer;
-       }
-
        public function hasData() {
                return !empty( $this->buffer );
        }
index 8078e2e..048b567 100644 (file)
@@ -270,8 +270,6 @@ class DeleteLogFormatter extends LogFormatter {
                                }
                        }
 
-                       $old = $this->parseBitField( $rawParams['6::ofield'] );
-                       $new = $this->parseBitField( $rawParams['7::nfield'] );
                        if ( !is_array( $rawParams['5::ids'] ) ) {
                                $rawParams['5::ids'] = explode( ',', $rawParams['5::ids'] );
                        }
@@ -279,8 +277,6 @@ class DeleteLogFormatter extends LogFormatter {
                        $params = [
                                '::type' => $rawParams['4::type'],
                                ':array:ids' => $rawParams['5::ids'],
-                               ':assoc:old' => [ 'bitmask' => $old ],
-                               ':assoc:new' => [ 'bitmask' => $new ],
                        ];
 
                        static $fields = [
@@ -289,9 +285,20 @@ class DeleteLogFormatter extends LogFormatter {
                                Revision::DELETED_USER => 'user',
                                Revision::DELETED_RESTRICTED => 'restricted',
                        ];
-                       foreach ( $fields as $bit => $key ) {
-                               $params[':assoc:old'][$key] = (bool)( $old & $bit );
-                               $params[':assoc:new'][$key] = (bool)( $new & $bit );
+
+                       if ( isset( $rawParams['6::ofield'] ) ) {
+                               $old = $this->parseBitField( $rawParams['6::ofield'] );
+                               $params[':assoc:old'] = [ 'bitmask' => $old ];
+                               foreach ( $fields as $bit => $key ) {
+                                       $params[':assoc:old'][$key] = (bool)( $old & $bit );
+                               }
+                       }
+                       if ( isset( $rawParams['7::nfield'] ) ) {
+                               $new = $this->parseBitField( $rawParams['7::nfield'] );
+                               $params[':assoc:new'] = [ 'bitmask' => $new ];
+                               foreach ( $fields as $bit => $key ) {
+                                       $params[':assoc:new'][$key] = (bool)( $new & $bit );
+                               }
                        }
                } elseif ( $subtype === 'restore' ) {
                        $rawParams = $entry->getParameters();
index 5313ca4..8a5f591 100644 (file)
@@ -250,7 +250,7 @@ class SqlBagOStuff extends BagOStuff {
                return false;
        }
 
-       public function getMulti( array $keys, $flags = 0 ) {
+       protected function doGetMulti( array $keys, $flags = 0 ) {
                $values = [];
 
                $blobs = $this->fetchBlobMulti( $keys );
@@ -261,7 +261,7 @@ class SqlBagOStuff extends BagOStuff {
                return $values;
        }
 
-       public function fetchBlobMulti( array $keys, $flags = 0 ) {
+       protected function fetchBlobMulti( array $keys, $flags = 0 ) {
                $values = []; // array of (key => value)
 
                $keysByTable = [];
@@ -391,8 +391,8 @@ class SqlBagOStuff extends BagOStuff {
                return $result;
        }
 
-       public function set( $key, $value, $exptime = 0, $flags = 0 ) {
-               $ok = $this->setMulti( [ $key => $value ], $exptime );
+       protected function doSet( $key, $value, $exptime = 0, $flags = 0 ) {
+               $ok = $this->insertMulti( [ $key => $value ], $exptime, $flags, true );
 
                return $ok;
        }
@@ -446,6 +446,10 @@ class SqlBagOStuff extends BagOStuff {
        }
 
        public function deleteMulti( array $keys, $flags = 0 ) {
+               return $this->purgeMulti( $keys, $flags );
+       }
+
+       public function purgeMulti( array $keys, $flags = 0 ) {
                $keysByTable = [];
                foreach ( $keys as $key ) {
                        list( $serverIndex, $tableName ) = $this->getTableByKey( $key );
@@ -482,8 +486,8 @@ class SqlBagOStuff extends BagOStuff {
                return $result;
        }
 
-       public function delete( $key, $flags = 0 ) {
-               $ok = $this->deleteMulti( [ $key ], $flags );
+       protected function doDelete( $key, $flags = 0 ) {
+               $ok = $this->purgeMulti( [ $key ], $flags );
 
                return $ok;
        }
@@ -730,10 +734,10 @@ class SqlBagOStuff extends BagOStuff {
         * On typical message and page data, this can provide a 3X decrease
         * in storage requirements.
         *
-        * @param mixed &$data
+        * @param mixed $data
         * @return string
         */
-       protected function serialize( &$data ) {
+       protected function serialize( $data ) {
                $serial = serialize( $data );
 
                if ( function_exists( 'gzdeflate' ) ) {
index 8df9ab2..013dd75 100644 (file)
@@ -152,7 +152,7 @@ class WikiFilePage extends WikiPage {
                $size = $this->mFile->getSize();
 
                /**
-                * @var $file File
+                * @var File $file
                 */
                foreach ( $dupes as $index => $file ) {
                        $key = $file->getRepoName() . ':' . $file->getName();
index faaaece..e71de84 100644 (file)
@@ -65,6 +65,7 @@ class ExtensionProcessor implements Processor {
        protected static $coreAttributes = [
                'SkinOOUIThemes',
                'TrackingCategories',
+               'RestRoutes',
        ];
 
        /**
index 515f287..2ae6d74 100644 (file)
@@ -196,8 +196,7 @@ class ResourceLoader implements LoggerAwareInterface {
                $cache = ObjectCache::getLocalServerInstance( CACHE_ANYTHING );
 
                $key = $cache->makeGlobalKey(
-                       'resourceloader',
-                       'filter',
+                       'resourceloader-filter',
                        $filter,
                        self::CACHE_VERSION,
                        md5( $data )
@@ -1709,7 +1708,6 @@ MESSAGE;
         * @param bool $printable
         * @param bool $handheld
         * @param array $extraQuery
-        *
         * @return array
         */
        public static function makeLoaderQuery( $modules, $lang, $skin, $user = null,
@@ -1718,9 +1716,17 @@ MESSAGE;
        ) {
                $query = [
                        'modules' => self::makePackedModulesString( $modules ),
-                       'lang' => $lang,
-                       'skin' => $skin,
                ];
+               // Keep urls short by omitting query parameters that
+               // match the defaults assumed by ResourceLoaderContext.
+               // Note: This relies on the defaults either being insignificant or forever constant,
+               // as otherwise cached urls could change in meaning when the defaults change.
+               if ( $lang !== 'qqx' ) {
+                       $query['lang'] = $lang;
+               }
+               if ( $skin !== 'fallback' ) {
+                       $query['skin'] = $skin;
+               }
                if ( $debug === true ) {
                        $query['debug'] = 'true';
                }
index 58152ea..c596a23 100644 (file)
@@ -134,6 +134,8 @@ class ResourceLoaderContext implements MessageLocalizer {
        }
 
        /**
+        * @deprecated since 1.34 Use ResourceLoaderModule::getConfig instead
+        * inside module methods. Use ResourceLoader::getConfig elsewhere.
         * @return Config
         */
        public function getConfig() {
@@ -148,6 +150,8 @@ class ResourceLoaderContext implements MessageLocalizer {
        }
 
        /**
+        * @deprecated since 1.34 Use ResourceLoaderModule::getLogger instead
+        * inside module methods. Use ResourceLoader::getLogger elsewhere.
         * @since 1.27
         * @return \Psr\Log\LoggerInterface
         */
index 66a4edf..dd7857e 100644 (file)
@@ -954,8 +954,7 @@ abstract class ResourceLoaderModule implements LoggerAwareInterface {
                $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
                return $cache->getWithSetCallback(
                        $cache->makeGlobalKey(
-                               'resourceloader',
-                               'jsparse',
+                               'resourceloader-jsparse',
                                self::$parseCacheVersion,
                                md5( $contents ),
                                $fileName
index d6cc646..efed418 100644 (file)
@@ -258,7 +258,7 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
                        }
 
                        if ( $versionHash !== '' && strlen( $versionHash ) !== 7 ) {
-                               $context->getLogger()->warning(
+                               $this->getLogger()->warning(
                                        "Module '{module}' produced an invalid version hash: '{version}'.",
                                        [
                                                'module' => $name,
index 4c11fce..d37c31b 100644 (file)
@@ -484,7 +484,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
 
                $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
                $allInfo = $cache->getWithSetCallback(
-                       $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID(), $hash ),
+                       $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID(), $hash ),
                        $cache::TTL_HOUR,
                        function ( $curVal, &$ttl, array &$setOpts ) use ( $func, $pageNames, $db, $fname ) {
                                $setOpts += Database::getCacheSetOptions( $db );
@@ -493,7 +493,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
                        },
                        [
                                'checkKeys' => [
-                                       $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $db->getDomainID() ) ]
+                                       $cache->makeGlobalKey( 'resourceloader-titleinfo', $db->getDomainID() ) ]
                        ]
                );
 
@@ -550,7 +550,7 @@ class ResourceLoaderWikiModule extends ResourceLoaderModule {
 
                if ( $purge ) {
                        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
-                       $key = $cache->makeGlobalKey( 'resourceloader', 'titleinfo', $domain );
+                       $key = $cache->makeGlobalKey( 'resourceloader-titleinfo', $domain );
                        $cache->touchCheckKey( $key );
                }
        }
index 5f80215..e1606b2 100644 (file)
@@ -166,14 +166,10 @@ class SpecialEmailUser extends UnlistedSpecialPage {
         * Validate target User
         *
         * @param string $target Target user name
-        * @param User|null $sender User sending the email
+        * @param User $sender User sending the email
         * @return User|string User object on success or a string on error
         */
-       public static function getTarget( $target, User $sender = null ) {
-               if ( $sender === null ) {
-                       wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' );
-               }
-
+       public static function getTarget( $target, User $sender ) {
                if ( $target == '' ) {
                        wfDebug( "Target is empty.\n" );
 
@@ -190,15 +186,11 @@ class SpecialEmailUser extends UnlistedSpecialPage {
         * Validate target User
         *
         * @param User $target Target user
-        * @param User|null $sender User sending the email
+        * @param User $sender User sending the email
         * @return string Error message or empty string if valid.
         * @since 1.30
         */
-       public static function validateTarget( $target, User $sender = null ) {
-               if ( $sender === null ) {
-                       wfDeprecated( __METHOD__ . ' without specifying the sending user', '1.30' );
-               }
-
+       public static function validateTarget( $target, User $sender ) {
                if ( !$target instanceof User || !$target->getId() ) {
                        wfDebug( "Target is invalid user.\n" );
 
@@ -217,25 +209,21 @@ class SpecialEmailUser extends UnlistedSpecialPage {
                        return 'nowikiemail';
                }
 
-               if ( $sender !== null && !$target->getOption( 'email-allow-new-users' ) &&
-                       $sender->isNewbie()
-               ) {
+               if ( !$target->getOption( 'email-allow-new-users' ) && $sender->isNewbie() ) {
                        wfDebug( "User does not allow user emails from new users.\n" );
 
                        return 'nowikiemail';
                }
 
-               if ( $sender !== null ) {
-                       $blacklist = $target->getOption( 'email-blacklist', '' );
-                       if ( $blacklist ) {
-                               $blacklist = MultiUsernameFilter::splitIds( $blacklist );
-                               $lookup = CentralIdLookup::factory();
-                               $senderId = $lookup->centralIdFromLocalUser( $sender );
-                               if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) {
-                                       wfDebug( "User does not allow user emails from this user.\n" );
+               $blacklist = $target->getOption( 'email-blacklist', '' );
+               if ( $blacklist ) {
+                       $blacklist = MultiUsernameFilter::splitIds( $blacklist );
+                       $lookup = CentralIdLookup::factory();
+                       $senderId = $lookup->centralIdFromLocalUser( $sender );
+                       if ( $senderId !== 0 && in_array( $senderId, $blacklist ) ) {
+                               wfDebug( "User does not allow user emails from this user.\n" );
 
-                                       return 'nowikiemail';
-                               }
+                               return 'nowikiemail';
                        }
                }
 
index 15b7c63..252df5b 100644 (file)
@@ -597,7 +597,15 @@ class MovePageForm extends UnlistedSpecialPage {
                # Do the actual move.
                $mp = new MovePage( $ot, $nt );
 
+               # check whether the requested actions are permitted / possible
                $userPermitted = $mp->checkPermissions( $user, $this->reason )->isOK();
+               if ( $ot->isTalkPage() || $nt->isTalkPage() ) {
+                       $this->moveTalk = false;
+               }
+               if ( $this->moveSubpages ) {
+                       $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+                       $this->moveSubpages = $permissionManager->userCan( 'move-subpages', $user, $ot );
+               }
 
                $status = $mp->moveIfAllowed( $user, $this->reason, $createRedirect );
                if ( !$status->isOK() ) {
@@ -646,19 +654,11 @@ class MovePageForm extends UnlistedSpecialPage {
                $movePage = $this;
                Hooks::run( 'SpecialMovepageAfterMove', [ &$movePage, &$ot, &$nt ] );
 
-               # Now we move extra pages we've been asked to move: subpages and talk
-               # pages.  First, if the old page or the new page is a talk page, we
-               # can't move any talk pages: cancel that.
-               if ( $ot->isTalkPage() || $nt->isTalkPage() ) {
-                       $this->moveTalk = false;
-               }
-
-               if ( count( $ot->getUserPermissionsErrors( 'move-subpages', $user ) ) ) {
-                       $this->moveSubpages = false;
-               }
-
-               /**
-                * Next make a list of id's.  This might be marginally less efficient
+               /*
+                * Now we move extra pages we've been asked to move: subpages and talk
+                * pages.
+                *
+                * First, make a list of id's.  This might be marginally less efficient
                 * than a more direct method, but this is not a highly performance-cri-
                 * tical code path and readable code is more important here.
                 *
index bdcb17b..e5dfceb 100644 (file)
@@ -1822,8 +1822,7 @@ class User implements IDBAccessObject, UserIdentity {
                        $fromReplica
                );
 
-               if ( $block instanceof AbstractBlock ) {
-                       wfDebug( __METHOD__ . ": Found block.\n" );
+               if ( $block ) {
                        $this->mBlock = $block;
                        $this->mBlockedby = $block->getByName();
                        $this->mBlockreason = $block->getReason();
index c28c1b6..9bc0ab2 100644 (file)
        "content-model-wikitext": "ويكى تكست",
        "content-model-text": "كلام عادى",
        "content-model-javascript": "جاڤاسكربت",
-       "expensive-parserfunction-warning": "<strong>تحذير:</strong> الصفحه دى فيهااستدعاءات دالة محلل كثيرة مكلفة.\n\nلازم تكون أقل من $2 {{PLURAL:$2|استدعاء|استدعاء}}، يوجد {{PLURAL:$1|الآن $1 استدعاء|الآن $1 استدعاء}}.",
+       "expensive-parserfunction-warning": "<strong>تحذير:</strong> الصفحه دى فيهااستدعاءات دالة محلل كثيرة مكلفة.\n\nلازم تكون أقل من $2 {{PLURAL:$2|استدعاء}}، يوجد {{PLURAL:$1|الآن $1 استدعاء}}.",
        "expensive-parserfunction-category": "صفحات فيها استدعاءات دوال محلل كثيرة ومكلفة",
        "post-expand-template-inclusion-warning": "<strong>تحذير:</strong> حجم تضمين القالب كبير قوي.\nبعض القوالب مش ح تتضمن.",
        "post-expand-template-inclusion-category": "الصفحات اللى تم تجاوز حجم تضمين القالب فيها",
        "upload_directory_missing": "مجلد التحميل($1) ضايع السيرفير وماقدرش يعمل واحد تاني.",
        "upload_directory_read_only": "مجلد التحميل ($1) مش ممكن الكتابة عليه بواسطة سيرڨر الويب.",
        "uploaderror": "غلطه فى التحميل",
-       "uploadtext": "استخدم الاستمارة علشان تحميل الملفات.\nلعرض أو البحث ف الملفات المتحملة سابقا، راجع عمليات [[Special:Log/delete|المسح]]، عمليات التحميل  موجودة فى [[Special:Log/upload|سجل التحميل]].\n\nعلشان تحط صورة فى صفحة، استخدم الوصلات فى الصيغ التالية:\n* <strong><code><nowiki>[[</nowiki>{{ns:file}}<nowiki>:File.jpg]]</nowiki></code></strong> علشان استخدام النسخة الكاملة لملف\n* <strong><code><nowiki>[[</nowiki>{{ns:file}}<nowiki>:File.png|200px|thumb|left|نص بديل]]</nowiki></code></strong> لاستخدام صورة عرضها 200 بكسل فى صندوق فى الجانب الأيسر مع \"نص بديل\" كوصف\n* <strong><code><nowiki>[[</nowiki>{{ns:media}}<nowiki>:File.ogg]]</nowiki></code></strong> للوصل للملف مباشرة بدون عرض الملف",
+       "uploadtext": "استخدم الاستمارة علشان تحميل الملفات.\nعلشان تشوف او تدور فى الفايلات اللى اتحملت قبل كده روح على [[Special:FileList|ليسته الفايلات اللى اتحملت]]، عمليات التحميل  موجودة فى [[Special:Log/upload|سجل التحميل]]، والحذف فى [[Special:Log/delete|سجل المسح]].\n\nعلشان تحط صورة فى صفحة، استخدم الوصلات فى الصيغ التالية:\n* <strong><code><nowiki>[[</nowiki>{{ns:file}}<nowiki>:File.jpg]]</nowiki></code></strong> علشان استخدام النسخة الكاملة لملف\n* <strong><code><nowiki>[[</nowiki>{{ns:file}}<nowiki>:File.png|200px|thumb|left|نص بديل]]</nowiki></code></strong> لاستخدام صورة عرضها 200 بكسل فى صندوق فى الجانب الأيسر مع \"نص بديل\" كوصف\n* <strong><code><nowiki>[[</nowiki>{{ns:media}}<nowiki>:File.ogg]]</nowiki></code></strong> للوصل للملف مباشرة بدون عرض الملف",
        "upload-permitted": "{{PLURAL:$2|نوع|انواع}} الملفات اللى مسموح بيها: $1.",
        "upload-preferred": "{{PLURAL:$2|نوع|انواع}} الملفات المفضله: $1.",
        "upload-prohibited": "{{PLURAL:$2|نوع|انواع}} الملفات الممنوعه: $1.",
index 5552739..ed29781 100644 (file)
        "category-article-count": "{{PLURAL:$2|Esta categoría contien namái la páxina siguiente.|{{PLURAL:$1|La páxina siguiente ta|Les $1 páxines siguientes tán}} nesta categoría, d'un total de $2.}}",
        "category-article-count-limited": "{{PLURAL:$1|La páxina siguiente ta|Les $1 páxines siguientes tán}} na categoría actual.",
        "category-file-count": "{{PLURAL:$2|Esta categoría contien namái el ficheru siguiente.|{{PLURAL:$1|El ficheru siguiente ta|Los $1 ficheros siguientes tán}} nesta categoría, d'un total de $2.}}",
-       "category-file-count-limited": "{{PLURAL:$1|El ficheru siguiente ta|Los $1 ficheeros siguientes tán}} na categoría actual.",
+       "category-file-count-limited": "{{PLURAL:$1|El ficheru siguiente ta|Los $1 ficheros siguientes tán}} na categoría actual.",
        "listingcontinuesabbrev": "cont.",
        "index-category": "Páxines indexaes",
        "noindex-category": "Páxines sin indexar",
        "session_fail_preview_html": "¡Sentímoslo! Nun pudo procesase la to edición por aciu d'una perda de datos de la sesión.\n\n<em>Como {{SITENAME}} tien el HTML puru activáu, la vista previa ta tapecida como precaución escontra ataques en JavaScript.</em>\n\n<strong>Si esti ye un intentu llexítimu d'edición, por favor vuelvi a intentalo.</strong>\nSi inda nun funciona, intenta [[Special:UserLogout|colar]] y volver a aniciar sesión, y comprueba que'l to restolador permite les cookies d'esti sitiu.",
        "token_suffix_mismatch": "'''La to edición nun s'aceutó porque'l to navegador mutiló los caráuteres de puntuación nel editor.'''\nLa edición nun foi aceutada pa prevenir corrupciones na páxina de testu.\nDacuando esto pasa por usar un serviciu proxy anónimu basáu en web que tenga fallos.",
        "edit_form_incomplete": "'''Delles partes del formulariu d'edición nun llegaron al sirvidor; comprueba que les ediciones tean intactes y vuelvi a tentalo.'''",
-       "editing": "Editando $1",
+       "editing": "Edición de «$1»",
        "creating": "Creando $1",
        "editingsection": "Editando $1 (seición)",
        "editingcomment": "Editando $1 (seición nueva)",
index 0f8d534..44ddda8 100644 (file)
@@ -15,7 +15,8 @@
                        "Macofe",
                        "Matěj Suchánek",
                        "Rachitrali",
-                       "Sultanselim baloch"
+                       "Sultanselim baloch",
+                       "FarsiNevis"
                ]
        },
        "tog-underline": ":لینکاں کِشک کن",
        "redirectedfrom": "(غیر مستقیم بوتگ چه $1)",
        "redirectpagesub": "صفحه غیر مستقیم",
        "redirectto": "مسیری ٹگل داتین بی:",
-       "lastmodifiedat": "  $2, $1.ای صفحه اهری تغییر دهگ بیته",
+       "lastmodifiedat": "اے تاک گُڈی برا $1 $2 ئا ٹگل دیگ بیتہ",
        "viewcount": "ای صفحه دسترسی بیتگ {{PLURAL:$1|بار|$1رند}}.",
        "protectedpage": "صفحه محافظتی",
        "jumpto": "کپ به:",
        "pool-queuefull": "مهزنء صف پر انت",
        "pool-errorunknown": "ناپجارین ارور",
        "pool-servererror": "سرویسء پول سینٹر ودی نبیت ($1).",
-       "aboutsite": "باره {{SITENAME}}",
+       "aboutsite": "{{SITENAME}}ءِ بارہ‌ئا",
        "aboutpage": "Project:باره",
        "copyright": "محتوا مان اجازت نامهٔ $1 انت مگان ایشی که آئی هلاپء آرگ ببیت انت.",
        "copyrightpage": "{{ns:project}}:حق کپی",
        "currentevents": "هنوکین رویداد",
        "currentevents-url": "Project:هنوکین رویداد",
-       "disclaimers": "بÛ\8c Ù\85Û\8cارÛ\8c Ú¯Û\8cاÙ\86",
+       "disclaimers": "بÛ\92 Ù\85Û\8cارÛ\8c",
        "disclaimerpage": "Project:عمومی بی میاریگان",
        "edithelp": "کمک اصلاح",
        "helppage-top-gethelp": "کومک",
        "policy-url": "Project:سیاست",
        "portal": "دیوانءِ درگت",
        "portal-url": "Project:پرتال انجمن",
-       "privacy": "سÛ\8cاست Ø­Ù\81ظ Ø§Ø³Ø±Ø§Ø±",
+       "privacy": "رازدارÛ\8cØ¡Ù\90 Ù¾Ø¦Û\8cÙ\85",
        "privacypage": "Project:سیاست حفظ اسرار",
        "badaccess": "حطا اجازت",
        "badaccess-group0": "شما مجاز نهیت عملی که درخواست کت اجرا کنیت",
        "newuserlogpagetext": ".شی یک ورودی چه شرکتن کاربر",
        "rightslog": "ورودان حقوق کاربر",
        "rightslogtext": "شی یک آماری چه تغییرات په حقوق کاربری انت.",
-       "action-read": "وانگ این صفحه",
+       "action-read": "اے تاکءِ وانگ",
        "action-edit": "اصلاح ای صفحه",
        "action-createpage": "شرکتن ای صفحه",
        "action-createtalk": "شرکتن صفحات بحث",
        "backend-fail-batchsize": "دسته‌ای مشتمل بر $1 {{PLURAL:$1|عملکرد|عملکرد}} پرونده به پشتیبان ذخیره داده شد؛ حداکثر مجاز $2 {{PLURAL:$2|عملکرد|عملکرد}} است.",
        "backend-fail-usable": "امکان خواندن یا نوشتن پروندهٔ $1 وجود نداشت چرا که سطح دسترسی کافی نیست یا شاخه/محفظهٔ مورد نظر وجود ندارد.",
        "filejournal-fail-dbconnect": "امکان وصل شدن به پایگاه داده دفترخانه برای پشتیبان ذخیره‌سازی «$1» وجود نداشت.",
-       "filejournal-fail-dbquery": "اÙ\85کاÙ\86 Ø¨Ù\87 Ø±Ù\88ز Ú©Ø±Ø¯Ù\86 Ù¾Ø§Û\8cگاÙ\87 Ø¯Ø§Ø¯Ù\87 دفترخانه برای پشتیبان ذخیره‌سازی «$1» وجود نداشت.",
+       "filejournal-fail-dbquery": "اÙ\85کاÙ\86 Ø±Ù\88زاÙ\85دسازÛ\8c Ø¯Ø§Ø¯Ú¯Ø§Ù\86 دفترخانه برای پشتیبان ذخیره‌سازی «$1» وجود نداشت.",
        "lockmanager-notlocked": "نمی‌توان قفل «$1» را گشود؛ چون قفل نشده‌است.",
        "lockmanager-fail-closelock": "امکان بستن پرونده قفل شده \"$1\" وجود ندارد.",
        "lockmanager-fail-deletelock": "امکان حذف پرونده قفل شده \"$1\" وجود ندارد.",
index 63611ff..fec1795 100644 (file)
        "unwatch": "Не назіраць",
        "unwatchthispage": "Перастаць назіраць",
        "notanarticle": "Не старонка зьместу",
-       "notvisiblerev": "Ð\92Ñ\8dÑ\80Ñ\81Ñ\96Ñ\8f была выдаленая",
+       "notvisiblerev": "Ð\90поÑ\88нÑ\8fÑ\8f Ð²Ñ\8dÑ\80Ñ\81Ñ\96Ñ\8f Ð°Ñ\9eÑ\82аÑ\80Ñ\81Ñ\82ва Ñ\96нÑ\88ага Ñ\9eдзелÑ\8cнÑ\96ка была выдаленая",
        "watchlist-details": "У вашым сьпісе назіраньня $1 {{PLURAL:$1|старонка|старонкі|старонак}} (плюс старонкі размоваў).",
-       "wlheader-enotif": "Апавяшчэньне па e-mail уключанае.",
+       "wlheader-enotif": "Апавяшчэньне праз электронную пошту ўключанае.",
        "wlheader-showupdated": "Старонкі, зьмененыя з часу вашага апошняга візыту, вылучаныя <strong>тоўстым</strong> шрыфтам.",
        "wlnote": "Ніжэй {{PLURAL:$1|паказаная <strong>$1</strong> апошняя зьмена|паказаныя <strong>$1</strong> апошнія зьмены|паказаныя <strong>$1</strong> апошніх зьменаў}} за <strong>$2</strong> {{PLURAL:$2|гадзіну|гадзіны|гадзінаў}}, па стане на $4 $3.",
        "wlshowlast": "Паказаць за апошнія $1 гадзінаў, $2 дзён",
index 042613f..f1bbf9b 100644 (file)
        "action-changetags": "добавяне и премахване на произволни етикети на индивидуални редакции и записи в дневниците",
        "action-deletechangetags": "изтриване на етикети от базата от данни",
        "action-purge": "почисти кеша на тази страница",
+       "action-ipblock-exempt": "пренебрегване на IP блокирания, автоматични блокирания и блокирани диапазони",
        "nchanges": "$1 {{PLURAL:$1|промяна|промени}}",
        "ntimes": "$1×",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|от последното посещение}}",
index 60a6acd..d1906f5 100644 (file)
@@ -22,7 +22,8 @@
                        "Lost Whispers",
                        "Épine",
                        "Fitoschido",
-                       "Vlad5250"
+                       "Vlad5250",
+                       "ئارام بکر"
                ]
        },
        "tog-underline": "ھێڵھێنان بەژێر بەستەرەکان:",
        "yourpassword": "تێپەڕوشە:",
        "userlogin-yourpassword": "تێپەڕوشە",
        "userlogin-yourpassword-ph": "تێپەڕوشەکەت بنووسە",
-       "createacct-yourpassword-ph": "تێپەروشەیەک بنووسە",
+       "createacct-yourpassword-ph": "تێپەڕوشەیەک بنووسە",
        "yourpasswordagain": "دیسان تێپەڕوشەکە بنووسەوە:",
-       "createacct-yourpasswordagain": "تێپەروشە پشتڕاست بکەرەوە",
-       "createacct-yourpasswordagain-ph": "تێپەروشە دیسان بنووسەوە",
+       "createacct-yourpasswordagain": "تێپەڕوشە پشتڕاست بکەرەوە",
+       "createacct-yourpasswordagain-ph": "تێپەڕوشە دیسان بنووسەوە",
        "userlogin-remembermypassword": "لەژوورەوە بمھێڵەرەوە",
        "userlogin-signwithsecure": "پەیوەندیی دڵنیا بەکاربھێنە",
        "cannotlogin-title": "ناتوانیت بچیتە ژوورەوە",
        "createaccountmail-help": "دەتوانرێت بەکار بھێندرێت بۆ دروستکردنی ھەژمار بۆ کەسێکی تر بەبێ زانینی تێپەڕ وشەکەی.",
        "createacct-realname": "ناوی ڕاستی (دڵخوازانە)",
        "createacct-reason": "ھۆکار",
-       "createacct-reason-ph": "بۆ ھەژمارێکی تر دروست دەکەی",
+       "createacct-reason-ph": "بۆچی ھەژمارێکی تر دروست دەکەیت",
        "createacct-submit": "ھەژمارەکەت دروست بکە",
        "createacct-another-submit": "ھەژمار دروست بکە",
        "createacct-continue-submit": "بەردەوامبوون لە دروستکردنی ھەژمار",
        "nocookiesnew": "ھەژماری بەکارھێنەری دروست کرا، بەڵام نەچوویتەوە ژوورەوە.\n{{SITENAME}} بۆ چوونەوە ژوورەوەی بەکارھێنەر کوکی بەکاردەھێنێت.\nتۆ کوکییەکەکەت لەکارخستووە.\nتکایە کوکییەکە کارا بکە، پاشان بە ناوی بەکارھێنەری و تێپەڕوشەکەت بچۆ ژوورەوە.",
        "nocookieslogin": "{{SITENAME}} بۆ چوونەژوورەوە لە کووکی‌یەکان کەڵک وەرئەگرێت.\nڕێگەت نەداوە بە کووکی‌یەکان.\nڕێگەیان پێ بدەو و دیسان تێبکۆشە.",
        "nocookiesfornew": "ھەژماری بەکارھێنەری دروست نەکرا، چون ناتوانین سەرچاوەکەی پشتڕاست بکەینەوە.\nدڵنیا بە کوکییەکانت چالاک کردووە، پەڕەکە بار بکەوە و دیسان ھەوڵ بدە.",
-       "createacct-loginerror": "ھەژمارەکە بە سەرکەوتوانە دروست کرا، بەڵام ناتوانرێت بە شێوەیەکی ئۆتۆماتیکی بکرێیتە ژوورەوە. تکایە سەردانی [[Special:UserLogin|ڕێنماییەکانی چوونەژوورەوە]] بکە.",
+       "createacct-loginerror": "ھەژمارەکە بە سەرکەوتووانە دروست کرا، بەڵام ناتوانرێت بە شێوەیەکی خۆکارانە بکرێیتە ژوورەوە. تکایە سەردانی [[Special:UserLogin|ڕێنماییەکانی چوونەژوورەوە]] بکە.",
        "noname": "ناوی بەکارهێنەرییەکی گۆنجاوت دیاری نەکردووه.",
        "loginsuccesstitle": "چوویە ناوەوە",
        "loginsuccess": "'''ئێستا بە ناوی «$1»ەوە لە {{SITENAME}} چوویتەتەژوورەوە.'''",
index b5c6e88..71904d1 100644 (file)
@@ -43,7 +43,8 @@
                        "Radana",
                        "Jan Růžička",
                        "Jaroslav Cerny",
-                       "Slepi"
+                       "Slepi",
+                       "Tchoř"
                ]
        },
        "tog-underline": "Podtrhávat odkazy:",
        "lockmanager-fail-closelock": "Soubor se zámkem pro „$1“ nelze zavřít.",
        "lockmanager-fail-deletelock": "Soubor se zámkem pro „$1“ nelze smazat.",
        "lockmanager-fail-acquirelock": "Zámek pro „$1“ nelze získat.",
-       "lockmanager-fail-openlock": "Soubor zámku „$1“ nelze otevřít. Ujistěte se, že váš adresář nahraných souborů je správně nakonfigurován a že váš webový server má povolení k zápisu do tohoto adresáře. Pro další informace viz https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgUploadDirectory.",
+       "lockmanager-fail-openlock": "Soubor zámku „$1“ nelze otevřít. Ujistěte se, že váš adresář nahraných souborů je správně nakonfigurován a že váš webový server má povolení k zápisu do tohoto adresáře. Pro další informace vizte https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:$wgUploadDirectory.",
        "lockmanager-fail-releaselock": "Zámek pro „$1“ nelze uvolnit.",
        "lockmanager-fail-db-bucket": "Nelze navázat spojení s dostatečným počtem databází zámků v bloku $1.",
        "lockmanager-fail-db-release": "Uzamčení databáze $1 nelze uvolnit.",
        "edit-error-long": "Chyby:\n\n$1",
        "revid": "revize $1",
        "pageid": "Stránka s ID $1",
-       "interfaceadmin-info": "$1\n\nOprávnění editovat celoprojektové soubory s CSS/JS/JSON bylo nedávno odděleno z oprávnění <code>editinterface</code>. Pokud nerozumíte, proč se vám zobrazuje tato chyba, viz [[mw:MediaWiki_1.32/interface-admin]].",
+       "interfaceadmin-info": "$1\n\nOprávnění editovat celoprojektové soubory s CSS/JS/JSON bylo nedávno odděleno z oprávnění <code>editinterface</code>. Pokud nerozumíte, proč se vám zobrazuje tato chyba, vizte [[mw:MediaWiki_1.32/interface-admin]].",
        "rawhtml-notallowed": "Značky &lt;html&gt; nelze používat mimo běžné stránky.",
        "gotointerwiki": "Opustit {{GRAMMAR:4sg|{{SITENAME}}}}",
        "gotointerwiki-invalid": "Zadaný název je neplatný.",
index 70c0b67..eeebca2 100644 (file)
        "moveddeleted-notice-recent": "متاسفانه صفحه قبلا حذف شده‌است (در ۲۴ ساعت اخیر) \nدلیل حذف و سیاههٔ انتقال، و حفاظت در پائین موجود است.",
        "log-fulllog": "مشاهدهٔ سیاههٔ کامل",
        "edit-hook-aborted": "ویرایش توسط قلاب لغو شد.\nتوضیحی در این مورد داده نشد.",
-       "edit-gone-missing": "اÙ\85کاÙ\86 Ø¨Ù\87â\80\8cرÙ\88ز Ú©Ø±Ø¯Ù\86 ØµÙ\81Ø­Ù\87 Ù\88جÙ\88د Ù\86دارد.\nبÙ\87 Ù\86ظرÙ\85Û\8câ\80\8cرسد Ú©Ù\87 ØµÙ\81Ø­Ù\87 Ø­Ø°Ù\81 Ø´Ø¯Ù\87 Ø¨Ø§Ø´Ø¯.",
+       "edit-gone-missing": "اÙ\85کاÙ\86 Ø±Ù\88زاÙ\85دسازÛ\8c ØµÙ\81Ø­Ù\87 Ù\88جÙ\88د Ù\86دارد.\nبÙ\87 Ù\86ظر Ù\85Û\8câ\80\8cرسد Ú©Ù\87 ØµÙ\81Ø­Ù\87 Ø­Ø°Ù\81 Ø´Ø¯Ù\87 Ø§Ø³Øª.",
        "edit-conflict": "تعارض ویرایشی.",
        "edit-no-change": "ویرایش شما نادیده گرفته شد، زیرا تغییری در متن داده نشده بود.",
        "edit-slots-cannot-add": "این {{PLURAL:$1|اسلات|اسلات‌ها}} پشتیبانی نمی‌شود: $2.",
        "revdelete-log": "دلیل:",
        "revdelete-submit": "اعمال بر {{PLURAL:$1|نسخهٔ|نسخه‌های}} انتخاب شده",
        "revdelete-success": "'''پیدایی نسخه به روز شد.'''",
-       "revdelete-failure": "'''Ù¾Û\8cداÛ\8cÛ\8c Ù\86سخÙ\87â\80\8cÙ\87ا Ù\82ابÙ\84 Ø¨Ù\87 Ø±Ù\88ز Ú©Ø±Ø¯Ù\86 نیست:'''\n$1",
+       "revdelete-failure": "'''Ù¾Û\8cداÛ\8cÛ\8c Ù\86سخÙ\87â\80\8cÙ\87ا Ù\82ابÙ\84 Ø±Ù\88زاÙ\85دسازÛ\8c نیست:'''\n$1",
        "logdelete-success": "تغییر پیدایی مورد انجام شد.",
        "logdelete-failure": "'''پیدایی سیاهه‌ها قابل تنظیم نیست:'''\n$1",
        "revdel-restore": "تغییر پیدایی",
        "backend-fail-batchsize": "دسته‌ای مشتمل بر $1 {{PLURAL:$1|عملکرد|عملکرد}} پرونده به پشتیبان ذخیره داده شد؛ حداکثر مجاز $2 {{PLURAL:$2|عملکرد|عملکرد}} است.",
        "backend-fail-usable": "امکان خواندن یا نوشتن پروندهٔ $1 وجود نداشت چرا که سطح دسترسی کافی نیست یا شاخه/محفظهٔ مورد نظر وجود ندارد.",
        "filejournal-fail-dbconnect": "امکان وصل شدن به پایگاه داده دفترخانه برای پشتیبان ذخیره‌سازی «$1» وجود نداشت.",
-       "filejournal-fail-dbquery": "اÙ\85کاÙ\86 Ø¨Ù\87 Ø±Ù\88ز Ú©Ø±Ø¯Ù\86 Ù¾Ø§Û\8cگاÙ\87 Ø¯Ø§Ø¯Ù\87 دفترخانه برای پشتیبان ذخیره‌سازی «$1» وجود نداشت.",
+       "filejournal-fail-dbquery": "اÙ\85کاÙ\86 Ø±Ù\88زاÙ\85دسازÛ\8c Ø¯Ø§Ø¯Ú¯Ø§Ù\86 دفترخانه برای پشتیبان ذخیره‌سازی «$1» وجود نداشت.",
        "lockmanager-notlocked": "نمی‌توان قفل «$1» را گشود؛ چون قفل نشده‌است.",
        "lockmanager-fail-closelock": "امکان بستن پروندهٔ قفل‌شدهٔ «$1» وجود ندارد.",
        "lockmanager-fail-deletelock": "امکان حذف پروندهٔ قفل‌شدهٔ «$1» وجود ندارد.",
        "nonfile-cannot-move-to-file": "امکان انتقال محتوای غیر پرونده به فضای نام پرونده وجود ندارد",
        "imagetypemismatch": "پسوند پرونده تازه با نوع آن سازگار نیست",
        "imageinvalidfilename": "نام پروندهٔ هدف نامعتبر است",
-       "fix-double-redirects": "بÙ\87 Ø±Ù\88ز Ú©Ø±Ø¯Ù\86 ØªÙ\85اÙ\85Û\8c تغییرمسیرهایی که به مقالهٔ اصلی اشاره می‌کنند",
+       "fix-double-redirects": "رÙ\88زاÙ\85دسازÛ\8c Ù\87Ù\85Ù\87Ù\94 تغییرمسیرهایی که به مقالهٔ اصلی اشاره می‌کنند",
        "move-leave-redirect": "بر جا گذاشتن یک تغییرمسیر",
        "protectedpagemovewarning": "'''هشدار:''' این صفحه قفل شده‌است به طوری که تنها کاربران با دسترسی مدیریت می‌توانند آن را انتقال دهند.\nآخرین موارد سیاهه در زیر آمده است:",
        "semiprotectedpagemovewarning": "'''تذکر:''' این صفحه قفل شده‌است به طوری که تنها کاربران ثبت نام کرده می‌توانند آن را انتقال دهند.\nآخرین موارد سیاهه در زیر آمده است:",
        "watchlistedit-raw-legend": "ویرایش فهرست خام پی‌گیری‌ها",
        "watchlistedit-raw-explain": "عنوان‌های موجود در فهرست پی‌گیری‌های شما در زیر نشان داده شده‌اند، و شما می‌توانید مواردی را حذف یا اضافه کنید؛ هر مورد در یک سطر جداگانه باید قرار بگیرد.\nدر پایان، دکمهٔ «{{int:Watchlistedit-raw-submit}}» را بفشارید.\nتوجه کنید که شما می‌توانید از [[Special:EditWatchlist|ویرایشگر استاندارد فهرست پی‌گیری‌ها]] هم استفاده کنید.",
        "watchlistedit-raw-titles": "عنوان‌ها:",
-       "watchlistedit-raw-submit": "بÙ\87â\80\8cرÙ\88زرساÙ\86ی پی‌گیری‌ها",
+       "watchlistedit-raw-submit": "رÙ\88زاÙ\85دسازی پی‌گیری‌ها",
        "watchlistedit-raw-done": "فهرست پی‌گیری‌های شما به روز شد.",
        "watchlistedit-raw-added": "$1 عنوان به فهرست پی‌گیری‌ها اضافه {{PLURAL:$1|شد|شدند}}:",
        "watchlistedit-raw-removed": "$1 عنوان حذف {{PLURAL:$1|شد|شدند}}:",
        "log-description-pagelang": "این سیاههٔ تغییرات صفحهٔ زبان‌ها است.",
        "logentry-pagelang-pagelang": "$1 زبان $3  از  $4  به  $5 {{GENDER:$2| تغییریافت}}",
        "default-skin-not-found": "اوه! پوسته پیش‌فرض برای ویکی شما تعریف‌شده در <code dir=\"ltr\"<$wgDefaultSkin</code> به عنوان <code>$1</code>، در دسترس نیست.\n\nبه نظر می‌آید نصب شما شامل پوسته‌های زیر می‌شود. [https://www.mediawiki.org/wiki/Manual:Skin_configuration راهنما: تنظیمات پوسته] را برای کسب اطلاعات در باره چگونگی فعال‌ساختن آن‌ها و انتخاب پیش‌فرض ببینید.\n\n$2\n\n; اگر اخیراً مدیاویکی را نصب کرده‌اید:\n: احتمالاً از گیت، یا به طور مستقیم از کد مبدأ که از چند متد دیگر استفاده می‌کند نصب کردید. انتظار می‌رود. چند {{PLURAL:$4|پوسته|پوسته}} از [https://www.mediawiki.org/wiki/Category:All_skins فهرست پوسته mediawiki.org] نصب کنید، که همراه چندین پوسته و افزونه هستند. شما می‌توانید شاخه <code>skins/</code> را از آن نسخه‌برداری کرده و بچسبانید.\n\n:* [https://www.mediawiki.org/wiki/Download_from_Git#Using_Git_to_download_MediaWiki_skins استفاده از گیت برای دریافت پوسته‌ها].\n: انجام این کار با مخزن گیت‌تان تداخل نمی‌کند اگر توسعه‌دهنده مدیاویکی هستید.\n\n; اگر اخیراً مدیاویکی را ارتقاء دادید:\n: مدیاویکی ۱٫۲۴ و تازه‌تر دیگر به طور خودکار پوسته‌های نصب‌شده را فعال نمی‌کند ([https://www.mediawiki.org/wiki/Manual:Skin_autodiscovery راهنما: کشف خودکار پوسته] را ببینید). شما می‌توانید خطوط زیر را به داخل <code>LocalSettings.php</code> بچسبانید تا {{PLURAL:$5|همه|همه}} پوسته‌های نصب‌شده را فعال کنید:\n\n<pre dir=\"ltr\">$3</pre>\n\n; اگر اخیراً <code>LocalSettings.php</code> را تغییر دادید:\n: نام پوسته‌ها را برای غلط املایی دوباره بررسی کنید.",
-       "default-skin-not-found-no-skins": "پوستهٔ پیش‌فرض برای ویکی شما تعریف‌شده در<code>$wgDefaultSkin</code> به عنوان <code>$1</code>، هست موجود نیست.\n\nشما پوسته‌ها را نصب نکرده‌اید.\n\n:اگر مدیاویکی را به‌روز یا نصب کرده‌اید:\n:ممکن است از گیت یا از کد منبع با روش‌های دیگر نصب کرده‌اید. انتظار می‌رود MediaWiki 1.24 یا جدیدتر در پوشهٔ اصلی هیچ پوسته‌ای نداشته باشند.\nسعی کنید تعدادی پوسته از [https://www.mediawiki.org/wiki/Category:All_skins پوشهٔ پوسته‌های مدیاویکی]، با:\n:*دریافت [https://www.mediawiki.org/wiki/Download نصب‌کننده تاربال]، که با چندین پوسته و افزونه هست. شما می توانید پوستهٔ <code>skins/</code> را از آن کپی و پیست کنید.\n:*کلون کردن یکی از <code dir=\"ltr\">mediawiki/skins/*</code> از مخزن در پوشهٔ <code>skins/</code> مدیاویکی‌تان.\n:اگر توسعه‌دهندهٔ مدیاویکی هستید، انجام این کار نباید تعارضی با مخزن گیت شما داشته باشد. برای اطلاعات بیشتر و فعال کردن پوسته‌ها و انتخاب آنها به عنوان پیش‌فرض [https://www.mediawiki.org/wiki/Manual:Skin_configuration Manual: تنظیمات پوسته] را مشاهده کنید.",
+       "default-skin-not-found-no-skins": "پوستهٔ پیش‌فرض برای ویکی شما تعریف‌شده در<code>$wgDefaultSkin</code> به‌عنوان <code>$1</code>، هست موجود نیست.\n\nشما پوسته‌ها را نصب نکرده‌اید.\n\n:اگر مدیاویکی را روزامد یا نصب کرده‌اید:\n:ممکن است از گیت یا از کد منبع با روش‌های دیگر نصب کرده‌اید. انتظار می‌رود MediaWiki 1.24 یا جدیدتر در پوشهٔ اصلی هیچ پوسته‌ای نداشته باشند.\nسعی کنید تعدادی پوسته از [https://www.mediawiki.org/wiki/Category:All_skins پوشهٔ پوسته‌های مدیاویکی]، با:\n:*دریافت [https://www.mediawiki.org/wiki/Download نصب‌کننده تاربال]، که با چندین پوسته و افزونه هست. شما می‌توانید پوستهٔ <code>skins/</code> را از آن کپی و پیست کنید.\n:*کلون کردن یکی از <code dir=\"ltr\">mediawiki/skins/*</code> از مخزن در پوشهٔ <code>skins/</code> مدیاویکی‌تان.\n:اگر توسعه‌دهندهٔ مدیاویکی هستید، انجام این کار نباید تعارضی با مخزن گیت شما داشته باشد. برای اطلاعات بیشتر و فعال کردن پوسته‌ها و انتخاب آنها به‌عنوان پیش‌فرض [https://www.mediawiki.org/wiki/Manual:Skin_configuration Manual: تنظیمات پوسته] را مشاهده کنید.",
        "default-skin-not-found-row-enabled": "* <code>$1</code> / $2 (فعال)",
        "default-skin-not-found-row-disabled": "* <code>$1</code> / $2 (<strong>غیرفعال</strong>)",
        "mediastatistics": "آمار رسانه‌ها",
index 07f305e..f92ca53 100644 (file)
        "sharedupload-desc-edit": "Ce fichier provient de : $1. Il peut être utilisé par d'autres projets.\nVous voulez peut-être modifier la description sur sa [$2 page de description].",
        "sharedupload-desc-create": "Ce fichier provient de : $1. Il peut être utilisé par d'autres projets.\nVous voulez peut-être modifier la description sur sa [$2 page de description].",
        "filepage-nofile": "Aucun fichier de ce nom n’existe.",
-       "filepage-nofile-link": "Aucun fichier de ce nom n’existe, mais vous pouvez [$1 en importer un].",
+       "filepage-nofile-link": "Aucun fichier de ce nom n’existe, mais vous pouvez [$1 en téléverser un].",
        "uploadnewversion-linktext": "Importer une nouvelle version de ce fichier",
        "shared-repo-from": "de : $1",
        "shared-repo": "un dépôt partagé",
index ae90b48..534f1dd 100644 (file)
        "aboutsite": "Oer {{SITENAME}}",
        "aboutpage": "Project:Ynfo",
        "copyright": "Ynhâld is beskikber ûnder de $1.",
-       "copyrightpage": "{{ns:project}}:Auteursrjocht",
+       "copyrightpage": "{{ns:project}}:Auteursrjochten",
        "currentevents": "Rinnende saken",
        "currentevents-url": "Project:Rinnende saken",
        "disclaimers": "Foarbehâld",
        "perfcached": "Dit is bewarre ynformaasje dy't mooglik ferâldere is. In maksimum fan {{PLURAL:$1|ien resultaat is|$1 resultaten binne}} beskikber yn de cache.",
        "perfcachedts": "De neikommende gegevens komme út de bewarre ynformaasje, dizze is it lêst fernijd op $1. In maksimum fan {{PLURAL:$4|ien resultaat is|$4 resultaten binne}} beskikber yn de cache.",
        "querypage-no-updates": "Dizze side kin net bywurke wurde. Dizze gegevens wurde net ferfarske.",
-       "viewsource": "Besjoch de boarne",
+       "viewsource": "Boarne besjen",
        "viewsource-title": "Besjoch de boarne foar $1",
        "actionthrottled": "Hanneling opkeard",
        "actionthrottledtext": "As maatregel tsjin spam is it tal kearen per tiidsienheid beheind dat jo dizze hanneling ferrjochtsje kinne. Jo binne oer de limyt. Besykje it in tal minuten letter wer.",
        "search-nonefound": "Der binne gjin resultaten foar jo sykopdracht.",
        "powersearch-legend": "Utwreidich sykje",
        "powersearch-ns": "Nammeromten trochsykje:",
-       "powersearch-togglelabel": "Oanfinke:",
+       "powersearch-togglelabel": "Oanfinkje:",
        "powersearch-toggleall": "Alles",
        "powersearch-togglenone": "Gjint",
        "powersearch-remember": "Seleksje ûnthâlde foar sykopdrachten yn 'e takomst",
        "undelete-show-file-submit": "Ja",
        "namespace": "Nammeromte:",
        "invert": "Seleksje útsein",
+       "tooltip-invert": "Oanfinkje om sidewizigings yn de selektearre nammeromte (en oanfinke de byhearrende nammeromte) te ferbergjen",
+       "tooltip-whatlinkshere-invert": "Oanfinkje om ferwizingssiden yn de selektearre nammeromte te ferbergjen.",
        "namespace_association": "Byhearrende nammeromte",
+       "tooltip-namespace_association": "Oanfinkje om by de selektearre nammeromte ek de ferbûne nammeromte foar oerlis of ynhâld te belûken",
        "blanknamespace": "(Haad)",
        "contributions": "Bydragen fan 'e {{GENDER:$1|meidogger|meidochster}}",
        "contributions-title": "Bydragen fan $1",
index 1385d75..dc73bef 100644 (file)
        "passwordpolicies-policyflag-forcechange": "lecserélés követelése bejelentkezéskor",
        "passwordpolicies-policyflag-suggestchangeonlogin": "lecserélés ajánlása bejelentkezéskor",
        "unprotected-js": "Biztonsági okokból JavaScript nem tölthető be védtelen lapokról. Kérlek egyedül a MediaWiki névtérben készíts JavaScriptet, vagy szerkesztői allapként.",
-       "userlogout-continue": "Amennyiben ki szeretnél jelentkezni, [$1 használd a kijelentkezési oldalt]."
+       "userlogout-continue": "Biztos ki szeretnél jelentkezni?"
 }
index af8afe7..53d3cc7 100644 (file)
        "passwordpolicies-policyflag-suggestchangeonlogin": "로그인할 때 변경 제안",
        "easydeflate-invaliddeflate": "주어진 컨텐츠가 적절히 압축되지 않았습니다",
        "unprotected-js": "보안 상의 이유로 자바스크립트는 보호되지 않은 문서로부터 불러올 수 없습니다. 미디어위키: 이름공간이나 사용자의 하위 문서에서만 자바스크립트를 만들어 주십시오.",
-       "userlogout-continue": "로그아웃하려면 [$1 페이지 로그아웃 문서로 이동하십시오]."
+       "userlogout-continue": "로그아웃하시겠습니까?"
 }
index fb8d8d7..3f4dc7c 100644 (file)
@@ -13,7 +13,8 @@
                        "Alirezaaa",
                        "Fitoschido",
                        "Matěj Suchánek",
-                       "Physicsch"
+                       "Physicsch",
+                       "FarsiNevis"
                ]
        },
        "tog-underline": "خط کیشائن ژێر پیوندەل:",
        "moveddeleted-notice-recent": "متاسفانه صفحه قبلا حذف شده‌است (در ۲۴ ساعت اخیر) \nدلیل حذف و سیاههٔ انتقال در پائین موجود است.",
        "log-fulllog": "مشاهدهٔ سیاههٔ کامل",
        "edit-hook-aborted": "ویرایش توسط قلاب لغو شد.\nتوضیحی در این مورد داده نشد.",
-       "edit-gone-missing": "اÙ\85کاÙ\86 Ø¨Ù\87â\80\8cرÙ\88ز Ú©Ø±Ø¯Ù\86 ØµÙ\81Ø­Ù\87 Ù\88جÙ\88د Ù\86دارد.\nبÙ\87 Ù\86ظرÙ\85Û\8câ\80\8cرسد Ú©Ù\87 ØµÙ\81Ø­Ù\87 Ø­Ø°Ù\81 Ø´Ø¯Ù\87 Ø¨Ø§Ø´Ø¯.",
+       "edit-gone-missing": "اÙ\85کاÙ\86 Ø±Ù\88زاÙ\85دسازÛ\8c ØµÙ\81Ø­Ù\87 Ù\88جÙ\88د Ù\86دارد.\nبÙ\87 Ù\86ظر Ù\85Û\8câ\80\8cرسد Ú©Ù\87 ØµÙ\81Ø­Ù\87 Ø­Ø°Ù\81 Ø´Ø¯Ù\87 Ø§Ø³Øª.",
        "edit-conflict": "تعارض ویرایشی.",
        "edit-no-change": "ویرایش شما نادیده گرفته شد، زیرا تغییری در متن داده نشده بود.",
        "postedit-confirmation-created": "وةڵگة دؤرس بیة",
        "revdelete-log": ":دةلیل",
        "revdelete-submit": "اعمال بر {{PLURAL:$1|نسخهٔ|نسخه‌های}} انتخاب شده",
        "revdelete-success": "نمایش رویزیون به‌روژ بوو",
-       "revdelete-failure": "'''Ù¾Û\8cداÛ\8cÛ\8c Ù\88رÚ\98Ù\86 Ù\87ا Ù\82ابÙ\84 Ø¨Ù\87 Ø±Ù\88ز Ú©Ø±Ø¯Ù\86 نیست:'''\n$1",
+       "revdelete-failure": "'''Ù¾Û\8cداÛ\8cÛ\8c Ù\86سخÙ\87â\80\8cÙ\87ا Ù\82ابÙ\84 Ø±Ù\88زاÙ\85دسازÛ\8c نیست:'''\n$1",
        "logdelete-success": "ورود نمایش ست",
        "logdelete-failure": "'''پیدایی سیاهه‌ها قابل تنظیم نیست:'''\n$1",
        "revdel-restore": "گؤەڕانن/تغییر پیدایی",
        "backend-fail-batchsize": "دسته‌ای مشتمل بر $1 {{PLURAL:$1|عملکرد|عملکردها}} پرونده به پشتیبان ذخیره داده شد؛ حداکثر مجاز $2 {{PLURAL:$2|عملکرد|عملکردها}} است.",
        "backend-fail-usable": "امکان خواندن یا نوشتن پروندهٔ $1 وجود نداشت چرا که سطح دسترسی کافی نیست یا شاخه/محفظهٔ مورد نظر وجود ندارد.",
        "filejournal-fail-dbconnect": "امکان وصل شدن به پایگاه داده دفترخانه برای پشتیبان ذخیره‌سازی «$1» وجود نداشت.",
-       "filejournal-fail-dbquery": "اÙ\85کاÙ\86 Ø¨Ù\87 Ø±Ù\88ز Ú©Ø±Ø¯Ù\86 Ù¾Ø§Û\8cگاÙ\87 Ø¯Ø§Ø¯Ù\87 دفترخانه برای پشتیبان ذخیره‌سازی «$1» وجود نداشت.",
+       "filejournal-fail-dbquery": "اÙ\85کاÙ\86 Ø±Ù\88زاÙ\85دسازÛ\8c Ø¯Ø§Ø¯Ú¯Ø§Ù\86 دفترخانه برای پشتیبان ذخیره‌سازی «$1» وجود نداشت.",
        "lockmanager-notlocked": "نمی‌توان قفل «$1» را گشود؛ چون قفل نشده‌است.",
        "lockmanager-fail-closelock": "امکان بستن پرونده قفل شده \"$1\" وجود ندارد.",
        "lockmanager-fail-deletelock": "امکان حذف پرونده قفل شده \"$1\" وجود ندارد.",
        "nonfile-cannot-move-to-file": "امکان انتقال محتوای غیر پرونده به فضای نام پرونده وجود ندارد",
        "imagetypemismatch": "پسوند پرونده تازه با نوع آن سازگار نیست",
        "imageinvalidfilename": "نام پروندهٔ هدف نامجاز است",
-       "fix-double-redirects": "بÙ\87 Ø±Ù\88ز Ú©Ø±Ø¯Ù\86 ØªÙ\85اÙ\85Û\8c تغییرمسیرهایی که به مقالهٔ اصلی اشاره می‌کنند",
+       "fix-double-redirects": "رÙ\88زاÙ\85دسازÛ\8c Ù\87Ù\85Ù\87Ù\94 تغییرمسیرهایی که به مقالهٔ اصلی اشاره می‌کنند",
        "move-leave-redirect": "بر جا گذاشتن یک تغییرمسیر",
        "protectedpagemovewarning": "'''هشدار:''' این صفحه قفل شده‌است به طوری که تنها کاربران با دسترسی مدیریت می‌توانند آن را انتقال دهند.\nآخرین موارد سیاهه در زیر آمده است:",
        "semiprotectedpagemovewarning": "'''تذکر:''' این صفحه قفل شده‌است به طوری که تنها کاربران ثبت نام کرده می‌توانند آن را انتقال دهند.\nآخرین موارد سیاهه در زیر آمده است:",
        "watchlistedit-raw-legend": "ویرایش فهرست خام پی‌گیری‌ها",
        "watchlistedit-raw-explain": "عنوان‌های موجود در فهرست پی‌گیری‌های شما در زیر نشان داده شده‌اند، و شما می‌توانید مواردی را حذف یا اضافه کنید؛ هر مورد در یک سطر جداگانه باید قرار بگیرد.\nدر پایان، دکمهٔ «{{int:Watchlistedit-raw-submit}}» را بفشارید.\nتوجه کنید که شما می‌توانید از [[Special:EditWatchlist|ویرایشگر استاندارد فهرست پی‌گیری‌ها]] هم استفاده کنید.",
        "watchlistedit-raw-titles": "عنوانةل:",
-       "watchlistedit-raw-submit": "بÙ\87â\80\8cرÙ\88زرساÙ\86ی پی‌گیری‌ها",
+       "watchlistedit-raw-submit": "رÙ\88زاÙ\85دسازی پی‌گیری‌ها",
        "watchlistedit-raw-done": "فهرست پی‌گیری‌های شما به روز شد.",
        "watchlistedit-raw-added": "$1 عنوان به فهرست پی‌گیری‌ها اضافه {{PLURAL:$1|شد|شدند}}:",
        "watchlistedit-raw-removed": "$1 عنوان حذف {{PLURAL:$1|شد|شدند}}:",
        "log-description-pagelang": "ای پهرستنومه در بلگه زونا آلشت گرته.",
        "logentry-pagelang-pagelang": "$1 {{GENDER:$2| تغییریافت}} زبان صفحه برای  $3  از  $4  به  $5 .",
        "default-skin-not-found": "اوه! پوسته پیش‌فرض برای ویکی شما تعریف‌شده در <code dir=\"ltr\"<$wgDefaultSkin</code> به عنوان <code>$1</code>، در دسترس نیست.\n\nبه نظر می‌آید نصب شما شامل پوسته‌های زیر می‌شود. [https://www.mediawiki.org/wiki/Manual:Skin_configuration راهنما: تنظیمات پوسته] را برای کسب اطلاعات در باره چگونگی فعال‌ساختن آن‌ها و انتخاب پیش‌فرض ببینید.\n\n$2\n\n; اگر اخیراً مدیاویکی را نصب کرده‌اید:\n: احتمالاً از گیت، یا به طور مستقیم از کد مبدأ که از چند متد دیگر استفاده می‌کند نصب کردید. انتظار می‌رود. چند {{PLURAL:$4|پوسته|پوسته}} از [https://www.mediawiki.org/wiki/Category:All_skins فهرست پوسته mediawiki.org] نصب کنید، که همراه چندین پوسته و افزونه هستند. شما می‌توانید شاخه <code>skins/</code> را از آن نسخه‌برداری کرده و بچسبانید.\n\n:* [https://www.mediawiki.org/wiki/Download_from_Git#Using_Git_to_download_MediaWiki_skins استفاده از گیت برای دریافت پوسته‌ها].\n: انجام این کار با مخزن گیت‌تان تداخل نمی‌کند اگر توسعه‌دهنده مدیاویکی هستید.\n\n; اگر اخیراً مدیاویکی را ارتقاء دادید:\n: مدیاویکی ۱٫۲۴ و تازه‌تر دیگر به طور خودکار پوسته‌های نصب‌شده را فعال نمی‌کند ([https://www.mediawiki.org/wiki/Manual:Skin_autodiscovery راهنما: کشف خودکار پوسته] را ببینید). شما می‌توانید خطوط زیر را به داخل <code>LocalSettings.php</code> بچسبانید تا {{PLURAL:$5|همه|همه}} پوسته‌های نصب‌شده را فعال کنید:\n\n<pre dir=\"ltr\">$3</pre>\n\n; اگر اخیراً <code>LocalSettings.php</code> را تغییر دادید:\n: نام پوسته‌ها را برای غلط املایی دوباره بررسی کنید.",
-       "default-skin-not-found-no-skins": "پوستهٔ پیش‌فرض برای ویکی شما تعریف‌شده در<code>$wgDefaultSkin</code> به عنوان <code>$1</code>، هست موجود نیست.\n\nشما پوسته‌ها را نصب نکرده‌اید.\n\n:اگر مدیاویکی را به‌روز یا نصب کرده‌اید:\n:ممکن است از گیت یا از کد منبع با روش‌های دیگر نصب کرده‌اید. انتظار می‌رود MediaWiki 1.24 یا جدیدتر در پوشهٔ اصلی هیچ پوسته‌ای نداشته باشند.\nسعی کنید تعدادی پوسته از [https://www.mediawiki.org/wiki/Category:All_skins پوشهٔ پوسته‌های مدیاویکی]، با:\n:*دریافت [https://www.mediawiki.org/wiki/Download نصب‌کننده تاربال]، که با چندین پوسته و افزونه هست. شما می توانید پوستهٔ <code>skins/</code> را از آن کپی و پیست کنید.\n:*کلون کردن یکی از <code dir=\"ltr\">mediawiki/skins/*</code> از مخزن در پوشهٔ <code>skins/</code> مدیاویکی‌تان.\n:اگر توسعه‌دهندهٔ مدیاویکی هستید، انجام این کار نباید تعارضی با مخزن گیت شما داشته باشد. برای اطلاعات بیشتر و فعال کردن پوسته‌ها و انتخاب آنها به عنوان پیش‌فرض [https://www.mediawiki.org/wiki/Manual:Skin_configuration Manual: تنظیمات پوسته] را مشاهده کنید.",
+       "default-skin-not-found-no-skins": "پوستهٔ پیش‌فرض برای ویکی شما تعریف‌شده در<code>$wgDefaultSkin</code> به‌عنوان <code>$1</code>، هست موجود نیست.\n\nشما پوسته‌ها را نصب نکرده‌اید.\n\n:اگر مدیاویکی را روزامد یا نصب کرده‌اید:\n:ممکن است از گیت یا از کد منبع با روش‌های دیگر نصب کرده‌اید. انتظار می‌رود MediaWiki 1.24 یا جدیدتر در پوشهٔ اصلی هیچ پوسته‌ای نداشته باشند.\nسعی کنید تعدادی پوسته از [https://www.mediawiki.org/wiki/Category:All_skins پوشهٔ پوسته‌های مدیاویکی]، با:\n:*دریافت [https://www.mediawiki.org/wiki/Download نصب‌کننده تاربال]، که با چندین پوسته و افزونه هست. شما می توانید پوستهٔ <code>skins/</code> را از آن کپی و پیست کنید.\n:*کلون کردن یکی از <code dir=\"ltr\">mediawiki/skins/*</code> از مخزن در پوشهٔ <code>skins/</code> مدیاویکی‌تان.\n:اگر توسعه‌دهندهٔ مدیاویکی هستید، انجام این کار نباید تعارضی با مخزن گیت شما داشته باشد. برای اطلاعات بیشتر و فعال کردن پوسته‌ها و انتخاب آنها به‌عنوان پیش‌فرض [https://www.mediawiki.org/wiki/Manual:Skin_configuration Manual: تنظیمات پوسته] را مشاهده کنید.",
        "default-skin-not-found-row-enabled": "* <code>$1</code> / $2 (فعال)",
        "default-skin-not-found-row-disabled": "* <code>$1</code> / $2 ('''غیر فعال''')",
        "mediastatistics": "آمار رسانه‌ها",
index 57f0c4b..a41b0e8 100644 (file)
        "permissionserrorstext-withaction": "شما سی $2 سلا \nنهاگیری نارؽت {{PLURAL:$1|دلٛیلٛ|دلٛیلٛؽا}}:",
        "recreate-moveddeleted-warn": "'''ڤ ڤیرتو با:شما بٱلگاٛیی کاْ ھا ڤادما ۉ پاکسا بیٱ د نۊ دۏرس کردؽتٱ.'''\nبایٱد د ڤیرتو با کاْ آیا ھنی نوئاگیری ڤیرایش اؽ بٱلگٱ خۊئٱ.\nپاکسا کاری ۉ جا ڤ جا کاری اؽ بٱلگٱ سی هال ۉ بار پٱلٛٱمار شما آمادٱ بیٱ:",
        "moveddeleted-notice": "اؽ بٱلگٱ پاکسا بیٱ.\nپاکسا کاری ۉ جا ڤ جا کاری اؽ بٱلگٱ سی هال ۉ بار پٱلٛٱمار شما آمادٱ بیٱ.",
-       "log-fulllog": "دیئن هأمە پئهئرستنوٙمە یا",
-       "edit-hook-aborted": "Ú¤Û\8cراÛ\8cئشت Ú¤Ø§ Ù\82Ù\88Ù\84اڤ Ù\86ئھاگئرÛ\8c Ø¨Û\8cÛ\8cÛ\95.\nÚ¾Û\8cÚ\86 ØªÙ\88ضÛ\8cÛ\8c Ø³Û\8cØ´ Ù\86Û\8c.",
+       "log-fulllog": "دیئن هٱمٱ پهرستنومٱیا",
+       "edit-hook-aborted": "Ú¤Û\8cراÛ\8cØ´ Ú¤Ø§ Ù\82Ù\88Ù\84اڤ Ù\86ھاگÛ\8cرÛ\8c Ø¨Û\8cÙ±.\nÚ¾Û\8cÚ\98 ØªÛ\89زÛ\8cÙ\87Û\8c Ø³Û\8cØ´ Ù\86ؽ.",
        "edit-gone-missing": "نأبوٙە ئی بألگە نە ڤئ ھئنگوم بأکیت.\nچئنی ڤئ نأظأر میا کئ ڤئ پاکسا بییە.",
        "edit-conflict": "ری ڤئ ری کاری د ڤیرایئشت.",
        "edit-no-change": "سی یە کئ ھیچ آلئشتکاری د نیسئسە أنجوم نأگئرئتە د ڤیرایئشتکای شوم تیە پوٙشی بییە.",
index 5f14b5b..9b65424 100644 (file)
@@ -5,7 +5,8 @@
                        "علی ساکی لرستانی",
                        "Mjbmr",
                        "Hosseinblue",
-                       "MtDu"
+                       "MtDu",
+                       "Shahriar dehghani"
                ]
        },
        "tog-underline": "لینکیا خط وه دومن",
        "logentry-move-move": "$1 {{GENDER:$2|انتقال دادھ بیه}} بلگه $3 ۉھ $4",
        "logentry-newusers-create": "حسآۉ کارڤأر $1 ڤابیە {{GENDER:$2|راس ڤیدھ }}",
        "logentry-upload-upload": "$1 {{GENDER:$2|بلم گیر کردھ ۉابی}} $3",
-       "searchsuggest-search": "جۉستأن"
+       "searchsuggest-search": "جۉستأن",
+       "userlogout-continue": "ایخیت برِیِتو وَدَر"
 }
index 263abd6..9c42c2a 100644 (file)
        "accmailtext": "Nejauši ģenerēta parole lietotājam [[User talk:$1|$1]] tika nosūtīta uz $2.\n\nŠī konta paroli pēc ielogošanās varēs nomainīt ''[[Special:ChangePassword|šeit]]''.",
        "newarticle": "(Jauns raksts)",
        "newarticletext": "Šajā projektā vēl nav lapas ar šādu nosaukumu.\nLai izveidotu lapu, sāc rakstīt teksta logā apakšā (par teksta formatēšanu un sīkākai informācija skatīt [$1 palīdzības lapu]).\nJa tu šeit nonāci kļūdas pēc, vienkārši uzspied <strong>back</strong> pogu pārlūkprogrammā.",
-       "anontalkpagetext": "----\n<em>Šī ir anonīma dalībnieka, kurš vēl nav izveidojis lietotāja kontu vai to nelieto, diskusiju lapa.</em>\nTādēļ mums ir jāizmanto IP adrese, lai viņu identificētu.\nŠāda IP adrese var būt vairākiem dalībniekiem.\nJa tu esi anonīms dalībnieks un uzskati, ka tev ir adresēti neatbilstoši komentāri, lūdzu, [[Special:CreateAccount|izveido kontu]] vai [[Special:UserLogin|pieslēdzies]], lai izvairītos no turpmākām neskaidrībām un tu netiktu sajaukts ar citiem anonīmiem dalībniekiem.",
+       "anontalkpagetext": "----\n<em>Šī ir anonīma dalībnieka, kurš vēl nav izveidojis lietotāja kontu vai to nelieto, diskusiju lapa.</em>\nTādēļ mums ir jāizmanto IP adrese, lai viņu identificētu.\nŠāda IP adrese var būt kopīga vairākiem dalībniekiem.\nJa esi anonīms dalībnieks un uzskati, ka tev ir adresēti neatbilstoši komentāri, lūdzu, [[Special:CreateAccount|izveido kontu]] vai [[Special:UserLogin|pieslēdzies]], lai izvairītos no turpmākām neskaidrībām un netiktu sajaukts ar citiem anonīmiem dalībniekiem.",
        "noarticletext": "Šajā lapā šobrīd nav nekāda teksta.\nTu vari [[Special:Search/{{PAGENAME}}|meklēt citās lapās pēc šīs lapas nosaukuma]],\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} meklēt saistītos reģistru ierakstos]\nvai arī [{{fullurl:{{FULLPAGENAME}}|action=edit}} izveidot šo lapu]</span>.",
        "noarticletext-nopermission": "Šajā lapā pašlaik nav nekāda teksta.\nTu vari [[Special:Search/{{PAGENAME}}|meklēt šīs lapas nosaukumu]] citās lapās,\nvai <span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} meklēt saistītus reģistru ierakstus]</span>, bet jums nav atļauja izveidot šo lapu.",
        "userpage-userdoesnotexist": "Lietotājs \"<nowiki>$1</nowiki>\" nav reģistrēts.\nLūdzu, pārliecinies vai vēlies izveidot/izmainīt šo lapu.",
        "action-sendemail": "sūtīt e-pastus",
        "action-editmyoptions": "labot savas izvēles",
        "action-deletechangetags": "dzēst iezīmes no datubāzes",
+       "action-unblockself": "atbloķēt sevi",
        "nchanges": "$1 {{PLURAL:$1|izmaiņas|izmaiņa|izmaiņas}}",
        "enhancedrc-since-last-visit": "$1 {{PLURAL:$1|kopš pēdējā apmeklējuma}}",
        "enhancedrc-history": "vēsture",
        "dellogpage": "Dzēšanas reģistrs",
        "dellogpagetext": "Šajā lapā ir pēdējo dzēsto lapu saraksts.",
        "deletionlog": "dzēšanas reģistrs",
+       "log-name-create": "Lapu izveides žurnāls",
        "reverted": "Atjaunots uz iepriekšējo versiju",
        "deletecomment": "Iemesls:",
        "deleteotherreason": "Cits/papildu iemesls:",
index 5f668ab..b5128d8 100644 (file)
        "page_first": "ပထမဆုံး",
        "page_last": "နောက်ဆုံး",
        "histlegend": "တည်းဖြတ်မူများကို နှိုင်းယှဉ်ရန် radio boxes လေးများကို မှတ်သားပြီးနောက် Enter ရိုက်ချပါ သို့ အောက်ခြေမှ ခလုတ်ကို နှိပ်ပါ။<br />\nLegend: <strong>({{int:cur}})</strong> = နောက်ဆုံးမူနှင့် ကွဲပြားချက် <strong>({{int:last}})</strong> = ယင်းရှေ့မူနှင့် ကွဲပြားချက်, <strong>{{int:minoreditletter}}</strong> = အရေးမကြီးသော ပြုပြင်မှု.",
-       "history-fieldset-title": "á\80\9aá\80\81á\80\84á\80ºá\80\99á\80°á\80\99á\80»á\80¬á\80¸ á\80\9bá\80¾á\80¬á\80\96á\80½á\80±ရန်",
+       "history-fieldset-title": "á\80\9aá\80\81á\80\84á\80ºá\80\99á\80°á\80\99á\80»á\80¬á\80¸ á\80\85á\80­á\80\85á\80\85á\80ºရန်",
        "history-show-deleted": "ဖျက်ထားသော မူများသာ",
        "histfirst": "အဟောင်းဆုံး",
        "histlast": "အသစ်ဆုံး",
        "rcfilters-savedqueries-add-new-title": "လက်ရှိ စိစစ်မှုအပြင်အဆင်များကို သိမ်းရန်",
        "rcfilters-restore-default-filters": "မူလပုံသေ စိစစ်မှုများအတိုင်း ပြန်ထားရန်",
        "rcfilters-clear-all-filters": "စိစစ်မှုများအားလုံး ရှင်းလင်းရန်",
-       "rcfilters-show-new-changes": "နောက်ဆုံး ပြောင်းလဲမှုများကို ကြည့်ရန်",
+       "rcfilters-show-new-changes": "$1 ကတည်းက ပြောင်းလဲမှုအသစ်များကို ကြည့်ရန်",
        "rcfilters-search-placeholder": "စိစစ်မှုစနစ် အပြောင်းအလဲများ (စိစစ်စနစ်အမည်အတွက် menu သို့မဟုတ် ရှာဖွေခလုတ်ကို အသုံးပြုပါ)",
        "rcfilters-invalid-filter": "မရေရာသော စိစစ်မှု",
        "rcfilters-empty-filter": "သက်ဝင်နေသော စိစစ်မှုစနစ်များ မရှိပါ။ ပံ့ပိုးမှုအားလုံးကို ပြသထားသည်။",
        "deleting-backlinks-warning": "<strong>သတိပေးချက်။</strong> သင်ဖျက်ပစ်တော့မည့် စာမျက်နှာအား [[Special:WhatLinksHere/{{FULLPAGENAME}}|အခြားစာမျက်နှာများမှ]] ချိတ်ဆက်ထားခြင်း သို့မဟုတ် ထည့်သွင်းထားခြင်း ရှိနေသည်။",
        "deleting-subpages-warning": "<strong>သတိပေးချက်။</strong> သင်ဖျက်တော့မည့် စာမျက်နှာတွင် [[Special:PrefixIndex/{{FULLPAGENAME}}/|{{PLURAL:$1|စာမျက်နှာခွဲ တစ်ခု|စာမျက်နှာခွဲ $1 ခု|51=စာမျက်နှာခွဲ ၅၀ ကျော်}}]] ရှိနေသည်။",
        "rollback": "နောက်ပြန်ပြင် တည်းဖြတ်မှုများ",
+       "rollback-confirmation-confirm": "ကျေးဇူးပြု၍ အတည်ပြုပါ-",
        "rollback-confirmation-yes": "နောက်ပြန် ပြန်သွားရန်",
        "rollback-confirmation-no": "မလုပ်တော့ပါ",
        "rollbacklink": "နောက်ပြန် ပြန်သွားရန်",
        "ipbreason-dropdown": "*ယေဘုယျ ပိတ်ပင်တားဆီးရခြင်း အကြောင်းပြချက်များ\n** မှားယွင်းအချက်အလက်များကို ထည့်သွင်းမှု\n** စာမျက်နှာများမှ အကြောင်းအရာကို ဖယ်ရှားမှု\n** ပြင်ပဆိုဒ်များသို့လင့်ခ်ချိတ်၍ ဖွမှု\n** စာမျက်နှာများတွင် ပေါက်တတ်ကရများ ထည့်သွင်းမှု\n** ခြိမ်းခြောက်ခြင်း အပြုအမူ/အနှောက်အယှက်ပေးခြင်း\n** အကောင့်များစွာကို အလွဲသုံးစားလုပ်မှု\n** လက်ခံနိုင်ဖွယ်မရှိသော အသုံးပြုသူအမည်",
        "ipb-hardblock": "ဤအိုင်ပီလိပ်စာမှ လော့ဂ်အင်ဝင်ထားသော အသုံးပြုသူများကို တည်းဖြတ်ခြင်းမှ တားမြစ်ရန်",
        "ipbcreateaccount": "အကောင့်ဖန်တီးခြင်း",
-       "ipbemailban": "á\80¡á\80®á\80¸á\80\99á\80±á\80¸á\80\95á\80­á\80¯á\80·á\80\81á\80¼á\80\84á\80ºá\80¸á\80\99á\80¾ á\80¡á\80\9eá\80¯á\80¶á\80¸á\80\95á\80¼á\80¯á\80\9eá\80°á\80\80á\80­á\80¯ á\80\90á\80¬á\80¸á\80\86á\80®á\80¸á\80\9bá\80\94်",
+       "ipbemailban": "á\80¡á\80®á\80¸á\80\99á\80±á\80¸á\80\9cá\80ºá\80\95á\80­á\80¯á\80·á\80\94á\80±á\80\9eá\80\8a်",
        "ipbenableautoblock": "ဤအသုံးပြုသူ အသုံးပြုသော အိုင်ပီလိပ်စာနှင့် သူတို့ ပြင်ဆင်ရန် ကြိုးစားသည့် နောက်ဆက်တွဲ အိုင်ပီလိပ်စာများကိုပါ အလိုအလျောက်ပိတ်ပင်ရန်",
        "ipbsubmit": "ဤအသုံးပြုသူကို ပိတ်ပင်ရန်",
        "ipbother": "အခြားအချိန်:",
        "ipboptions": "၂ နာရီ:2 hours,၁ ရက်:1 day,၃ ရက်:3 days,၁ ပတ်:1 week,၂ ပတ်:2 weeks,၁ လ:1 month,၃ လ:3 months,၆ လ:6 months,၁ နှစ်:1 year,အနန္တ:infinite",
        "ipbhidename": "အသုံးပြုသူအမည်ကို တည်းဖြတ်မှုများနှင့် စာရင်းမှထဲတွင် ဝှက်ထားရန်",
        "ipbwatchuser": "ဤအသုံးပြုသူ၏ စာမျက်နှာနှင့် ဆွေးနွေးချက်တို့ကို စောင့်ကြည့်ရန်",
-       "ipb-disableusertalk": "á\80\95á\80­á\80\90á\80ºá\80\95á\80\84á\80ºá\80\91á\80¬á\80¸á\80\85á\80\89á\80ºá\80¡á\80\90á\80½á\80\84á\80ºá\80¸ á\80¤á\80¡á\80\9eá\80¯á\80¶á\80¸á\80\95á\80¼á\80¯á\80\9eá\80°á\80¡á\80¬á\80¸ á\80\9eá\80°á\80\90á\80­á\80¯á\80·á\81\8f á\80\80á\80­á\80¯á\80\9aá\80ºá\80\95á\80­á\80¯á\80\84á\80ºá\80\86á\80½á\80±á\80¸á\80\94á\80½á\80±á\80¸á\80\81á\80»á\80\80á\80º á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\80á\80­á\80¯ á\80\95á\80¼á\80\84á\80ºá\80\86á\80\84á\80ºá\80\81á\80¼á\80\84á\80ºá\80¸á\80\99á\80¾ á\80\95á\80­á\80\90á\80ºá\80\95á\80\84á\80ºá\80\9bá\80\94်",
+       "ipb-disableusertalk": "á\80\9eá\80°á\80\90á\80­á\80¯á\80·á\81\8f á\80\80á\80­á\80¯á\80\9aá\80ºá\80\95á\80­á\80¯á\80\84á\80ºá\80\86á\80½á\80±á\80¸á\80\94á\80½á\80±á\80¸á\80\81á\80»á\80\80á\80º á\80\85á\80¬á\80\99á\80»á\80\80á\80ºá\80\94á\80¾á\80¬á\80\80á\80­á\80¯ á\80\95á\80¼á\80\84á\80ºá\80\86á\80\84á\80ºá\80\81á\80¼á\80\84á\80ºá\80¸á\80\94á\80±á\80\9eá\80\8a်",
        "ipb-change-block": "အသုံးပြုသူအား ဤအပြင်အဆင်များဖြင့် ထပ်မံပိတ်ပင်ရန်",
        "ipb-confirm": "ပိတ်ပင်မှုကို အတည်ပြု",
        "ipb-partial": "တစ်စိတ်တစ်ပိုင်း",
index 40405e6..900278b 100644 (file)
        "invalidtitle": "ߞߎ߲߬ߕߐ߮ ߓߍ߲߬ߓߊߟߌ",
        "exception-nologin": "ߌ ߜߊ߲߬ߞߎ߲߬ߣߍ߲߬ ߕߍ߫",
        "virus-unknownscanner": "ߢߐߛߌߙߋ߲ߞߟߊ߬ ߡߊߟߐ߲ߓߊߟߌ",
+       "logouttext": "<strong>ߌ ߜߊ߲߬ߞߎ߲߬ߓߐ߬ߣߍ߲߬ ߕߍ߫.</strong>\n\nߞߐߜߍ ߘߏ߫ ߟߎ߫ ߕߘߍ߬ ߘߌ߫ ߞߍ߫ ߓߊ߯ߙߊ߫ ߟߊ߫ ߞߵߌ ߜߊ߲߬ߞߎ߲߬ߣߍ߲ ߕߏ߫߸ ߝߏ߫ ߣߴߌ ߞߵߌ ߟߊ߫ ߛߏ߲߯ߓߊߟߊ߲ ߢߡߊߘߏ߲߰ߣߍ߲ ߠߎ߬ ߖߏ߬ߛߌ߬.",
        "logging-out-notify": "ߌ ߜߊ߲߬ߞߎ߲߬ߣߍ߲ ߓߐ ߦߴߌ ߘߐ߫߸ ߡߊ߬ߞߐ߬ߣߐ߲߬ߠߌ߲ ߞߍ߫ ߖߊ߰ߣߌ߲߬.",
        "logout-failed": "ߌ ߕߍߣߊ߬ ߛߋ߫ ߟߴߌ ߜߊ߲߬ߞߎ߬ߣߍ߲ ߓߐ߫ ߟߊ߫ ߕߊ߲߫ $1",
        "cannotlogoutnow-title": "ߌ ߕߍ߫ ߣߊ߬ ߛߋ߫ ߟߴߌ ߜߊ߲߬ߞߎ߬ߣߍ߲ ߓߐ߫ ߟߊ߫ ߕߊ߲߫",
        "createaccounterror": "ߖߊ߬ߕߋ߬ߘߊ߬ ߕߍ߫ ߣߊ߬ ߛߐ߲߬ ߠߊ߫ ߛߌ߲ߘߌ߫ ߟߊ߫: $1",
        "nocookiesnew": "ߖߊ߬ߕߋ߬ߘߊ߬ ߟߊߓߊ߯ߙߕߊ ߓߘߊ߫ ߛߌ߲ߘߌ߫߸ ߞߏ߬ߣߌ߲߬ ߌ ߜߊ߲߬ߞߎ߲߬ߣߍ߲߬ ߕߍ߫.\n{{SITENAME}} ߦߋ߫ ߞߎߞߌߦߋ ߟߊ߫ ߞߊ߬ ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߜߊ߲߬ߞߎ߲߬.\nߌ ߓߘߊ߫ ߞߎߞߌߦߋ ߓߴߊ߬ ߟߊ߫.\nߊ߬ߟߎ߫ ߓߌ߬ߟߵߊ߬ ߟߊ߫ ߖߊ߰ߣߌ߲߫߸ ߏ߬ ߓߊ߰ ߞߍ߫ ߌ ߦߴߌ ߜߊ߲߬ߞߎ߲߫ ߌ ߟߊ߫ ߖߊ߬ߕߋ߬ߘߊ ߣߌ߫ ߕߊ߬ߡߌ߲߬ߞߊ߲߬ ߞߎߘߊ߫ ߘߌ߫.",
        "nocookieslogin": "\n{{SITENAME}} ߦߋ߫ ߞߎߞߌߦߋ ߟߊߓߊ߯ߙߊ߫ ߟߊ߫ ߞߊ߬ ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߟߎ߬ ߜߊ߲߬ߞߎ߲߬.\nߌ ߓߘߊ߫ ߞߎߞߌߦߋ ߓߴߊ߬ ߟߊ߫.\nߊ߬ ߓߌ߬ߟߵߊ߬ ߟߊ߫ ߖߊ߰ߣߌ߲߫ ߞߵߊ߬ ߡߊߝߍߣߍ߲߫ ߕߎ߲߯.",
+       "nocookiesfornew": "ߖߊ߬ߕߋ߬ߘߊ߬ ߟߊߓߊ߯ߙߕߊ ߣߌ߲߬ ߡߊ߫ ߛߌ߲ߘߌ߫ ߡߎߣߎ߲߬߸ ߓߊ ߊ߲ ߕߍ߫ ߛߋ߫ ߊ߬ ߓߐߛߎ߲ ߠߊߘߤߊ߬ ߟߊ߫. ߌ ߦߋ߫ ߘߍ߲߬ߞߣߍ߬ߦߴߊ߬ ߡߊ߬ ߞߏ߫ ߌ ߓߘߊ߫ ߞߎߞߌߦߋ ߓߌ߬ߟߵߊ߬ ߟߊ߫߸ ߞߐߜߍ ߣߌ߲߬ ߠߊߢߎ߲߫ ߞߊ߬ ߓߊ߲߫ ߞߵߊ߬ ߡߊߝߍߣߍ߲߫ ߕߎ߲߯.",
+       "createacct-loginerror": "ߖߊ߬ߕߋ߬ߘߊ ߓߘߊ߫ ߓߊ߲߫ ߛߌ߲ߘߌ߫ ߟߊ߫ ߝߛߊߦߌ߫ ߞߏ߬ߣߌ߲߬ ߌ ߕߍߣߊ߬ ߛߋ߫ ߟߴߌ ߜߊ߲߬ߞߎ߲߬ ߠߊ߫ ߞߍߒߖߘߍߦߋ߫ ߓߟߏߡߊ߬.ߖߊ߰ߣߌ߲߬ ߌ ߦߴߊ߬ ߡߌ߬ߘߵߊ߬ ߛߎ߲ ߖߊ߰ߣߌ߲߬ ߦߊ߲߬ [[Special:UserLogin|manual login]].",
        "noname": "ߟߊ߬ߓߊ߰ߙߊ߬ ߕߐ߯ ߖߐ߲ߖߐ߲߫ ߟߊߘߊ߲߫ ߣߍ߲߫ ߕߴߌ ߓߟߏ߫.",
        "loginsuccesstitle": "ߌ ߜߊ߲߬ߞߎ߲߬",
        "loginsuccess": "<strong>ߌ ߓߘߴߌ ߜߊ߲߬ߞߎ߲߬ {{SITENAME}} ߟߊ߫ ߕߊ߲߬ $1</strong>",
        "nosuchuser": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߛߌ߫ ߕߍ߫ ߕߐ߮ ߟߊ߫  \"$1\".\nߟߊ߬ߓߊ߰ߙߊ߬ ߕߐ߮ ߓߐߣߍ߲߫ ߦߋ߫ ߘߏ߫ ߟߊ߫. \nߌ ߟߊ߫ ߌ ߟߊ߫ ߛߓߍߟߌ ߝߛߍ߬ߝߛߍ߬߸ ߥߟߊ߫ [[Special:CreateAccount|create a new account]].",
+       "nosuchusershort": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߛߌ߫ ߕߍ߫ ߕߐ߮  \"$1\" ߟߊ߫.\nߌ ߟߊ߫ ߛߓߍߟߌ ߞߎ߬ߙߎ߲߬ߘߎ ߝߛߍ߬ߝߛߍ߬.",
        "nouserspecified": "ߌ ߞߊߞߊ߲߫ ߞߊ߬ ߕߐ߯ ߟߊߓߊ߯ߙߕߊ߫ ߞߋߟߋ߲߫ ߡߊߕߍ߰",
        "login-userblocked": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߣߌ߲߬ ߓߊ߬ߟߊ߲߬ߣߍ߲߫ ߠߋ߫. ߜߊ߲߬ߞߎ߲߬ߠߌ߲ ߠߊߘߌ߬ߢߍ߬ߣߍ߲߬ ߕߍ߫.",
        "wrongpassword": "ߟߊ߬ߓߊ߰ߙߊ߬ ߕߐ߮ ߓߍ߲߬ ߣߍ߲߬ ߕߍ߫ ߥߟߊ߫ ߕߊ߬ߡߌ߲߬ߞߊ߲߬ ߠߊߘߏ߲߬ߣߍ߲.\nߖߊ߰ߣߌ߲߬ ߌ ߦߴߊ߬ ߡߊߝߍߣߍ߲߫ ߕߎ߲߯.",
        "passwordtoolong": "ߕߊ߬ߡߌ߲߬ߞߊ߲ ߡߊ߲߫ ߞߊ߲߫ ߞߊ߬ ߖߊ߲߰ߧߊ߬ {{PLURAL:$1|ߛߓߍߘߋ߲ ߁|$1 ߛߓߍߘߋ߲ ߠߎ߬}}.",
        "passwordtoopopular": " ߝߘߏ߬ߓߊ߬ ߕߊ߬ߡߌ߲߬ߞߊ߲߬ ߡߊߟߐ߲ߣߍ߲ ߠߎ߬ ߕߍ߫ ߣߊ߬ ߛߋ߫ ߟߊ߫ ߟߊߓߊ߯ߙߊߊ߫ ߟߊ߫. ߌ ߦߋ߫ ߕߊ߬ߡߌ߲߬ߞߊ߲ ߜߘߍ߫ ߛߎߥߊ߲ߘߌ߫ ߖߊ߰ߣߌ߲߬ ߡߍ߲ ߡߊߟߐ߲߫ ߜߏߡߊ߲߫.",
        "passwordinlargeblacklist": "ߕߊ߬ߡߌ߲߬ߞߊ߲߬ ߠߊߘߏ߲߬ߣߍ߲ ߦߋ߫ ߝߘߏ߬ߓߊ߬ ߕߊ߬ߡߌ߲߬ߞߊ߲߬ ߡߊߟߐ߲ߣߍ߲ߓߊ ߟߎ߬ ߛߙߍߘߍ ߟߋ߬ ߘߐ߫.\nߖߊ߰ߣߌ߲߬ ߌ ߦߋ߫ ߕߊ߬ߡߌ߲߬ߞߊ߲߬ ߦߙߋߞߋ ߘߏ߫ ߛߎߥߊ߲ߘߌ߫.",
+       "password-name-match": "ߌ ߟߊ߫ ߕߊ߬ߡߌ߲߬ߞߊ߲ ߦߋ߫ ߝߘߏ߬ ߌ ߕߐ߯ ߟߊߓߊ߯ߙߕߊ ߡߊ߬.",
+       "password-login-forbidden": "ߟߊ߬ߓߊ߰ߙߊ߬ ߕߐ߮ ߣߌ߲߬ ߣߌ߫ ߕߊ߬ߡߌ߲߬ߞߊ߲ ߣߌ߲߬ ߠߊߓߊ߯ߙߊ ߓߘߊ߫ ߟߊߕߐ߲߫.",
        "mailmypassword": "ߕߊ߬ߡߌ߲߬ߞߊ߲ ߡߊߦߟߍ߬ߡߊ߲߬",
        "passwordremindertitle": "{{SITENAME}} ߕߊ߬ߡߌ߲߬ߞߊ߲ ߕߎ߬ߡߊ߬ߞߎ߲߬ߡߊ ߞߎߘߊ",
        "noemail": "ߢߎߡߍߙߋ߲߫ ߞߏ߲ߘߏ ߛߌ߫ ߕߍ߫ ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ \"$1\" ߟߊ߫",
        "postedit-confirmation-restored": "ߞߐߜߍ ߓߘߊ߫ ߓߊ߲߫ ߘߐߓߍ߲߬ ߠߊ߫.",
        "postedit-confirmation-saved": "ߌ ߟߊ߫ ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲ ߓߘߊ߫ ߟߊߞߎ߲߬ߘߎ߬.",
        "postedit-confirmation-published": "ߌ ߟߊ߫ ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲ ߓߘߊ߫ ߟߊߥߊ߲߬ߞߊ߫.",
+       "edit-already-exists": "ߌ ߕߴߛߋ߫ ߞߐߜߍ߫ ߞߎߘߊ߫ ߛߌ߲ߘߌ߫ ߟߊ߫.\nߊ߬ ߦߋ߫ ߦߋ߲߬ ߞߘߐ߬ߡߊ߲߬.",
        "invalid-content-data": "ߞߣߐߘߐ ߓߟߏߡߟߊ ߓߍ߲߬ߓߊߟߌ",
        "content-not-allowed-here": "\"$1\" ߞߣߐߘߐ ߟߊߘߤߊ߬ߣߍ߲߬ ߕߍ߫ ߞߐߜߍ ߘߐ߫ [[:$2]] ߛߍ߲ߞߍߘߊ ߘߐ߫  \"$3\"",
        "editwarning-warning": "ߣߴߌ ߓߐ߫ ߘߊ߫ ߞߐߜߍ ߣߌ߲߬ ߞߊ߲߬߸ ߌ ߘߌ߫ ߓߣߐ߬ ߌ ߟߊ߫ ߡߊ߬ߝߊ߬ߟߋ߲߬ߠߌ߲߬ ߞߍߣߍ߲ ߠߎ߬ ߓߍ߯ ߘߐ߫.\nߣߴߌ ߘߏ߲߬ ߜߊ߲߬ߞߎ߲߬ߣߍ߲߬ ߞߍ߫ ߘߊ߫߸ ߌ ߘߌ߫ ߛߋ߫ ߖߊ߬ߛߙߋ߬ߡߊ߬ߟߊ ߣߌ߲߬ ߓߐ߫ ߟߴߊ߬ ߟߊ߫  \"{{int:prefs-editing}}\" ߘߐ߫߸ ߌ ߟߊ߫ ߟߊ߬ߝߌ߬ߛߦߊ߬ߟߌ ߥߟߊ߬ߘߊ ߘߐ߫.",
        "revertmerge": "ߊ߬ ߓߐߢߐ߲߮ߞߊ߲߬",
        "history-title": "$1 ߡߛߊ߬ߦߌ߲߬ߠߌ߲ ߘߐ߬ߝߐ",
        "difference-title": "ߘߊ߲߬ߝߘߊ߬ߓߐ ߡߍ߲ ߦߋ߫ ߡߛߊ߬ߦߌ߲߬ߠߌ߲ $1 ߕߍ߫",
+       "difference-title-multipage": "ߘߊ߲߬ߝߘߊ߬ߓߐ ߡߍ߲ ߦߋ߫ ߞߐߜߍ ߟߎ߬ ߕߍ߫ \"$1\" ߣߌ߫  \"$2\"",
+       "difference-multipage": "(ߘߊ߲߬ߝߘߊ߬ߓߐ ߡߍ߲ ߦߋ߫ ߞߐߜߍ ߟߎ߬ ߕߍ߫)",
        "lineno": "$1 ߛߌ߬ߕߊߙߌ:",
        "compareselectedversions": "ߘߟߊߡߌߘߊ߫ ߛߎߥߊ߲ߘߌߣߍ߲ ߠߎ߬ ߟߊߢߐ߲߯ߡߊ߫",
+       "showhideselectedversions": "ߟߢߊ߬ߟߌ߬ ߓߊߓߌ߬ߟߊ߬ߣߍ߲ ߦߋߢߊ ߡߊߝߊ߬ߟߋ߲߬",
        "editundo": "ߊ߬ ߘߐߛߊ߬",
        "diff-empty": "(ߝߊߙߊ߲ߝߊ߯ߛߌ߫ ߕߴߊ߬ߟߎ߬ ߕߍ߫)",
        "diff-multi-sameuser": "({{PLURAL:$1|One intermediate revision|$1 intermediate revisions}} ߟߊ߬ߓߊ߰ߙߊ߬ ߞߋߟߋ߲ ߓߟߏ߫߸ ߏ߬ ߡߊ߫ ߦߌ߬ߘߊ߬)",
        "diff-multi-otherusers": "({{PLURAL:$1|ߕߍߟߐ ߡߊߛߊߦߌ߲߬ߞߏ߬ ߞߋߟߋ߲߫|ߕߍߟߐ ߡߊߛߊ߬ߦߌ߲}} {{PLURAL:$2|ߟߊߓߊ߯ߙߟߊ߫ ߘߏ߫ ߜߘߍ߫|ߟߊߓߊ߯ߙߟߊ ߟߎ߬}} ߏ߬ ߡߊ߫ ߟߊ߲ߞߣߍߡߊ߫)",
+       "diff-multi-manyusers": "({{PLURAL:$1|ߕߍߟߊߘߐ߫ ߟߢߊߟߌ߫ ߞߋߟߋ߲߫|$1 ߕߍߟߊߘߐ߫ ߟߢߊߟߌ ߟߎ߬}} ߞߊ߬ ߝߘߊ߫ {{PLURAL:$2|ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ|ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߟߎ߬}} ߦߙߌߞߊ ߟߎ߬ $2 ߡߊ߫ ߦߌ߬ߘߊ߬)",
+       "diff-paragraph-moved-tonew": "ߛߌ߬ߘߊ߰ߙߋ߲ ߠߎ߬ ߡߊ߫ ߛߋ߲߬ߓߐ߫. ߛߐ߲߬ߞߌ߲߬ߠߌ߲ ߞߍ߫ ߞߵߌ ߕߐ߬ߡߐ߲߬ ߞߊ߬ ߥߊ߫ ߘߌ߲߬ߞߌ߬ߙߊ߬ ߜߘߍ߫ ߘߐ߫.",
+       "diff-paragraph-moved-toold": "ߛߌ߬ߘߊ߰ߙߋ߲ ߓߘߊ߫ ߛߋ߲߬ߓߐ߫. ߛߐ߲߬ߞߌ߲߬ߠߌ߲ ߞߍ߫ ߞߵߌ ߕߐ߬ߡߐ߲߬ ߞߊ߬ ߥߊ߫ ߘߌ߲߬ߞߌ߬ߙߊ߬ ߞߘߐ ߘߐ߫.",
        "searchresults": "ߢߌߣߌ߲ߠߌ߲ ߞߐߝߟߌ ߟߎ߬",
+       "search-filter-title-prefix": "ߞߐߜߍ ߡߍ߲ ߠߎ߬ ߞߎ߲߬ߕߐ߮ ߦߋ߫ ߘߊߡߌ߬ߣߊ߬ ߟߊ߫  \"$1\" ߡߊ߬ ߏ߬ ߟߎ߫ ߟߋ߬ ߘߐߙߐ߲߫ ߢߌߣߌ߲ ߦߴߌ ߘߐ߫.",
        "search-filter-title-prefix-reset": "ߞߐߜߍ ߓߍ߯ ߢߌߣߌ߲߫",
        "searchresults-title": "ߣߌ߲߬ \"$1\" ߢߌߣߌ߲ߠߌ߲ ߞߐߝߟߌ",
        "prevn": "ߕߊ߬ߡߌ߲߬ߣߍ߲ ߠߎ߬ {{PLURAL:$1|$1}}",
        "nextn": "ߟߊߕߎ߲߰ߠߊ {{PLURAL:$1|$1}}",
+       "prev-page": "ߞߐߜߍ ߢߍߕߊ",
+       "next-page": "ߞߐߜߍ߫ ߣߊ߬ߕߐ",
        "prevn-title": "ߢߝߍߕߊ $1 {{PLURAL:$1|result|results}}",
        "nextn-title": "ߢߍߕߊ $1 {{PLURAL:$1|ߞߐߖߋߓߌ}}",
        "shown-title": "ߞߐߜߍ߫ ߞߋ߬ߟߋ߲߬ߞߋ߬ߟߋ߲߬ߠߊ $1{{PLURAL:$1|ߞߐߝߟߌ |ߞߐߝߟߌ ߟߎ߬ }} ߦߌߘߊߞߊ߬",
        "search-category": "(ߦߌߟߡߊ $1)",
        "search-file-match": "(ߞߐߕߐ߮ ߞߣߐߘߐ ߓߘߊ߫ ߟߊߞߊ߬ߝߏ߬)",
        "search-suggest": "ߌ ߞߊ߲߫ ߦߋ߫ ߣߌ߲߬ ߠߋ߬ ߡߊ߬ $1",
+       "search-rewritten": "$1 ߞߐߝߟߌ ߦߌ߬ߘߊ ߦߴߌ ߘߐ߫. $2 ߢߌߣߌ߲߫ ߞߋߟߋ߲ߘߌ߫.",
+       "search-interwiki-caption": "ߖߊ߬ߕߋ߬ߘߐ߬ߛߌ߮ ߡߊ߬ߡߛߏ ߟߎ߬ ߞߐߝߟߌ",
+       "search-interwiki-default": "ߞߐߝߐߟߌ ߞߵߊ߬ ߕߊ߬ $1:",
+       "search-interwiki-more": "(ߡߊ߬ߞߊ߬ߝߏ߬ ߜߘߍ ߟߎ߬)",
        "search-interwiki-more-results": "ߞߐߝߟߌ߫ ߜߘߍ ߟߎ߬",
+       "search-relatedarticle": "ߓߌ߬ߟߊ߬ߢߐ߲߰ߡߊ",
+       "searchrelated": "ߓߌ߬ߟߊ߬ߢߐ߲߰ߡߊ",
        "searchall": "ߊ߬ ߓߍ߯",
        "search-showingresults": "{{PLURAL:$4|Result <strong>$1</strong> of <strong>$3</strong>|Results <strong>$1 – $2</strong> of <strong>$3</strong>}}",
        "search-nonefound": "ߖߋ߬ߓߟߌ߬ ߛߌ߫ ߕߍ߫ ߢߌ߬ߣߌ߲߬ߞߊ߬ߟߌ ߣߌ߲߫ ߞߊ߲߬.",
        "powersearch-legend": "ߢߌߣߌ߲ߠߌ߲ ߖߊ߲߬ߝߊ߬ߣߍ߲",
        "powersearch-ns": "ߊ߬ ߢߌߣߌ߲߫ ߕߐ߯ߛߓߍ ߞߣߍ ߘߐ߫.",
        "powersearch-togglelabel": "ߝߛߍ߬ߝߛߍ߬ߟߌ",
+       "powersearch-toggleall": "ߊ߬ ߓߍ߯",
+       "powersearch-togglenone": "ߝߏߦߌ߬",
+       "search-external": "ߞߐߞߊ߲߫ ߢߌߣߌ߲ߠߌ߲",
+       "search-error": "ߝߎ߬ߕߎ߲߬ߕߌ ߘߏ߫ ߓߘߴߊ߬ ߞߎ߲߬ߓߐ߫ ߞߵߌ ߕߏ߫ $1 ߢߌߣߌ߲ ߠߊ߫",
+       "search-warning": "ߖߊ߬ߛߙߋ߬ߡߊ߬ߟߊ ߘߏ߫ ߓߘߴߊ߬ ߞߎ߲߬ߓߐ߫ ߞߵߌ ߕߏ߫ $1 ߢߌߣߌ߲ ߠߊ߫",
        "preferences": "ߟߊ߬ߝߌ߬ߛߦߊ߬ߟߌ",
        "mypreferences": "ߟߊ߬ߝߌ߬ߛߦߊ߬ߟߌ",
        "prefs-edits": "ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲ ߠߎ߬ ߦߙߌߞߊ:",
        "prefs-watchlist-edits-max": "ߝߙߍߕߍ ߞߐߘߊ߲: ߁߀߀߀",
        "prefs-watchlist-token": "ߜߋ߬ߟߎ߲߬ߠߌ߲߬ ߛߙߍߘߍ ߖߐߟߐ߲ߞߐ",
        "prefs-watchlist-managetokens": "ߖߐߟߐ߲ߞߐ ߘߊߘߐߓߍ߲߭",
+       "prefs-misc": "ߜߘߍ ߟߎ߬",
        "prefs-resetpass": "ߕߊ߬ߡߌ߲߬ߞߊ߲ ߡߊߝߊ߬ߟߋ߲߬",
        "prefs-changeemail": "ߢߎߡߍߙߋ߲߫ ߞߏ߲ߘߏ ߡߊߝߊ߬ߟߋ߲߬ ߥߟߊ߫ ߌ ߦߴߊ߬ ߛߋ߲߬ߓߐ߫",
        "prefs-setemail": "ߢߎߡߍߙߋ߲߫ ߞߏ߲ߘߏ ߘߏ߫ ߟߊߘߏ߲߬",
        "prefs-email": "ߢߎߡߍߙߋ߲ ߞߏ߲ߘߏ ߛߎߥߊ߲ߘߟߌ",
+       "prefs-rendering": "ߟߊ߲ߞߣߍߡߊ",
        "saveprefs": "ߊ߬ ߟߊߞߎ߲߬ߘߎ߬",
+       "prefs-editing": "ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲ ߦߴߌ ߘߐ߫",
+       "searchresultshead": "ߢߌߣߌ߲ߠߌ߲",
+       "stub-threshold-sample-link": "ߣߐ߰ߡߊ߲",
+       "stub-threshold-disabled": "ߊ߬ ߓߘߊ߫ ߓߐ߫ ߊ߬ ߟߊ߫.",
+       "recentchangesdays": "ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲߬ ߠߊ߬ߓߊ߲ ߠߎ߬ ߦߌ߬ߘߊ߬ ߕߐ߬ ߟߏ߲ ߡߍ߲ ߠߎ߬ ߘߐ߫.",
+       "recentchangesdays-max": "{{PLURAL:$1|ߟߏ߲|ߟߏ߲ ߠߎ߬}} ߞߐߘߊ߲ $1",
+       "recentchangescount": "ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲ ߠߎ߬ ߦߙߌߞߊ ߡߍ߲ ߦߌ߬ߘߊ߬ߕߊ ߦߋ߫ ߡߊ߬ߦߟߍ߬ߡߊ߲߬ߠߌ߲߬ ߠߊ߬ߓߊ߲ ߘߐ߫߸ ߞߐߜߍ ߟߊ߫ ߘߐ߬ߝߐ ߘߐ߫߸ ߊ߬ ߘߎ߲ߛߓߍ ߟߎ߬ ߘߐ߫߸ ߓߐߛߎ߲ ߓߟߏ߫.",
+       "prefs-help-recentchangescount": "ߝߙߍߕߍ ߞߐߘߊ߲: ߁߀߀߀",
+       "savedprefs": "ߌ ߟߊ߫ ߟߊ߬ߝߌ߬ߛߦߊ߬ߟߌ ߓߘߊ߫ ߟߊߞߎ߲߬ߘߎ߬.",
+       "savedrights": "ߌ ߟߊ߫ ߞߙߎ߫ ߟߊߓߊ߯ߙߕߊ {{GENDER:$1|$1}} ߓߘߊ߫ ߟߊߞߎ߲߬ߘߎ߬.",
+       "timezonelegend": "ߕߌ߲߬ߞߎߘߎ߲ ߕߎ߬ߡߊ",
+       "localtime": "ߕߌ߲߬ߞߎߘߎ߲ ߕߎ߬ߡߊ:",
+       "timezoneuseserverdefault": "ߥߞߌ߫ ߓߐߛߎ߲ ($1) ߠߊߓߊ߯ߙߊ߫",
+       "timezone-useoffset-placeholder": "ߞߏߟߊߒߞߏߡߊ ߡߐ߬ߟߐ߲: : ߴߴ߀߇:߀߀ߴߴ ߥߟߊ߫ ߴߴ߀߁:߀߀ߴߴ",
+       "servertime": "ߡߊ߬ߛߐ߬ߟߊ ߕߎ߬ߡߊ߬ߙߋ߲:",
+       "guesstimezone": "ߊ߬ ߡߊߛߐ߬ߘߐ߲߬ ߛߏ߲߯ߓߊߟߊ߲ ߝߍ߬",
        "timezoneregion-africa": "ߊߝߙߌߞߌ߬",
+       "timezoneregion-america": "ߊߡߋߙߌߞߌ߬",
+       "timezoneregion-antarctica": "ߜߟߌߟߌߓߊ",
+       "timezoneregion-arctic": "ߞߐ߬ߘߎ߰ߞߊ",
+       "timezoneregion-asia": "ߊߖ߭ߌ߫",
+       "timezoneregion-atlantic": "ߟߌ߲ߓߊ߲߫ ߡߊ߲ߞߊ߲",
+       "timezoneregion-australia": "ߏߛߑߕߙߊߟߌ߫",
+       "timezoneregion-europe": "ߋߙߐߔߎ߬",
+       "timezoneregion-indian": "ߌ߲ߘߌ߫ ߟߌ߲ߓߊ߲",
+       "timezoneregion-pacific": "ߖߐ߮ ߟߌ߲ߓߊ߲",
+       "allowemail": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߕߐ߭ ߟߎ߬ ߟߊߘߌ߬ߢߍ߬ ߢߎߡߍߙߋ߲߫ ߗߋ ߘߐ߫ ߒ ߡߊ߬",
+       "email-allow-new-users-label": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߊ ߞߎߘߊ ߟߎ߬ߟߊߘߌ߬ߢߍ߬ ߢߎߡߍߙߋ߲߫ ߗߋ ߘߐ߫ ߒ ߡߊ߬",
+       "prefs-searchoptions": "ߢߌߣߌ߲ߠߌ߲",
+       "prefs-namespaces": "ߕߐ߯ ߛߓߍ ߞߣߍ",
+       "default": "ߓߐߛߎ߲",
+       "prefs-files": "ߞߐߕߐ߮ ߟߎ߬",
+       "prefs-custom-css": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߌ CSS",
+       "prefs-custom-json": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߌ JSON",
+       "prefs-custom-js": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߌ JavaScript",
+       "prefs-emailconfirm-label": "ߢߎߡߍߙߋ߲ ߟߊ߬ߛߙߋ߬ߦߊ߬ߟߌ:",
+       "youremail": "ߢߎߡߍߙߋ߲߫ ߞߏ߲ߘߏ:",
+       "username": "{{GENDER:$1|ߟߊ߬ߓߊ߰ߙߊ߬ ߕߐ߮}}:",
+       "prefs-memberingroups": "{{GENDER:$2|ߛߌ߲߬ߝߏ߲}} {{PLURAL:$1|ߞߙߎ|ߞߙߎ ߟߎ߬}} ߘߐ߫:",
+       "group-membership-link-with-expiry": "$1 (ߝߏ߫ $2)",
+       "prefs-registration": "ߕߐ߯ߛߓߍߟߌ ߕߎ߬ߡߊ:",
+       "yourrealname": "ߕߐ߯ ߓߘߍ:",
+       "yourlanguage": "ߞߊ߲:",
+       "yourvariant": "ߞߣߐߘߐ ߞߊ߲ ߠߎ߬ ߓߐߣߍ߲߫ ߢߐ߲߮ ߡߊ߬:",
+       "yournick": "ߞߟߊ߬ߣߐ߰ ߞߎߘߊ:",
+       "badsig": "ߞߟߊ߬ߣߐ߮ ߢߊ߲ߞߌߛߊ߲ ߓߍ߲߬ߓߊߟߌ.\nHTLM ߘߎ߲ߛߓߍ ߝߛߍ߬ߝߛߍ߬.",
+       "badsiglength": "ߌ ߟߊ߫ ߞߟߊ߬ߣߐ߮ ߖߊ߰ߡߊ߲߬ ߞߏߖߎ߰.\nߊ߬ ߡߊ߲ߞߊ߲߫ ߞߊ߬ ߕߊ߬ߡߌ߲߬ $1 {{PLURAL:$1|ߛߓߍߟߌ|ߛߓߍߟߌ ߟߎ߬}} ߖߊ߲߰ߧߊ ߟߊ߫.",
+       "yourgender": "ߌ ߕߐ߮ ߛߓߍ߫ ߢߊ ߢߎ߬ߡߊ߲߬ ߞߊߘߴߌ ߦߋ߫؟",
+       "gender-male": "ߊ߬ (ߗߍ߭) ߓߘߊ߫ ߥߞߌ߫ ߞߐߜߍ ߡߊߦߟߍ߬ߡߊ߲߫",
+       "gender-female": "ߊ߬ (ߡߏ߬ߛߏ) ߓߘߊ߫ ߥߞߌ߫ ߞߐߜߍ ߡߊߦߟߍ߬ߡߊ߲߫",
+       "email": "ߢߎߡߍߙߋ߲ߞߏ߲ߘߏ",
+       "prefs-help-email-required": "ߢߎߡߍߙߋ߲ߞߏ߲ߘߏ ߛߊ߲߬ߓߊ߬ߕߐ߮ ߞߊ߬ߣߌ߲߬ߣߍ߲߫.",
+       "prefs-info": "ߞߎ߲߬ߠߊ߬ߝߎߟߋ߲ ߓߊߖߎ",
+       "prefs-i18n": "ߡߊ߲߬ߕߏ߬ߕߍ߬ߦߊ߬ߟߌ",
+       "prefs-signature": "ߞߟߊ߬ߣߐ߮",
+       "prefs-dateformat": "ߕߎ߬ߡߊ߬ߘߊ ߖߙߎߡߎ߲",
+       "prefs-advancedediting": "ߢߣߊߕߊߟߌ ߞߙߎߞߙߍ",
+       "prefs-developertools": "ߟߊ߬ߥߙߎ߬ߞߌ߬ߟߊ ߖߐ߯ߙߊ߲ ߠߎ߬",
+       "prefs-editor": "ߛߓߍߦߟߊ",
+       "prefs-preview": "ߟߊ߬ߕߎ߲߰ߠߊ",
+       "prefs-advancedrc": "ߢߣߊߕߊߟߌ ߖߊ߲߬ߝߊ߬ߣߍ߲",
+       "prefs-advancedrendering": "ߢߣߊߕߊߟߌ ߖߊ߲߬ߝߊ߬ߣߍ߲",
+       "prefs-advancedsearchoptions": "ߢߣߊߕߊߟߌ ߖߊ߲߬ߝߊ߬ߣߍ߲",
+       "prefs-advancedwatchlist": "ߢߣߊߕߊߟߌ ߖߊ߲߬ߝߊ߬ߣߍ߲",
+       "prefs-displayrc": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߌ ߢߣߊߕߊߟߌ",
+       "prefs-displaywatchlist": "ߟߊ߬ߓߊ߰ߙߊ߬ߟߌ ߢߣߊߕߊߟߌ",
        "group-bot": "ߓߏߕ",
        "group-sysop": "ߞߎ߲߬ߠߊ߬ߛߌ߰ߟߊ",
        "grouppage-bot": "{{ns:project}}:ߓߏߕ",
index f870148..a852dcc 100644 (file)
                        "Romanko Mikhail",
                        "Diralik",
                        "1233qwer1234qwer4",
-                       "Саша Волохов"
+                       "Саша Волохов",
+                       "Serhio Magpie"
                ]
        },
        "tog-underline": "Подчёркивание ссылок:",
        "noindex-category": "Неиндексируемые страницы",
        "broken-file-category": "Страницы с неработающими файловыми ссылками",
        "categoryviewer-pagedlinks": "($1) ($2)",
-       "category-header-numerals": "$1â\80\93$2",
+       "category-header-numerals": "$1â\80\94$2",
        "about": "Описание",
        "article": "Статья",
        "newwindow": "(в новом окне)",
index 0c427e6..a17018b 100644 (file)
        "logentry-newusers-create": "La registhrazioni di l'utenti $1 è isthadda {{GENDER:$2|criadda}}",
        "logentry-upload-upload": "$1 {{GENDER:$2|à carriggaddu}} $3",
        "rightsnone": "(nisciunu)",
-       "searchsuggest-search": "Zercha di dentru a {{SITENAME}}"
+       "searchsuggest-search": "Zercha di dentru a {{SITENAME}}",
+       "userlogout-continue": "Bói iscí?"
 }
index 3da455d..f30aa1c 100644 (file)
        "virus-scanfailed": "скенирање није успело (код $1)",
        "virus-unknownscanner": "непознати антивирус:",
        "logouttext": "<strong>Сада сте одјављени.</strong>\n\nЗапамтите да неке странице могу наставити да се приказују као да сте још увек пријављени, све док не обришете кеш свог прегледача.",
+       "logging-out-notify": "Одјављивање је у току, сачекајте.",
+       "logout-failed": "Тренутно није могуће одјавити се: $1",
        "cannotlogoutnow-title": "Одјава тренутно није могућа",
        "cannotlogoutnow-text": "Одјава није могућа током употребе $1.",
        "welcomeuser": "Добро дошли, $1!",
        "badretype": "Лозинке које сте унели се не поклапају.",
        "usernameinprogress": "Налог за ово корисничко име се већ прави, сачекајте.",
        "userexists": "Унесено корисничко име је већ у употреби.\nОдаберите друго.",
+       "createacct-normalization": "Ваше корисничко име биће прилагођено на „$2” због техничких ограничења.",
        "loginerror": "Грешка при пријављивању",
        "createacct-error": "Дошло је до грешке при отварању налога",
        "createaccounterror": "Није могуће отворити налог: $1",
        "grant-editmycssjs": "Уређивање вашег корисничког Це-Ес-Еса/ЈСОН-а/јаваскрипта",
        "grant-editmyoptions": "Уређивање ваших корисничких подешавања и ЈСОН поставке",
        "grant-editmywatchlist": "Уређивање вашег списка надгледања",
+       "grant-editsiteconfig": "уређивање CSS-а/JS-а корисника и целог сајта",
        "grant-editpage": "Уређивање постојећих страница",
        "grant-editprotected": "Уређивање заштићених страница",
        "grant-highvolume": "Мењање великог обима",
        "action-changetags": "додате и уклоните разне ознаке на појединачним изменама и уносима у дневницима",
        "action-deletechangetags": "бришете ознаке из базе података",
        "action-purge": "освежите ову страницу",
+       "action-bigdelete": "бришете странице с великим историјама измена",
        "action-blockemail": "блокирате кориснику слање е-порука",
+       "action-bot": "будете сматрани аутоматизованим процесом",
+       "action-editprotected": "уређуете странице заштићене нивоом „{{int:protect-level-sysop}}”",
+       "action-editsemiprotected": "уређуете странице заштићене нивоом „{{int:protect-level-autoconfirmed}}”",
+       "action-editinterface": "уређујете кориснички интерфејс",
+       "action-editusercss": "уређујете CSS датотеке других корисника",
+       "action-edituserjson": "уређујете JSON датотеке других корисника",
+       "action-edituserjs": "уређујете JavaScript датотеке других корисника",
        "action-editsitecss": "уређујете CSS на новоу сајта",
        "action-editsitejson": "уређујете JSON на нивоу сајта",
        "action-editsitejs": "уређујете JavaScript на новоу сајта",
+       "action-editmyusercss": "уређујете сопствене CSS датотеке",
+       "action-editmyuserjson": "уређујете сопствене JSON датотеке",
+       "action-editmyuserjs": "уређујете сопствене JavaScript датотеке",
        "action-hideuser": "блокирате корисничко име, сакривајући га од јавности",
        "nchanges": "$1 {{PLURAL:$1|промена|промене|промена}}",
        "ntimes": "$1×",
        "blocklink": "блокирај",
        "unblocklink": "деблокирај",
        "change-blocklink": "промени блокаду",
+       "empty-username": "(корисничко име није доступно)",
        "contribslink": "доприноси",
        "emaillink": "пошаљи е-поруку",
        "autoblocker": "Аутоматски сте блокирани јер делите IP адресу с корисником/цом [[User:$1|$1]].\nРазлог блокирања корисника/це $1 је „$2“",
        "specialpages-group-developer": "Програмерске алатке",
        "blankpage": "Празна страница",
        "intentionallyblankpage": "Ова страница је намерно остављена празном.",
+       "disabledspecialpage-disabled": "Администратор система је онемогућио ову страницу.",
        "external_image_whitelist": " #Оставите овај ред онаквим какав јесте<pre>\n#Испод додајте одломке регуларних израза (само део који се налази између //)\n#Они ће бити упоређени с адресама спољашњих слика\n#Оне које се поклапају биће приказане као слике, а преостале као везе до слика\n#Редови који почињу с тарабом се сматрају коментарима\n#Сви уноси су осетљиви на мала и велика слова\n\n#Додајте све одломке регуларних израза изнад овог реда. Овај ред не дирајте</pre>",
        "tags": "Важеће ознаке промена",
        "tag-filter": "Филтер [[Special:Tags|ознака]]:",
        "mw-widgets-abandonedit-discard": "Одбаци измене",
        "mw-widgets-abandonedit-keep": "Настави са уређивањем",
        "mw-widgets-abandonedit-title": "Јесте ли сигурни?",
+       "mw-widgets-copytextlayout-copy": "Копирај",
+       "mw-widgets-copytextlayout-copy-fail": "Копирање у оставу није успело.",
+       "mw-widgets-copytextlayout-copy-success": "Копирано у оставу.",
        "mw-widgets-dateinput-no-date": "Датум није изабран",
        "mw-widgets-dateinput-placeholder-day": "ГГГГ-ММ-ДД",
        "mw-widgets-dateinput-placeholder-month": "ГГГГ-ММ",
        "authmanager-autocreate-noperm": "Аутоматско отварање налога није дозвољено.",
        "authmanager-autocreate-exception": "Аутоматско креирање налога је привремено онемогућено због претходних грешака.",
        "authmanager-userdoesnotexist": "Кориснички налог „$1“ није отворен.",
+       "authmanager-userlogin-remembermypassword-help": "Да ли лозинка треба да се памти дуже од дужине сесије.",
        "authmanager-username-help": "Корисничко име за потврду идентитета.",
        "authmanager-password-help": "Лозинка за потврду идентитета.",
        "authmanager-domain-help": "Домен за спољашњу потврду идентитета.",
index b2b14cb..71fff56 100644 (file)
@@ -98,9 +98,6 @@ class MigrateArchiveText extends LoggedUpdateMaintenance {
 
                                                if ( $wgDefaultExternalStore ) {
                                                        $data = ExternalStore::insertToDefault( $data );
-                                                       if ( !$data ) {
-                                                               throw new MWException( "Unable to store text to external storage" );
-                                                       }
                                                        if ( $flags ) {
                                                                $flags .= ',';
                                                        }
index f9416be..6c8b51f 100644 (file)
@@ -1596,6 +1596,10 @@ abstract class MediaWikiTestCase extends PHPUnit\Framework\TestCase {
         * Stub. If a test suite needs to test against a specific database schema, it should
         * override this method and return the appropriate information from it.
         *
+        * 'create', 'drop' and 'alter' in the returned array should list all the tables affected
+        * by the 'scripts', even if the test is only interested in a subset of them, otherwise
+        * the overrides may not be fully cleaned up, leading to errors later.
+        *
         * @param IMaintainableDatabase $db The DB connection to use for the mock schema.
         *        May be used to check the current state of the schema, to determine what
         *        overrides are needed.
index 1272b01..f073f6e 100644 (file)
@@ -2715,14 +2715,14 @@ class OutputPageTest extends MediaWikiTestCase {
                        [
                                [ 'test.foo', ResourceLoaderModule::TYPE_SCRIPTS ],
                                "<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){"
-                                       . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.foo\u0026only=scripts\u0026skin=fallback");'
+                                       . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.foo\u0026only=scripts");'
                                        . "});</script>"
                        ],
                        // Multiple only=styles load
                        [
                                [ [ 'test.baz', 'test.foo', 'test.bar' ], ResourceLoaderModule::TYPE_STYLES ],
 
-                               '<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles&amp;skin=fallback"/>'
+                               '<link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.bar%2Cbaz%2Cfoo&amp;only=styles"/>'
                        ],
                        // Private embed (only=scripts)
                        [
@@ -2747,14 +2747,14 @@ class OutputPageTest extends MediaWikiTestCase {
                        // noscript group
                        [
                                [ 'test.noscript', ResourceLoaderModule::TYPE_STYLES ],
-                               '<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.noscript&amp;only=styles&amp;skin=fallback"/></noscript>'
+                               '<noscript><link rel="stylesheet" href="http://127.0.0.1:8080/w/load.php?lang=en&amp;modules=test.noscript&amp;only=styles"/></noscript>'
                        ],
                        // Load two modules in separate groups
                        [
                                [ [ 'test.group.foo', 'test.group.bar' ], ResourceLoaderModule::TYPE_COMBINED ],
                                "<script nonce=\"secret\">(RLQ=window.RLQ||[]).push(function(){"
-                                       . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.bar\u0026skin=fallback");'
-                                       . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.foo\u0026skin=fallback");'
+                                       . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.bar");'
+                                       . 'mw.loader.load("http://127.0.0.1:8080/w/load.php?lang=en\u0026modules=test.group.foo");'
                                        . "});</script>"
                        ],
                ];
@@ -2819,13 +2819,13 @@ class OutputPageTest extends MediaWikiTestCase {
                        'default logged-out' => [
                                'exemptStyleModules' => [ 'site' => [ 'site.styles' ] ],
                                '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
-                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles&amp;skin=fallback"/>',
+                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles"/>',
                        ],
                        'default logged-in' => [
                                'exemptStyleModules' => [ 'site' => [ 'site.styles' ], 'user' => [ 'user.styles' ] ],
                                '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
-                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles&amp;skin=fallback"/>' . "\n" .
-                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;skin=fallback&amp;version=1ai9g6t"/>',
+                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles"/>' . "\n" .
+                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;version=1ai9g6t"/>',
                        ],
                        'custom modules' => [
                                'exemptStyleModules' => [
@@ -2833,10 +2833,10 @@ class OutputPageTest extends MediaWikiTestCase {
                                        'user' => [ 'user.styles', 'example.user' ],
                                ],
                                '<meta name="ResourceLoaderDynamicStyles" content=""/>' . "\n" .
-                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.site.a%2Cb&amp;only=styles&amp;skin=fallback"/>' . "\n" .
-                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles&amp;skin=fallback"/>' . "\n" .
-                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.user&amp;only=styles&amp;skin=fallback&amp;version=0a56zyi"/>' . "\n" .
-                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;skin=fallback&amp;version=1ai9g6t"/>',
+                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.site.a%2Cb&amp;only=styles"/>' . "\n" .
+                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=site.styles&amp;only=styles"/>' . "\n" .
+                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=example.user&amp;only=styles&amp;version=0a56zyi"/>' . "\n" .
+                               '<link rel="stylesheet" href="/w/load.php?lang=en&amp;modules=user.styles&amp;only=styles&amp;version=1ai9g6t"/>',
                        ],
                ];
                // phpcs:enable
diff --git a/tests/phpunit/includes/Rest/ResponseFactoryTest.php b/tests/phpunit/includes/Rest/ResponseFactoryTest.php
new file mode 100644 (file)
index 0000000..6ccacda
--- /dev/null
@@ -0,0 +1,139 @@
+<?php
+
+namespace MediaWiki\Tests\Rest;
+
+use ArrayIterator;
+use MediaWiki\Rest\HttpException;
+use MediaWiki\Rest\ResponseFactory;
+use MediaWikiTestCase;
+
+/** @covers \MediaWiki\Rest\ResponseFactory */
+class ResponseFactoryTest extends MediaWikiTestCase {
+       public static function provideEncodeJson() {
+               return [
+                       [ (object)[], '{}' ],
+                       [ '/', '"/"' ],
+                       [ '£', '"£"' ],
+                       [ [], '[]' ],
+               ];
+       }
+
+       /** @dataProvider provideEncodeJson */
+       public function testEncodeJson( $input, $expected ) {
+               $rf = new ResponseFactory;
+               $this->assertSame( $expected, $rf->encodeJson( $input ) );
+       }
+
+       public function testCreateJson() {
+               $rf = new ResponseFactory;
+               $response = $rf->createJson( [] );
+               $response->getBody()->rewind();
+               $this->assertSame( 'application/json', $response->getHeaderLine( 'Content-Type' ) );
+               $this->assertSame( '[]', $response->getBody()->getContents() );
+               // Make sure getSize() is functional, since testCreateNoContent() depends on it
+               $this->assertSame( 2, $response->getBody()->getSize() );
+       }
+
+       public function testCreateNoContent() {
+               $rf = new ResponseFactory;
+               $response = $rf->createNoContent();
+               $this->assertSame( [], $response->getHeader( 'Content-Type' ) );
+               $this->assertSame( 0, $response->getBody()->getSize() );
+               $this->assertSame( 204, $response->getStatusCode() );
+       }
+
+       public function testCreatePermanentRedirect() {
+               $rf = new ResponseFactory;
+               $response = $rf->createPermanentRedirect( 'http://www.example.com/' );
+               $this->assertSame( [ 'http://www.example.com/' ], $response->getHeader( 'Location' ) );
+               $this->assertSame( 301, $response->getStatusCode() );
+       }
+
+       public function testCreateTemporaryRedirect() {
+               $rf = new ResponseFactory;
+               $response = $rf->createTemporaryRedirect( 'http://www.example.com/' );
+               $this->assertSame( [ 'http://www.example.com/' ], $response->getHeader( 'Location' ) );
+               $this->assertSame( 307, $response->getStatusCode() );
+       }
+
+       public function testCreateSeeOther() {
+               $rf = new ResponseFactory;
+               $response = $rf->createSeeOther( 'http://www.example.com/' );
+               $this->assertSame( [ 'http://www.example.com/' ], $response->getHeader( 'Location' ) );
+               $this->assertSame( 303, $response->getStatusCode() );
+       }
+
+       public function testCreateNotModified() {
+               $rf = new ResponseFactory;
+               $response = $rf->createNotModified();
+               $this->assertSame( 0, $response->getBody()->getSize() );
+               $this->assertSame( 304, $response->getStatusCode() );
+       }
+
+       /** @expectedException \InvalidArgumentException */
+       public function testCreateHttpErrorInvalid() {
+               $rf = new ResponseFactory;
+               $rf->createHttpError( 200 );
+       }
+
+       public function testCreateHttpError() {
+               $rf = new ResponseFactory;
+               $response = $rf->createHttpError( 415, [ 'message' => '...' ] );
+               $this->assertSame( 415, $response->getStatusCode() );
+               $body = $response->getBody();
+               $body->rewind();
+               $data = json_decode( $body->getContents(), true );
+               $this->assertSame( 415, $data['httpCode'] );
+               $this->assertSame( '...', $data['message'] );
+       }
+
+       public function testCreateFromExceptionUnlogged() {
+               $rf = new ResponseFactory;
+               $response = $rf->createFromException( new HttpException( 'hello', 415 ) );
+               $this->assertSame( 415, $response->getStatusCode() );
+               $body = $response->getBody();
+               $body->rewind();
+               $data = json_decode( $body->getContents(), true );
+               $this->assertSame( 415, $data['httpCode'] );
+               $this->assertSame( 'hello', $data['message'] );
+       }
+
+       public function testCreateFromExceptionLogged() {
+               $rf = new ResponseFactory;
+               $response = $rf->createFromException( new \Exception( "hello", 415 ) );
+               $this->assertSame( 500, $response->getStatusCode() );
+               $body = $response->getBody();
+               $body->rewind();
+               $data = json_decode( $body->getContents(), true );
+               $this->assertSame( 500, $data['httpCode'] );
+               $this->assertSame( 'Error: exception of type Exception', $data['message'] );
+       }
+
+       public static function provideCreateFromReturnValue() {
+               return [
+                       [ 'hello', '{"value":"hello"}' ],
+                       [ true, '{"value":true}' ],
+                       [ [ 'x' => 'y' ], '{"x":"y"}' ],
+                       [ [ 'x', 'y' ], '["x","y"]' ],
+                       [ [ 'a', 'x' => 'y' ], '{"0":"a","x":"y"}' ],
+                       [ (object)[ 'a', 'x' => 'y' ], '{"0":"a","x":"y"}' ],
+                       [ [], '[]' ],
+                       [ (object)[], '{}' ],
+               ];
+       }
+
+       /** @dataProvider provideCreateFromReturnValue */
+       public function testCreateFromReturnValue( $input, $expected ) {
+               $rf = new ResponseFactory;
+               $response = $rf->createFromReturnValue( $input );
+               $body = $response->getBody();
+               $body->rewind();
+               $this->assertSame( $expected, $body->getContents() );
+       }
+
+       /** @expectedException \InvalidArgumentException */
+       public function testCreateFromReturnValueInvalid() {
+               $rf = new ResponseFactory;
+               $rf->createFromReturnValue( new ArrayIterator );
+       }
+}
index c6ed8a7..47a6d81 100644 (file)
@@ -347,8 +347,6 @@ class ApiStashEditTest extends ApiTestCase {
                $cache = $editStash->cache;
 
                $editInfo = $cache->get( $key );
-               $outputKey = $cache->makeKey( 'stashed-edit-output', $editInfo->outputID );
-               $editInfo->output = $cache->get( $outputKey );
                $editInfo->output->setCacheTime( wfTimestamp( TS_MW,
                        wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() ) - $howOld - 1 ) );
 
diff --git a/tests/phpunit/includes/block/CompositeBlockTest.php b/tests/phpunit/includes/block/CompositeBlockTest.php
new file mode 100644 (file)
index 0000000..5cd86b8
--- /dev/null
@@ -0,0 +1,254 @@
+<?php
+
+use MediaWiki\Block\BlockRestrictionStore;
+use MediaWiki\Block\CompositeBlock;
+use MediaWiki\Block\Restriction\PageRestriction;
+use MediaWiki\Block\Restriction\NamespaceRestriction;
+use MediaWiki\Block\SystemBlock;
+use MediaWiki\MediaWikiServices;
+
+/**
+ * @group Database
+ * @group Blocking
+ * @coversDefaultClass \MediaWiki\Block\CompositeBlock
+ */
+class CompositeBlockTest extends MediaWikiLangTestCase {
+       private function getPartialBlocks() {
+               $sysopId = $this->getTestSysop()->getUser()->getId();
+
+               $userBlock = new Block( [
+                       'address' => $this->getTestUser()->getUser(),
+                       'by' => $sysopId,
+                       'sitewide' => false,
+               ] );
+               $ipBlock = new Block( [
+                       'address' => '127.0.0.1',
+                       'by' => $sysopId,
+                       'sitewide' => false,
+               ] );
+
+               $userBlock->insert();
+               $ipBlock->insert();
+
+               return [
+                       'user' => $userBlock,
+                       'ip' => $ipBlock,
+               ];
+       }
+
+       private function deleteBlocks( $blocks ) {
+               foreach ( $blocks as $block ) {
+                       $block->delete();
+               }
+       }
+
+       /**
+        * @covers ::__construct
+        * @dataProvider provideTestStrictestParametersApplied
+        */
+       public function testStrictestParametersApplied( $blocks, $expected ) {
+               $this->setMwGlobals( [
+                       'wgBlockDisablesLogin' => false,
+                       'wgBlockAllowsUTEdit' => true,
+               ] );
+
+               $block = new CompositeBlock( [
+                       'originalBlocks' => $blocks,
+               ] );
+
+               $this->assertSame( $expected[ 'hideName' ], $block->getHideName() );
+               $this->assertSame( $expected[ 'sitewide' ], $block->isSitewide() );
+               $this->assertSame( $expected[ 'blockEmail' ], $block->isEmailBlocked() );
+               $this->assertSame( $expected[ 'allowUsertalk' ], $block->isUsertalkEditAllowed() );
+       }
+
+       public static function provideTestStrictestParametersApplied() {
+               return [
+                       'Sitewide block and partial block' => [
+                               [
+                                       new Block( [
+                                               'sitewide' => false,
+                                               'blockEmail' => true,
+                                               'allowUsertalk' => true,
+                                       ] ),
+                                       new Block( [
+                                               'sitewide' => true,
+                                               'blockEmail' => false,
+                                               'allowUsertalk' => false,
+                                       ] ),
+                               ],
+                               [
+                                       'hideName' => false,
+                                       'sitewide' => true,
+                                       'blockEmail' => true,
+                                       'allowUsertalk' => false,
+                               ],
+                       ],
+                       'Partial block and system block' => [
+                               [
+                                       new Block( [
+                                               'sitewide' => false,
+                                               'blockEmail' => true,
+                                               'allowUsertalk' => false,
+                                       ] ),
+                                       new SystemBlock( [
+                                               'systemBlock' => 'proxy',
+                                       ] ),
+                               ],
+                               [
+                                       'hideName' => false,
+                                       'sitewide' => true,
+                                       'blockEmail' => true,
+                                       'allowUsertalk' => false,
+                               ],
+                       ],
+                       'System block and user name hiding block' => [
+                               [
+                                       new Block( [
+                                               'hideName' => true,
+                                               'sitewide' => true,
+                                               'blockEmail' => true,
+                                               'allowUsertalk' => false,
+                                       ] ),
+                                       new SystemBlock( [
+                                               'systemBlock' => 'proxy',
+                                       ] ),
+                               ],
+                               [
+                                       'hideName' => true,
+                                       'sitewide' => true,
+                                       'blockEmail' => true,
+                                       'allowUsertalk' => false,
+                               ],
+                       ],
+                       'Two lenient partial blocks' => [
+                               [
+                                       new Block( [
+                                               'sitewide' => false,
+                                               'blockEmail' => false,
+                                               'allowUsertalk' => true,
+                                       ] ),
+                                       new Block( [
+                                               'sitewide' => false,
+                                               'blockEmail' => false,
+                                               'allowUsertalk' => true,
+                                       ] ),
+                               ],
+                               [
+                                       'hideName' => false,
+                                       'sitewide' => false,
+                                       'blockEmail' => false,
+                                       'allowUsertalk' => true,
+                               ],
+                       ],
+               ];
+       }
+
+       /**
+        * @covers ::appliesToTitle
+        */
+       public function testBlockAppliesToTitle() {
+               $this->setMwGlobals( [
+                       'wgBlockDisablesLogin' => false,
+               ] );
+
+               $blocks = $this->getPartialBlocks();
+
+               $block = new CompositeBlock( [
+                       'originalBlocks' => $blocks,
+               ] );
+
+               $pageFoo = $this->getExistingTestPage( 'Foo' );
+               $pageBar = $this->getExistingTestPage( 'User:Bar' );
+
+               $this->getBlockRestrictionStore()->insert( [
+                       new PageRestriction( $blocks[ 'user' ]->getId(), $pageFoo->getId() ),
+                       new NamespaceRestriction( $blocks[ 'ip' ]->getId(), NS_USER ),
+               ] );
+
+               $this->assertTrue( $block->appliesToTitle( $pageFoo->getTitle() ) );
+               $this->assertTrue( $block->appliesToTitle( $pageBar->getTitle() ) );
+
+               $this->deleteBlocks( $blocks );
+       }
+
+       /**
+        * @covers ::appliesToUsertalk
+        * @covers ::appliesToPage
+        * @covers ::appliesToNamespace
+        */
+       public function testBlockAppliesToUsertalk() {
+               $this->setMwGlobals( [
+                       'wgBlockAllowsUTEdit' => true,
+                       'wgBlockDisablesLogin' => false,
+               ] );
+
+               $blocks = $this->getPartialBlocks();
+
+               $block = new CompositeBlock( [
+                       'originalBlocks' => $blocks,
+               ] );
+
+               $title = $blocks[ 'user' ]->getTarget()->getTalkPage();
+               $page = $this->getExistingTestPage( 'User talk:' . $title->getText() );
+
+               $this->getBlockRestrictionStore()->insert( [
+                       new PageRestriction( $blocks[ 'user' ]->getId(), $page->getId() ),
+                       new NamespaceRestriction( $blocks[ 'ip' ]->getId(), NS_USER ),
+               ] );
+
+               $this->assertTrue( $block->appliesToUsertalk( $blocks[ 'user' ]->getTarget()->getTalkPage() ) );
+
+               $this->deleteBlocks( $blocks );
+       }
+
+       /**
+        * @covers ::appliesToRight
+        * @dataProvider provideTestBlockAppliesToRight
+        */
+       public function testBlockAppliesToRight( $blocks, $right, $expected ) {
+               $this->setMwGlobals( [
+                       'wgBlockDisablesLogin' => false,
+               ] );
+
+               $block = new CompositeBlock( [
+                       'originalBlocks' => $blocks,
+               ] );
+
+               $this->assertSame( $block->appliesToRight( $right ), $expected );
+       }
+
+       public static function provideTestBlockAppliesToRight() {
+               return [
+                       'Read is not blocked' => [
+                               [
+                                       new Block(),
+                                       new Block(),
+                               ],
+                               'read',
+                               false,
+                       ],
+                       'Email is blocked if blocked by any blocks' => [
+                               [
+                                       new Block( [
+                                               'blockEmail' => true,
+                                       ] ),
+                                       new Block( [
+                                               'blockEmail' => false,
+                                       ] ),
+                               ],
+                               'sendemail',
+                               true,
+                       ],
+               ];
+       }
+
+       /**
+        * Get an instance of BlockRestrictionStore
+        *
+        * @return BlockRestrictionStore
+        */
+       protected function getBlockRestrictionStore() : BlockRestrictionStore {
+               return MediaWikiServices::getInstance()->getBlockRestrictionStore();
+       }
+}
index 7fc070c..169e4bf 100644 (file)
@@ -26,6 +26,7 @@ use Wikimedia\Rdbms\DatabaseDomain;
 use Wikimedia\Rdbms\Database;
 use Wikimedia\Rdbms\LoadBalancer;
 use Wikimedia\Rdbms\LoadMonitorNull;
+use Wikimedia\TestingAccessWrapper;
 
 /**
  * @group Database
@@ -165,7 +166,8 @@ class LoadBalancerTest extends MediaWikiTestCase {
                global $wgDBserver, $wgDBname, $wgDBuser, $wgDBpassword, $wgDBtype, $wgSQLiteDataDir;
 
                $servers = [
-                       [ // master
+                       // Master DB
+                       0 => [
                                'host' => $wgDBserver,
                                'dbname' => $wgDBname,
                                'tablePrefix' => $this->dbPrefix(),
@@ -176,7 +178,19 @@ class LoadBalancerTest extends MediaWikiTestCase {
                                'load' => 0,
                                'flags' => $flags
                        ],
-                       [ // emulated replica
+                       // Main replica DBs
+                       1 => [
+                               'host' => $wgDBserver,
+                               'dbname' => $wgDBname,
+                               'tablePrefix' => $this->dbPrefix(),
+                               'user' => $wgDBuser,
+                               'password' => $wgDBpassword,
+                               'type' => $wgDBtype,
+                               'dbDirectory' => $wgSQLiteDataDir,
+                               'load' => 100,
+                               'flags' => $flags
+                       ],
+                       2 => [
                                'host' => $wgDBserver,
                                'dbname' => $wgDBname,
                                'tablePrefix' => $this->dbPrefix(),
@@ -186,6 +200,66 @@ class LoadBalancerTest extends MediaWikiTestCase {
                                'dbDirectory' => $wgSQLiteDataDir,
                                'load' => 100,
                                'flags' => $flags
+                       ],
+                       // RC replica DBs
+                       3 => [
+                               'host' => $wgDBserver,
+                               'dbname' => $wgDBname,
+                               'tablePrefix' => $this->dbPrefix(),
+                               'user' => $wgDBuser,
+                               'password' => $wgDBpassword,
+                               'type' => $wgDBtype,
+                               'dbDirectory' => $wgSQLiteDataDir,
+                               'load' => 0,
+                               'groupLoads' => [
+                                       'recentchanges' => 100,
+                                       'watchlist' => 100
+                               ],
+                               'flags' => $flags
+                       ],
+                       // Logging replica DBs
+                       4 => [
+                               'host' => $wgDBserver,
+                               'dbname' => $wgDBname,
+                               'tablePrefix' => $this->dbPrefix(),
+                               'user' => $wgDBuser,
+                               'password' => $wgDBpassword,
+                               'type' => $wgDBtype,
+                               'dbDirectory' => $wgSQLiteDataDir,
+                               'load' => 0,
+                               'groupLoads' => [
+                                       'logging' => 100
+                               ],
+                               'flags' => $flags
+                       ],
+                       5 => [
+                               'host' => $wgDBserver,
+                               'dbname' => $wgDBname,
+                               'tablePrefix' => $this->dbPrefix(),
+                               'user' => $wgDBuser,
+                               'password' => $wgDBpassword,
+                               'type' => $wgDBtype,
+                               'dbDirectory' => $wgSQLiteDataDir,
+                               'load' => 0,
+                               'groupLoads' => [
+                                       'logging' => 100
+                               ],
+                               'flags' => $flags
+                       ],
+                       // Maintenance query replica DBs
+                       6 => [
+                               'host' => $wgDBserver,
+                               'dbname' => $wgDBname,
+                               'tablePrefix' => $this->dbPrefix(),
+                               'user' => $wgDBuser,
+                               'password' => $wgDBpassword,
+                               'type' => $wgDBtype,
+                               'dbDirectory' => $wgSQLiteDataDir,
+                               'load' => 0,
+                               'groupLoads' => [
+                                       'vslow' => 100
+                               ],
+                               'flags' => $flags
                        ]
                ];
 
@@ -488,4 +562,47 @@ class LoadBalancerTest extends MediaWikiTestCase {
 
                $rConn->insert( 'test', [ 't' => 1 ], __METHOD__ );
        }
+
+       public function testQueryGroupIndex() {
+               $lb = $this->newMultiServerLocalLoadBalancer();
+               /** @var LoadBalancer $lbWrapper */
+               $lbWrapper = TestingAccessWrapper::newFromObject( $lb );
+
+               $rGeneric = $lb->getConnectionRef( DB_REPLICA );
+               $mainIndexPicked = $rGeneric->getLBInfo( 'serverIndex' );
+
+               $this->assertEquals( $mainIndexPicked, $lbWrapper->getExistingReaderIndex( false ) );
+               $this->assertTrue( in_array( $mainIndexPicked, [ 1, 2 ] ) );
+               for ( $i = 0; $i < 300; ++$i ) {
+                       $rLog = $lb->getConnectionRef( DB_REPLICA, [] );
+                       $this->assertEquals(
+                               $mainIndexPicked,
+                               $rLog->getLBInfo( 'serverIndex' ),
+                               "Main index unchanged" );
+               }
+
+               $rRC = $lb->getConnectionRef( DB_REPLICA, [ 'recentchanges' ] );
+               $rWL = $lb->getConnectionRef( DB_REPLICA, [ 'watchlist' ] );
+
+               $this->assertEquals( 3, $rRC->getLBInfo( 'serverIndex' ) );
+               $this->assertEquals( 3, $rWL->getLBInfo( 'serverIndex' ) );
+
+               $rLog = $lb->getConnectionRef( DB_REPLICA, [ 'logging', 'watchlist' ] );
+               $logIndexPicked = $rLog->getLBInfo( 'serverIndex' );
+
+               $this->assertEquals( $logIndexPicked, $lbWrapper->getExistingReaderIndex( 'logging' ) );
+               $this->assertTrue( in_array( $logIndexPicked, [ 4, 5 ] ) );
+
+               for ( $i = 0; $i < 300; ++$i ) {
+                       $rLog = $lb->getConnectionRef( DB_REPLICA, [ 'logging', 'watchlist' ] );
+                       $this->assertEquals(
+                               $logIndexPicked, $rLog->getLBInfo( 'serverIndex' ), "Index unchanged" );
+               }
+
+               $rVslow = $lb->getConnectionRef( DB_REPLICA, [ 'vslow', 'logging' ] );
+               $vslowIndexPicked = $rVslow->getLBInfo( 'serverIndex' );
+
+               $this->assertEquals( $vslowIndexPicked, $lbWrapper->getExistingReaderIndex( 'vslow' ) );
+               $this->assertEquals( 6, $vslowIndexPicked );
+       }
 }
index 4a09a2e..1d11fd8 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 use Wikimedia\ScopedCallback;
+use Wikimedia\TestingAccessWrapper;
 
 /**
  * @author Matthias Mullie <mmullie@wikimedia.org>
@@ -98,13 +99,13 @@ class BagOStuffTest extends MediaWikiTestCase {
                        $this->cache->merge( $key, $callback, 5, 1 ),
                        'Non-blocking merge (CAS)'
                );
+
                if ( $this->cache instanceof MultiWriteBagOStuff ) {
-                       $wrapper = \Wikimedia\TestingAccessWrapper::newFromObject( $this->cache );
-                       $n = count( $wrapper->caches );
+                       $wrapper = TestingAccessWrapper::newFromObject( $this->cache );
+                       $this->assertEquals( count( $wrapper->caches ), $calls );
                } else {
-                       $n = 1;
+                       $this->assertEquals( 1, $calls );
                }
-               $this->assertEquals( $n, $calls );
        }
 
        /**
@@ -115,10 +116,17 @@ class BagOStuffTest extends MediaWikiTestCase {
                $value = 'meow';
 
                $this->cache->add( $key, $value, 5 );
-               $this->assertTrue( $this->cache->changeTTL( $key, 5 ) );
+               $this->assertEquals( $value, $this->cache->get( $key ) );
+               $this->assertTrue( $this->cache->changeTTL( $key, 10 ) );
+               $this->assertTrue( $this->cache->changeTTL( $key, 10 ) );
+               $this->assertTrue( $this->cache->changeTTL( $key, 0 ) );
                $this->assertEquals( $this->cache->get( $key ), $value );
                $this->cache->delete( $key );
-               $this->assertFalse( $this->cache->changeTTL( $key, 5 ) );
+               $this->assertFalse( $this->cache->changeTTL( $key, 15 ) );
+
+               $this->cache->add( $key, $value, 5 );
+               $this->assertTrue( $this->cache->changeTTL( $key, time() - 3600 ) );
+               $this->assertFalse( $this->cache->get( $key ) );
        }
 
        /**
@@ -126,7 +134,9 @@ class BagOStuffTest extends MediaWikiTestCase {
         */
        public function testAdd() {
                $key = $this->cache->makeKey( self::TEST_KEY );
+               $this->assertFalse( $this->cache->get( $key ) );
                $this->assertTrue( $this->cache->add( $key, 'test', 5 ) );
+               $this->assertFalse( $this->cache->add( $key, 'test', 5 ) );
        }
 
        /**
@@ -237,20 +247,73 @@ class BagOStuffTest extends MediaWikiTestCase {
                        $this->cache->makeKey( 'test-6' ) => 'ever'
                ];
 
-               $this->cache->setMulti( $map, 5 );
+               $this->assertTrue( $this->cache->setMulti( $map ) );
                $this->assertEquals(
                        $map,
                        $this->cache->getMulti( array_keys( $map ) )
                );
 
-               $this->assertTrue( $this->cache->deleteMulti( array_keys( $map ), 5 ) );
+               $this->assertTrue( $this->cache->deleteMulti( array_keys( $map ) ) );
 
+               $this->assertEquals(
+                       [],
+                       $this->cache->getMulti( array_keys( $map ), BagOStuff::READ_LATEST )
+               );
                $this->assertEquals(
                        [],
                        $this->cache->getMulti( array_keys( $map ) )
                );
        }
 
+       /**
+        * @covers BagOStuff::get
+        * @covers BagOStuff::getMulti
+        * @covers BagOStuff::merge
+        * @covers BagOStuff::delete
+        */
+       public function testSetSegmentable() {
+               $key = $this->cache->makeKey( self::TEST_KEY );
+               $tiny = 418;
+               $small = wfRandomString( 32 );
+               // 64 * 8 * 32768 = 16777216 bytes
+               $big = str_repeat( wfRandomString( 32 ) . '-' . wfRandomString( 32 ), 32768 );
+
+               $callback = function ( $cache, $key, $oldValue ) {
+                       return $oldValue . '!';
+               };
+
+               foreach ( [ $tiny, $small, $big ] as $value ) {
+                       $this->cache->set( $key, $value, 10, BagOStuff::WRITE_ALLOW_SEGMENTS );
+                       $this->assertEquals( $value, $this->cache->get( $key ) );
+                       $this->assertEquals( $value, $this->cache->getMulti( [ $key ] )[$key] );
+
+                       $this->assertTrue( $this->cache->merge( $key, $callback, 5 ) );
+                       $this->assertEquals( "$value!", $this->cache->get( $key ) );
+                       $this->assertEquals( "$value!", $this->cache->getMulti( [ $key ] )[$key] );
+
+                       $this->assertTrue( $this->cache->deleteMulti( [ $key ] ) );
+                       $this->assertFalse( $this->cache->get( $key ) );
+                       $this->assertEquals( [], $this->cache->getMulti( [ $key ] ) );
+
+                       $this->cache->set( $key, "@$value", 10, BagOStuff::WRITE_ALLOW_SEGMENTS );
+                       $this->assertEquals( "@$value", $this->cache->get( $key ) );
+                       $this->assertTrue( $this->cache->delete( $key, BagOStuff::WRITE_PRUNE_SEGMENTS ) );
+                       $this->assertFalse( $this->cache->get( $key ) );
+                       $this->assertEquals( [], $this->cache->getMulti( [ $key ] ) );
+               }
+
+               $this->cache->set( $key, 666, 10, BagOStuff::WRITE_ALLOW_SEGMENTS );
+
+               $this->assertEquals( 667, $this->cache->incr( $key ) );
+               $this->assertEquals( 667, $this->cache->get( $key ) );
+
+               $this->assertEquals( 664, $this->cache->decr( $key, 3 ) );
+               $this->assertEquals( 664, $this->cache->get( $key ) );
+
+               $this->assertTrue( $this->cache->delete( $key ) );
+               $this->assertFalse( $this->cache->get( $key ) );
+       }
+
        /**
         * @covers BagOStuff::getScopedLock
         */
@@ -316,4 +379,11 @@ class BagOStuffTest extends MediaWikiTestCase {
                $this->assertTrue( $this->cache->unlock( $key2 ) );
                $this->assertTrue( $this->cache->unlock( $key2 ) );
        }
+
+       public function tearDown() {
+               $this->cache->delete( $this->cache->makeKey( self::TEST_KEY ) );
+               $this->cache->delete( $this->cache->makeKey( self::TEST_KEY ) . ':lock' );
+
+               parent::tearDown();
+       }
 }
index c0d2555..0e133d8 100644 (file)
@@ -1878,7 +1878,7 @@ class DatabaseSQLTest extends PHPUnit\Framework\TestCase {
 
        /**
         * @expectedException \Wikimedia\Rdbms\DBTransactionStateError
-        * @covers \Wikimedia\Rdbms\Database::assertTransactionStatus
+        * @covers \Wikimedia\Rdbms\Database::assertQueryIsCurrentlyAllowed
         */
        public function testTransactionErrorState1() {
                $wrapper = TestingAccessWrapper::newFromObject( $this->database );
index 0e6855d..6648c31 100644 (file)
@@ -457,7 +457,7 @@ class DeleteLogFormatterTest extends LogFormatterTestCase {
                                ],
                        ],
 
-                       // Legacy format
+                       // Legacy formats
                        [
                                [
                                        'type' => 'suppress',
@@ -495,6 +495,27 @@ class DeleteLogFormatterTest extends LogFormatterTestCase {
                                        ],
                                ],
                        ],
+                       [
+                               [
+                                       'type' => 'delete',
+                                       'action' => 'revision',
+                                       'comment' => 'Old rows might lack ofield/nfield (T224815)',
+                                       'namespace' => NS_MAIN,
+                                       'title' => 'Page',
+                                       'params' => [
+                                               'oldid',
+                                               '1234',
+                                       ],
+                               ],
+                               [
+                                       'legacy' => true,
+                                       'text' => 'User changed visibility of revisions on page Page',
+                                       'api' => [
+                                               'type' => 'oldid',
+                                               'ids' => [ '1234' ],
+                                       ],
+                               ],
+                       ]
                ];
        }
 
index 432754b..45971da 100644 (file)
@@ -8,7 +8,7 @@ class MemcachedBagOStuffTest extends MediaWikiTestCase {
 
        protected function setUp() {
                parent::setUp();
-               $this->cache = new MemcachedBagOStuff( [ 'keyspace' => 'test' ] );
+               $this->cache = new MemcachedPhpBagOStuff( [ 'keyspace' => 'test', 'servers' => [] ] );
        }
 
        /**
index 206160c..03a3e24 100644 (file)
@@ -116,9 +116,9 @@ Deprecation message.' ]
                        . '<script>(RLQ=window.RLQ||[]).push(function(){'
                        . 'mw.loader.implement("test.private@{blankVer}",null,{"css":[]});'
                        . '});</script>' . "\n"
-                       . '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.deprecated%2Cpure&amp;only=styles&amp;skin=fallback"/>' . "\n"
+                       . '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.deprecated%2Cpure&amp;only=styles"/>' . "\n"
                        . '<style>.private{}</style>' . "\n"
-                       . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback"></script>';
+                       . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts"></script>';
                // phpcs:enable
                $expected = self::expandVariables( $expected );
 
@@ -136,7 +136,7 @@ Deprecation message.' ]
 
                // phpcs:disable Generic.Files.LineLength
                $expected = '<script>document.documentElement.className=document.documentElement.className.replace(/(^|\s)client-nojs(\s|$)/,"$1client-js$2");</script>' . "\n"
-                       . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback&amp;target=example"></script>';
+                       . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;target=example"></script>';
                // phpcs:enable
 
                $this->assertSame( $expected, (string)$client->getHeadHtml() );
@@ -153,7 +153,7 @@ Deprecation message.' ]
 
                // phpcs:disable Generic.Files.LineLength
                $expected = '<script>document.documentElement.className=document.documentElement.className.replace(/(^|\s)client-nojs(\s|$)/,"$1client-js$2");</script>' . "\n"
-                       . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;safemode=1&amp;skin=fallback"></script>';
+                       . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;safemode=1"></script>';
                // phpcs:enable
 
                $this->assertSame( $expected, (string)$client->getHeadHtml() );
@@ -170,7 +170,7 @@ Deprecation message.' ]
 
                // phpcs:disable Generic.Files.LineLength
                $expected = '<script>document.documentElement.className=document.documentElement.className.replace(/(^|\s)client-nojs(\s|$)/,"$1client-js$2");</script>' . "\n"
-                       . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts&amp;skin=fallback"></script>';
+                       . '<script async="" src="/w/load.php?lang=nl&amp;modules=startup&amp;only=scripts"></script>';
                // phpcs:enable
 
                $this->assertSame( $expected, (string)$client->getHeadHtml() );
@@ -228,50 +228,50 @@ Deprecation message.' ]
                                'modules' => [ 'test.scripts.raw' ],
                                'only' => ResourceLoaderModule::TYPE_SCRIPTS,
                                'extra' => [],
-                               'output' => '<script async="" src="/w/load.php?lang=nl&amp;modules=test.scripts.raw&amp;only=scripts&amp;skin=fallback"></script>',
+                               'output' => '<script async="" src="/w/load.php?lang=nl&amp;modules=test.scripts.raw&amp;only=scripts"></script>',
                        ],
                        [
                                'context' => [],
                                'modules' => [ 'test.scripts.raw' ],
                                'only' => ResourceLoaderModule::TYPE_SCRIPTS,
                                'extra' => [ 'sync' => '1' ],
-                               'output' => '<script src="/w/load.php?lang=nl&amp;modules=test.scripts.raw&amp;only=scripts&amp;skin=fallback&amp;sync=1"></script>',
+                               'output' => '<script src="/w/load.php?lang=nl&amp;modules=test.scripts.raw&amp;only=scripts&amp;sync=1"></script>',
                        ],
                        [
                                'context' => [],
                                'modules' => [ 'test.scripts.user' ],
                                'only' => ResourceLoaderModule::TYPE_SCRIPTS,
                                'extra' => [],
-                               'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?lang=nl\u0026modules=test.scripts.user\u0026only=scripts\u0026skin=fallback\u0026user=Example\u0026version=0a56zyi");});</script>',
+                               'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?lang=nl\u0026modules=test.scripts.user\u0026only=scripts\u0026user=Example\u0026version=0a56zyi");});</script>',
                        ],
                        [
                                'context' => [],
                                'modules' => [ 'test.user' ],
                                'only' => ResourceLoaderModule::TYPE_COMBINED,
                                'extra' => [],
-                               'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?lang=nl\u0026modules=test.user\u0026skin=fallback\u0026user=Example\u0026version=0a56zyi");});</script>',
+                               'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?lang=nl\u0026modules=test.user\u0026user=Example\u0026version=0a56zyi");});</script>',
                        ],
                        [
                                'context' => [ 'debug' => 'true' ],
                                'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
                                'only' => ResourceLoaderModule::TYPE_STYLES,
                                'extra' => [],
-                               'output' => '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.mixed&amp;only=styles&amp;skin=fallback"/>' . "\n"
-                                       . '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>',
+                               'output' => '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.mixed&amp;only=styles"/>' . "\n"
+                                       . '<link rel="stylesheet" href="/w/load.php?debug=true&amp;lang=nl&amp;modules=test.styles.pure&amp;only=styles"/>',
                        ],
                        [
                                'context' => [ 'debug' => 'false' ],
                                'modules' => [ 'test.styles.pure', 'test.styles.mixed' ],
                                'only' => ResourceLoaderModule::TYPE_STYLES,
                                'extra' => [],
-                               'output' => '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.mixed%2Cpure&amp;only=styles&amp;skin=fallback"/>',
+                               'output' => '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.mixed%2Cpure&amp;only=styles"/>',
                        ],
                        [
                                'context' => [],
                                'modules' => [ 'test.styles.noscript' ],
                                'only' => ResourceLoaderModule::TYPE_STYLES,
                                'extra' => [],
-                               'output' => '<noscript><link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.noscript&amp;only=styles&amp;skin=fallback"/></noscript>',
+                               'output' => '<noscript><link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.noscript&amp;only=styles"/></noscript>',
                        ],
                        [
                                'context' => [],
@@ -299,7 +299,7 @@ Deprecation message.' ]
                                'modules' => [ 'test', 'test.shouldembed' ],
                                'only' => ResourceLoaderModule::TYPE_COMBINED,
                                'extra' => [],
-                               'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?lang=nl\u0026modules=test\u0026skin=fallback");mw.loader.implement("test.shouldembed@09p30q0",null,{"css":[]});});</script>',
+                               'output' => '<script>(RLQ=window.RLQ||[]).push(function(){mw.loader.load("/w/load.php?lang=nl\u0026modules=test");mw.loader.implement("test.shouldembed@09p30q0",null,{"css":[]});});</script>',
                        ],
                        [
                                'context' => [],
@@ -307,7 +307,7 @@ Deprecation message.' ]
                                'only' => ResourceLoaderModule::TYPE_STYLES,
                                'extra' => [],
                                'output' =>
-                                       '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.pure&amp;only=styles&amp;skin=fallback"/>' . "\n"
+                                       '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.styles.pure&amp;only=styles"/>' . "\n"
                                        . '<style>.shouldembed{}</style>'
                        ],
                        [
@@ -316,9 +316,9 @@ Deprecation message.' ]
                                'only' => ResourceLoaderModule::TYPE_STYLES,
                                'extra' => [],
                                'output' =>
-                                       '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.ordering.a%2Cb&amp;only=styles&amp;skin=fallback"/>' . "\n"
+                                       '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.ordering.a%2Cb&amp;only=styles"/>' . "\n"
                                        . '<style>.orderingC{}.orderingD{}</style>' . "\n"
-                                       . '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.ordering.e&amp;only=styles&amp;skin=fallback"/>'
+                                       . '<link rel="stylesheet" href="/w/load.php?lang=nl&amp;modules=test.ordering.e&amp;only=styles"/>'
                        ],
                ];
                // phpcs:enable
index 7f4d9a8..2ec8ea9 100644 (file)
@@ -47,8 +47,10 @@ class ResourceLoaderContextTest extends PHPUnit\Framework\TestCase {
 
        public function testAccessors() {
                $ctx = new ResourceLoaderContext( $this->getResourceLoader(), new FauxRequest( [] ) );
+               $this->assertInstanceOf( ResourceLoader::class, $ctx->getResourceLoader() );
+               $this->assertInstanceOf( Config::class, $ctx->getConfig() );
                $this->assertInstanceOf( WebRequest::class, $ctx->getRequest() );
-               $this->assertInstanceOf( \Psr\Log\LoggerInterface::class, $ctx->getLogger() );
+               $this->assertInstanceOf( Psr\Log\LoggerInterface::class, $ctx->getLogger() );
        }
 
        public function testTypicalRequest() {
index b1262a3..7eb8fd5 100644 (file)
@@ -402,7 +402,7 @@ class NamespaceInfoTest extends MediaWikiTestCase {
        }
 
        /**
-        * @param $contentNamespaces To pass to constructor
+        * @param mixed $contentNamespaces To pass to constructor
         * @param array $expected
         * @dataProvider provideGetContentNamespaces
         * @covers NamespaceInfo::getContentNamespaces
index 55a29e3..b0c0fec 100644 (file)
@@ -2,6 +2,7 @@
 
 use MediaWiki\Auth\AuthManager;
 use MediaWiki\Block\DatabaseBlock;
+use MediaWiki\Block\CompositeBlock;
 use MediaWiki\Block\SystemBlock;
 
 /**
@@ -141,6 +142,34 @@ class PasswordResetTest extends MediaWikiTestCase {
                                'globalBlock' => null,
                                'isAllowed' => false,
                        ],
+                       'blocked with multiple blocks, all allowing password reset' => [
+                               'passwordResetRoutes' => [ 'username' => true ],
+                               'enableEmail' => true,
+                               'allowsAuthenticationDataChange' => true,
+                               'canEditPrivate' => true,
+                               'block' => new CompositeBlock( [
+                                       'originalBlocks' => [
+                                               new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
+                                               new Block( [] ),
+                                       ]
+                               ] ),
+                               'globalBlock' => null,
+                               'isAllowed' => true,
+                       ],
+                       'blocked with multiple blocks, not all allowing password reset' => [
+                               'passwordResetRoutes' => [ 'username' => true ],
+                               'enableEmail' => true,
+                               'allowsAuthenticationDataChange' => true,
+                               'canEditPrivate' => true,
+                               'block' => new CompositeBlock( [
+                                       'originalBlocks' => [
+                                               new SystemBlock( [ 'systemBlock' => 'wgSoftBlockRanges', 'anonOnly' => true ] ),
+                                               new SystemBlock( [ 'systemBlock' => 'proxy' ] ),
+                                       ]
+                               ] ),
+                               'globalBlock' => null,
+                               'isAllowed' => false,
+                       ],
                        'all OK' => [
                                'passwordResetRoutes' => [ 'username' => true ],
                                'enableEmail' => true,
index 14ddd9f..79c6e96 100644 (file)
@@ -4,6 +4,7 @@ define( 'NS_UNITTEST', 5600 );
 define( 'NS_UNITTEST_TALK', 5601 );
 
 use MediaWiki\Block\DatabaseBlock;
+use MediaWiki\Block\CompositeBlock;
 use MediaWiki\Block\Restriction\PageRestriction;
 use MediaWiki\Block\Restriction\NamespaceRestriction;
 use MediaWiki\Block\SystemBlock;
@@ -66,6 +67,15 @@ class UserTest extends MediaWikiTestCase {
                ];
        }
 
+       private function setSessionUser( User $user, WebRequest $request ) {
+               $this->setMwGlobals( 'wgUser', $user );
+               RequestContext::getMain()->setUser( $user );
+               RequestContext::getMain()->setRequest( $request );
+               TestingAccessWrapper::newFromObject( $user )->mRequest = $request;
+               $request->getSession()->setUser( $user );
+               $this->overrideMwServices();
+       }
+
        /**
         * @covers User::getGroupPermissions
         */
@@ -779,28 +789,20 @@ class UserTest extends MediaWikiTestCase {
         * @covers User::getBlockedStatus
         */
        public function testSoftBlockRanges() {
-               $setSessionUser = function ( User $user, WebRequest $request ) {
-                       $this->setMwGlobals( 'wgUser', $user );
-                       RequestContext::getMain()->setUser( $user );
-                       RequestContext::getMain()->setRequest( $request );
-                       TestingAccessWrapper::newFromObject( $user )->mRequest = $request;
-                       $request->getSession()->setUser( $user );
-                       $this->overrideMwServices();
-               };
                $this->setMwGlobals( 'wgSoftBlockRanges', [ '10.0.0.0/8' ] );
 
                // IP isn't in $wgSoftBlockRanges
                $wgUser = new User();
                $request = new FauxRequest();
                $request->setIP( '192.168.0.1' );
-               $setSessionUser( $wgUser, $request );
+               $this->setSessionUser( $wgUser, $request );
                $this->assertNull( $wgUser->getBlock() );
 
                // IP is in $wgSoftBlockRanges
                $wgUser = new User();
                $request = new FauxRequest();
                $request->setIP( '10.20.30.40' );
-               $setSessionUser( $wgUser, $request );
+               $this->setSessionUser( $wgUser, $request );
                $block = $wgUser->getBlock();
                $this->assertInstanceOf( SystemBlock::class, $block );
                $this->assertSame( 'wgSoftBlockRanges', $block->getSystemBlockType() );
@@ -809,7 +811,7 @@ class UserTest extends MediaWikiTestCase {
                $wgUser = $this->getTestUser()->getUser();
                $request = new FauxRequest();
                $request->setIP( '10.20.30.40' );
-               $setSessionUser( $wgUser, $request );
+               $this->setSessionUser( $wgUser, $request );
                $this->assertFalse( $wgUser->isAnon(), 'sanity check' );
                $this->assertNull( $wgUser->getBlock() );
        }
@@ -1316,6 +1318,35 @@ class UserTest extends MediaWikiTestCase {
                $this->assertFalse( $user->isBlockedFrom( $ut ) );
        }
 
+       /**
+        * @covers User::getBlockedStatus
+        */
+       public function testCompositeBlocks() {
+               $user = $this->getMutableTestUser()->getUser();
+               $request = $user->getRequest();
+               $this->setSessionUser( $user, $request );
+
+               $ipBlock = new Block( [
+                       'address' => $user->getRequest()->getIP(),
+                       'by' => $this->getTestSysop()->getUser()->getId(),
+                       'createAccount' => true,
+               ] );
+               $ipBlock->insert();
+
+               $userBlock = new Block( [
+                       'address' => $user,
+                       'by' => $this->getTestSysop()->getUser()->getId(),
+                       'createAccount' => false,
+               ] );
+               $userBlock->insert();
+
+               $block = $user->getBlock();
+               $this->assertInstanceOf( CompositeBlock::class, $block );
+               $this->assertTrue( $block->isCreateAccountBlocked() );
+               $this->assertTrue( $block->appliesToPasswordReset() );
+               $this->assertTrue( $block->appliesToNamespace( NS_MAIN ) );
+       }
+
        /**
         * @covers User::isBlockedFrom
         * @dataProvider provideIsBlockedFrom
index a18ee41..9beabfc 100644 (file)
@@ -66,7 +66,7 @@ describe( 'Rollback with confirmation', function () {
                }, 5000, 'Expected rollback page to appear.' );
        } );
 
-       it( 'should verify rollbacks via GET requests are confirmed on a follow-up page', function () {
+       it.skip( 'should verify rollbacks via GET requests are confirmed on a follow-up page', function () {
                var rollbackActionUrl = HistoryPage.rollbackLink.getAttribute( 'href' );
                browser.url( rollbackActionUrl );