Merge "Add a custom block message for composite blocks"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 14 Jun 2019 18:12:51 +0000 (18:12 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 14 Jun 2019 18:12:51 +0000 (18:12 +0000)
97 files changed:
.travis.yml
RELEASE-NOTES-1.34
docs/extension.schema.v2.json
img_auth.php
includes/AutoLoader.php
includes/DefaultSettings.php
includes/Defines.php
includes/OutputPage.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/Revision/RevisionRecord.php
includes/Setup.php
includes/Storage/BlobStoreFactory.php
includes/Storage/DerivedPageDataUpdater.php
includes/Storage/PageUpdater.php
includes/Storage/SqlBlobStore.php
includes/actions/InfoAction.php
includes/actions/McrUndoAction.php
includes/api/i18n/fa.json
includes/api/i18n/ko.json
includes/diff/DifferenceEngine.php
includes/import/WikiImporter.php
includes/installer/Installer.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/rdbms/loadbalancer/LoadBalancer.php
includes/libs/stats/BufferingStatsdDataFactory.php
includes/media/TransformationalImageHandler.php
includes/page/WikiPage.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/SpecialExport.php
includes/specials/SpecialUndelete.php
includes/specials/pagers/ImageListPager.php
includes/widget/search/FullSearchResultWidget.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
tests/phpunit/MediaWikiTestCase.php
tests/phpunit/includes/Rest/EntryPointTest.php [new file with mode: 0644]
tests/phpunit/includes/Rest/Handler/HelloHandlerTest.php [new file with mode: 0644]
tests/phpunit/includes/Rest/HeaderContainerTest.php [new file with mode: 0644]
tests/phpunit/includes/Rest/PathTemplateMatcher/PathMatcherTest.php [new file with mode: 0644]
tests/phpunit/includes/Rest/ResponseFactoryTest.php [new file with mode: 0644]
tests/phpunit/includes/Rest/StringStreamTest.php [new file with mode: 0644]
tests/phpunit/includes/Rest/testRoutes.json [new file with mode: 0644]
tests/phpunit/includes/Revision/RevisionRendererTest.php
tests/phpunit/includes/db/LoadBalancerTest.php
tests/phpunit/includes/resourceloader/ResourceLoaderContextTest.php
thumb.php

index ada60e4..bf905e0 100644 (file)
@@ -24,18 +24,10 @@ 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
+    - php: 7.3
+    - php: 7.2
+    - php: 7.1
+    - 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..cc1015c 100644 (file)
@@ -205,6 +205,10 @@ 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.
+* The constant DB_SLAVE, deprecated in 1.28, has been removed. Use DB_REPLICA.
 * …
 
 === Deprecations in 1.34 ===
@@ -258,6 +262,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 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 ba4ed74..1434125 100644 (file)
@@ -79,6 +79,8 @@ function wfImageAuthMain() {
                return;
        }
 
+       $user = RequestContext::getMain()->getUser();
+
        // Various extensions may have their own backends that need access.
        // Check if there is a special backend and storage base path for this file.
        foreach ( $wgImgAuthUrlPathMap as $prefix => $storageDir ) {
@@ -87,7 +89,7 @@ function wfImageAuthMain() {
                        $be = FileBackendGroup::singleton()->backendFromPath( $storageDir );
                        $filename = $storageDir . substr( $path, strlen( $prefix ) ); // strip prefix
                        // Check basic user authorization
-                       if ( !RequestContext::getMain()->getUser()->isAllowed( 'read' ) ) {
+                       if ( !$user->isAllowed( 'read' ) ) {
                                wfForbidden( 'img-auth-accessdenied', 'img-auth-noread', $path );
                                return;
                        }
@@ -157,7 +159,9 @@ function wfImageAuthMain() {
 
                // Check user authorization for this title
                // Checks Whitelist too
-               if ( !$title->userCan( 'read' ) ) {
+               $permissionManager = \MediaWiki\MediaWikiServices::getInstance()->getPermissionManager();
+
+               if ( !$permissionManager->userCan( 'read', $user, $title ) ) {
                        wfForbidden( 'img-auth-accessdenied', 'img-auth-noread', $name );
                        return;
                }
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 5f98b44..e5cd5ed 100644 (file)
@@ -30,10 +30,6 @@ use Wikimedia\Rdbms\IDatabase;
  */
 
 # Obsolete aliases
-/**
- * @deprecated since 1.28, use DB_REPLICA instead
- */
-define( 'DB_SLAVE', -1 );
 
 /**@{
  * Obsolete IDatabase::makeList() constants
index 5227aa1..5833677 100644 (file)
@@ -2882,8 +2882,11 @@ class OutputPage extends ContextSource {
                                        $query['returntoquery'] = wfArrayToCgi( $returntoquery );
                                }
                        }
+
+                       $services = MediaWikiServices::getInstance();
+
                        $title = SpecialPage::getTitleFor( 'Userlogin' );
-                       $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
+                       $linkRenderer = $services->getLinkRenderer();
                        $loginUrl = $title->getLinkURL( $query, false, PROTO_RELATIVE );
                        $loginLink = $linkRenderer->makeKnownLink(
                                $title,
@@ -2895,9 +2898,13 @@ class OutputPage extends ContextSource {
                        $this->prepareErrorPage( $this->msg( 'loginreqtitle' ) );
                        $this->addHTML( $this->msg( $msg )->rawParams( $loginLink )->params( $loginUrl )->parse() );
 
+                       $permissionManager = $services->getPermissionManager();
+
                        # Don't return to a page the user can't read otherwise
                        # we'll end up in a pointless loop
-                       if ( $displayReturnto && $displayReturnto->userCan( 'read', $this->getUser() ) ) {
+                       if ( $displayReturnto && $permissionManager->userCan(
+                               'read', $this->getUser(), $displayReturnto
+                       ) ) {
                                $this->returnToMain( null, $displayReturnto );
                        }
                } else {
diff --git a/includes/Rest/CopyableStreamInterface.php b/includes/Rest/CopyableStreamInterface.php
new file mode 100644 (file)
index 0000000..3e18e16
--- /dev/null
@@ -0,0 +1,21 @@
+<?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.
+        *
+        * Equivalent to reading from the object until EOF and writing the
+        * resulting data to $stream. The position will be advanced to the end.
+        *
+        * 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..795999a
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+use ExtensionRegistry;
+use MediaWiki\MediaWikiServices;
+use RequestContext;
+use Title;
+use WebResponse;
+
+class EntryPoint {
+       /** @var RequestInterface */
+       private $request;
+       /** @var WebResponse */
+       private $webResponse;
+       /** @var Router */
+       private $router;
+
+       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
+               );
+
+               $entryPoint = new self(
+                       $request,
+                       $wgRequest->response(),
+                       $router );
+               $entryPoint->execute();
+       }
+
+       public function __construct( RequestInterface $request, WebResponse $webResponse,
+               Router $router
+       ) {
+               $this->request = $request;
+               $this->webResponse = $webResponse;
+               $this->router = $router;
+       }
+
+       public function execute() {
+               $response = $this->router->execute( $this->request );
+
+               $this->webResponse->header(
+                       'HTTP/' . $response->getProtocolVersion() . ' ' .
+                       $response->getStatusCode() . ' ' .
+                       $response->getReasonPhrase() );
+
+               foreach ( $response->getRawHeaderLines() as $line ) {
+                       $this->webResponse->header( $line );
+               }
+
+               foreach ( $response->getCookies() as $cookie ) {
+                       $this->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..a71f6a6
--- /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..4bed899
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * Shared code between RequestData and RequestFromGlobals
+ */
+abstract class RequestBase implements RequestInterface {
+       /**
+        * @var HeaderContainer|null
+        */
+       private $headerCollection;
+
+       /** @var array */
+       private $pathParams = [];
+
+       /** @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 setPathParams( $params ) {
+               $this->pathParams = $params;
+       }
+
+       public function getPathParams() {
+               return $this->pathParams;
+       }
+
+       public function getPathParam( $name ) {
+               return $this->pathParams[$name] ?? null;
+       }
+
+       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..997350c
--- /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
+        *     - pathParams: The 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->setPathParams( $params['pathParams'] ?? [] );
+               $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..eba389a
--- /dev/null
@@ -0,0 +1,265 @@
+<?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();
+
+       // MediaWiki extensions to PSR-7
+
+       /**
+        * Get the parameters derived from the path template match
+        *
+        * @return string[]
+        */
+       function getPathParams();
+
+       /**
+        * Retrieve a single path parameter.
+        *
+        * Retrieves a single path parameter as described in getPathParams(). If
+        * the attribute has not been previously set, returns null.
+        *
+        * @see getPathParams()
+        * @param string $name The parameter name.
+        * @return string|null
+        */
+       function getPathParam( $name );
+
+       /**
+        * Erase all path parameters from the object and set the parameter array
+        * to the one specified.
+        *
+        * @param string[] $params
+        */
+       function setPathParams( $params );
+
+       /**
+        * 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..39bee89
--- /dev/null
@@ -0,0 +1,262 @@
+<?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;
+       }
+
+       /**
+        * Remove the path prefix $this->rootPath. Return the part of the path with the
+        * prefix removed, or false if the prefix did not match.
+        *
+        * @param string $path
+        * @return false|string
+        */
+       private function getRelativePath( $path ) {
+               if ( substr_compare( $path, $this->rootPath, 0, strlen( $this->rootPath ) ) !== 0 ) {
+                       return false;
+               }
+               return substr( $path, strlen( $this->rootPath ) );
+       }
+
+       /**
+        * Find the handler for a request and execute it
+        *
+        * @param RequestInterface $request
+        * @return ResponseInterface
+        */
+       public function execute( RequestInterface $request ) {
+               $path = $request->getUri()->getPath();
+               $relPath = $this->getRelativePath( $path );
+               if ( $relPath === false ) {
+                       return $this->responseFactory->createHttpError( 404 );
+               }
+
+               $matchers = $this->getMatchers();
+               $matcher = $matchers[$request->getMethod()] ?? null;
+               $match = $matcher ? $matcher->match( $relPath ) : null;
+
+               if ( !$match ) {
+                       // Check for 405 wrong method
+                       $allowed = [];
+                       foreach ( $matchers as $allowedMethod => $allowedMatcher ) {
+                               if ( $allowedMethod === $request->getMethod() ) {
+                                       continue;
+                               }
+                               if ( $allowedMatcher->match( $relPath ) ) {
+                                       $allowed[] = $allowedMethod;
+                               }
+                       }
+                       if ( $allowed ) {
+                               $response = $this->responseFactory->createHttpError( 405 );
+                               $response->setHeader( 'Allow', $allowed );
+                               return $response;
+                       } else {
+                               // Did not match with any other method, must be 404
+                               return $this->responseFactory->createHttpError( 404 );
+                       }
+               }
+
+               $request->setPathParams( $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..85749c6
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace MediaWiki\Rest;
+
+/**
+ * A handler base class which unpacks parameters 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()->getPathParams() );
+               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..3ad0d96
--- /dev/null
@@ -0,0 +1,138 @@
+<?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 ) {
+               fwrite( $stream, $this->getContents() );
+       }
+
+       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;
+               } elseif ( $this->offset >= strlen( $this->contents ) ) {
+                       $ret = '';
+               } else {
+                       $ret = substr( $this->contents, $this->offset, $length );
+               }
+               $this->offset += strlen( $ret );
+               return $ret;
+       }
+
+       public function getContents() {
+               if ( $this->offset === 0 ) {
+                       $ret = $this->contents;
+               } elseif ( $this->offset >= strlen( $this->contents ) ) {
+                       $ret = '';
+               } 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 95749c5..70a891c 100644 (file)
@@ -27,6 +27,7 @@ use Content;
 use InvalidArgumentException;
 use LogicException;
 use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
 use MediaWiki\User\UserIdentity;
 use MWException;
 use Title;
@@ -521,8 +522,11 @@ abstract class RevisionRecord {
                        } else {
                                $text = $title->getPrefixedText();
                                wfDebug( "Checking for $permissionlist on $text due to $field match on $bitfield\n" );
+
+                               $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
                                foreach ( $permissions as $perm ) {
-                                       if ( $title->userCan( $perm, $user ) ) {
+                                       if ( $permissionManager->userCan( $perm, $user, $title ) ) {
                                                return true;
                                        }
                                }
@@ -550,7 +554,7 @@ abstract class RevisionRecord {
                // null if mSlots is not empty.
 
                // NOTE: getId() and getPageId() may return null before a revision is saved, so don't
-               //check them.
+               // check them.
 
                return $this->getTimestamp() !== null
                        && $this->getComment( self::RAW ) !== null
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 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 467a8ac..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;
index e91863a..f8ba08c 100644 (file)
@@ -279,11 +279,13 @@ class InfoAction extends FormlessAction {
                // Language in which the page content is (supposed to be) written
                $pageLang = $title->getPageLanguage()->getCode();
 
+               $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
                $pageLangHtml = $pageLang . ' - ' .
                        Language::fetchLanguageName( $pageLang, $lang->getCode() );
                // Link to Special:PageLanguage with pre-filled page title if user has permissions
                if ( $config->get( 'PageLanguageUseDB' )
-                       && $title->userCan( 'pagelang', $user )
+                       && $permissionManager->userCan( 'pagelang', $user, $title )
                ) {
                        $pageLangHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
                                SpecialPage::getTitleValueFor( 'PageLanguage', $title->getPrefixedText() ),
@@ -300,7 +302,7 @@ class InfoAction extends FormlessAction {
                $modelHtml = htmlspecialchars( ContentHandler::getLocalizedName( $title->getContentModel() ) );
                // If the user can change it, add a link to Special:ChangeContentModel
                if ( $config->get( 'ContentHandlerUseDB' )
-                       && $title->userCan( 'editcontentmodel', $user )
+                       && $permissionManager->userCan( 'editcontentmodel', $user, $title )
                ) {
                        $modelHtml .= ' ' . $this->msg( 'parentheses' )->rawParams( $linkRenderer->makeLink(
                                SpecialPage::getTitleValueFor( 'ChangeContentModel', $title->getPrefixedText() ),
index e9de846..41cd24e 100644 (file)
@@ -336,8 +336,14 @@ class McrUndoAction extends FormAction {
                        $updater->setOriginalRevisionId( false );
                        $updater->setUndidRevisionId( $this->undo );
 
+                       $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
                        // TODO: Ugh.
-                       if ( $wgUseRCPatrol && $this->getTitle()->userCan( 'autopatrol', $this->getUser() ) ) {
+                       if ( $wgUseRCPatrol && $permissionManager->userCan(
+                               'autopatrol',
+                               $this->getUser(),
+                               $this->getTitle() )
+                       ) {
                                $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
                        }
 
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 f7658fc..86d1a43 100644 (file)
@@ -22,6 +22,7 @@
  */
 
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Permissions\PermissionManager;
 use MediaWiki\Revision\RevisionRecord;
 use MediaWiki\Revision\SlotRecord;
 use MediaWiki\Storage\NameTableAccessException;
@@ -538,8 +539,14 @@ class DifferenceEngine extends ContextSource {
                                $samePage = false;
                        }
 
-                       if ( $samePage && $this->mNewPage && $this->mNewPage->quickUserCan( 'edit', $user ) ) {
-                               if ( $this->mNewRev->isCurrent() && $this->mNewPage->userCan( 'rollback', $user ) ) {
+                       $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
+                       if ( $samePage && $this->mNewPage && $permissionManager->userCan(
+                               'edit', $user, $this->mNewPage, PermissionManager::RIGOR_QUICK
+                       ) ) {
+                               if ( $this->mNewRev->isCurrent() && $permissionManager->userCan(
+                                       'rollback', $user, $this->mNewPage
+                               ) ) {
                                        $rollbackLink = Linker::generateRollback( $this->mNewRev, $this->getContext() );
                                        if ( $rollbackLink ) {
                                                $out->preventClickjacking();
index 8f58344..00bb61f 100644 (file)
@@ -1099,14 +1099,23 @@ class WikiImporter {
                } elseif ( !$title->canExist() ) {
                        $this->notice( 'import-error-special', $title->getPrefixedText() );
                        return false;
-               } elseif ( !$title->userCan( 'edit' ) && !$commandLineMode ) {
-                       # Do not import if the importing wiki user cannot edit this page
-                       $this->notice( 'import-error-edit', $title->getPrefixedText() );
-                       return false;
-               } elseif ( !$title->exists() && !$title->userCan( 'create' ) && !$commandLineMode ) {
-                       # Do not import if the importing wiki user cannot create this page
-                       $this->notice( 'import-error-create', $title->getPrefixedText() );
-                       return false;
+               } elseif ( !$commandLineMode ) {
+                       $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+                       $user = RequestContext::getMain()->getUser();
+
+                       if ( !$permissionManager->userCan( 'edit', $user, $title ) ) {
+                               # Do not import if the importing wiki user cannot edit this page
+                               $this->notice( 'import-error-edit', $title->getPrefixedText() );
+
+                               return false;
+                       }
+
+                       if ( !$title->exists() && !$permissionManager->userCan( 'create', $user, $title ) ) {
+                               # Do not import if the importing wiki user cannot create this page
+                               $this->notice( 'import-error-create', $title->getPrefixedText() );
+
+                               return false;
+                       }
                }
 
                return [ $title, $foreignTitle ];
index 26f9bf0..33d4fcc 100644 (file)
@@ -1803,7 +1803,7 @@ abstract class Installer {
        /**
         * Add an installation step following the given step.
         *
-        * @param callable $callback A valid installation callback array, in this form:
+        * @param array $callback A valid installation callback array, in this form:
         *    [ 'name' => 'some-unique-name', 'callback' => [ $obj, 'function' ] ];
         * @param string $findStep The step to find. Omit to put the step at the beginning
         */
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 3fd0189..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 */
@@ -398,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 ) {
@@ -435,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 );
@@ -448,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
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 dbeca0b..95053cf 100644 (file)
@@ -231,7 +231,7 @@ abstract class TransformationalImageHandler extends ImageHandler {
                }
 
                // $scaler will return a MediaTransformError on failure, or false on success.
-               // If the scaler is succesful, it will have created a thumbnail at the destination
+               // If the scaler is successful, it will have created a thumbnail at the destination
                // path.
                if ( is_array( $scaler ) && is_callable( $scaler ) ) {
                        // Allow subclasses to specify their own rendering methods.
index 332b1ee..e29e2b1 100644 (file)
@@ -1904,7 +1904,11 @@ class WikiPage implements Page, IDBAccessObject {
                // TODO: this logic should not be in the storage layer, it's here for compatibility
                // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
                // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
-               if ( $needsPatrol && $this->getTitle()->userCan( 'autopatrol', $user ) ) {
+               $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
+               if ( $needsPatrol && $permissionManager->userCan(
+                       'autopatrol', $user, $this->getTitle()
+               ) ) {
                        $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
                }
 
@@ -3267,7 +3271,11 @@ class WikiPage implements Page, IDBAccessObject {
                // TODO: this logic should not be in the storage layer, it's here for compatibility
                // with 1.31 behavior. Applying the 'autopatrol' right should be done in the same
                // place the 'bot' right is handled, which is currently in EditPage::attemptSave.
-               if ( $wgUseRCPatrol && $this->getTitle()->userCan( 'autopatrol', $guser ) ) {
+               $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
+               if ( $wgUseRCPatrol && $permissionManager->userCan(
+                       'autopatrol', $guser, $this->getTitle()
+               ) ) {
                        $updater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
                }
 
index faaaece..e71de84 100644 (file)
@@ -65,6 +65,7 @@ class ExtensionProcessor implements Processor {
        protected static $coreAttributes = [
                'SkinOOUIThemes',
                'TrackingCategories',
+               'RestRoutes',
        ];
 
        /**
index e338f93..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 )
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 ef61ac5..5a63581 100644 (file)
@@ -24,6 +24,7 @@
  */
 
 use MediaWiki\Logger\LoggerFactory;
+use MediaWiki\MediaWikiServices;
 
 /**
  * A special page that allows users to export pages in a XML file
@@ -387,6 +388,8 @@ class SpecialExport extends SpecialPage {
                if ( $exportall ) {
                        $exporter->allPages();
                } else {
+                       $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
                        foreach ( $pages as $page ) {
                                # T10824: Only export pages the user can read
                                $title = Title::newFromText( $page );
@@ -395,7 +398,7 @@ class SpecialExport extends SpecialPage {
                                        continue;
                                }
 
-                               if ( !$title->userCan( 'read', $this->getUser() ) ) {
+                               if ( !$permissionManager->userCan( 'read', $this->getUser(), $title ) ) {
                                        // @todo Perhaps output an <error> tag or something.
                                        continue;
                                }
index 456face..05c622a 100644 (file)
@@ -138,8 +138,10 @@ class SpecialUndelete extends SpecialPage {
         */
        protected function isAllowed( $permission, User $user = null ) {
                $user = $user ?: $this->getUser();
+               $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
                if ( $this->mTargetObj !== null ) {
-                       return $this->mTargetObj->userCan( $permission, $user );
+                       return $permissionManager->userCan( $permission, $user, $this->mTargetObj );
                } else {
                        return $user->isAllowed( $permission );
                }
index 1d29efb..36909aa 100644 (file)
@@ -478,7 +478,9 @@ class ImageListPager extends TablePager {
 
                                        // Add delete links if allowed
                                        // From https://github.com/Wikia/app/pull/3859
-                                       if ( $filePage->userCan( 'delete', $this->getUser() ) ) {
+                                       $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+
+                                       if ( $permissionManager->userCan( 'delete', $this->getUser(), $filePage ) ) {
                                                $deleteMsg = $this->msg( 'listfiles-delete' )->text();
 
                                                $delete = $linkRenderer->makeKnownLink(
index d700570..74d15a4 100644 (file)
@@ -48,7 +48,10 @@ class FullSearchResultWidget implements SearchResultWidget {
                // This is not quite safe, but better than showing excerpts from
                // non-readable pages. Note that hiding the entry entirely would
                // screw up paging (really?).
-               if ( !$result->getTitle()->userCan( 'read', $this->specialPage->getUser() ) ) {
+               $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+               if ( !$permissionManager->userCan(
+                       'read', $this->specialPage->getUser(), $result->getTitle()
+               ) ) {
                        return "<li>{$link}</li>";
                }
 
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 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.
diff --git a/tests/phpunit/includes/Rest/EntryPointTest.php b/tests/phpunit/includes/Rest/EntryPointTest.php
new file mode 100644 (file)
index 0000000..4f87a70
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+namespace MediaWiki\Tests\Rest;
+
+use EmptyBagOStuff;
+use GuzzleHttp\Psr7\Uri;
+use GuzzleHttp\Psr7\Stream;
+use MediaWiki\Rest\Handler;
+use MediaWikiTestCase;
+use MediaWiki\Rest\EntryPoint;
+use MediaWiki\Rest\RequestData;
+use MediaWiki\Rest\ResponseFactory;
+use MediaWiki\Rest\Router;
+use WebResponse;
+
+/**
+ * @covers \MediaWiki\Rest\EntryPoint
+ * @covers \MediaWiki\Rest\Router
+ */
+class EntryPointTest extends MediaWikiTestCase {
+       private static $mockHandler;
+
+       private function createRouter() {
+               return new Router(
+                       [ __DIR__ . '/testRoutes.json' ],
+                       [],
+                       '/rest',
+                       new EmptyBagOStuff(),
+                       new ResponseFactory() );
+       }
+
+       private function createWebResponse() {
+               return $this->getMockBuilder( WebResponse::class )
+                       ->setMethods( [ 'header' ] )
+                       ->getMock();
+       }
+
+       public static function mockHandlerHeader() {
+               return new class extends Handler {
+                       public function execute() {
+                               $response = $this->getResponseFactory()->create();
+                               $response->setHeader( 'Foo', 'Bar' );
+                               return $response;
+                       }
+               };
+       }
+
+       public function testHeader() {
+               $webResponse = $this->createWebResponse();
+               $webResponse->expects( $this->any() )
+                       ->method( 'header' )
+                       ->withConsecutive(
+                               [ 'HTTP/1.1 200 OK', true, null ],
+                               [ 'Foo: Bar', true, null ]
+                       );
+
+               $entryPoint = new EntryPoint(
+                       new RequestData( [ 'uri' => new Uri( '/rest/mock/EntryPoint/header' ) ] ),
+                       $webResponse,
+                       $this->createRouter() );
+               $entryPoint->execute();
+               $this->assertTrue( true );
+       }
+
+       public static function mockHandlerBodyRewind() {
+               return new class extends Handler {
+                       public function execute() {
+                               $response = $this->getResponseFactory()->create();
+                               $stream = new Stream( fopen( 'php://memory', 'w+' ) );
+                               $stream->write( 'hello' );
+                               $response->setBody( $stream );
+                               return $response;
+                       }
+               };
+       }
+
+       /**
+        * Make sure EntryPoint rewinds a seekable body stream before reading.
+        */
+       public function testBodyRewind() {
+               $entryPoint = new EntryPoint(
+                       new RequestData( [ 'uri' => new Uri( '/rest/mock/EntryPoint/bodyRewind' ) ] ),
+                       $this->createWebResponse(),
+                       $this->createRouter() );
+               ob_start();
+               $entryPoint->execute();
+               $this->assertSame( 'hello', ob_get_clean() );
+       }
+
+}
diff --git a/tests/phpunit/includes/Rest/Handler/HelloHandlerTest.php b/tests/phpunit/includes/Rest/Handler/HelloHandlerTest.php
new file mode 100644 (file)
index 0000000..afbaafb
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+
+namespace MediaWiki\Tests\Rest\Handler;
+
+use EmptyBagOStuff;
+use GuzzleHttp\Psr7\Uri;
+use MediaWiki\Rest\RequestData;
+use MediaWiki\Rest\ResponseFactory;
+use MediaWiki\Rest\Router;
+use MediaWikiTestCase;
+
+/**
+ * @covers \MediaWiki\Rest\Handler\HelloHandler
+ */
+class HelloHandlerTest extends MediaWikiTestCase {
+       public static function provideTestViaRouter() {
+               return [
+                       'normal' => [
+                               [
+                                       'method' => 'GET',
+                                       'uri' => self::makeUri( '/user/Tim/hello' ),
+                               ],
+                               [
+                                       'statusCode' => 200,
+                                       'reasonPhrase' => 'OK',
+                                       'protocolVersion' => '1.1',
+                                       'body' => '{"message":"Hello, Tim!"}',
+                               ],
+                       ],
+                       'method not allowed' => [
+                               [
+                                       'method' => 'POST',
+                                       'uri' => self::makeUri( '/user/Tim/hello' ),
+                               ],
+                               [
+                                       'statusCode' => 405,
+                                       'reasonPhrase' => 'Method Not Allowed',
+                                       'protocolVersion' => '1.1',
+                                       'body' => '{"httpCode":405,"httpReason":"Method Not Allowed"}',
+                               ],
+                       ],
+               ];
+       }
+
+       private static function makeUri( $path ) {
+               return new Uri( "http://www.example.com/rest$path" );
+       }
+
+       /** @dataProvider provideTestViaRouter */
+       public function testViaRouter( $requestInfo, $responseInfo ) {
+               $router = new Router(
+                       [ __DIR__ . '/../testRoutes.json' ],
+                       [],
+                       '/rest',
+                       new EmptyBagOStuff(),
+                       new ResponseFactory() );
+               $request = new RequestData( $requestInfo );
+               $response = $router->execute( $request );
+               if ( isset( $responseInfo['statusCode'] ) ) {
+                       $this->assertSame( $responseInfo['statusCode'], $response->getStatusCode() );
+               }
+               if ( isset( $responseInfo['reasonPhrase'] ) ) {
+                       $this->assertSame( $responseInfo['reasonPhrase'], $response->getReasonPhrase() );
+               }
+               if ( isset( $responseInfo['protocolVersion'] ) ) {
+                       $this->assertSame( $responseInfo['protocolVersion'], $response->getProtocolVersion() );
+               }
+               if ( isset( $responseInfo['body'] ) ) {
+                       $this->assertSame( $responseInfo['body'], $response->getBody()->getContents() );
+               }
+               $this->assertSame(
+                       [],
+                       array_diff( array_keys( $responseInfo ), [
+                               'statusCode',
+                               'reasonPhrase',
+                               'protocolVersion',
+                               'body'
+                       ] ),
+                       '$responseInfo may not contain unknown keys' );
+       }
+}
diff --git a/tests/phpunit/includes/Rest/HeaderContainerTest.php b/tests/phpunit/includes/Rest/HeaderContainerTest.php
new file mode 100644 (file)
index 0000000..e0dbfdf
--- /dev/null
@@ -0,0 +1,172 @@
+<?php
+
+namespace MediaWiki\Tests\Rest;
+
+use MediaWikiTestCase;
+use MediaWiki\Rest\HeaderContainer;
+
+/**
+ * @covers \MediaWiki\Rest\HeaderContainer
+ */
+class HeaderContainerTest extends MediaWikiTestCase {
+       public static function provideSetHeader() {
+               return [
+                       'simple' => [
+                               [
+                                       [ 'Test', 'foo' ]
+                               ],
+                               [ 'Test' => [ 'foo' ] ],
+                               [ 'Test' => 'foo' ]
+                       ],
+                       'replace' => [
+                               [
+                                       [ 'Test', 'foo' ],
+                                       [ 'Test', 'bar' ],
+                               ],
+                               [ 'Test' => [ 'bar' ] ],
+                               [ 'Test' => 'bar' ],
+                       ],
+                       'array value' => [
+                               [
+                                       [ 'Test', [ '1', '2' ] ],
+                                       [ 'Test', [ '3', '4' ] ],
+                               ],
+                               [ 'Test' => [ '3', '4' ] ],
+                               [ 'Test' => '3, 4' ]
+                       ],
+                       'preserve most recent case' => [
+                               [
+                                       [ 'test', 'foo' ],
+                                       [ 'tesT', 'bar' ],
+                               ],
+                               [ 'tesT' => [ 'bar' ] ],
+                               [ 'tesT' => 'bar' ]
+                       ],
+                       'empty' => [ [], [], [] ],
+               ];
+       }
+
+       /** @dataProvider provideSetHeader */
+       public function testSetHeader( $setOps, $headers, $lines ) {
+               $hc = new HeaderContainer;
+               foreach ( $setOps as list( $name, $value ) ) {
+                       $hc->setHeader( $name, $value );
+               }
+               $this->assertSame( $headers, $hc->getHeaders() );
+               $this->assertSame( $lines, $hc->getHeaderLines() );
+       }
+
+       public static function provideAddHeader() {
+               return [
+                       'simple' => [
+                               [
+                                       [ 'Test', 'foo' ]
+                               ],
+                               [ 'Test' => [ 'foo' ] ],
+                               [ 'Test' => 'foo' ]
+                       ],
+                       'add' => [
+                               [
+                                       [ 'Test', 'foo' ],
+                                       [ 'Test', 'bar' ],
+                               ],
+                               [ 'Test' => [ 'foo', 'bar' ] ],
+                               [ 'Test' => 'foo, bar' ],
+                       ],
+                       'array value' => [
+                               [
+                                       [ 'Test', [ '1', '2' ] ],
+                                       [ 'Test', [ '3', '4' ] ],
+                               ],
+                               [ 'Test' => [ '1', '2', '3', '4' ] ],
+                               [ 'Test' => '1, 2, 3, 4' ]
+                       ],
+                       'preserve original case' => [
+                               [
+                                       [ 'Test', 'foo' ],
+                                       [ 'tesT', 'bar' ],
+                               ],
+                               [ 'Test' => [ 'foo', 'bar' ] ],
+                               [ 'Test' => 'foo, bar' ]
+                       ],
+               ];
+       }
+
+       /** @dataProvider provideAddHeader */
+       public function testAddHeader( $addOps, $headers, $lines ) {
+               $hc = new HeaderContainer;
+               foreach ( $addOps as list( $name, $value ) ) {
+                       $hc->addHeader( $name, $value );
+               }
+               $this->assertSame( $headers, $hc->getHeaders() );
+               $this->assertSame( $lines, $hc->getHeaderLines() );
+       }
+
+       public static function provideRemoveHeader() {
+               return [
+                       'simple' => [
+                               [ [ 'Test', 'foo' ] ],
+                               [ 'Test' ],
+                               [],
+                               []
+                       ],
+                       'case mismatch' => [
+                               [ [ 'Test', 'foo' ] ],
+                               [ 'tesT' ],
+                               [],
+                               []
+                       ],
+                       'remove nonexistent' => [
+                               [ [ 'A', '1' ] ],
+                               [ 'B' ],
+                               [ 'A' => [ '1' ] ],
+                               [ 'A' => '1' ]
+                       ],
+               ];
+       }
+
+       /** @dataProvider provideRemoveHeader */
+       public function testRemoveHeader( $addOps, $removeOps, $headers, $lines ) {
+               $hc = new HeaderContainer;
+               foreach ( $addOps as list( $name, $value ) ) {
+                       $hc->addHeader( $name, $value );
+               }
+               foreach ( $removeOps as $name ) {
+                       $hc->removeHeader( $name );
+               }
+               $this->assertSame( $headers, $hc->getHeaders() );
+               $this->assertSame( $lines, $hc->getHeaderLines() );
+       }
+
+       public function testHasHeader() {
+               $hc = new HeaderContainer;
+               $hc->addHeader( 'A', '1' );
+               $hc->addHeader( 'B', '2' );
+               $hc->addHeader( 'C', '3' );
+               $hc->removeHeader( 'B' );
+               $hc->removeHeader( 'c' );
+               $this->assertTrue( $hc->hasHeader( 'A' ) );
+               $this->assertTrue( $hc->hasHeader( 'a' ) );
+               $this->assertFalse( $hc->hasHeader( 'B' ) );
+               $this->assertFalse( $hc->hasHeader( 'c' ) );
+               $this->assertFalse( $hc->hasHeader( 'C' ) );
+       }
+
+       public function testGetRawHeaderLines() {
+               $hc = new HeaderContainer;
+               $hc->addHeader( 'A', '1' );
+               $hc->addHeader( 'a', '2' );
+               $hc->addHeader( 'b', '3' );
+               $hc->addHeader( 'Set-Cookie', 'x' );
+               $hc->addHeader( 'SET-cookie', 'y' );
+               $this->assertSame(
+                       [
+                               'A: 1, 2',
+                               'b: 3',
+                               'Set-Cookie: x',
+                               'Set-Cookie: y',
+                       ],
+                       $hc->getRawHeaderLines()
+               );
+       }
+}
diff --git a/tests/phpunit/includes/Rest/PathTemplateMatcher/PathMatcherTest.php b/tests/phpunit/includes/Rest/PathTemplateMatcher/PathMatcherTest.php
new file mode 100644 (file)
index 0000000..935cec1
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+namespace MediaWiki\Tests\Rest\PathTemplateMatcher;
+
+use MediaWiki\Rest\PathTemplateMatcher\PathConflict;
+use MediaWiki\Rest\PathTemplateMatcher\PathMatcher;
+use MediaWikiTestCase;
+
+/**
+ * @covers \MediaWiki\Rest\PathTemplateMatcher\PathMatcher
+ * @covers \MediaWiki\Rest\PathTemplateMatcher\PathConflict
+ */
+class PathMatcherTest extends MediaWikiTestCase {
+       private static $normalRoutes = [
+               '/a/b',
+               '/b/{x}',
+               '/c/{x}/d',
+               '/c/{x}/e',
+               '/c/{x}/{y}/d',
+       ];
+
+       public static function provideConflictingRoutes() {
+               return [
+                       [ '/a/b', 0, '/a/b' ],
+                       [ '/a/{x}', 0, '/a/b' ],
+                       [ '/{x}/c', 1, '/b/{x}' ],
+                       [ '/b/a', 1, '/b/{x}' ],
+                       [ '/b/{x}', 1, '/b/{x}' ],
+                       [ '/{x}/{y}/d', 2, '/c/{x}/d' ],
+               ];
+       }
+
+       public static function provideMatch() {
+               return [
+                       [ '', false ],
+                       [ '/a/b', [ 'params' => [], 'userData' => 0 ] ],
+                       [ '/b', false ],
+                       [ '/b/1', [ 'params' => [ 'x' => '1' ], 'userData' => 1 ] ],
+                       [ '/c/1/d', [ 'params' => [ 'x' => '1' ], 'userData' => 2 ] ],
+                       [ '/c/1/e', [ 'params' => [ 'x' => '1' ], 'userData' => 3 ] ],
+                       [ '/c/000/e', [ 'params' => [ 'x' => '000' ], 'userData' => 3 ] ],
+                       [ '/c/1/f', false ],
+                       [ '/c//e', [ 'params' => [ 'x' => '' ], 'userData' => 3 ] ],
+                       [ '/c///e', false ],
+               ];
+       }
+
+       public function createNormalRouter() {
+               $pm = new PathMatcher;
+               foreach ( self::$normalRoutes as $i => $route ) {
+                       $pm->add( $route, $i );
+               }
+               return $pm;
+       }
+
+       /** @dataProvider provideConflictingRoutes */
+       public function testAddConflict( $attempt, $expectedUserData, $expectedTemplate ) {
+               $pm = $this->createNormalRouter();
+               $actualTemplate = null;
+               $actualUserData = null;
+               try {
+                       $pm->add( $attempt, 'conflict' );
+               } catch ( PathConflict $pc ) {
+                       $actualTemplate = $pc->existingTemplate;
+                       $actualUserData = $pc->existingUserData;
+               }
+               $this->assertSame( $expectedUserData, $actualUserData );
+               $this->assertSame( $expectedTemplate, $actualTemplate );
+       }
+
+       /** @dataProvider provideMatch */
+       public function testMatch( $path, $expectedResult ) {
+               $pm = $this->createNormalRouter();
+               $result = $pm->match( $path );
+               $this->assertSame( $expectedResult, $result );
+       }
+}
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 );
+       }
+}
diff --git a/tests/phpunit/includes/Rest/StringStreamTest.php b/tests/phpunit/includes/Rest/StringStreamTest.php
new file mode 100644 (file)
index 0000000..f474643
--- /dev/null
@@ -0,0 +1,131 @@
+<?php
+
+namespace MediaWiki\Tests\Rest;
+
+use MediaWiki\Rest\StringStream;
+use MediaWikiTestCase;
+
+/** @covers \MediaWiki\Rest\StringStream */
+class StringStreamTest extends MediaWikiTestCase {
+       public static function provideSeekGetContents() {
+               return [
+                       [ 'abcde', 0, SEEK_SET, 'abcde' ],
+                       [ 'abcde', 1, SEEK_SET, 'bcde' ],
+                       [ 'abcde', 5, SEEK_SET, '' ],
+                       [ 'abcde', 1, SEEK_CUR, 'cde' ],
+                       [ 'abcde', 0, SEEK_END, '' ],
+               ];
+       }
+
+       /** @dataProvider provideSeekGetContents */
+       public function testCopyToStream( $input, $offset, $whence, $expected ) {
+               $ss = new StringStream;
+               $ss->write( $input );
+               $ss->seek( 1 );
+               $ss->seek( $offset, $whence );
+               $destStream = fopen( 'php://memory', 'w+' );
+               $ss->copyToStream( $destStream );
+               fseek( $destStream, 0 );
+               $result = stream_get_contents( $destStream );
+               $this->assertSame( $expected, $result );
+       }
+
+       public function testGetSize() {
+               $ss = new StringStream;
+               $this->assertSame( 0, $ss->getSize() );
+               $ss->write( "hello" );
+               $this->assertSame( 5, $ss->getSize() );
+               $ss->rewind();
+               $this->assertSame( 5, $ss->getSize() );
+       }
+
+       public function testTell() {
+               $ss = new StringStream;
+               $this->assertSame( $ss->tell(), 0 );
+               $ss->write( "abc" );
+               $this->assertSame( $ss->tell(), 3 );
+               $ss->seek( 0 );
+               $ss->read( 1 );
+               $this->assertSame( $ss->tell(), 1 );
+       }
+
+       public function testEof() {
+               $ss = new StringStream( 'abc' );
+               $this->assertFalse( $ss->eof() );
+               $ss->read( 1 );
+               $this->assertFalse( $ss->eof() );
+               $ss->read( 1 );
+               $this->assertFalse( $ss->eof() );
+               $ss->read( 1 );
+               $this->assertTrue( $ss->eof() );
+               $ss->rewind();
+               $this->assertFalse( $ss->eof() );
+       }
+
+       public function testIsSeekable() {
+               $ss = new StringStream;
+               $this->assertTrue( $ss->isSeekable() );
+       }
+
+       public function testIsReadable() {
+               $ss = new StringStream;
+               $this->assertTrue( $ss->isReadable() );
+       }
+
+       public function testIsWritable() {
+               $ss = new StringStream;
+               $this->assertTrue( $ss->isWritable() );
+       }
+
+       public function testSeekWrite() {
+               $ss = new StringStream;
+               $this->assertSame( '', (string)$ss );
+               $ss->write( 'a' );
+               $this->assertSame( 'a', (string)$ss );
+               $ss->write( 'b' );
+               $this->assertSame( 'ab', (string)$ss );
+               $ss->seek( 1 );
+               $ss->write( 'c' );
+               $this->assertSame( 'ac', (string)$ss );
+       }
+
+       /** @dataProvider provideSeekGetContents */
+       public function testSeekGetContents( $input, $offset, $whence, $expected ) {
+               $ss = new StringStream( $input );
+               $ss->seek( 1 );
+               $ss->seek( $offset, $whence );
+               $this->assertSame( $expected, $ss->getContents() );
+       }
+
+       public static function provideSeekRead() {
+               return [
+                       [ 'abcde', 0, SEEK_SET, 1, 'a' ],
+                       [ 'abcde', 0, SEEK_SET, 2, 'ab' ],
+                       [ 'abcde', 4, SEEK_SET, 2, 'e' ],
+                       [ 'abcde', 5, SEEK_SET, 1, '' ],
+                       [ 'abcde', 1, SEEK_CUR, 1, 'c' ],
+                       [ 'abcde', 0, SEEK_END, 1, '' ],
+                       [ 'abcde', -1, SEEK_END, 1, 'e' ],
+               ];
+       }
+
+       /** @dataProvider provideSeekRead */
+       public function testSeekRead( $input, $offset, $whence, $length, $expected ) {
+               $ss = new StringStream( $input );
+               $ss->seek( 1 );
+               $ss->seek( $offset, $whence );
+               $this->assertSame( $expected, $ss->read( $length ) );
+       }
+
+       /** @expectedException \InvalidArgumentException */
+       public function testReadBeyondEnd() {
+               $ss = new StringStream( 'abc' );
+               $ss->seek( 1, SEEK_END );
+       }
+
+       /** @expectedException \InvalidArgumentException */
+       public function testReadBeforeStart() {
+               $ss = new StringStream( 'abc' );
+               $ss->seek( -1 );
+       }
+}
diff --git a/tests/phpunit/includes/Rest/testRoutes.json b/tests/phpunit/includes/Rest/testRoutes.json
new file mode 100644 (file)
index 0000000..7e43bb0
--- /dev/null
@@ -0,0 +1,14 @@
+[
+       {
+               "path": "/user/{name}/hello",
+               "class": "MediaWiki\\Rest\\Handler\\HelloHandler"
+       },
+       {
+               "path": "/mock/EntryPoint/header",
+               "factory": "MediaWiki\\Tests\\Rest\\EntryPointTest::mockHandlerHeader"
+       },
+       {
+               "path": "/mock/EntryPoint/bodyRewind",
+               "factory": "MediaWiki\\Tests\\Rest\\EntryPointTest::mockHandlerBodyRewind"
+       }
+]
index 071ea68..d57625b 100644 (file)
@@ -6,6 +6,7 @@ use CommentStoreComment;
 use Content;
 use Language;
 use LogicException;
+use MediaWiki\Permissions\PermissionManager;
 use MediaWiki\Revision\MutableRevisionRecord;
 use MediaWiki\Revision\MainSlotRoleHandler;
 use MediaWiki\Revision\RevisionRecord;
@@ -29,6 +30,20 @@ use WikitextContent;
  */
 class RevisionRendererTest extends MediaWikiTestCase {
 
+       /** @var PermissionManager|\PHPUnit_Framework_MockObject_MockObject $permissionManagerMock */
+       private $permissionManagerMock;
+
+       protected function setUp() {
+               parent::setUp();
+
+               $this->permissionManagerMock = $this->createMock( PermissionManager::class );
+               $this->overrideMwServices( null, [
+                       'PermissionManager' => function (): PermissionManager {
+                               return $this->permissionManagerMock;
+                       }
+               ] );
+       }
+
        /**
         * @param int $articleId
         * @param int $revisionId
@@ -73,10 +88,10 @@ class RevisionRendererTest extends MediaWikiTestCase {
                                        return $mock->getArticleID() === $other->getArticleID();
                                }
                        );
-               $mock->expects( $this->any() )
+               $this->permissionManagerMock->expects( $this->any() )
                        ->method( 'userCan' )
                        ->willReturnCallback(
-                               function ( $perm, User $user ) use ( $mock ) {
+                               function ( $perm, User $user ) {
                                        return $user->isAllowed( $perm );
                                }
                        );
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 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 cf9bd2c..4e5c213 100644 (file)
--- a/thumb.php
+++ b/thumb.php
@@ -155,7 +155,11 @@ function wfStreamThumb( array $params ) {
        // Check permissions if there are read restrictions
        $varyHeader = [];
        if ( !in_array( 'read', User::getGroupPermissions( [ '*' ] ), true ) ) {
-               if ( !$img->getTitle() || !$img->getTitle()->userCan( 'read' ) ) {
+               $user = RequestContext::getMain()->getUser();
+               $permissionManager = MediaWikiServices::getInstance()->getPermissionManager();
+               $imgTitle = $img->getTitle();
+
+               if ( !$imgTitle || !$permissionManager->userCan( 'read', $user, $imgTitle ) ) {
                        wfThumbError( 403, 'Access denied. You do not have permission to access ' .
                                'the source file.' );
                        return;