Merge "resourceloader: Implement mw.inspect 'time' report"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Thu, 23 Aug 2018 18:18:56 +0000 (18:18 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Thu, 23 Aug 2018 18:18:56 +0000 (18:18 +0000)
60 files changed:
.phpcs.xml
RELEASE-NOTES-1.32
api.php
autoload.php
includes/ServiceWiring.php
includes/Storage/BlobStoreFactory.php
includes/Storage/RevisionStore.php
includes/Title.php
includes/api/ApiComparePages.php
includes/api/ApiMain.php
includes/api/ApiStashEdit.php
includes/api/i18n/en.json
includes/api/i18n/qqq.json
includes/api/i18n/zh-hant.json
includes/db/DatabaseOracle.php
includes/diff/DifferenceEngine.php
includes/htmlform/fields/HTMLCheckMatrix.php
includes/libs/filebackend/fsfile/TempFSFile.php
includes/libs/rdbms/database/DBConnRef.php
includes/libs/rdbms/database/Database.php
includes/libs/rdbms/database/DatabaseMssql.php
includes/libs/rdbms/database/DatabaseMysqlBase.php
includes/libs/rdbms/database/DatabasePostgres.php
includes/libs/rdbms/database/DatabaseSqlite.php
includes/libs/rdbms/database/IDatabase.php
includes/libs/rdbms/loadbalancer/LoadBalancer.php
includes/specials/pagers/UsersPager.php
includes/title/MediaWikiTitleCodec.php
includes/title/TitleValue.php
includes/widget/CheckMatrixWidget.php [new file with mode: 0644]
languages/i18n/ce.json
languages/i18n/da.json
languages/i18n/es.json
languages/i18n/ja.json
languages/i18n/kn.json
languages/i18n/lt.json
languages/i18n/nb.json
languages/i18n/nds-nl.json
languages/i18n/sr-ec.json
languages/i18n/tr.json
languages/i18n/zh-hans.json
languages/i18n/zh-hant.json
maintenance/Doxyfile
maintenance/backup.inc [deleted file]
maintenance/benchmarks/bench_strtr_str_replace.php [deleted file]
maintenance/benchmarks/benchmarkStringReplacement.php [new file with mode: 0644]
maintenance/deduplicateArchiveRevId.php
maintenance/dumpBackup.php
maintenance/dumpTextPass.php
maintenance/includes/BackupDumper.php [new file with mode: 0644]
maintenance/populateArchiveRevId.php
resources/Resources.php
resources/src/mediawiki.widgets/mw.widgets.CheckMatrixWidget.js [new file with mode: 0644]
tests/parser/ParserTestMockParser.php
tests/phan/config.php
tests/phpunit/includes/Storage/McrRevisionStoreDbTest.php
tests/phpunit/includes/Storage/RevisionStoreDbTestBase.php
tests/phpunit/includes/api/ApiComparePagesTest.php
tests/phpunit/includes/api/ApiStashEditTest.php
tests/phpunit/includes/title/MediaWikiTitleCodecTest.php

index 944c3e2..10394d3 100644 (file)
                <exclude-pattern>*/maintenance/7zip.inc</exclude-pattern>
                <exclude-pattern>*/maintenance/CodeCleanerGlobalsPass.inc</exclude-pattern>
                <exclude-pattern>*/maintenance/archives/upgradeLogging\.php</exclude-pattern>
-               <exclude-pattern>*/maintenance/backup.inc</exclude-pattern>
                <exclude-pattern>*/maintenance/benchmarks/bench_HTTP_HTTPS\.php</exclude-pattern>
                <exclude-pattern>*/maintenance/benchmarks/bench_Wikimedia_base_convert\.php</exclude-pattern>
                <exclude-pattern>*/maintenance/benchmarks/bench_delete_truncate\.php</exclude-pattern>
index 0ad2e41..a7fc232 100644 (file)
@@ -136,6 +136,18 @@ production.
 * action=query&prop=deletedrevisions, action=query&list=allrevisions, and
   action=query&list=alldeletedrevisions are changed similarly to
   &prop=revisions (see the three previous items).
+* (T174032) action=compare now supports multi-content revisions.
+  * It has a 'slots' parameter to select diffing of individual slots. The
+    default behavior is to return one combined diff.
+  * The 'fromtext', 'fromsection', 'fromcontentmodel', 'fromcontentformat',
+    'totext', 'tosection', 'tocontentmodel', and 'tocontentformat' parameters
+    are deprecated. Specify the new 'fromslots' and 'toslots' to identify which
+    slots have text supplied and the corresponding templated parameters for
+    each slot.
+  * The behavior of 'fromsection' and 'tosection' of extracting one section's
+    content is not being preserved. 'fromsection-{slot}' and 'tosection-{slot}'
+    instead expand the given text as if for a section edit. This effectively
+    declines T183823 in favor of T185723.
 
 === Action API internal changes in 1.32 ===
 * Added 'ApiParseMakeOutputPage' hook.
diff --git a/api.php b/api.php
index 9c5ac95..9cf7578 100644 (file)
--- a/api.php
+++ b/api.php
@@ -72,7 +72,11 @@ try {
        if ( !$processor instanceof ApiMain ) {
                throw new MWException( 'ApiBeforeMain hook set $processor to a non-ApiMain class' );
        }
-} catch ( Exception $e ) {
+} catch ( Exception $e ) { // @todo Remove this block when HHVM is no longer supported
+       // Crap. Try to report the exception in API format to be friendly to clients.
+       ApiMain::handleApiBeforeMainException( $e );
+       $processor = false;
+} catch ( Throwable $e ) {
        // Crap. Try to report the exception in API format to be friendly to clients.
        ApiMain::handleApiBeforeMainException( $e );
        $processor = false;
@@ -99,7 +103,9 @@ if ( $wgAPIRequestLog ) {
                try {
                        $manager = $processor->getModuleManager();
                        $module = $manager->getModule( $wgRequest->getVal( 'action' ), 'action' );
-               } catch ( Exception $ex ) {
+               } catch ( Exception $ex ) { // @todo Remove this block when HHVM is no longer supported
+                       $module = null;
+               } catch ( Throwable $ex ) {
                        $module = null;
                }
                if ( !$module || $module->mustBePosted() ) {
index e960f42..cef68b0 100644 (file)
@@ -174,7 +174,7 @@ $wgAutoloadLocalClasses = [
        'AvroValidator' => __DIR__ . '/includes/utils/AvroValidator.php',
        'BacklinkCache' => __DIR__ . '/includes/cache/BacklinkCache.php',
        'BacklinkJobUtils' => __DIR__ . '/includes/jobqueue/utils/BacklinkJobUtils.php',
-       'BackupDumper' => __DIR__ . '/maintenance/backup.inc',
+       'BackupDumper' => __DIR__ . '/maintenance/includes/BackupDumper.php',
        'BackupReader' => __DIR__ . '/maintenance/importDump.php',
        'BadRequestError' => __DIR__ . '/includes/exception/BadRequestError.php',
        'BadTitleError' => __DIR__ . '/includes/exception/BadTitleError.php',
@@ -188,7 +188,6 @@ $wgAutoloadLocalClasses = [
        'BcryptPassword' => __DIR__ . '/includes/password/BcryptPassword.php',
        'BenchHttpHttps' => __DIR__ . '/maintenance/benchmarks/bench_HTTP_HTTPS.php',
        'BenchIfSwitch' => __DIR__ . '/maintenance/benchmarks/bench_if_switch.php',
-       'BenchStrtrStrReplace' => __DIR__ . '/maintenance/benchmarks/bench_strtr_str_replace.php',
        'BenchUtf8TitleCheck' => __DIR__ . '/maintenance/benchmarks/bench_utf8_title_check.php',
        'BenchWfIsWindows' => __DIR__ . '/maintenance/benchmarks/bench_wfIsWindows.php',
        'BenchWikimediaBaseConvert' => __DIR__ . '/maintenance/benchmarks/bench_Wikimedia_base_convert.php',
@@ -201,6 +200,7 @@ $wgAutoloadLocalClasses = [
        'BenchmarkParse' => __DIR__ . '/maintenance/benchmarks/benchmarkParse.php',
        'BenchmarkPurge' => __DIR__ . '/maintenance/benchmarks/benchmarkPurge.php',
        'BenchmarkSanitizer' => __DIR__ . '/maintenance/benchmarks/benchmarkSanitizer.php',
+       'BenchmarkStringReplacement' => __DIR__ . '/maintenance/benchmarks/benchmarkStringReplacement.php',
        'BenchmarkTidy' => __DIR__ . '/maintenance/benchmarks/benchmarkTidy.php',
        'BenchmarkTitleValue' => __DIR__ . '/maintenance/benchmarks/benchmarkTitleValue.php',
        'Benchmarker' => __DIR__ . '/maintenance/benchmarks/Benchmarker.php',
@@ -936,6 +936,7 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\Special\\SpecialPageFactory' => __DIR__ . '/includes/specialpage/SpecialPageFactory.php',
        'MediaWiki\\User\\UserIdentity' => __DIR__ . '/includes/user/UserIdentity.php',
        'MediaWiki\\User\\UserIdentityValue' => __DIR__ . '/includes/user/UserIdentityValue.php',
+       'MediaWiki\\Widget\\CheckMatrixWidget' => __DIR__ . '/includes/widget/CheckMatrixWidget.php',
        'MediaWiki\\Widget\\ComplexNamespaceInputWidget' => __DIR__ . '/includes/widget/ComplexNamespaceInputWidget.php',
        'MediaWiki\\Widget\\ComplexTitleInputWidget' => __DIR__ . '/includes/widget/ComplexTitleInputWidget.php',
        'MediaWiki\\Widget\\DateInputWidget' => __DIR__ . '/includes/widget/DateInputWidget.php',
index 1a19465..99b2942 100644 (file)
@@ -72,7 +72,7 @@ return [
 
        'BlobStoreFactory' => function ( MediaWikiServices $services ) : BlobStoreFactory {
                return new BlobStoreFactory(
-                       $services->getDBLoadBalancer(),
+                       $services->getDBLoadBalancerFactory(),
                        $services->getMainWANObjectCache(),
                        $services->getMainConfig(),
                        $services->getContentLanguage()
index 63ca74d..4e1f97f 100644 (file)
@@ -23,7 +23,7 @@ namespace MediaWiki\Storage;
 use Config;
 use Language;
 use WANObjectCache;
-use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\LBFactory;
 
 /**
  * Service for instantiating BlobStores
@@ -35,9 +35,9 @@ use Wikimedia\Rdbms\LoadBalancer;
 class BlobStoreFactory {
 
        /**
-        * @var LoadBalancer
+        * @var LBFactory
         */
-       private $loadBalancer;
+       private $lbFactory;
 
        /**
         * @var WANObjectCache
@@ -55,12 +55,12 @@ class BlobStoreFactory {
        private $contLang;
 
        public function __construct(
-               LoadBalancer $loadBalancer,
+               LBFactory $lbFactory,
                WANObjectCache $cache,
                Config $mainConfig,
                Language $contLang
        ) {
-               $this->loadBalancer = $loadBalancer;
+               $this->lbFactory = $lbFactory;
                $this->cache = $cache;
                $this->config = $mainConfig;
                $this->contLang = $contLang;
@@ -85,8 +85,9 @@ class BlobStoreFactory {
         * @return SqlBlobStore
         */
        public function newSqlBlobStore( $wikiId = false ) {
+               $lb = $this->lbFactory->getMainLB( $wikiId );
                $store = new SqlBlobStore(
-                       $this->loadBalancer,
+                       $lb,
                        $this->cache,
                        $wikiId
                );
index 5769527..88d520c 100644 (file)
@@ -746,6 +746,76 @@ class RevisionStore
                if ( !isset( $revisionRow['rev_id'] ) ) {
                        // only if auto-increment was used
                        $revisionRow['rev_id'] = intval( $dbw->insertId() );
+
+                       if ( $dbw->getType() === 'mysql' ) {
+                               // (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34 don't save the
+                               // auto-increment value to disk, so on server restart it might reuse IDs from deleted
+                               // revisions. We can fix that with an insert with an explicit rev_id value, if necessary.
+
+                               $maxRevId = intval( $dbw->selectField( 'archive', 'MAX(ar_rev_id)', '', __METHOD__ ) );
+                               $table = 'archive';
+                               if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
+                                       $maxRevId2 = intval( $dbw->selectField( 'slots', 'MAX(slot_revision_id)', '', __METHOD__ ) );
+                                       if ( $maxRevId2 >= $maxRevId ) {
+                                               $maxRevId = $maxRevId2;
+                                               $table = 'slots';
+                                       }
+                               }
+
+                               if ( $maxRevId >= $revisionRow['rev_id'] ) {
+                                       $this->logger->debug(
+                                               '__METHOD__: Inserted revision {revid} but {table} has revisions up to {maxrevid}.'
+                                                       . ' Trying to fix it.',
+                                               [
+                                                       'revid' => $revisionRow['rev_id'],
+                                                       'table' => $table,
+                                                       'maxrevid' => $maxRevId,
+                                               ]
+                                       );
+
+                                       if ( !$dbw->lock( 'fix-for-T202032', __METHOD__ ) ) {
+                                               throw new MWException( 'Failed to get database lock for T202032' );
+                                       }
+                                       $fname = __METHOD__;
+                                       $dbw->onTransactionResolution( function ( $trigger, $dbw ) use ( $fname ) {
+                                               $dbw->unlock( 'fix-for-T202032', $fname );
+                                       } );
+
+                                       $dbw->delete( 'revision', [ 'rev_id' => $revisionRow['rev_id'] ], __METHOD__ );
+
+                                       // The locking here is mostly to make MySQL bypass the REPEATABLE-READ transaction
+                                       // isolation (weird MySQL "feature"). It does seem to block concurrent auto-incrementing
+                                       // inserts too, though, at least on MariaDB 10.1.29.
+                                       //
+                                       // Don't try to lock `revision` in this way, it'll deadlock if there are concurrent
+                                       // transactions in this code path thanks to the row lock from the original ->insert() above.
+                                       //
+                                       // And we have to use raw SQL to bypass the "aggregation used with a locking SELECT" warning
+                                       // that's for non-MySQL DBs.
+                                       $row1 = $dbw->query(
+                                               $dbw->selectSqlText( 'archive', [ 'v' => "MAX(ar_rev_id)" ], '', __METHOD__ ) . ' FOR UPDATE'
+                                       )->fetchObject();
+                                       if ( $this->hasMcrSchemaFlags( SCHEMA_COMPAT_WRITE_NEW ) ) {
+                                               $row2 = $dbw->query(
+                                                       $dbw->selectSqlText( 'slots', [ 'v' => "MAX(slot_revision_id)" ], '', __METHOD__ )
+                                                               . ' FOR UPDATE'
+                                               )->fetchObject();
+                                       } else {
+                                               $row2 = null;
+                                       }
+                                       $maxRevId = max(
+                                               $maxRevId,
+                                               $row1 ? intval( $row1->v ) : 0,
+                                               $row2 ? intval( $row2->v ) : 0
+                                       );
+
+                                       // If we don't have SCHEMA_COMPAT_WRITE_NEW, all except the first of any concurrent
+                                       // transactions will throw a duplicate key error here. It doesn't seem worth trying
+                                       // to avoid that.
+                                       $revisionRow['rev_id'] = $maxRevId + 1;
+                                       $dbw->insert( 'revision', $revisionRow, __METHOD__ );
+                               }
+                       }
                }
 
                $commentCallback( $revisionRow['rev_id'] );
@@ -2174,7 +2244,9 @@ class RevisionStore
                                // NOTE: even when this class is set to not read from the old schema, callers
                                // should still be able to join against the text table, as long as we are still
                                // writing the old schema for compatibility.
-                               wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
+                               // TODO: This should trigger a deprecation warning eventually (T200918), but not
+                               // before all known usages are removed (see T198341 and T201164).
+                               // wfDeprecated( __METHOD__ . ' with `text` option', '1.32' );
                        }
 
                        $ret['tables'][] = 'text';
index e74824c..c919b18 100644 (file)
@@ -134,8 +134,15 @@ class Title implements LinkTarget {
        /** @var bool Boolean for initialisation on demand */
        public $mRestrictionsLoaded = false;
 
-       /** @var string Text form including namespace/interwiki, initialised on demand */
-       protected $mPrefixedText = null;
+       /**
+        * Text form including namespace/interwiki, initialised on demand
+        *
+        * Only public to share cache with TitleFormatter
+        *
+        * @private
+        * @var string
+        */
+       public $prefixedText = null;
 
        /** @var mixed Cached value for getTitleProtection (create protection) */
        public $mTitleProtection;
@@ -1669,12 +1676,12 @@ class Title implements LinkTarget {
         * @return string The prefixed title, with spaces
         */
        public function getPrefixedText() {
-               if ( $this->mPrefixedText === null ) {
+               if ( $this->prefixedText === null ) {
                        $s = $this->prefix( $this->mTextform );
                        $s = strtr( $s, '_', ' ' );
-                       $this->mPrefixedText = $s;
+                       $this->prefixedText = $s;
                }
-               return $this->mPrefixedText;
+               return $this->prefixedText;
        }
 
        /**
index 93c35d3..6bfa35d 100644 (file)
  * @file
  */
 
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionStore;
+
 class ApiComparePages extends ApiBase {
 
-       private $guessed = false, $guessedTitle, $guessedModel, $props;
+       /** @var RevisionStore */
+       private $revisionStore;
+
+       private $guessedTitle = false, $props;
+
+       public function __construct( ApiMain $mainModule, $moduleName, $modulePrefix = '' ) {
+               parent::__construct( $mainModule, $moduleName, $modulePrefix );
+               $this->revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
+       }
 
        public function execute() {
                $params = $this->extractRequestParams();
 
                // Parameter validation
-               $this->requireAtLeastOneParameter( $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext' );
-               $this->requireAtLeastOneParameter( $params, 'totitle', 'toid', 'torev', 'totext', 'torelative' );
+               $this->requireAtLeastOneParameter(
+                       $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext', 'fromslots'
+               );
+               $this->requireAtLeastOneParameter(
+                       $params, 'totitle', 'toid', 'torev', 'totext', 'torelative', 'toslots'
+               );
 
                $this->props = array_flip( $params['prop'] );
 
                // Cache responses publicly by default. This may be overridden later.
                $this->getMain()->setCacheMode( 'public' );
 
-               // Get the 'from' Revision and Content
-               list( $fromRev, $fromContent, $relRev ) = $this->getDiffContent( 'from', $params );
+               // Get the 'from' RevisionRecord
+               list( $fromRev, $fromRelRev, $fromValsRev ) = $this->getDiffRevision( 'from', $params );
 
-               // Get the 'to' Revision and Content
+               // Get the 'to' RevisionRecord
                if ( $params['torelative'] !== null ) {
-                       if ( !$relRev ) {
+                       if ( !$fromRelRev ) {
                                $this->dieWithError( 'apierror-compare-relative-to-nothing' );
                        }
                        switch ( $params['torelative'] ) {
                                case 'prev':
                                        // Swap 'from' and 'to'
-                                       $toRev = $fromRev;
-                                       $toContent = $fromContent;
-                                       $fromRev = $relRev->getPrevious();
-                                       $fromContent = $fromRev
-                                               ? $fromRev->getContent( Revision::FOR_THIS_USER, $this->getUser() )
-                                               : $toContent->getContentHandler()->makeEmptyContent();
-                                       if ( !$fromContent ) {
-                                               $this->dieWithError(
-                                                       [ 'apierror-missingcontent-revid', $fromRev->getId() ], 'missingcontent'
-                                               );
-                                       }
+                                       list( $toRev, $toRelRev2, $toValsRev ) = [ $fromRev, $fromRelRev, $fromValsRev ];
+                                       $fromRev = $this->revisionStore->getPreviousRevision( $fromRelRev );
+                                       $fromRelRev = $fromRev;
+                                       $fromValsRev = $fromRev;
                                        break;
 
                                case 'next':
-                                       $toRev = $relRev->getNext();
-                                       $toContent = $toRev
-                                               ? $toRev->getContent( Revision::FOR_THIS_USER, $this->getUser() )
-                                               : $fromContent;
-                                       if ( !$toContent ) {
-                                               $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
-                                       }
+                                       $toRev = $this->revisionStore->getNextRevision( $fromRelRev );
+                                       $toRelRev = $toRev;
+                                       $toValsRev = $toRev;
                                        break;
 
                                case 'cur':
-                                       $title = $relRev->getTitle();
-                                       $id = $title->getLatestRevID();
-                                       $toRev = $id ? Revision::newFromId( $id ) : null;
+                                       $title = $fromRelRev->getPageAsLinkTarget();
+                                       $toRev = $this->revisionStore->getRevisionByTitle( $title );
                                        if ( !$toRev ) {
+                                               $title = Title::newFromLinkTarget( $title );
                                                $this->dieWithError(
                                                        [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ], 'nosuchrevid'
                                                );
                                        }
-                                       $toContent = $toRev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
-                                       if ( !$toContent ) {
-                                               $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
-                                       }
+                                       $toRelRev = $toRev;
+                                       $toValsRev = $toRev;
                                        break;
                        }
-                       $relRev2 = null;
                } else {
-                       list( $toRev, $toContent, $relRev2 ) = $this->getDiffContent( 'to', $params );
+                       list( $toRev, $toRelRev, $toValsRev ) = $this->getDiffRevision( 'to', $params );
                }
 
-               // Should never happen, but just in case...
-               if ( !$fromContent || !$toContent ) {
+               // Handle missing from or to revisions
+               if ( !$fromRev || !$toRev ) {
                        $this->dieWithError( 'apierror-baddiff' );
                }
 
-               // Extract sections, if told to
-               if ( isset( $params['fromsection'] ) ) {
-                       $fromContent = $fromContent->getSection( $params['fromsection'] );
-                       if ( !$fromContent ) {
-                               $this->dieWithError(
-                                       [ 'apierror-compare-nosuchfromsection', wfEscapeWikiText( $params['fromsection'] ) ],
-                                       'nosuchfromsection'
-                               );
-                       }
+               // Handle revdel
+               if ( !$fromRev->audienceCan(
+                       RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_THIS_USER, $this->getUser()
+               ) ) {
+                       $this->dieWithError( [ 'apierror-missingcontent-revid', $fromRev->getId() ], 'missingcontent' );
                }
-               if ( isset( $params['tosection'] ) ) {
-                       $toContent = $toContent->getSection( $params['tosection'] );
-                       if ( !$toContent ) {
-                               $this->dieWithError(
-                                       [ 'apierror-compare-nosuchtosection', wfEscapeWikiText( $params['tosection'] ) ],
-                                       'nosuchtosection'
-                               );
-                       }
+               if ( !$toRev->audienceCan(
+                       RevisionRecord::DELETED_TEXT, RevisionRecord::FOR_THIS_USER, $this->getUser()
+               ) ) {
+                       $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
                }
 
                // Get the diff
                $context = new DerivativeContext( $this->getContext() );
-               if ( $relRev && $relRev->getTitle() ) {
-                       $context->setTitle( $relRev->getTitle() );
-               } elseif ( $relRev2 && $relRev2->getTitle() ) {
-                       $context->setTitle( $relRev2->getTitle() );
+               if ( $fromRelRev && $fromRelRev->getPageAsLinkTarget() ) {
+                       $context->setTitle( Title::newFromLinkTarget( $fromRelRev->getPageAsLinkTarget() ) );
+               } elseif ( $toRelRev && $toRelRev->getPageAsLinkTarget() ) {
+                       $context->setTitle( Title::newFromLinkTarget( $toRelRev->getPageAsLinkTarget() ) );
                } else {
-                       $this->guessTitleAndModel();
-                       if ( $this->guessedTitle ) {
-                               $context->setTitle( $this->guessedTitle );
+                       $guessedTitle = $this->guessTitle();
+                       if ( $guessedTitle ) {
+                               $context->setTitle( $guessedTitle );
                        }
                }
-               $de = $fromContent->getContentHandler()->createDifferenceEngine(
-                       $context,
-                       $fromRev ? $fromRev->getId() : 0,
-                       $toRev ? $toRev->getId() : 0,
-                       /* $rcid = */ null,
-                       /* $refreshCache = */ false,
-                       /* $unhide = */ true
-               );
-               $de->setContent( $fromContent, $toContent );
-               $difftext = $de->getDiffBody();
-               if ( $difftext === false ) {
-                       $this->dieWithError( 'apierror-baddiff' );
+               $de = new DifferenceEngine( $context );
+               $de->setRevisions( $fromRev, $toRev );
+               if ( $params['slots'] === null ) {
+                       $difftext = $de->getDiffBody();
+                       if ( $difftext === false ) {
+                               $this->dieWithError( 'apierror-baddiff' );
+                       }
+               } else {
+                       $difftext = [];
+                       foreach ( $params['slots'] as $role ) {
+                               $difftext[$role] = $de->getDiffBodyForRole( $role );
+                       }
                }
 
                // Fill in the response
                $vals = [];
-               $this->setVals( $vals, 'from', $fromRev );
-               $this->setVals( $vals, 'to', $toRev );
+               $this->setVals( $vals, 'from', $fromValsRev );
+               $this->setVals( $vals, 'to', $toValsRev );
 
                if ( isset( $this->props['rel'] ) ) {
-                       if ( $fromRev ) {
-                               $rev = $fromRev->getPrevious();
+                       if ( !$fromRev instanceof MutableRevisionRecord ) {
+                               $rev = $this->revisionStore->getPreviousRevision( $fromRev );
                                if ( $rev ) {
                                        $vals['prev'] = $rev->getId();
                                }
                        }
-                       if ( $toRev ) {
-                               $rev = $toRev->getNext();
+                       if ( !$toRev instanceof MutableRevisionRecord ) {
+                               $rev = $this->revisionStore->getNextRevision( $toRev );
                                if ( $rev ) {
                                        $vals['next'] = $rev->getId();
                                }
@@ -161,10 +156,18 @@ class ApiComparePages extends ApiBase {
                }
 
                if ( isset( $this->props['diffsize'] ) ) {
-                       $vals['diffsize'] = strlen( $difftext );
+                       $vals['diffsize'] = 0;
+                       foreach ( (array)$difftext as $text ) {
+                               $vals['diffsize'] += strlen( $text );
+                       }
                }
                if ( isset( $this->props['diff'] ) ) {
-                       ApiResult::setContentValue( $vals, 'body', $difftext );
+                       if ( is_array( $difftext ) ) {
+                               ApiResult::setArrayType( $difftext, 'kvp', 'diff' );
+                               $vals['bodies'] = $difftext;
+                       } else {
+                               ApiResult::setContentValue( $vals, 'body', $difftext );
+                       }
                }
 
                // Diffs can be really big and there's little point in having
@@ -174,49 +177,55 @@ class ApiComparePages extends ApiBase {
        }
 
        /**
-        * Guess an appropriate default Title and content model for this request
+        * Load a revision by ID
         *
-        * Fills in $this->guessedTitle based on the first of 'fromrev',
-        * 'fromtitle', 'fromid', 'torev', 'totitle', and 'toid' that's present and
-        * valid.
+        * Falls back to checking the archive table if appropriate.
+        *
+        * @param int $id
+        * @return RevisionRecord|null
+        */
+       private function getRevisionById( $id ) {
+               $rev = $this->revisionStore->getRevisionById( $id );
+               if ( !$rev && $this->getUser()->isAllowedAny( 'deletedtext', 'undelete' ) ) {
+                       // Try the 'archive' table
+                       $arQuery = $this->revisionStore->getArchiveQueryInfo();
+                       $row = $this->getDB()->selectRow(
+                               $arQuery['tables'],
+                               array_merge(
+                                       $arQuery['fields'],
+                                       [ 'ar_namespace', 'ar_title' ]
+                               ),
+                               [ 'ar_rev_id' => $id ],
+                               __METHOD__,
+                               [],
+                               $arQuery['joins']
+                       );
+                       if ( $row ) {
+                               $rev = $this->revisionStore->newRevisionFromArchiveRow( $row );
+                               $rev->isArchive = true;
+                       }
+               }
+               return $rev;
+       }
+
+       /**
+        * Guess an appropriate default Title for this request
         *
-        * Fills in $this->guessedModel based on the Revision or Title used to
-        * determine $this->guessedTitle, or the 'fromcontentmodel' or
-        * 'tocontentmodel' parameters if no title was guessed.
+        * @return Title|null
         */
-       private function guessTitleAndModel() {
-               if ( $this->guessed ) {
-                       return;
+       private function guessTitle() {
+               if ( $this->guessedTitle !== false ) {
+                       return $this->guessedTitle;
                }
 
-               $this->guessed = true;
+               $this->guessedTitle = null;
                $params = $this->extractRequestParams();
 
                foreach ( [ 'from', 'to' ] as $prefix ) {
                        if ( $params["{$prefix}rev"] !== null ) {
-                               $revId = $params["{$prefix}rev"];
-                               $rev = Revision::newFromId( $revId );
-                               if ( !$rev ) {
-                                       // Titles of deleted revisions aren't secret, per T51088
-                                       $arQuery = Revision::getArchiveQueryInfo();
-                                       $row = $this->getDB()->selectRow(
-                                               $arQuery['tables'],
-                                               array_merge(
-                                                       $arQuery['fields'],
-                                                       [ 'ar_namespace', 'ar_title' ]
-                                               ),
-                                               [ 'ar_rev_id' => $revId ],
-                                               __METHOD__,
-                                               [],
-                                               $arQuery['joins']
-                                       );
-                                       if ( $row ) {
-                                               $rev = Revision::newFromArchiveRow( $row );
-                                       }
-                               }
+                               $rev = $this->getRevisionById( $params["{$prefix}rev"] );
                                if ( $rev ) {
-                                       $this->guessedTitle = $rev->getTitle();
-                                       $this->guessedModel = $rev->getContentModel();
+                                       $this->guessedTitle = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
                                        break;
                                }
                        }
@@ -238,38 +247,84 @@ class ApiComparePages extends ApiBase {
                        }
                }
 
-               if ( !$this->guessedModel ) {
-                       if ( $this->guessedTitle ) {
-                               $this->guessedModel = $this->guessedTitle->getContentModel();
-                       } elseif ( $params['fromcontentmodel'] !== null ) {
-                               $this->guessedModel = $params['fromcontentmodel'];
-                       } elseif ( $params['tocontentmodel'] !== null ) {
-                               $this->guessedModel = $params['tocontentmodel'];
+               return $this->guessedTitle;
+       }
+
+       /**
+        * Guess an appropriate default content model for this request
+        * @param string $role Slot for which to guess the model
+        * @return string|null Guessed content model
+        */
+       private function guessModel( $role ) {
+               $params = $this->extractRequestParams();
+
+               $title = null;
+               foreach ( [ 'from', 'to' ] as $prefix ) {
+                       if ( $params["{$prefix}rev"] !== null ) {
+                               $rev = $this->getRevisionById( $params["{$prefix}rev"] );
+                               if ( $rev ) {
+                                       if ( $rev->hasSlot( $role ) ) {
+                                               return $rev->getSlot( $role, RevisionRecord::RAW )->getModel();
+                                       }
+                               }
+                       }
+               }
+
+               $guessedTitle = $this->guessTitle();
+               if ( $guessedTitle && $role === 'main' ) {
+                       // @todo: Use SlotRoleRegistry and do this for all slots
+                       return $guessedTitle->getContentModel();
+               }
+
+               if ( isset( $params["fromcontentmodel-$role"] ) ) {
+                       return $params["fromcontentmodel-$role"];
+               }
+               if ( isset( $params["tocontentmodel-$role"] ) ) {
+                       return $params["tocontentmodel-$role"];
+               }
+
+               if ( $role === 'main' ) {
+                       if ( isset( $params['fromcontentmodel'] ) ) {
+                               return $params['fromcontentmodel'];
+                       }
+                       if ( isset( $params['tocontentmodel'] ) ) {
+                               return $params['tocontentmodel'];
                        }
                }
+
+               return null;
        }
 
        /**
-        * Get the Revision and Content for one side of the diff
+        * Get the RevisionRecord for one side of the diff
         *
-        * This uses the appropriate set of 'rev', 'id', 'title', 'text', 'pst',
-        * 'contentmodel', and 'contentformat' parameters to determine what content
+        * This uses the appropriate set of parameters to determine what content
         * should be diffed.
         *
         * Returns three values:
-        * - The revision used to retrieve the content, if any
-        * - The content to be diffed
-        * - The revision specified, if any, even if not used to retrieve the
-        *   Content
+        * - A RevisionRecord holding the content
+        * - The revision specified, if any, even if content was supplied
+        * - The revision to pass to setVals(), if any
         *
         * @param string $prefix 'from' or 'to'
         * @param array $params
-        * @return array [ Revision|null, Content, Revision|null ]
+        * @return array [ RevisionRecord|null, RevisionRecord|null, RevisionRecord|null ]
         */
-       private function getDiffContent( $prefix, array $params ) {
+       private function getDiffRevision( $prefix, array $params ) {
+               // Back compat params
+               $this->requireMaxOneParameter( $params, "{$prefix}text", "{$prefix}slots" );
+               $this->requireMaxOneParameter( $params, "{$prefix}section", "{$prefix}slots" );
+               if ( $params["{$prefix}text"] !== null ) {
+                       $params["{$prefix}slots"] = [ 'main' ];
+                       $params["{$prefix}text-main"] = $params["{$prefix}text"];
+                       $params["{$prefix}section-main"] = null;
+                       $params["{$prefix}contentmodel-main"] = $params["{$prefix}contentmodel"];
+                       $params["{$prefix}contentformat-main"] = $params["{$prefix}contentformat"];
+               }
+
                $title = null;
                $rev = null;
-               $suppliedContent = $params["{$prefix}text"] !== null;
+               $suppliedContent = $params["{$prefix}slots"] !== null;
 
                // Get the revision and title, if applicable
                $revId = null;
@@ -308,94 +363,146 @@ class ApiComparePages extends ApiBase {
                        }
                }
                if ( $revId !== null ) {
-                       $rev = Revision::newFromId( $revId );
-                       if ( !$rev && $this->getUser()->isAllowedAny( 'deletedtext', 'undelete' ) ) {
-                               // Try the 'archive' table
-                               $arQuery = Revision::getArchiveQueryInfo();
-                               $row = $this->getDB()->selectRow(
-                                       $arQuery['tables'],
-                                       array_merge(
-                                               $arQuery['fields'],
-                                               [ 'ar_namespace', 'ar_title' ]
-                                       ),
-                                       [ 'ar_rev_id' => $revId ],
-                                       __METHOD__,
-                                       [],
-                                       $arQuery['joins']
-                               );
-                               if ( $row ) {
-                                       $rev = Revision::newFromArchiveRow( $row );
-                                       $rev->isArchive = true;
-                               }
-                       }
+                       $rev = $this->getRevisionById( $revId );
                        if ( !$rev ) {
                                $this->dieWithError( [ 'apierror-nosuchrevid', $revId ] );
                        }
-                       $title = $rev->getTitle();
+                       $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
 
                        // If we don't have supplied content, return here. Otherwise,
                        // continue on below with the supplied content.
                        if ( !$suppliedContent ) {
-                               $content = $rev->getContent( Revision::FOR_THIS_USER, $this->getUser() );
-                               if ( !$content ) {
-                                       $this->dieWithError( [ 'apierror-missingcontent-revid', $revId ], 'missingcontent' );
+                               $newRev = $rev;
+
+                               // Deprecated 'fromsection'/'tosection'
+                               if ( isset( $params["{$prefix}section"] ) ) {
+                                       $section = $params["{$prefix}section"];
+                                       $newRev = MutableRevisionRecord::newFromParentRevision( $rev );
+                                       $content = $rev->getContent( 'main', RevisionRecord::FOR_THIS_USER, $this->getUser() );
+                                       if ( !$content ) {
+                                               $this->dieWithError(
+                                                       [ 'apierror-missingcontent-revid-role', $rev->getId(), 'main' ], 'missingcontent'
+                                               );
+                                       }
+                                       $content = $content ? $content->getSection( $section ) : null;
+                                       if ( !$content ) {
+                                               $this->dieWithError(
+                                                       [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ],
+                                                       "nosuch{$prefix}section"
+                                               );
+                                       }
+                                       $newRev->setContent( 'main', $content );
                                }
-                               return [ $rev, $content, $rev ];
+
+                               return [ $newRev, $rev, $rev ];
                        }
                }
 
                // Override $content based on supplied text
-               $model = $params["{$prefix}contentmodel"];
-               $format = $params["{$prefix}contentformat"];
-
-               if ( !$model && $rev ) {
-                       $model = $rev->getContentModel();
-               }
-               if ( !$model && $title ) {
-                       $model = $title->getContentModel();
-               }
-               if ( !$model ) {
-                       $this->guessTitleAndModel();
-                       $model = $this->guessedModel;
-               }
-               if ( !$model ) {
-                       $model = CONTENT_MODEL_WIKITEXT;
-                       $this->addWarning( [ 'apiwarn-compare-nocontentmodel', $model ] );
-               }
-
                if ( !$title ) {
-                       $this->guessTitleAndModel();
-                       $title = $this->guessedTitle;
+                       $title = $this->guessTitle();
                }
-
-               try {
-                       $content = ContentHandler::makeContent( $params["{$prefix}text"], $title, $model, $format );
-               } catch ( MWContentSerializationException $ex ) {
-                       $this->dieWithException( $ex, [
-                               'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
+               if ( $rev ) {
+                       $newRev = MutableRevisionRecord::newFromParentRevision( $rev );
+               } else {
+                       $newRev = $this->revisionStore->newMutableRevisionFromArray( [
+                               'title' => $title ?: Title::makeTitle( NS_SPECIAL, 'Badtitle/' . __METHOD__ )
                        ] );
                }
+               foreach ( $params["{$prefix}slots"] as $role ) {
+                       $text = $params["{$prefix}text-{$role}"];
+                       if ( $text === null ) {
+                               $newRev->removeSlot( $role );
+                               continue;
+                       }
+
+                       $model = $params["{$prefix}contentmodel-{$role}"];
+                       $format = $params["{$prefix}contentformat-{$role}"];
 
-               if ( $params["{$prefix}pst"] ) {
-                       if ( !$title ) {
-                               $this->dieWithError( 'apierror-compare-no-title' );
+                       if ( !$model && $rev && $rev->hasSlot( $role ) ) {
+                               $model = $rev->getSlot( $role, RevisionRecord::RAW )->getModel();
+                       }
+                       if ( !$model && $title && $role === 'main' ) {
+                               // @todo: Use SlotRoleRegistry and do this for all slots
+                               $model = $title->getContentModel();
+                       }
+                       if ( !$model ) {
+                               $model = $this->guessModel( $role );
+                       }
+                       if ( !$model ) {
+                               $model = CONTENT_MODEL_WIKITEXT;
+                               $this->addWarning( [ 'apiwarn-compare-nocontentmodel', $model ] );
+                       }
+
+                       try {
+                               $content = ContentHandler::makeContent( $text, $title, $model, $format );
+                       } catch ( MWContentSerializationException $ex ) {
+                               $this->dieWithException( $ex, [
+                                       'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
+                               ] );
+                       }
+
+                       if ( $params["{$prefix}pst"] ) {
+                               if ( !$title ) {
+                                       $this->dieWithError( 'apierror-compare-no-title' );
+                               }
+                               $popts = ParserOptions::newFromContext( $this->getContext() );
+                               $content = $content->preSaveTransform( $title, $this->getUser(), $popts );
+                       }
+
+                       $section = $params["{$prefix}section-{$role}"];
+                       if ( $section !== null && $section !== '' ) {
+                               if ( !$rev ) {
+                                       $this->dieWithError( "apierror-compare-no{$prefix}revision" );
+                               }
+                               $oldContent = $rev->getContent( $role, RevisionRecord::FOR_THIS_USER, $this->getUser() );
+                               if ( !$oldContent ) {
+                                       $this->dieWithError(
+                                               [ 'apierror-missingcontent-revid-role', $rev->getId(), wfEscapeWikiText( $role ) ],
+                                               'missingcontent'
+                                       );
+                               }
+                               if ( !$oldContent->getContentHandler()->supportsSections() ) {
+                                       $this->dieWithError( [ 'apierror-sectionsnotsupported', $content->getModel() ] );
+                               }
+                               try {
+                                       $content = $oldContent->replaceSection( $section, $content, '' );
+                               } catch ( Exception $ex ) {
+                                       // Probably a content model mismatch.
+                                       $content = null;
+                               }
+                               if ( !$content ) {
+                                       $this->dieWithError( [ 'apierror-sectionreplacefailed' ] );
+                               }
+                       }
+
+                       // Deprecated 'fromsection'/'tosection'
+                       if ( $role === 'main' && isset( $params["{$prefix}section"] ) ) {
+                               $section = $params["{$prefix}section"];
+                               $content = $content->getSection( $section );
+                               if ( !$content ) {
+                                       $this->dieWithError(
+                                               [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ],
+                                               "nosuch{$prefix}section"
+                                       );
+                               }
                        }
-                       $popts = ParserOptions::newFromContext( $this->getContext() );
-                       $content = $content->preSaveTransform( $title, $this->getUser(), $popts );
-               }
 
-               return [ null, $content, $rev ];
+                       $newRev->setContent( $role, $content );
+               }
+               return [ $newRev, $rev, null ];
        }
 
        /**
-        * Set value fields from a Revision object
+        * Set value fields from a RevisionRecord object
+        *
         * @param array &$vals Result array to set data into
         * @param string $prefix 'from' or 'to'
-        * @param Revision|null $rev
+        * @param RevisionRecord|null $rev
         */
        private function setVals( &$vals, $prefix, $rev ) {
                if ( $rev ) {
-                       $title = $rev->getTitle();
+                       $title = $rev->getPageAsLinkTarget();
                        if ( isset( $this->props['ids'] ) ) {
                                $vals["{$prefix}id"] = $title->getArticleID();
                                $vals["{$prefix}revid"] = $rev->getId();
@@ -408,41 +515,42 @@ class ApiComparePages extends ApiBase {
                        }
 
                        $anyHidden = false;
-                       if ( $rev->isDeleted( Revision::DELETED_TEXT ) ) {
+                       if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
                                $vals["{$prefix}texthidden"] = true;
                                $anyHidden = true;
                        }
 
-                       if ( $rev->isDeleted( Revision::DELETED_USER ) ) {
+                       if ( $rev->isDeleted( RevisionRecord::DELETED_USER ) ) {
                                $vals["{$prefix}userhidden"] = true;
                                $anyHidden = true;
                        }
-                       if ( isset( $this->props['user'] ) &&
-                               $rev->userCan( Revision::DELETED_USER, $this->getUser() )
-                       ) {
-                               $vals["{$prefix}user"] = $rev->getUserText( Revision::RAW );
-                               $vals["{$prefix}userid"] = $rev->getUser( Revision::RAW );
+                       if ( isset( $this->props['user'] ) ) {
+                               $user = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->getUser() );
+                               if ( $user ) {
+                                       $vals["{$prefix}user"] = $user->getName();
+                                       $vals["{$prefix}userid"] = $user->getId();
+                               }
                        }
 
-                       if ( $rev->isDeleted( Revision::DELETED_COMMENT ) ) {
+                       if ( $rev->isDeleted( RevisionRecord::DELETED_COMMENT ) ) {
                                $vals["{$prefix}commenthidden"] = true;
                                $anyHidden = true;
                        }
-                       if ( $rev->userCan( Revision::DELETED_COMMENT, $this->getUser() ) ) {
-                               if ( isset( $this->props['comment'] ) ) {
-                                       $vals["{$prefix}comment"] = $rev->getComment( Revision::RAW );
-                               }
-                               if ( isset( $this->props['parsedcomment'] ) ) {
+                       if ( isset( $this->props['comment'] ) || isset( $this->props['parsedcomment'] ) ) {
+                               $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->getUser() );
+                               if ( $comment !== null ) {
+                                       if ( isset( $this->props['comment'] ) ) {
+                                               $vals["{$prefix}comment"] = $comment->text;
+                                       }
                                        $vals["{$prefix}parsedcomment"] = Linker::formatComment(
-                                               $rev->getComment( Revision::RAW ),
-                                               $rev->getTitle()
+                                               $comment->text, Title::newFromLinkTarget( $title )
                                        );
                                }
                        }
 
                        if ( $anyHidden ) {
                                $this->getMain()->setCacheMode( 'private' );
-                               if ( $rev->isDeleted( Revision::DELETED_RESTRICTED ) ) {
+                               if ( $rev->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) {
                                        $vals["{$prefix}suppressed"] = true;
                                }
                        }
@@ -455,6 +563,12 @@ class ApiComparePages extends ApiBase {
        }
 
        public function getAllowedParams() {
+               $slotRoles = MediaWikiServices::getInstance()->getSlotRoleStore()->getMap();
+               if ( !in_array( 'main', $slotRoles, true ) ) {
+                       $slotRoles[] = 'main';
+               }
+               sort( $slotRoles, SORT_STRING );
+
                // Parameters for the 'from' and 'to' content
                $fromToParams = [
                        'title' => null,
@@ -464,24 +578,58 @@ class ApiComparePages extends ApiBase {
                        'rev' => [
                                ApiBase::PARAM_TYPE => 'integer'
                        ],
-                       'text' => [
-                               ApiBase::PARAM_TYPE => 'text'
+
+                       'slots' => [
+                               ApiBase::PARAM_TYPE => $slotRoles,
+                               ApiBase::PARAM_ISMULTI => true,
+                       ],
+                       'text-{slot}' => [
+                               ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below
+                               ApiBase::PARAM_TYPE => 'text',
+                       ],
+                       'section-{slot}' => [
+                               ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below
+                               ApiBase::PARAM_TYPE => 'string',
+                       ],
+                       'contentformat-{slot}' => [
+                               ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below
+                               ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
+                       ],
+                       'contentmodel-{slot}' => [
+                               ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below
+                               ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
                        ],
-                       'section' => null,
                        'pst' => false,
+
+                       'text' => [
+                               ApiBase::PARAM_TYPE => 'text',
+                               ApiBase::PARAM_DEPRECATED => true,
+                       ],
                        'contentformat' => [
                                ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
+                               ApiBase::PARAM_DEPRECATED => true,
                        ],
                        'contentmodel' => [
                                ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
-                       ]
+                               ApiBase::PARAM_DEPRECATED => true,
+                       ],
+                       'section' => [
+                               ApiBase::PARAM_DFLT => null,
+                               ApiBase::PARAM_DEPRECATED => true,
+                       ],
                ];
 
                $ret = [];
                foreach ( $fromToParams as $k => $v ) {
+                       if ( isset( $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] ) ) {
+                               $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] = 'fromslots';
+                       }
                        $ret["from$k"] = $v;
                }
                foreach ( $fromToParams as $k => $v ) {
+                       if ( isset( $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] ) ) {
+                               $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] = 'toslots';
+                       }
                        $ret["to$k"] = $v;
                }
 
@@ -508,6 +656,12 @@ class ApiComparePages extends ApiBase {
                        ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
                ];
 
+               $ret['slots'] = [
+                       ApiBase::PARAM_TYPE => $slotRoles,
+                       ApiBase::PARAM_ISMULTI => true,
+                       ApiBase::PARAM_ALL => true,
+               ];
+
                return $ret;
        }
 
index 03d2952..3b305f9 100644 (file)
@@ -534,7 +534,11 @@ class ApiMain extends ApiBase {
                        MediaWikiServices::getInstance()->getStatsdDataFactory()->timing(
                                'api.' . $this->mModule->getModuleName() . '.executeTiming', 1000 * $runTime
                        );
-               } catch ( Exception $e ) {
+               } catch ( Exception $e ) { // @todo Remove this block when HHVM is no longer supported
+                       $this->handleException( $e );
+                       $this->logRequest( microtime( true ) - $t, $e );
+                       $isError = true;
+               } catch ( Throwable $e ) {
                        $this->handleException( $e );
                        $this->logRequest( microtime( true ) - $t, $e );
                        $isError = true;
@@ -558,9 +562,9 @@ class ApiMain extends ApiBase {
         * Handle an exception as an API response
         *
         * @since 1.23
-        * @param Exception $e
+        * @param Exception|Throwable $e
         */
-       protected function handleException( Exception $e ) {
+       protected function handleException( $e ) {
                // T65145: Rollback any open database transactions
                if ( !( $e instanceof ApiUsageException || $e instanceof UsageException ) ) {
                        // UsageExceptions are intentional, so don't rollback if that's the case
@@ -600,7 +604,10 @@ class ApiMain extends ApiBase {
                        foreach ( $ex->getStatusValue()->getErrors() as $error ) {
                                try {
                                        $this->mPrinter->addWarning( $error );
-                               } catch ( Exception $ex2 ) {
+                               } catch ( Exception $ex2 ) { // @todo Remove this block when HHVM is no longer supported
+                                       // WTF?
+                                       $this->addWarning( $error );
+                               } catch ( Throwable $ex2 ) {
                                        // WTF?
                                        $this->addWarning( $error );
                                }
@@ -631,17 +638,20 @@ class ApiMain extends ApiBase {
         * friendly to clients. If it fails, it will rethrow the exception.
         *
         * @since 1.23
-        * @param Exception $e
-        * @throws Exception
+        * @param Exception|Throwable $e
+        * @throws Exception|Throwable
         */
-       public static function handleApiBeforeMainException( Exception $e ) {
+       public static function handleApiBeforeMainException( $e ) {
                ob_start();
 
                try {
                        $main = new self( RequestContext::getMain(), false );
                        $main->handleException( $e );
                        $main->logRequest( 0, $e );
-               } catch ( Exception $e2 ) {
+               } catch ( Exception $e2 ) { // @todo Remove this block when HHVM is no longer supported
+                       // Nope, even that didn't work. Punt.
+                       throw $e;
+               } catch ( Throwable $e2 ) {
                        // Nope, even that didn't work. Punt.
                        throw $e;
                }
@@ -1009,7 +1019,7 @@ class ApiMain extends ApiBase {
         * text around the exception's (presumably English) message as a single
         * error (no warnings).
         *
-        * @param Exception $e
+        * @param Exception|Throwable $e
         * @param string $type 'error' or 'warning'
         * @return ApiMessage[]
         * @since 1.27
@@ -1054,7 +1064,7 @@ class ApiMain extends ApiBase {
 
        /**
         * Replace the result data with the information about an exception.
-        * @param Exception $e
+        * @param Exception|Throwable $e
         * @return string[] Error codes
         */
        protected function substituteResultWithError( $e ) {
@@ -1609,7 +1619,7 @@ class ApiMain extends ApiBase {
        /**
         * Log the preceding request
         * @param float $time Time in seconds
-        * @param Exception|null $e Exception caught while processing the request
+        * @param Exception|Throwable|null $e Exception caught while processing the request
         */
        protected function logRequest( $time, $e = null ) {
                $request = $this->getRequest();
index a3e3e57..ab9ae8e 100644 (file)
@@ -234,6 +234,7 @@ class ApiStashEdit extends ApiBase {
                                        return self::ERROR_CACHE;
                                }
                        } else {
+                               // @todo Doesn't seem reachable, see @todo in buildStashValue
                                $logger->info( "Uncacheable parser output for key '{cachekey}' ('{title}') [{code}].",
                                        [ 'cachekey' => $key, 'title' => $titleStr, 'code' => $code ] );
                                return self::ERROR_UNCACHEABLE;
@@ -410,6 +411,9 @@ class ApiStashEdit extends ApiBase {
                }
 
                if ( $ttl <= 0 ) {
+                       // @todo It doesn't seem like this can occur, because it would mean an entry older than
+                       // getCacheExpiry() seconds, which is much longer than PRESUME_FRESH_TTL_SEC, and
+                       // anything older than PRESUME_FRESH_TTL_SEC will have been thrown out already.
                        return [ null, 0, 'no_ttl' ];
                }
 
index 3c74f25..e29ddf9 100644 (file)
        "apihelp-compare-param-fromtitle": "First title to compare.",
        "apihelp-compare-param-fromid": "First page ID to compare.",
        "apihelp-compare-param-fromrev": "First revision to compare.",
-       "apihelp-compare-param-fromtext": "Use this text instead of the content of the revision specified by <var>fromtitle</var>, <var>fromid</var> or <var>fromrev</var>.",
+       "apihelp-compare-param-frompst": "Do a pre-save transform on <var>fromtext-&#x7B;slot}</var>.",
+       "apihelp-compare-param-fromslots": "Override content of the revision specified by <var>fromtitle</var>, <var>fromid</var> or <var>fromrev</var>.\n\nThis parameter specifies the slots that are to be modified. Use <var>fromtext-&#x7B;slot}</var>, <var>fromcontentmodel-&#x7B;slot}</var>, and <var>fromcontentformat-&#x7B;slot}</var> to specify content for each slot.",
+       "apihelp-compare-param-fromtext-{slot}": "Text of the specified slot. If omitted, the slot is removed from the revision.",
+       "apihelp-compare-param-fromsection-{slot}": "When <var>fromtext-&#x7B;slot}</var> is the content of a single section, this is the section number. It will be merged into the revision specified by <var>fromtitle</var>, <var>fromid</var> or <var>fromrev</var> as if for a section edit.",
+       "apihelp-compare-param-fromcontentmodel-{slot}": "Content model of <var>fromtext-&#x7B;slot}</var>. If not supplied, it will be guessed based on the other parameters.",
+       "apihelp-compare-param-fromcontentformat-{slot}": "Content serialization format of <var>fromtext-&#x7B;slot}</var>.",
+       "apihelp-compare-param-fromtext": "Specify <kbd>fromslots=main</kbd> and use <var>fromtext-main</var> instead.",
+       "apihelp-compare-param-fromcontentmodel": "Specify <kbd>fromslots=main</kbd> and use <var>fromcontentmodel-main</var> instead.",
+       "apihelp-compare-param-fromcontentformat": "Specify <kbd>fromslots=main</kbd> and use <var>fromcontentformat-main</var> instead.",
        "apihelp-compare-param-fromsection": "Only use the specified section of the specified 'from' content.",
-       "apihelp-compare-param-frompst": "Do a pre-save transform on <var>fromtext</var>.",
-       "apihelp-compare-param-fromcontentmodel": "Content model of <var>fromtext</var>. If not supplied, it will be guessed based on the other parameters.",
-       "apihelp-compare-param-fromcontentformat": "Content serialization format of <var>fromtext</var>.",
        "apihelp-compare-param-totitle": "Second title to compare.",
        "apihelp-compare-param-toid": "Second page ID to compare.",
        "apihelp-compare-param-torev": "Second revision to compare.",
        "apihelp-compare-param-torelative": "Use a revision relative to the revision determined from <var>fromtitle</var>, <var>fromid</var> or <var>fromrev</var>. All of the other 'to' options will be ignored.",
-       "apihelp-compare-param-totext": "Use this text instead of the content of the revision specified by <var>totitle</var>, <var>toid</var> or <var>torev</var>.",
-       "apihelp-compare-param-tosection": "Only use the specified section of the specified 'to' content.",
        "apihelp-compare-param-topst": "Do a pre-save transform on <var>totext</var>.",
-       "apihelp-compare-param-tocontentmodel": "Content model of <var>totext</var>. If not supplied, it will be guessed based on the other parameters.",
-       "apihelp-compare-param-tocontentformat": "Content serialization format of <var>totext</var>.",
+       "apihelp-compare-param-toslots": "Override content of the revision specified by <var>totitle</var>, <var>toid</var> or <var>torev</var>.\n\nThis parameter specifies the slots that are to be modified. Use <var>totext-&#x7B;slot}</var>, <var>tocontentmodel-&#x7B;slot}</var>, and <var>tocontentformat-&#x7B;slot}</var> to specify content for each slot.",
+       "apihelp-compare-param-totext-{slot}": "Text of the specified slot. If omitted, the slot is removed from the revision.",
+       "apihelp-compare-param-tosection-{slot}": "When <var>totext-&#x7B;slot}</var> is the content of a single section, this is the section number. It will be merged into the revision specified by <var>totitle</var>, <var>toid</var> or <var>torev</var> as if for a section edit.",
+       "apihelp-compare-param-toslots": "Specify content to use instead of the content of the revision specified by <var>totitle</var>, <var>toid</var> or <var>torev</var>.\n\nThis parameter specifies the slots that have content. Use <var>totext-&#x7B;slot}</var>, <var>tocontentmodel-&#x7B;slot}</var>, and <var>tocontentformat-&#x7B;slot}</var> to specify content for each slot.",
+       "apihelp-compare-param-totext-{slot}": "Text of the specified slot.",
+       "apihelp-compare-param-tocontentmodel-{slot}": "Content model of <var>totext-&#x7B;slot}</var>. If not supplied, it will be guessed based on the other parameters.",
+       "apihelp-compare-param-tocontentformat-{slot}": "Content serialization format of <var>totext-&#x7B;slot}</var>.",
+       "apihelp-compare-param-totext": "Specify <kbd>toslots=main</kbd> and use <var>totext-main</var> instead.",
+       "apihelp-compare-param-tocontentmodel": "Specify <kbd>toslots=main</kbd> and use <var>tocontentmodel-main</var> instead.",
+       "apihelp-compare-param-tocontentformat": "Specify <kbd>toslots=main</kbd> and use <var>tocontentformat-main</var> instead.",
+       "apihelp-compare-param-tosection": "Only use the specified section of the specified 'to' content.",
        "apihelp-compare-param-prop": "Which pieces of information to get.",
        "apihelp-compare-paramvalue-prop-diff": "The diff HTML.",
        "apihelp-compare-paramvalue-prop-diffsize": "The size of the diff HTML, in bytes.",
        "apihelp-compare-paramvalue-prop-comment": "The comment on the 'from' and 'to' revisions.",
        "apihelp-compare-paramvalue-prop-parsedcomment": "The parsed comment on the 'from' and 'to' revisions.",
        "apihelp-compare-paramvalue-prop-size": "The size of the 'from' and 'to' revisions.",
+       "apihelp-compare-param-slots": "Return individual diffs for these slots, rather than one combined diff for all slots.",
        "apihelp-compare-example-1": "Create a diff between revision 1 and 2.",
 
        "apihelp-createaccount-summary": "Create a new user account.",
        "apierror-compare-no-title": "Cannot pre-save transform without a title. Try specifying <var>fromtitle</var> or <var>totitle</var>.",
        "apierror-compare-nosuchfromsection": "There is no section $1 in the 'from' content.",
        "apierror-compare-nosuchtosection": "There is no section $1 in the 'to' content.",
+       "apierror-compare-nofromrevision": "No 'from' revision. Specify <var>fromrev</var>, <var>fromtitle</var>, or <var>fromid</var>.",
+       "apierror-compare-notorevision": "No 'to' revision. Specify <var>torev</var>, <var>totitle</var>, or <var>toid</var>.",
        "apierror-compare-relative-to-nothing": "No 'from' revision for <var>torelative</var> to be relative to.",
        "apierror-contentserializationexception": "Content serialization failed: $1",
        "apierror-contenttoobig": "The content you supplied exceeds the article size limit of $1 {{PLURAL:$1|kilobyte|kilobytes}}.",
        "apierror-mimesearchdisabled": "MIME search is disabled in Miser Mode.",
        "apierror-missingcontent-pageid": "Missing content for page ID $1.",
        "apierror-missingcontent-revid": "Missing content for revision ID $1.",
+       "apierror-missingcontent-revid-role": "Missing content for revision ID $1 for role $2.",
        "apierror-missingparam-at-least-one-of": "{{PLURAL:$2|The parameter|At least one of the parameters}} $1 is required.",
        "apierror-missingparam-one-of": "{{PLURAL:$2|The parameter|One of the parameters}} $1 is required.",
        "apierror-missingparam": "The <var>$1</var> parameter must be set.",
index f158f27..e58683a 100644 (file)
        "apihelp-clientlogin-example-login2": "{{doc-apihelp-example|clientlogin}}",
        "apihelp-compare-summary": "{{doc-apihelp-summary|compare}}",
        "apihelp-compare-extended-description": "{{doc-apihelp-extended-description|compare}}",
-       "apihelp-compare-param-fromtitle": "{{doc-apihelp-param|compare|fromtitle}}",
+       "apihelp-compare-param-fromcontentformat": "{{doc-apihelp-param|compare|fromcontentformat}}",
+       "apihelp-compare-param-fromcontentformat-{slot}": "{{doc-apihelp-param|compare|fromcontentformat-&#x7B;slot} }}",
+       "apihelp-compare-param-fromcontentmodel": "{{doc-apihelp-param|compare|fromcontentmodel}}",
+       "apihelp-compare-param-fromcontentmodel-{slot}": "{{doc-apihelp-param|compare|fromcontentmodel-&#x7B;slot} }}",
        "apihelp-compare-param-fromid": "{{doc-apihelp-param|compare|fromid}}",
+       "apihelp-compare-param-frompst": "{{doc-apihelp-param|compare|frompst}}",
        "apihelp-compare-param-fromrev": "{{doc-apihelp-param|compare|fromrev}}",
-       "apihelp-compare-param-fromtext": "{{doc-apihelp-param|compare|fromtext}}",
        "apihelp-compare-param-fromsection": "{{doc-apihelp-param|compare|fromsection}}",
-       "apihelp-compare-param-frompst": "{{doc-apihelp-param|compare|frompst}}",
-       "apihelp-compare-param-fromcontentmodel": "{{doc-apihelp-param|compare|fromcontentmodel}}",
-       "apihelp-compare-param-fromcontentformat": "{{doc-apihelp-param|compare|fromcontentformat}}",
-       "apihelp-compare-param-totitle": "{{doc-apihelp-param|compare|totitle}}",
+       "apihelp-compare-param-fromsection-{slot}": "{{doc-apihelp-param|compare|fromsection-&#x7B;slot} }}",
+       "apihelp-compare-param-fromslots": "{{doc-apihelp-param|compare|fromslots}}",
+       "apihelp-compare-param-fromtext": "{{doc-apihelp-param|compare|fromtext}}",
+       "apihelp-compare-param-fromtext-{slot}": "{{doc-apihelp-param|compare|fromtext-&#x7B;slot} }}",
+       "apihelp-compare-param-fromtitle": "{{doc-apihelp-param|compare|fromtitle}}",
+       "apihelp-compare-param-tocontentformat": "{{doc-apihelp-param|compare|tocontentformat}}",
+       "apihelp-compare-param-tocontentformat-{slot}": "{{doc-apihelp-param|compare|tocontentformat-&#x7B;slot} }}",
+       "apihelp-compare-param-tocontentmodel": "{{doc-apihelp-param|compare|tocontentmodel}}",
+       "apihelp-compare-param-tocontentmodel-{slot}": "{{doc-apihelp-param|compare|tocontentmodel-&#x7B;slot} }}",
        "apihelp-compare-param-toid": "{{doc-apihelp-param|compare|toid}}",
-       "apihelp-compare-param-torev": "{{doc-apihelp-param|compare|torev}}",
+       "apihelp-compare-param-topst": "{{doc-apihelp-param|compare|topst}}",
        "apihelp-compare-param-torelative": "{{doc-apihelp-param|compare|torelative}}",
-       "apihelp-compare-param-totext": "{{doc-apihelp-param|compare|totext}}",
+       "apihelp-compare-param-torev": "{{doc-apihelp-param|compare|torev}}",
        "apihelp-compare-param-tosection": "{{doc-apihelp-param|compare|tosection}}",
-       "apihelp-compare-param-topst": "{{doc-apihelp-param|compare|topst}}",
-       "apihelp-compare-param-tocontentmodel": "{{doc-apihelp-param|compare|tocontentmodel}}",
-       "apihelp-compare-param-tocontentformat": "{{doc-apihelp-param|compare|tocontentformat}}",
+       "apihelp-compare-param-tosection-{slot}": "{{doc-apihelp-param|compare|tosection-&#x7B;slot} }}",
+       "apihelp-compare-param-toslots": "{{doc-apihelp-param|compare|toslots}}",
+       "apihelp-compare-param-totext": "{{doc-apihelp-param|compare|totext}}",
+       "apihelp-compare-param-totext-{slot}": "{{doc-apihelp-param|compare|totext-&#x7B;slot} }}",
+       "apihelp-compare-param-totitle": "{{doc-apihelp-param|compare|totitle}}",
        "apihelp-compare-param-prop": "{{doc-apihelp-param|compare|prop}}",
        "apihelp-compare-paramvalue-prop-diff": "{{doc-apihelp-paramvalue|compare|prop|diff}}",
        "apihelp-compare-paramvalue-prop-diffsize": "{{doc-apihelp-paramvalue|compare|prop|diffsize}}",
        "apihelp-compare-paramvalue-prop-comment": "{{doc-apihelp-paramvalue|compare|prop|comment}}",
        "apihelp-compare-paramvalue-prop-parsedcomment": "{{doc-apihelp-paramvalue|compare|prop|parsedcomment}}",
        "apihelp-compare-paramvalue-prop-size": "{{doc-apihelp-paramvalue|compare|prop|size}}",
+       "apihelp-compare-param-slots": "{{doc-apihelp-param|compare|slots}}",
        "apihelp-compare-example-1": "{{doc-apihelp-example|compare}}",
        "apihelp-createaccount-summary": "{{doc-apihelp-summary|createaccount}}",
        "apihelp-createaccount-param-preservestate": "{{doc-apihelp-param|createaccount|preservestate|info=This message is displayed in addition to {{msg-mw|api-help-authmanagerhelper-preservestate}}.}}",
        "apierror-chunk-too-small": "{{doc-apierror}}\n\nParameters:\n* $1 - Minimum size in bytes.",
        "apierror-cidrtoobroad": "{{doc-apierror}}\n\nParameters:\n* $1 - \"IPv4\" or \"IPv6\"\n* $2 - Minimum CIDR mask length.",
        "apierror-compare-no-title": "{{doc-apierror}}",
+       "apierror-compare-nofromrevision": "{{doc-apierror}}",
        "apierror-compare-nosuchfromsection": "{{doc-apierror}}\n\nParameters:\n* $1 - Section identifier. Probably a number or \"T-\" followed by a number.",
        "apierror-compare-nosuchtosection": "{{doc-apierror}}\n\nParameters:\n* $1 - Section identifier. Probably a number or \"T-\" followed by a number.",
+       "apierror-compare-notorevision": "{{doc-apierror}}",
        "apierror-compare-relative-to-nothing": "{{doc-apierror}}",
        "apierror-contentserializationexception": "{{doc-apierror}}\n\nParameters:\n* $1 - Exception text, may end with punctuation. Currently this is probably English, hopefully we'll fix that in the future.",
        "apierror-contenttoobig": "{{doc-apierror}}\n\nParameters:\n* $1 - Maximum article size in kilobytes.",
        "apierror-mimesearchdisabled": "{{doc-apierror}}",
        "apierror-missingcontent-pageid": "{{doc-apierror}}\n\nParameters:\n* $1 - Page ID number.",
        "apierror-missingcontent-revid": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number",
+       "apierror-missingcontent-revid-role": "{{doc-apierror}}\n\nParameters:\n* $1 - Revision ID number\n* $2 - Role name",
        "apierror-missingparam-at-least-one-of": "{{doc-apierror}}\n\nParameters:\n* $1 - List of parameter names.\n* $2 - Number of parameters.",
        "apierror-missingparam-one-of": "{{doc-apierror}}\n\nParameters:\n* $1 - List of parameter names.\n* $2 - Number of parameters.",
        "apierror-missingparam": "{{doc-apierror}}\n\nParameters:\n* $1 - Parameter name.",
index 1e590c9..82b3ea8 100644 (file)
        "apihelp-query+alldeletedrevisions-param-user": "此列出由該使用者作出的修訂。",
        "apihelp-query+alldeletedrevisions-param-excludeuser": "不要列出由該使用者作出的修訂。",
        "apihelp-query+alldeletedrevisions-param-namespace": "僅列出此命名空間的頁面。",
+       "apihelp-query+allfileusages-param-prefix": "搜尋以此值為開頭的所有檔案標題。",
        "apihelp-query+allfileusages-param-prop": "要包含到的資訊部份:",
        "apihelp-query+allfileusages-paramvalue-prop-title": "添加檔案標題。",
        "apihelp-query+allfileusages-param-limit": "要回傳的項目總數。",
        "apihelp-query+allimages-param-sha1base36": "以 base 36 的圖片 SHA1 雜湊值(使用在 MediaWiki)。",
        "apihelp-query+allimages-param-mime": "所要搜尋的 MIME 類型,例如:<kbd>image/jpeg</kbd>。",
        "apihelp-query+allimages-param-limit": "要回傳的圖片總數。",
+       "apihelp-query+allimages-example-B": "搜尋以字母 <kbd>B</kbd> 為開頭的所有檔案清單。",
+       "apihelp-query+allimages-example-recent": "顯示近期已上傳檔案的清單,類似於 [[Special:NewFiles]]。",
        "apihelp-query+alllinks-param-from": "要起始列舉的連結標題。",
        "apihelp-query+alllinks-param-to": "要終止列舉的連結標題。",
        "apihelp-query+alllinks-param-prop": "要包含的資訊部份:",
        "apihelp-query+alllinks-param-namespace": "要列舉的命名空間。",
        "apihelp-query+alllinks-param-limit": "要回傳的項目總數。",
        "apihelp-query+alllinks-param-dir": "列出時所採用的方向。",
+       "apihelp-query+alllinks-example-unique": "列出唯一的連結標題。",
        "apihelp-query+alllinks-example-unique-generator": "取得所有已連結標題,標記為遺失。",
        "apihelp-query+alllinks-example-generator": "取得包含連結的頁面。",
        "apihelp-query+allmessages-summary": "返回來自該網站的訊息。",
        "apihelp-query+allusers-param-prop": "要包含的資訊部份:",
        "apihelp-query+allusers-paramvalue-prop-rights": "列出使用者所擁有的權限。",
        "apihelp-query+allusers-paramvalue-prop-editcount": "添加使用者的編輯次數。",
+       "apihelp-query+allusers-param-witheditsonly": "僅列出有做過編輯的使用者。",
        "apihelp-query+allusers-example-Y": "列出以<kbd>Y</kbd>開頭的使用者。",
        "apihelp-query+authmanagerinfo-summary": "取得目前身分核對狀態的資訊。",
        "apihelp-query+backlinks-summary": "找出連結至指定頁面的所有頁面。",
        "apihelp-query+blocks-paramvalue-prop-userid": "添加已封鎖使用者的使用者 ID。",
        "apihelp-query+blocks-paramvalue-prop-by": "添加進行封鎖中的使用者之使用者名稱。",
        "apihelp-query+blocks-paramvalue-prop-byid": "添加進行封鎖中的使用者之使用者 ID。",
+       "apihelp-query+blocks-paramvalue-prop-reason": "添加封鎖的原因。",
        "apihelp-query+blocks-example-simple": "列出封鎖。",
        "apihelp-query+blocks-example-users": "列出使用者 <kbd>Alice</kbd> 與 <kbd>Bob</kbd> 的封鎖。",
        "apihelp-query+categories-summary": "列出頁面隸屬的所有分類。",
+       "apihelp-query+categories-param-show": "要顯示出的分類種類。",
        "apihelp-query+categories-param-limit": "要回傳的分類數量。",
        "apihelp-query+categoryinfo-summary": "回傳有關指定分類的資訊。",
        "apihelp-query+categorymembers-summary": "在指定的分類中列出所有頁面。",
+       "apihelp-query+categorymembers-param-prop": "要包含的資訊部份:",
        "apihelp-query+categorymembers-paramvalue-prop-ids": "添加頁面 ID。",
+       "apihelp-query+categorymembers-paramvalue-prop-title": "添加標題與頁面的命名空間 ID。",
        "apihelp-query+categorymembers-param-limit": "回傳的頁面數量上限。",
        "apihelp-query+categorymembers-param-sort": "作為排序順序的屬性。",
        "apihelp-query+categorymembers-param-startsortkey": "請改用 $1starthexsortkey。",
        "apihelp-query+deletedrevs-param-end": "終止列舉的時間戳記。",
        "apihelp-query+deletedrevs-param-from": "在此標題開始列出。",
        "apihelp-query+deletedrevs-param-to": "在此標題停止列出。",
+       "apihelp-query+deletedrevs-param-tag": "僅列出以此標籤所標記的修訂。",
        "apihelp-query+deletedrevs-param-user": "此列出由該使用者作出的修訂。",
        "apihelp-query+deletedrevs-param-excludeuser": "不要列出由該使用者作出的修訂。",
        "apihelp-query+deletedrevs-param-namespace": "僅列出此命名空間的頁面。",
index 4977762..876b9bb 100644 (file)
@@ -81,15 +81,6 @@ class DatabaseOracle extends Database {
                return false;
        }
 
-       /**
-        * Usually aborts on failure
-        * @param string $server
-        * @param string $user
-        * @param string $password
-        * @param string $dbName
-        * @throws DBConnectionError
-        * @return resource|null
-        */
        function open( $server, $user, $password, $dbName ) {
                global $wgDBOracleDRCP;
                if ( !function_exists( 'oci_connect' ) ) {
@@ -173,7 +164,7 @@ class DatabaseOracle extends Database {
                $this->doQuery( 'ALTER SESSION SET NLS_TIMESTAMP_TZ_FORMAT=\'DD-MM-YYYY HH24:MI:SS.FF6\'' );
                $this->doQuery( 'ALTER SESSION SET NLS_NUMERIC_CHARACTERS=\'.,\'' );
 
-               return $this->conn;
+               return (bool)$this->conn;
        }
 
        /**
index 2ceda21..891f0fe 100644 (file)
@@ -1014,6 +1014,34 @@ class DifferenceEngine extends ContextSource {
                return $difftext;
        }
 
+       /**
+        * Get the diff table body for one slot, without header
+        *
+        * @param string $role
+        * @return string|false
+        */
+       public function getDiffBodyForRole( $role ) {
+               $diffRenderers = $this->getSlotDiffRenderers();
+               if ( !isset( $diffRenderers[$role] ) ) {
+                       return false;
+               }
+
+               $slotContents = $this->getSlotContents();
+               $slotDiff = $diffRenderers[$role]->getDiff( $slotContents[$role]['old'],
+                       $slotContents[$role]['new'] );
+               if ( !$slotDiff ) {
+                       return false;
+               }
+
+               if ( $role !== 'main' ) {
+                       // TODO use human-readable role name at least
+                       $slotTitle = $role;
+                       $slotDiff = $this->getSlotHeader( $slotTitle ) . $slotDiff;
+               }
+
+               return $this->localiseDiff( $slotDiff );
+       }
+
        /**
         * Get a slot header for inclusion in a diff body (as a table row).
         *
index da68a62..a679e45 100644 (file)
@@ -129,7 +129,7 @@ class HTMLCheckMatrix extends HTMLFormField implements HTMLNestedFilterable {
                                        $thisAttribs['class'] = 'checkmatrix-forced checkmatrix-forced-on';
                                }
 
-                               $checkbox = $this->getOneCheckbox( $checked, $attribs + $thisAttribs );
+                               $checkbox = $this->getOneCheckboxHTML( $checked, $attribs + $thisAttribs );
 
                                $rowContents .= Html::rawElement(
                                        'td',
@@ -148,24 +148,35 @@ class HTMLCheckMatrix extends HTMLFormField implements HTMLNestedFilterable {
                return $html;
        }
 
-       protected function getOneCheckbox( $checked, $attribs ) {
-               if ( $this->mParent instanceof OOUIHTMLForm ) {
-                       return new OOUI\CheckboxInputWidget( [
-                               'name' => "{$this->mName}[]",
-                               'selected' => $checked,
-                       ] + OOUI\Element::configFromHtmlAttributes(
-                               $attribs
-                       ) );
-               } else {
-                       $checkbox = Xml::check( "{$this->mName}[]", $checked, $attribs );
-                       if ( $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
-                               $checkbox = Html::openElement( 'div', [ 'class' => 'mw-ui-checkbox' ] ) .
-                                       $checkbox .
-                                       Html::element( 'label', [ 'for' => $attribs['id'] ] ) .
-                                       Html::closeElement( 'div' );
-                       }
-                       return $checkbox;
+       public function getInputOOUI( $value ) {
+               $attribs = $this->getAttributes( [ 'disabled', 'tabindex' ] );
+
+               return new MediaWiki\Widget\CheckMatrixWidget(
+                       [
+                               'name' => $this->mName,
+                               'infusable' => true,
+                               'id' => $this->mID,
+                               'rows' => $this->mParams['rows'],
+                               'columns' => $this->mParams['columns'],
+                               'tooltips' => $this->mParams['tooltips'],
+                               'forcedOff' => isset( $this->mParams['force-options-off'] ) ?
+                                       $this->mParams['force-options-off'] : [],
+                               'forcedOn' => isset( $this->mParams['force-options-on'] ) ?
+                                       $this->mParams['force-options-on'] : [],
+                               'values' => $value
+                       ] + OOUI\Element::configFromHtmlAttributes( $attribs )
+               );
+       }
+
+       protected function getOneCheckboxHTML( $checked, $attribs ) {
+               $checkbox = Xml::check( "{$this->mName}[]", $checked, $attribs );
+               if ( $this->mParent->getConfig()->get( 'UseMediaWikiUIEverywhere' ) ) {
+                       $checkbox = Html::openElement( 'div', [ 'class' => 'mw-ui-checkbox' ] ) .
+                               $checkbox .
+                               Html::element( 'label', [ 'for' => $attribs['id'] ] ) .
+                               Html::closeElement( 'div' );
                }
+               return $checkbox;
        }
 
        protected function isTagForcedOff( $tag ) {
@@ -262,4 +273,12 @@ class HTMLCheckMatrix extends HTMLFormField implements HTMLNestedFilterable {
 
                return $res;
        }
+
+       protected function getOOUIModules() {
+               return [ 'mediawiki.widgets.CheckMatrixWidget' ];
+       }
+
+       protected function shouldInfuseOOUI() {
+               return true;
+       }
 }
index 00d2028..321424f 100644 (file)
@@ -61,7 +61,7 @@ class TempFSFile extends FSFile {
                        if ( !is_string( $tmpDirectory ) ) {
                                $tmpDirectory = self::getUsableTempDirectory();
                        }
-                       $path = wfTempDir() . '/' . $prefix . $hex . $ext;
+                       $path = $tmpDirectory . '/' . $prefix . $hex . $ext;
                        Wikimedia\suppressWarnings();
                        $newFileHandle = fopen( $path, 'x' );
                        Wikimedia\restoreWarnings();
index eba1657..0de90c9 100644 (file)
@@ -178,10 +178,6 @@ class DBConnRef implements IDatabase {
                return $this->__call( __FUNCTION__, func_get_args() );
        }
 
-       public function open( $server, $user, $password, $dbName ) {
-               return $this->__call( __FUNCTION__, func_get_args() );
-       }
-
        public function fetchObject( $res ) {
                return $this->__call( __FUNCTION__, func_get_args() );
        }
index e35e082..e276d09 100644 (file)
@@ -266,7 +266,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
        /** @var int[] Prior flags member variable values */
        private $priorFlags = [];
 
-       /** @var object|string Class name or object With profileIn/profileOut methods */
+       /** @var mixed Class name or object With profileIn/profileOut methods */
        protected $profiler;
        /** @var TransactionProfiler */
        protected $trxProfiler;
@@ -373,6 +373,18 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                }
        }
 
+       /**
+        * Open a new connection to the database (closing any existing one)
+        *
+        * @param string $server Database server host
+        * @param string $user Database user name
+        * @param string $password Database user password
+        * @param string $dbName Database name
+        * @return bool
+        * @throws DBConnectionError
+        */
+       abstract protected function open( $server, $user, $password, $dbName );
+
        /**
         * Construct a Database subclass instance given a database type and parameters
         *
@@ -3496,7 +3508,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
                                        list( $phpCallback ) = $callback;
                                        $phpCallback( $this );
                                } catch ( Exception $ex ) {
-                                       $this->errorLogger( $ex );
+                                       ( $this->errorLogger )( $ex );
                                        $e = $e ?: $ex;
                                }
                        }
@@ -4018,7 +4030,7 @@ abstract class Database implements IDatabase, IMaintainableDatabase, LoggerAware
         * a wrapper. Nowadays, raw database objects are never exposed to external
         * callers, so this is unnecessary in external code.
         *
-        * @param bool|ResultWrapper|resource|object $result
+        * @param bool|ResultWrapper|resource $result
         * @return bool|ResultWrapper
         */
        protected function resultObject( $result ) {
index fed6f14..1246e44 100644 (file)
@@ -77,16 +77,7 @@ class DatabaseMssql extends Database {
                parent::__construct( $params );
        }
 
-       /**
-        * Usually aborts on failure
-        * @param string $server
-        * @param string $user
-        * @param string $password
-        * @param string $dbName
-        * @throws DBConnectionError
-        * @return bool|resource|null
-        */
-       public function open( $server, $user, $password, $dbName ) {
+       protected function open( $server, $user, $password, $dbName ) {
                # Test for driver support, to avoid suppressed fatal error
                if ( !function_exists( 'sqlsrv_connect' ) ) {
                        throw new DBConnectionError(
@@ -130,7 +121,7 @@ class DatabaseMssql extends Database {
 
                $this->opened = true;
 
-               return $this->conn;
+               return (bool)$this->conn;
        }
 
        /**
@@ -243,7 +234,7 @@ class DatabaseMssql extends Database {
        }
 
        /**
-        * @param MssqlResultWrapper $res
+        * @param IResultWrapper $res
         * @return stdClass
         */
        public function fetchObject( $res ) {
@@ -252,7 +243,7 @@ class DatabaseMssql extends Database {
        }
 
        /**
-        * @param MssqlResultWrapper $res
+        * @param IResultWrapper $res
         * @return array
         */
        public function fetchRow( $res ) {
index 57fab54..0f57551 100644 (file)
@@ -120,15 +120,7 @@ abstract class DatabaseMysqlBase extends Database {
                return 'mysql';
        }
 
-       /**
-        * @param string $server
-        * @param string $user
-        * @param string $password
-        * @param string $dbName
-        * @throws Exception|DBConnectionError
-        * @return bool
-        */
-       public function open( $server, $user, $password, $dbName ) {
+       protected function open( $server, $user, $password, $dbName ) {
                # Close/unset connection handle
                $this->close();
 
index a959d72..3c2f145 100644 (file)
@@ -86,7 +86,7 @@ class DatabasePostgres extends Database {
                return false;
        }
 
-       public function open( $server, $user, $password, $dbName ) {
+       protected function open( $server, $user, $password, $dbName ) {
                # Test for Postgres support, to avoid suppressed fatal error
                if ( !function_exists( 'pg_connect' ) ) {
                        throw new DBConnectionError(
index 25fbba0..1b9675a 100644 (file)
@@ -155,24 +155,14 @@ class DatabaseSqlite extends Database {
                return false;
        }
 
-       /** Open an SQLite database and return a resource handle to it
-        *  NOTE: only $dbName is used, the other parameters are irrelevant for SQLite databases
-        *
-        * @param string $server
-        * @param string $user Unused
-        * @param string $pass
-        * @param string $dbName
-        *
-        * @throws DBConnectionError
-        * @return bool
-        */
-       function open( $server, $user, $pass, $dbName ) {
+       protected function open( $server, $user, $pass, $dbName ) {
                $this->close();
                $fileName = self::generateFileName( $this->dbDir, $dbName );
                if ( !is_readable( $fileName ) ) {
                        $this->conn = false;
                        throw new DBConnectionError( $this, "SQLite database not accessible" );
                }
+               // Only $dbName is used, the other parameters are irrelevant for SQLite databases
                $this->openFile( $fileName, $dbName );
 
                return (bool)$this->conn;
index 7da259d..f97db3a 100644 (file)
@@ -370,18 +370,6 @@ interface IDatabase {
         */
        public function getType();
 
-       /**
-        * Open a new connection to the database (closing any existing one)
-        *
-        * @param string $server Database server host
-        * @param string $user Database user name
-        * @param string $password Database user password
-        * @param string $dbName Database name
-        * @return bool
-        * @throws DBConnectionError
-        */
-       public function open( $server, $user, $password, $dbName );
-
        /**
         * Fetch the next row from the given result object, in object form.
         * Fields can be retrieved with $row->fieldname, with fields acting like
index fbc3be9..00b4130 100644 (file)
@@ -938,10 +938,6 @@ class LoadBalancer implements ILoadBalancer {
                        $server = $this->servers[$i];
                        $server['serverIndex'] = $i;
                        $server['autoCommitOnly'] = $autoCommit;
-                       if ( $this->localDomain->getDatabase() !== null ) {
-                               // Use the local domain table prefix if the local domain is specified
-                               $server['tablePrefix'] = $this->localDomain->getTablePrefix();
-                       }
                        $conn = $this->reallyOpenConnection( $server, $this->localDomain );
                        $host = $this->getServerName( $i );
                        if ( $conn->isOpen() ) {
@@ -1037,7 +1033,6 @@ class LoadBalancer implements ILoadBalancer {
                                $this->errorConnection = $conn;
                                $conn = false;
                        } else {
-                               $conn->tablePrefix( $prefix ); // as specified
                                // Note that if $domain is an empty string, getDomainID() might not match it
                                $this->conns[$connInUseKey][$i][$conn->getDomainID()] = $conn;
                                $this->connLogger->debug( __METHOD__ . ": opened new connection for $i/$domain" );
@@ -1081,20 +1076,20 @@ class LoadBalancer implements ILoadBalancer {
         * Returns a Database object whether or not the connection was successful.
         *
         * @param array $server
-        * @param DatabaseDomain $domainOverride Use an unspecified domain to not select any database
+        * @param DatabaseDomain $domain Domain the connection is for, possibly unspecified
         * @return Database
         * @throws DBAccessError
         * @throws InvalidArgumentException
         */
-       protected function reallyOpenConnection( array $server, DatabaseDomain $domainOverride ) {
+       protected function reallyOpenConnection( array $server, DatabaseDomain $domain ) {
                if ( $this->disabled ) {
                        throw new DBAccessError();
                }
 
-               // Handle $domainOverride being a specified or an unspecified domain
-               if ( $domainOverride->getDatabase() === null ) {
-                       // Normally, an RDBMS requires a DB name specified on connection and the $server
-                       // configuration array is assumed to already specify an appropriate DB name.
+               if ( $domain->getDatabase() === null ) {
+                       // The database domain does not specify a DB name and some database systems require a
+                       // valid DB specified on connection. The $server configuration array contains a default
+                       // DB name to use for connections in such cases.
                        if ( $server['type'] === 'mysql' ) {
                                // For MySQL, DATABASE and SCHEMA are synonyms, connections need not specify a DB,
                                // and the DB name in $server might not exist due to legacy reasons (the default
@@ -1102,10 +1097,16 @@ class LoadBalancer implements ILoadBalancer {
                                $server['dbname'] = null;
                        }
                } else {
-                       $server['dbname'] = $domainOverride->getDatabase();
-                       $server['schema'] = $domainOverride->getSchema();
+                       $server['dbname'] = $domain->getDatabase();
+               }
+
+               if ( $domain->getSchema() !== null ) {
+                       $server['schema'] = $domain->getSchema();
                }
 
+               // It is always possible to connect with any prefix, even the empty string
+               $server['tablePrefix'] = $domain->getTablePrefix();
+
                // Let the handle know what the cluster master is (e.g. "db1052")
                $masterName = $this->getServerName( $this->getWriterIndex() );
                $server['clusterMasterHost'] = $masterName;
index aa757e6..bc24d26 100644 (file)
@@ -333,7 +333,7 @@ class UsersPager extends AlphabeticPager {
                Hooks::run( 'SpecialListusersHeaderForm', [ $this, &$beforeSubmitButtonHookOut ] );
 
                if ( $beforeSubmitButtonHookOut !== '' ) {
-                       $formDescriptior[ 'beforeSubmitButtonHookOut' ] = [
+                       $formDescriptor[ 'beforeSubmitButtonHookOut' ] = [
                                'class' => HTMLInfoField::class,
                                'raw' => true,
                                'default' => $beforeSubmitButtonHookOut
@@ -349,7 +349,7 @@ class UsersPager extends AlphabeticPager {
                Hooks::run( 'SpecialListusersHeader', [ $this, &$beforeClosingFieldsetHookOut ] );
 
                if ( $beforeClosingFieldsetHookOut !== '' ) {
-                       $formDescriptior[ 'beforeClosingFieldsetHookOut' ] = [
+                       $formDescriptor[ 'beforeClosingFieldsetHookOut' ] = [
                                'class' => HTMLInfoField::class,
                                'raw' => true,
                                'default' => $beforeClosingFieldsetHookOut
index 15f8ff0..f6a4c06 100644 (file)
@@ -79,7 +79,7 @@ class MediaWikiTitleCodec implements TitleFormatter, TitleParser {
         * @param string $text
         *
         * @throws InvalidArgumentException If the namespace is invalid
-        * @return string
+        * @return string Namespace name with underscores (not spaces)
         */
        public function getNamespaceName( $namespace, $text ) {
                if ( $this->language->needsGenderDistinction() &&
@@ -112,29 +112,30 @@ class MediaWikiTitleCodec implements TitleFormatter, TitleParser {
         * @return string
         */
        public function formatTitle( $namespace, $text, $fragment = '', $interwiki = '' ) {
-               if ( $namespace !== 0 && $namespace !== false ) {
-                       // Try to get a namespace name, but fallback
-                       // to empty string if it doesn't exist. And
-                       // assume that ns 0 is the empty string.
+               $out = '';
+               if ( $interwiki !== '' ) {
+                       $out = $interwiki . ':';
+               }
+
+               if ( $namespace != 0 ) {
                        try {
                                $nsName = $this->getNamespaceName( $namespace, $text );
                        } catch ( InvalidArgumentException $e ) {
-                               $nsName = '';
+                               // See T165149. Awkward, but better than erroneously linking to the main namespace.
+                               $nsName = $this->language->getNsText( NS_SPECIAL ) . ":Badtitle/NS{$namespace}";
                        }
-                       $text = $nsName . ':' . $text;
-               }
 
-               if ( $fragment !== '' ) {
-                       $text = $text . '#' . $fragment;
+                       $out .= $nsName . ':';
                }
+               $out .= $text;
 
-               if ( $interwiki !== '' ) {
-                       $text = $interwiki . ':' . $text;
+               if ( $fragment !== '' ) {
+                       $out .= '#' . $fragment;
                }
 
-               $text = str_replace( '_', ' ', $text );
+               $out = str_replace( '_', ' ', $out );
 
-               return $text;
+               return $out;
        }
 
        /**
@@ -185,12 +186,16 @@ class MediaWikiTitleCodec implements TitleFormatter, TitleParser {
         * @return string
         */
        public function getPrefixedText( LinkTarget $title ) {
-               return $this->formatTitle(
-                       $title->getNamespace(),
-                       $title->getText(),
-                       '',
-                       $title->getInterwiki()
-               );
+               if ( !isset( $title->prefixedText ) ) {
+                       $title->prefixedText = $this->formatTitle(
+                               $title->getNamespace(),
+                               $title->getText(),
+                               '',
+                               $title->getInterwiki()
+                       );
+               }
+
+               return $title->prefixedText;
        }
 
        /**
@@ -200,28 +205,12 @@ class MediaWikiTitleCodec implements TitleFormatter, TitleParser {
         * @return string
         */
        public function getPrefixedDBkey( LinkTarget $target ) {
-               $key = '';
-               if ( $target->isExternal() ) {
-                       $key .= $target->getInterwiki() . ':';
-               }
-               // Try to get a namespace name, but fallback
-               // to empty string if it doesn't exist
-               try {
-                       $nsName = $this->getNamespaceName(
-                               $target->getNamespace(),
-                               $target->getText()
-                       );
-               } catch ( InvalidArgumentException $e ) {
-                       $nsName = '';
-               }
-
-               if ( $target->getNamespace() !== 0 ) {
-                       $key .= $nsName . ':';
-               }
-
-               $key .= $target->getText();
-
-               return strtr( $key, ' ', '_' );
+               return strtr( $this->formatTitle(
+                       $target->getNamespace(),
+                       $target->getDBkey(),
+                       '',
+                       $target->getInterwiki()
+               ), ' ', '_' );
        }
 
        /**
index 43a399a..698bc4f 100644 (file)
@@ -59,6 +59,16 @@ class TitleValue implements LinkTarget {
         */
        protected $interwiki;
 
+       /**
+        * Text form including namespace/interwiki, initialised on demand
+        *
+        * Only public to share cache with TitleFormatter
+        *
+        * @private
+        * @var string
+        */
+       public $prefixedText = null;
+
        /**
         * Constructs a TitleValue.
         *
diff --git a/includes/widget/CheckMatrixWidget.php b/includes/widget/CheckMatrixWidget.php
new file mode 100644 (file)
index 0000000..7783f31
--- /dev/null
@@ -0,0 +1,204 @@
+<?php
+
+namespace MediaWiki\Widget;
+
+/**
+ * Check matrix widget. Displays a matrix of checkboxes for given options
+ *
+ * @copyright 2018 MediaWiki Widgets Team and others; see AUTHORS.txt
+ * @license MIT
+ */
+class CheckMatrixWidget extends \OOUI\Widget {
+
+       protected $name = '';
+       protected $columns = [];
+       protected $rows = [];
+       protected $tooltips = [];
+       protected $values = [];
+       protected $forcedOn = [];
+       protected $forcedOff = [];
+
+       /**
+        * CheckMatrixWidget constructor
+        *
+        * Operates similarly to MultiSelectWidget, but instead of using an array of
+        * options, uses an array of rows and an array of columns to dynamically
+        * construct a matrix of options. The tags used to identify a particular cell
+        * are of the form "columnName-rowName"
+        *
+        * @param array $config Configuration array with the following options:
+        *   - columns
+        *     - Required list of columns in the matrix.
+        *   - rows
+        *     - Required list of rows in the matrix.
+        *   - force-options-on
+        *     - Accepts array of column-row tags to be displayed as enabled but unavailable to change
+        *   - force-options-off
+        *     - Accepts array of column-row tags to be displayed as disabled but unavailable to change.
+        *   - tooltips
+        *     - Optional array mapping row label to tooltip content
+        *   - tooltip-class
+        *     - Optional CSS class used on tooltip container span. Defaults to mw-icon-question.
+        */
+       public function __construct( array $config = [] ) {
+               // Configuration initialization
+
+               parent::__construct( $config );
+
+               $this->name = isset( $config['name'] ) ?
+                       $config[ 'name' ] : null;
+               $this->id = isset( $config['id'] ) ?
+                       $config['id'] : null;
+
+               // Properties
+               $this->rows = isset( $config['rows'] ) ?
+                       $config['rows'] : [];
+               $this->columns = isset( $config['columns'] ) ?
+                       $config['columns'] : [];
+               $this->tooltips = isset( $config['tooltips'] ) ?
+                       $config['tooltips'] : [];
+
+               $this->values = isset( $config['values'] ) ?
+                       $config['values'] : [];
+
+               $this->forcedOn = isset( $config['forcedOn'] ) ?
+                       $config['forcedOn'] : [];
+               $this->forcedOff = isset( $config['forcedOff'] ) ?
+                       $config['forcedOff'] : [];
+
+               // Build the table
+               $table = new \OOUI\Tag( 'table' );
+               $tr = new \OOUI\Tag( 'tr' );
+               // Build the header
+               $tr->appendContent( $this->getCellTag( "\u{00A0}" ) );
+               foreach ( $this->columns as $columnLabel => $columnTag ) {
+                       $tr->appendContent(
+                               $this->getCellTag( $columnLabel )
+                       );
+               }
+               $table->appendContent( $tr );
+
+               // Build the options matrix
+               foreach ( $this->rows as $rowLabel => $rowTag ) {
+                       $table->appendContent(
+                               $this->getTableRow( $rowLabel, $rowTag )
+                       );
+               }
+
+               // Initialization
+               $this->addClasses( [ 'mw-widget-checkMatrixWidget' ] );
+               $this->appendContent( $table );
+       }
+
+       /**
+        * Get a formatted table row for the option, with
+        * a checkbox widget.
+        *
+        * @param  string $label Row label
+        * @param  string $tag   Row tag name
+        * @return \OOUI\Tag The resulting table row
+        */
+       private function getTableRow( $label, $tag ) {
+               $row = new \OOUI\Tag( 'tr' );
+               $tooltip = $this->getTooltip( $label );
+               $labelFieldConfig = $tooltip ? [ 'help' => $tooltip ] : [];
+               // Build label cell
+               $labelField = new \OOUI\FieldLayout(
+                       new \OOUI\Widget(), // Empty widget, since we don't have the checkboxes here
+                       [
+                               'label' => $label,
+                               'align' => 'inline',
+                       ] + $labelFieldConfig
+               );
+               $row->appendContent( $this->getCellTag( $labelField ) );
+
+               // Build checkbox column cells
+               foreach ( $this->columns as $columnTag ) {
+                       $thisTag = "$columnTag-$tag";
+
+                       // Construct a checkbox
+                       $checkbox = new \OOUI\CheckboxInputWidget( [
+                               'value' => $thisTag,
+                               'name' => $this->name ? "{$this->name}[]" : null,
+                               'id' => $this->id ? "{$this->id}-$thisTag" : null,
+                               'selected' => $this->isTagChecked( $thisTag ),
+                               'disabled' => $this->isTagDisabled( $thisTag ),
+                       ] );
+
+                       $row->appendContent( $this->getCellTag( $checkbox ) );
+               }
+               return $row;
+       }
+
+       /**
+        * Get an individual cell tag with requested content
+        *
+        * @param  string $content Content for the <td> cell
+        * @return \OOUI\Tag Resulting cell
+        */
+       private function getCellTag( $content ) {
+               $cell = new \OOUI\Tag( 'td' );
+               $cell->appendContent( $content );
+               return $cell;
+       }
+
+       /**
+        * Check whether the given tag's checkbox should
+        * be checked
+        *
+        * @param  string $tagName Tag name
+        * @return boolean Tag should be checked
+        */
+       private function isTagChecked( $tagName ) {
+               // If the tag is in the value list
+               return in_array( $tagName, (array)$this->values, true ) ||
+                       // Or if the tag is forced on
+                       in_array( $tagName, (array)$this->forcedOn, true );
+       }
+
+       /**
+        * Check whether the given tag's checkbox should
+        * be disabled
+        *
+        * @param  string $tagName Tag name
+        * @return boolean Tag should be disabled
+        */
+       private function isTagDisabled( $tagName ) {
+               return (
+                       // If the entire widget is disabled
+                       $this->isDisabled() ||
+                       // If the tag is 'forced on' or 'forced off'
+                       in_array( $tagName, (array)$this->forcedOn, true ) ||
+                       in_array( $tagName, (array)$this->forcedOff, true )
+               );
+       }
+
+       /**
+        * Get the tooltip help associated with this row
+        *
+        * @param  string $label Label name
+        * @return string Tooltip. Null if none is available.
+        */
+       private function getTooltip( $label ) {
+               return isset( $this->tooltips[ $label ] ) ?
+                       $this->tooltips[ $label ] : null;
+       }
+
+       protected function getJavaScriptClassName() {
+               return 'mw.widgets.CheckMatrixWidget';
+       }
+
+       public function getConfig( &$config ) {
+               $config += [
+                       'name' => $this->name,
+                       'id' => $this->id,
+                       'rows' => $this->rows,
+                       'columns' => $this->columns,
+                       'tooltips' => $this->tooltips,
+                       'forcedOff' => $this->forcedOff,
+                       'forcedOn' => $this->forcedOn,
+                       'values' => $this->values,
+               ];
+               return parent::getConfig( $config );
+       }
+}
index 351e40e..f95f33a 100644 (file)
        "anonymous": "{{PLURAL:$1|1=ЦӀе хьулйина декъашхо|ЦӀе хьулйина декъашхой}} {{grammar:genitive|{{SITENAME}}}}",
        "siteuser": "декъашхо {{grammar:genitive|{{SITENAME}}}} $1",
        "anonuser": "цӀе хьулйина декъашхо {{grammar:genitive|{{SITENAME}}}} $1",
-       "lastmodifiedatby": "Ð¥Ó\80аÑ\80а Ð°Ð³Ó\80о Ñ\82Ó\80аÑ\8cÑ\85Ñ\85Ñ\8cаÑ\80а Ñ\85ийÑ\86ина: $1 $2, Ñ\85ийÑ\86ам Ð±Ð¸Ð½Ð° — $3",
+       "lastmodifiedatby": "Ð¥Ó\80аÑ\80а Ð°Ð³Ó\80о Ñ\82Ó\80аÑ\8cÑ\85Ñ\85Ñ\8cаÑ\80а Ñ\85ийÑ\86ам Ð±Ð¸Ð½Ð°: $1 $2, Ñ\85ийÑ\86аман Ð°Ð²Ñ\82оÑ\80 — $3",
        "othercontribs": "Кхуллуш дакъалецира декъашхоша: $1.",
        "others": "кхин",
        "siteusers": "{{PLURAL:$2|1=декъашхо|декъашхой}} {{grammar:genitive|{{SITENAME}}}} $1",
index eebf96c..21cc02a 100644 (file)
        "resetpass-submit-loggedin": "Skift adgangskode",
        "resetpass-submit-cancel": "Annuller",
        "resetpass-wrong-oldpass": "Ugyldig midlertidig eller gældende adgangskode.\nDu har muligvis allerede ændret din adgangskode eller bedt om en ny midlertidig kode.",
-       "resetpass-recycled": "Vær venlig at ændre din adgangskode til noget andet end din nuværende adgangskode.",
+       "resetpass-recycled": "Ændr venligst din adgangskode til noget andet end din nuværende adgangskode.",
        "resetpass-temp-emailed": "Du loggede på med en midlertidig kode tilsendt på e-mail.\nFor at afslutte indlogning skal du angive en ny adgangskode her:",
        "resetpass-temp-password": "Midlertidig adgangskode",
        "resetpass-abort-generic": "Ændring af adgangskode er blevet afbrudt af en udvidelse",
        "resetpass-expired": "Din adgangskode er udløbet. Angiv en ny adgangskode for at logge på.",
-       "resetpass-expired-soft": "Din adgangskode er udløbet og skal ændres. Vær venlig at ændre den nu, eller tryk \"{{int:authprovider-resetpass-skip-label}}\" for at ændre den senere.",
-       "resetpass-validity-soft": "Din adgangskode er ikke gyldig:  $1 \n\nVær venlig at ændre den nu, eller tryk \"{{int:authprovider-resetpass-skip-label}}\" for at ændre den senere.",
+       "resetpass-expired-soft": "Din adgangskode er udløbet og skal ændres. Ændr den venligst nu, eller tryk \"{{int:authprovider-resetpass-skip-label}}\" for at ændre den senere.",
+       "resetpass-validity-soft": "Din adgangskode er ikke gyldig:  $1 \n\nVælg venligst en ny adgangskode nu, eller tryk \"{{int:authprovider-resetpass-skip-label}}\" for at ændre den senere.",
        "passwordreset": "Nulstil adgangskode",
        "passwordreset-text-one": "Udfyld denne formular for at nulstille din adgangskode.",
        "passwordreset-text-many": "{{PLURAL:$1|Udfyld et af felterne for at modtage en midlertidig adgangskode via e-mail.}}",
        "previewerrortext": "Der opstod en fejl under forsøget på at lave en forhåndsvisning af dine ændringer.",
        "blockedtitle": "Du eller din IP-adresse er blokeret",
        "blockedtext": "<strong>Dit brugernavn eller din IP-adresse er blevet blokeret.</strong>\n\nBlokeringen er foretaget af $1.\nDen anførte grund er <em>$2</em>.\n\nBlokeringen starter: $8\nBlokeringen udløber: $6\nBlokeringen er rettet mod: $7\n\nDu kan kontakte $1 eller en af de andre [[{{MediaWiki:Grouppage-sysop}}|administratorer]] for at diskutere blokeringen.\nDu kan ikke bruge funktionen \"{{int:emailuser}}\" medmindre der er angivet en gyldig e-mailadresse i dine [[Special:Preferences|kontoindstillinger]], og du ikke er blevet blokeret fra at bruge den.\n\nDin nuværende IP-adresse er $3, og blokerings-id er #$5.\nAngiv venligst alle ovenstående detaljer ved henvendelser om blokeringen.",
-       "autoblockedtext": "Din IP-adresse er blevet blokeret automatisk fordi den blev brugt af en anden bruger som er blevet blokeret af $1.\nBegrundelsen for det er:\n\n:''$2''\n\n* Blokeringsperiodens start: $8\n* Blokeringen udløber: $6\n* Blokeringen er ment for: $7\n\nDu kan kontakte $1 eller en af de andre [[{{MediaWiki:Grouppage-sysop}}|administratorer]] for at diskutere blokeringen.\n\nBemærk at du ikke kan bruge funktionen \"e-mail til denne bruger\" medmindre du har en gyldig e-mailadresse registreret i din [[Special:Preferences|brugerindstilling]], og du ikke er blevet blokeret fra at bruge den.\n\nDin nuværende IP-adresse er $3, og blokerings-id'et er #$5.\nAngiv venligst alle de ovenstående detaljer ved eventuelle henvendelser.",
+       "autoblockedtext": "Din IP-adresse er blevet blokeret automatisk fordi den blev brugt af en anden bruger som er blevet blokeret af $1.\nDen givne begrundelse er:\n\n:<em>$2</em>\n\n* Blokeringsperiodens start: $8\n* Blokeringen udløber: $6\n* Blokeringen er rettet mod: $7\n\nDu kan kontakte $1 eller en af de andre [[{{MediaWiki:Grouppage-sysop}}|administratorer]] for at diskutere blokeringen.\n\nBemærk at du ikke kan bruge funktionen \"{{int:emailuser}}\" medmindre du har en gyldig e-mailadresse registreret i dine [[Special:Preferences|brugerindstillinger]] og du ikke er blevet blokeret fra at bruge den.\n\nDin nuværende IP-adresse er $3, og blokerings-id'et er #$5.\nAngiv venligst alle de ovenstående detaljer ved eventuelle henvendelser.",
        "systemblockedtext": "Dit brugernavn eller din IP-adresse er automatisk blokeret af MediaWiki.\nBegrundelsen for det er:\n\n:<em>$2</em>\n\n* Blokeringsperiodens start: $8\n* Blokeringen udløber: $6\n* Blokeringen er ment for: $7\n\nDin nuværende IP-adresse er $3.\nAngiv venligst alle de ovenstående detaljer ved eventuelle henvendelser.",
        "blockednoreason": "ingen begrundelse givet",
        "whitelistedittext": "Du skal $1 for at kunne redigere sider.",
        "readonlywarning": "<strong>Advarsel: Databasen er låst på grund af vedligeholdelse, så du kan ikke gemme dine ændringer lige nu.</strong>\nDet kan være en god idé at kopiere din tekst over i en tekstfil og gemme den til senere.\n\nAdministratoren, som låste databasen, gav denne forklaring: $1",
        "protectedpagewarning": "'''ADVARSEL: Denne side er skrivebeskyttet, så kun administratorer kan redigere den.'''<br />\nDen seneste logpost vises nedenfor:",
        "semiprotectedpagewarning": "'''Bemærk: Siden er låst, så kun registrerede brugere kan ændre den.'''\n<br />Den seneste logpost vises nedenfor:",
-       "cascadeprotectedwarning": "<strong>Advarsel:</strong> Denne side er blevet beskyttet, så den kun kan ændres af brugere med administratorrettigheder, fordi indholdet er inkluderet i følgende {{PLURAL:$1|side|sider}} med nedarvet sidebeskyttelse:",
+       "cascadeprotectedwarning": "<strong>Advarsel:</strong> Denne side er blevet beskyttet, så kun brugere med [[Special:ListGroupRights|bestemte rettigheder]] kan ændre den, fordi indholdet er inkluderet i følgende {{PLURAL:$1|side|sider}} med nedarvet sidebeskyttelse:",
        "titleprotectedwarning": "ADVARSEL:  Den side er låst så kun [[Special:ListGroupRights|visse brugere]] kan oprette den.'''\n<br />Den seneste logpost vises nedenfor:",
        "templatesused": "{{PLURAL:$1|Skabelon|Skabeloner}} der er brugt på denne side:",
        "templatesusedpreview": "Følgende {{PLURAL:$1|skabelon|skabeloner}} bruges i denne forhåndsvisning:",
        "recentchangesdays": "Antal dage som skal vises i seneste ændringer:",
        "recentchangesdays-max": "(maks. $1 {{PLURAL:$1|dag|dage}})",
        "recentchangescount": "Antal redigeringer som skal vises som standard i sidste ændringer, sidehistorikker og logger:",
-       "prefs-help-recentchangescount": "Det gælder for seneste ændringer, historikker og logger.",
+       "prefs-help-recentchangescount": "Maksimalt antal: 1000",
        "prefs-help-watchlist-token2": "Dette er den hemmelige nøgle til web-feed af din overvågningsliste.\nHvis andre kender den, vil man være i stand til at læse din overvågningsliste, så del den ikke.\n[[Special:ResetTokens|Klik her]] hvis du har brug at nulstille den.",
        "savedprefs": "Dine indstillinger er blevet gemt.",
        "savedrights": "Brugergrupperne for {{GENDER:$1|$1}} er blevet gemt.",
        "uploadstash-summary": "Denne side giver adgang til filer, de er uploadet (eller er i gang med at blive det), men som endnu ikke er offentliggjort på wikien. Disse filer er kun synlige for brugeren, der har uploadet dem.",
        "uploadstash-clear": "Ryd stashede filer",
        "uploadstash-nofiles": "Du har ingen stashede filer.",
-       "uploadstash-badtoken": "Udførelse af handlingen mislykkedes, måske fordi dine redigerings legitimationsoplysninger udløbet. Prøv igen.",
+       "uploadstash-badtoken": "Udførelsen af handlingen mislykkedes, måske fordi dine legitimationsoplysninger for redigering er udløbet. Prøv venligst igen.",
        "uploadstash-errclear": "Rydning af filerne mislykkedes.",
        "uploadstash-refresh": "Opdatér filoversigten",
        "uploadstash-thumbnail": "vis miniature",
        "filehist-filesize": "Filstørrelse",
        "filehist-comment": "Kommentar",
        "imagelinks": "Filanvendelse",
-       "linkstoimage": "{{PLURAL:$1|Den følgende side|De følgende $1 sider}} henviser til denne fil:",
+       "linkstoimage": "{{PLURAL:$1|Den følgende side|De følgende $1 sider}} bruger denne fil:",
        "linkstoimage-more": "Flere end $1 {{PLURAL:$1|side|sider}} henviser til denne fil.\nDen følgende liste viser kun {{PLURAL:$1|den første henvisning|de $1 første henvisninger}}.\nEn [[Special:WhatLinksHere/$2|komplet liste]] er tilgængelig.",
-       "nolinkstoimage": "Der er ingen sider der henviser til denne fil.",
+       "nolinkstoimage": "Der er ingen sider der bruger denne fil.",
        "morelinkstoimage": "Se [[Special:WhatLinksHere/$1|flere henvisninger]] til denne fil.",
        "linkstoimage-redirect": "$1 (filomdirigering) $2",
        "duplicatesoffile": "Følgende {{PLURAL:$1|fil er en dublet|filer er dubletter}} af denne fil ([[Special:FileDuplicateSearch/$2|flere detaljer]]):",
        "editcomment": "Redigeringsbeskrivelsen var: <em>$1</em>.",
        "revertpage": "Gendannet til seneste version af [[User:$1|$1]], fjerner ændringer fra [[Special:Contributions/$2|$2]] ([[User talk:$2|diskussion]])",
        "revertpage-nouser": "Gendannet til seneste version af {{GENDER:$1|[[User:$1|$1]]}}, fjerner ændringer fra en skjult bruger",
-       "rollback-success": "Ændringerne fra $1 er fjernet,\nog den seneste version af $2 er gendannet.",
+       "rollback-success": "Ændringerne foretaget af {{GENDER:$3|$1}} er blevet tilbagestillet, og den seneste version af {{GENDER:$4|$2}} er gendannet.",
        "sessionfailure-title": "Sessionsfejl",
-       "sessionfailure": "Der lader til at være et problem med din loginsession; denne handling blev annulleret som en sikkerhedsforanstaltning mod kapring af sessionen. Tryk på \"tilbage\"-knappen og genindlæs den side du kom fra, og prøv dernæst igen.",
+       "sessionfailure": "Der lader til at være et problem med din loginsession; denne handling blev annulleret som en sikkerhedsforanstaltning mod kapring af sessionen. Genindsend venligst formularen.",
        "changecontentmodel-legend": "Ændr indholdsmodel",
        "changecontentmodel-title-label": "Sidetitel",
        "changecontentmodel-model-label": "Ny indholdsmodel",
        "sp-contributions-newbies-sub": "Fra nye kontoer",
        "sp-contributions-newbies-title": "Brugerbidrag fra nye konti",
        "sp-contributions-blocklog": "blokeringslog",
-       "sp-contributions-suppresslog": "undertrykte brugerbidrag",
+       "sp-contributions-suppresslog": "undertrykte {{GENDER:$1|brugerbidrag}}",
        "sp-contributions-deleted": "slettede {{GENDER:$1|brugerbidrag}}",
        "sp-contributions-uploads": "uploads",
        "sp-contributions-logs": "loglister",
        "sp-contributions-talk": "diskussion",
-       "sp-contributions-userrights": "håndtering af brugerrettigheder",
+       "sp-contributions-userrights": "håndtering af {{GENDER:$1|brugerrettigheder}}",
        "sp-contributions-blocked-notice": "Denne bruger er i øjeblikket blokeret. Loggen over den seneste blokering kan ses nedenfor:",
        "sp-contributions-blocked-notice-anon": "Denne IP-adresse er i øjeblikket blokeret.\nDen seneste post i blokeringsloggen vises nedenfor:",
        "sp-contributions-search": "Søg efter bidrag",
        "lockedbyandtime": "(af $1 den $2 kl. $3)",
        "move-page": "Flyt $1",
        "move-page-legend": "Flyt side",
-       "movepagetext": "Når du bruger formularen herunder, vil du få omdøbt en side og flyttet hele sidens historie til det nye navn.\nDen gamle titel vil blive en omdirigeringsside til den nye titel.\nDu kan opdatere omdirigeringer, der peger på den oprindelige titel, automatisk.\nHvis du vælger ikke at opdatere dem automatisk, så sørg for at tjekke efter [[Special:DoubleRedirects|dobbelte]] eller [[Special:BrokenRedirects|dårlige omdirigeringer]].\nDu er ansvarlig for, at alle henvisninger stadig peger derhen, hvor det er meningen de skal pege.\n\nBemærk at siden '''ikke''' kan flyttes, hvis der allerede er en side med den nye titel, medmindre den side er en omdirigering uden nogen redigeringshistorik.\nDet betyder, at du kan flytte en side tilbage hvor den kom fra, hvis du kommer til at lave en fejl, og det betyder, at du ikke kan overskrive en eksisterende side.\n\n'''ADVARSEL!'''\nDette kan være en drastisk og uventet ændring for en populær side; vær sikker på, at du forstår konsekvenserne af dette før du fortsætter.",
-       "movepagetext-noredirectfixer": "Brug formularen herunder du vil omdøbe en side og flyttet hele sidens historie til det nye navn.\nDen gamle titel vil blive en omdirigeringsside til den nye titel.\nVær sikker på at tjekke for [[Special:DoubleRedirects|dobbelte]] eller [[Special:BrokenRedirects|ødelagte omdirigeringer]].\nDu er ansvarlig for at sikre, at alle henvisninger stadig peger på et sted hvor det giver meningen at gå.\n\nBemærk, at siden '''ikke''' kan flyttes hvis der allerede er en side med den nye titel, medmindre den er tom eller er en omdirigering, og har ingen historie.\nDet betyder at du kan omdøbe en side tilbage hvor den kom fra, hvis du laver en fejl, og du kan ikke overskrive en eksisterende side.\n\n'''Advarsel!'''\nDette kan være en drastisk og uventet ændring for en populær side;\ndu skal være sikker på at du forstår konsekvenserne af dette før du fortsætter.",
+       "movepagetext": "Når du bruger formularen herunder, vil du få omdøbt en side og flyttet hele sidens historie til det nye navn.\nDen gamle titel vil blive en omdirigeringsside til den nye titel.\nDu kan opdatere omdirigeringer, der peger på den oprindelige titel, automatisk.\nHvis du vælger ikke at opdatere dem automatisk, så sørg for at tjekke efter [[Special:DoubleRedirects|dobbelte]] eller [[Special:BrokenRedirects|ødelagte omdirigeringer]].\nDu er ansvarlig for, at alle henvisninger stadig peger derhen, hvor det er meningen de skal pege.\n\nBemærk at siden <strong>ikke</strong> kan flyttes, hvis der allerede er en side med den nye titel, medmindre den side er en omdirigering uden nogen redigeringshistorik.\nDet betyder, at du kan flytte en side tilbage hvor den kom fra, hvis du kommer til at lave en fejl, og det betyder, at du ikke kan overskrive en eksisterende side.\n\n<strong>Bemærk:</strong>\nDette kan være en drastisk og uventet ændring for en populær side; vær sikker på, at du forstår konsekvenserne af dette før du fortsætter.",
+       "movepagetext-noredirectfixer": "Brug af formularen herunder vil omdøbe en side og flytte hele sidens historie til det nye navn.\nDen gamle titel vil blive en omdirigeringsside til den nye titel.\nVær sikker på at tjekke for [[Special:DoubleRedirects|dobbelte]] eller [[Special:BrokenRedirects|ødelagte omdirigeringer]].\nDu er ansvarlig for at sikre, at alle henvisninger stadig peger på det, som det er meningen, de skal pege på.\n\nBemærk, at siden <strong>ikke</strong> kan flyttes, hvis der allerede er en side med den nye titel, medmindre det er en omdirigeringsside uden historie.\nDet betyder, at du kan omdøbe en side tilbage hvor den kom fra, hvis du laver en fejl, og at du ikke kan overskrive en eksisterende side.\n\n<strong>Bemærk:</strong>\nDette kan være en drastisk og uventet ændring for en populær side; vær sikker på, at du forstår konsekvenserne af dette før du fortsætter.",
        "movepagetalktext": "Den tilhørende diskussionsside, hvis der er en, vil automatisk blive flyttet med siden '''medmindre:''' *Du flytter siden til et andet navnerum,\n*En ikke-tom diskussionsside allerede eksisterer under det nye navn, eller\n*Du fjerner markeringen i boksen nedenunder.\n\nI disse tilfælde er du nødt til at flytte eller sammenflette siden manuelt.",
        "moveuserpage-warning": "'''Advarsel:''' Du er ved at flytte en brugerside. Bemærk at det kun er siden, der vil blive flyttet – brugeren bliver ''ikke'' omdøbt.",
        "movenologintext": "Du skal være registreret bruger og [[Special:UserLogin|logget på]] for at flytte en side.",
        "delete_and_move_text": "==Sletning nødvendig==\n\nArtiklen \"[[:$1]]\" eksisterer allerede. Vil du slette den for at gøre plads til flytningen?",
        "delete_and_move_confirm": "Ja, slet siden",
        "delete_and_move_reason": "Slettet for at gøre plads til flytning fra \"[[$1]]\"",
-       "selfmove": "Begge sider har samme navn. Man kan ikke flytte en side oven i sig selv.",
+       "selfmove": "Titlen er den samme; man kan ikke flytte en side til samme side.",
        "immobile-source-namespace": "Kan ikke flytte sider i navnerummet \"$1\"",
        "immobile-target-namespace": "Kan ikke flytte sider til navnerummet \"$1\"",
        "immobile-target-namespace-iw": "En side kan ikke flyttes til en interwiki-henvisning.",
        "fix-double-redirects": "Opdater henvisninger til det oprindelige navn",
        "move-leave-redirect": "Efterlad en omdirigering",
        "protectedpagemovewarning": "'''Bemærk:''' Denne side er låst så kun administratorer kan flytte den.<br />\nDen seneste logpost vises nedenfor:",
-       "semiprotectedpagemovewarning": "'''Bemærk:''' Denne side er låst så kun registrerede brugere kan flytte den.<br />\nDen seneste logpost vises nedenfor:",
+       "semiprotectedpagemovewarning": "<strong>Bemærk:</strong> Denne side er låst, så kun automatisk bekræftede brugere kan flytte den.\nDen seneste logpost vises nedenfor som reference:",
        "move-over-sharedrepo": "== Fil findes ==\n[[:$1]] findes på en delt kilde. Ved at flytte en fil til denne titel vil overskrive den eksisterende delte fil.",
        "file-exists-sharedrepo": "Det valgte filnavn er allerede i brug på en delt kilde.\nVælg venligst et andet navn.",
        "export": "Eksportér sider",
index b06bcc7..2fd9783 100644 (file)
                        "Amaia",
                        "Tiberius1701",
                        "Astroemi",
-                       "Jelou"
+                       "Jelou",
+                       "Ktranz"
                ]
        },
        "tog-underline": "Subrayar los enlaces:",
        "ns-specialprotected": "No se pueden editar las páginas especiales.",
        "titleprotected": "Este título ha sido protegido contra creación por [[User:$1|$1]].\nEl motivo proporcionado es <em>$2</em>.",
        "filereadonlyerror": "No se puede modificar el archivo \"$1\" porque el repositorio de archivos \"$2\" es de solo lectura.\n\nEl administrador del sistema que lo ha bloqueado ofrece esta explicación: \"$3\".",
+       "invalidtitle": "Título inválido",
        "invalidtitle-knownnamespace": "El título con el espacio de nombres «$2» y el texto «$3» no es válido",
        "invalidtitle-unknownnamespace": "El título con el espacio de nombres desconocido (n.º $1) y el texto «$2» no es válido",
        "exception-nologin": "No has accedido",
        "uploadstash-zero-length": "El archivo está vacío.",
        "invalid-chunk-offset": "Desplazamiento inválido del fragmento",
        "img-auth-accessdenied": "Acceso denegado",
-       "img-auth-nopathinfo": "Falta PATH_INFO.\nEl servidor no está configurado para proporcionar esta información.\nEs posible que esté basado en CGI y que no sea compatible con img_auth.\nConsulte https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.",
+       "img-auth-nopathinfo": "Falta la información de ruta.\nEl servidor tiene que estar configurado para proporcionar las variables REQUEST_URI y/o PATH_INFO.\nSi lo está, intentá habilitar $wgUsePathInfo.\nConsulte https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.",
        "img-auth-notindir": "La ruta solicitada no figura en la carpeta de subidas configurada.",
        "img-auth-badtitle": "Incapaz de construir un título válido de «$1».",
        "img-auth-nologinnWL": "No has iniciado sesión y «$1» no está en la lista blanca.",
        "filehist-filesize": "Tamaño del archivo",
        "filehist-comment": "Comentario",
        "imagelinks": "Usos del archivo",
-       "linkstoimage": "{{PLURAL:$1|La siguiente página enlaza|Las siguientes páginas enlazan}} a este archivo:",
-       "linkstoimage-more": "Hay más de {{PLURAL:$1|una página que enlaza|$1 páginas que enlazan}} con este archivo.\nLa lista siguiente sólo muestra {{PLURAL:$1|la primera página que enlaza|las primeras $1 páginas que enlazan}} con este archivo.\nTambién puedes consultar la [[Special:WhatLinksHere/$2|lista completa]].",
-       "nolinkstoimage": "No hay páginas que enlacen a esta imagen.",
+       "linkstoimage": "{{PLURAL:$1|La siguiente página usa|Las siguientes páginas usan}} a este archivo:",
+       "linkstoimage-more": "Hay más de {{PLURAL:$1|una página que usa|$1 páginas que usan}} este archivo.\nLa lista siguiente sólo muestra {{PLURAL:$1|la primera página que usa|las primeras $1 páginas que usan}} este archivo.\nTambién puedes consultar la [[Special:WhatLinksHere/$2|lista completa]].",
+       "nolinkstoimage": "No hay páginas que enlacen a este archivo.",
        "morelinkstoimage": "Mira [[Special:WhatLinksHere/$1|más enlaces]] a este archivo.",
        "linkstoimage-redirect": "$1 (archivo de redirección) $2",
        "duplicatesoffile": "{{PLURAL:$1|El siguiente archivo es un duplicado|Los siguientes $1 archivos son duplicados}} de éste ([[Special:FileDuplicateSearch/$2|más detalles]]):",
        "limitreport-expansiondepth": "Profundidad máxima de expansión",
        "limitreport-expensivefunctioncount": "Cuenta de la función expansiva del analizador",
        "limitreport-unstrip-depth": "Profundidad de recursión de función «unstrip»",
+       "limitreport-unstrip-size": "Unstrip tamaño post-expandido",
        "limitreport-unstrip-size-value": "$1/$2 {{PLURAL:$2|byte|bytes}}",
        "expandtemplates": "Expandir plantillas",
        "expand_templates_intro": "Esta página especial toma un texto wiki y expande todas sus plantillas recursivamente.\nTambién expande las funciones sintácticas como <code><nowiki>{{</nowiki>#language:…}}</code>, y variables como\n<code><nowiki>{{</nowiki>CURRENTDAY}}</code>. De hecho, expande casi cualquier cosa que esté entre llaves dobles.",
        "passwordpolicies-policy-passwordcannotmatchusername": "La contraseña no puede ser la misma que el nombre de usuario",
        "passwordpolicies-policy-passwordcannotmatchblacklist": "La contraseña no puede coincidir con la lista de contraseñas específicamente prohibidas",
        "passwordpolicies-policy-maximalpasswordlength": "La contraseña no puede tener más de $1 {{PLURAL:$1|caracter|caracteres}}",
-       "passwordpolicies-policy-passwordcannotbepopular": "La contraseña no puede {{PLURAL:$1|ser la contraseña más popular|encontrarse en la lista de $1 contraseñas populares}}"
+       "passwordpolicies-policy-passwordcannotbepopular": "La contraseña no puede {{PLURAL:$1|ser la contraseña más popular|encontrarse en la lista de $1 contraseñas populares}}",
+       "easydeflate-invaliddeflate": "El contenido proporcionado no esta comprimido correctamente"
 }
index b955139..297cf75 100644 (file)
        "botpasswords-restriction-failed": "ボットパスワード制限によりログインできません。",
        "botpasswords-invalid-name": "指定された利用者名には、ボット用パスワードの区切りである「$1」 が含まれていません。",
        "botpasswords-not-exist": "利用者「$1」はボット「$2」のパスワードを所持していません。",
+       "botpasswords-needs-reset": "{{GENDER:$1|利用者}}「$1」のボット名「$2」のためのパスワードはリセットする必要があります。",
        "resetpass_forbidden": "パスワードは変更できません",
        "resetpass_forbidden-reason": "パスワードは変更できません: $1",
        "resetpass-no-info": "このページに直接アクセスするためにはログインしている必要があります。",
        "grouppage-autoconfirmed": "{{ns:project}}:自動承認された利用者",
        "grouppage-bot": "{{ns:project}}:ボット",
        "grouppage-sysop": "{{ns:project}}:管理者",
+       "grouppage-interface-admin": "{{ns:project}}:インターフェース管理者",
        "grouppage-bureaucrat": "{{ns:project}}:ビューロクラット",
        "grouppage-suppress": "{{ns:project}}:秘匿者",
        "right-read": "ページを閲覧",
index 5038969..e751f0c 100644 (file)
@@ -32,7 +32,8 @@
                        "Yogesh",
                        "Lokesha kunchadka",
                        "Anoop rao",
-                       "Rakshika"
+                       "Rakshika",
+                       "Gopala Krishna A"
                ]
        },
        "tog-underline": "ಕೊಂಡಿಗಳ ಕೆಳಗೆ ಗೆರೆ ತೋರಿಸಿ",
        "botpasswords-existing": "ಆಸ್ಥಿತ್ವದಲ್ಲಿರುವ ಬಾಟ್ ಪ್ರವೇಶಪದ",
        "botpasswords-createnew": "ಹೊಸ ಬಾಟ್ ಪ್ರವೇಶಪದ ರಚಿಸಿ",
        "botpasswords-editexisting": "ಆಸ್ಥಿತ್ವದಲ್ಲಿರುವ ಬಾಟ್ ಪ್ರವೇಶಪದ ಸ೦ಪಾದಿಸಿ",
+       "botpasswords-label-create": "ಸೃಷ್ಟಿಸು",
        "resetpass_forbidden": "ಪ್ರವೇಶಪದಗಳನ್ನು ಬದಲಾಯಿಸುವಂತಿಲ್ಲ.",
        "resetpass-no-info": "ನೀವು ಈ ಪುಟವನ್ನು ನೇರತಲುಪಲು ಲಾಗಿನ್ ಆಗಿರುವುದು ಆವಶ್ಯಕ.",
        "resetpass-submit-loggedin": "ಪ್ರವೇಶಪದ ಬದಲಾಯಿಸು",
index 78a2748..cfd23ac 100644 (file)
        "resetpass-submit-loggedin": "Keisti slaptažodį",
        "resetpass-submit-cancel": "Atšaukti",
        "resetpass-wrong-oldpass": "Klaidingas laikinas ar esamas slaptažodis.\nJūs galbūt jau sėkmingai pakeitėte savo slaptažodį ar jau prašėte naujo laikino slaptažodžio.",
-       "resetpass-recycled": "Atkurkite savo slaptažodį kitokiu, nei buvo prieš tai.",
+       "resetpass-recycled": "Pakeiskite savo slaptažodį kitokiu, nei buvo prieš tai.",
        "resetpass-temp-emailed": "Jūs prisijungęs laikinu slaptažodžiu, gautu per elektroninį paštą. Kad baigtumėte jungtis, čia turite nustatyti naują slaptažodį:",
        "resetpass-temp-password": "Laikinas slaptažodis:",
        "resetpass-abort-generic": "Slaptažodžio keitimas buvo nutrauktas nuo ekstenzijos.",
        "resetpass-expired": "Jūsų slaptažodžio galiojimas baigėsi. Prašome nustatyti naują prisijungimo slaptažodį.",
-       "resetpass-expired-soft": "Jūsų slaptažodžio galiojimas baigėsi ir jį reikia atkurti iš naujo. Pasirinkite naują slaptažodį dabar arba spauskite \"{{int:authprovider-resetpass-skip-label}}\", kad būtų atstatytas vėliau.",
-       "resetpass-validity-soft": "Jūsų slaptažodis netinkamas: $1\n\nPasirinkite naują slaptažodį dabar arba spauskite \"{{int:authprovider-resetpass-skip-label}}\", kad būtų atkurtas vėliau.",
+       "resetpass-expired-soft": "Jūsų slaptažodžio galiojimas baigėsi ir jį reikia pakeisti. Pasirinkite naują slaptažodį dabar arba spauskite \"{{int:authprovider-resetpass-skip-label}}\", kad būtų pakeistas vėliau.",
+       "resetpass-validity-soft": "Jūsų slaptažodis netinkamas: $1\n\nPasirinkite naują slaptažodį dabar arba spauskite \"{{int:authprovider-resetpass-skip-label}}\", kad būtų pakeistas vėliau.",
        "passwordreset": "Atkurti slaptažodį",
        "passwordreset-text-one": "Užpildykite šią formą, norėdami atkurti savo slaptažodį.",
        "passwordreset-text-many": "{{PLURAL:$1|Užpildykite vieną iš laukelių, kad el. paštu gautumėte laikinąjį slaptažodį.}}",
        "filehist-filesize": "Rinkmenos dydis",
        "filehist-comment": "Paaiškinimas",
        "imagelinks": "Rinkmenos naudojimas",
-       "linkstoimage": "{{PLURAL:$1|Šis puslapis|Šie puslapiai}} nurodo į šią rinkmeną:",
-       "linkstoimage-more": "Daugiau nei $1 {{PLURAL:$1|puslapis|puslapiai|puslapių}} rodo į šį failą.\nŠis sąrašas rodo tik {{PLURAL:$1|puslapio|pirmų $1 puslapių}} nuorodas į šį failą.\nYra pasiekiamas ir [[Special:WhatLinksHere/$2|visas sąrašas]].",
-       "nolinkstoimage": "Į rinkmeną nenurodo joks puslapis.",
+       "linkstoimage": "{{PLURAL:$1|Šis puslapis|Šie puslapiai}} naudoja šią rinkmeną:",
+       "linkstoimage-more": "Daugiau nei $1 {{PLURAL:$1|puslapis|puslapiai|puslapių}} naudoja šią rinkmeną.\nŠis sąrašas rodo tik {{PLURAL:$1|puslapį, naudojantį|pirmus $1 puslapius, naudojančius|pirmus $1 puslapių, naudojančių}} šį failą.\nYra pasiekiamas ir [[Special:WhatLinksHere/$2|visas sąrašas]].",
+       "nolinkstoimage": "Rinkmena nėra naudojama jokiame puslapyje.",
        "morelinkstoimage": "Žiūrėti [[Special:WhatLinksHere/$1|daugiau nuorodų]] į šį failą.",
        "linkstoimage-redirect": "$1 (failo peradresavimas) $2",
        "duplicatesoffile": "Šis failas turi {{PLURAL:$1|$1 dublikatą|$1 dublikatus|$1 dublikatų}} ([[Special:FileDuplicateSearch/$2|daugiau informacijos]]):",
index cffe621..dee448f 100644 (file)
        "recentchangeslinked-page": "Sidenavn:",
        "recentchangeslinked-to": "Vis endringer på sider som lenker til den gitte siden istedet",
        "recentchanges-page-added-to-category": "[[:$1]] ble lagt til i kategorien",
-       "recentchanges-page-added-to-category-bundled": "[[:$1]] lagt til i kategori, [[Special:WhatLinksHere/$1|denne siden er inkludert i andre sider]]",
+       "recentchanges-page-added-to-category-bundled": "[[:$1]] lagt til i kategorien; [[Special:WhatLinksHere/$1|denne siden er inkludert i andre sider]]",
        "recentchanges-page-removed-from-category": "[[:$1]] fjernet fra kategori",
        "recentchanges-page-removed-from-category-bundled": "[[:$1]] fjernet fra kategori, [[Special:WhatLinksHere/$1|denne siden er inkludert i andre sider]]",
        "autochange-username": "Automatisk MediaWiki-endring",
index c3c8b24..e8d6c7a 100644 (file)
        "logout": "Ofmelden",
        "userlogout": "Aofmelden",
        "notloggedin": "Neet an-emelded",
-       "userlogin-noaccount": "Heb jy noch gyn gebrukersname?",
+       "userlogin-noaccount": "Heb jy noch geen gebrukersname?",
        "userlogin-joinproject": "Wörd lid van {{SITENAME}}",
        "createaccount": "Inskryven",
        "userlogin-resetpassword-link": "Juuw wachtwoord vergeaten?",
        "rcfilters-days-show-days": "$1 {{PLURAL:$1|dag|dagen}}",
        "rcfilters-days-show-hours": "$1 {{PLURAL:$1|uur|uren}}",
        "rcfilters-quickfilters": "Up-eslöägen filters",
-       "rcfilters-quickfilters-placeholder-title": "Noch gyn filters up-eslöägen",
+       "rcfilters-quickfilters-placeholder-title": "Noch geen filters up-eslöägen",
        "rcfilters-quickfilters-placeholder-description": "Üm juuw filterinstellingen up te slån en et låter te gebruken, klik up et bladwyserikoon underan by \"Aktive filters\".",
        "rcfilters-savedqueries-apply-label": "Instellingen opslaon",
        "rcfilters-savedqueries-cancel-label": "Aofbreken",
        "rcfilters-restore-default-filters": "Standardfilters weerummezetten",
        "rcfilters-clear-all-filters": "Alle filters vortdoon",
        "rcfilters-show-new-changes": "Låt nyste wysigingen seen",
-       "rcfilters-search-placeholder": "Filter wysigingen (gebruuk et menü of söök up filtername)",
+       "rcfilters-search-placeholder": "Filter wysigingen (gebruuk et menu of söök up filtername)",
        "rcfilters-filterlist-feedbacklink": "Låt uns weaten wat jy van disse (nye) filterhülpmiddels vinden",
        "rcfilters-highlightbutton-title": "Resultåten markeren",
        "rcfilters-highlightmenu-title": "Kies n kleur",
index 6f69618..ae7317d 100644 (file)
        "botpasswords-no-provider": "BotPasswordsSessionProvider није доступан.",
        "botpasswords-restriction-failed": "Не можете се пријавити због ограничења лозинки за ботове.",
        "botpasswords-not-exist": "Корисник „$1“ нема лозинку бота „$2“.",
-       "resetpass_forbidden": "Ð\9bозинка Ð½Ðµ Ð¼Ð¾Ð¶Ðµ Ð±Ð¸Ñ\82и Ð¿Ñ\80омеÑ\9aена",
-       "resetpass_forbidden-reason": "Ð\9bозинке Ð½Ð¸Ñ\98е Ð¼Ð¾Ð³Ñ\83Ñ\9bе Ð¿Ñ\80омениÑ\82и: $1",
+       "resetpass_forbidden": "Ð\9dе Ð¼Ð¾Ð³Ñ\83 Ð´Ð° Ð¿Ñ\80оменим Ð»Ð¾Ð·Ð¸Ð½ÐºÐµ",
+       "resetpass_forbidden-reason": "Ð\9dе Ð¼Ð¾Ð³Ñ\83 Ð´Ð° Ð¿Ñ\80оменим Ð»Ð¾Ð·Ð¸Ð½ÐºÐµ: $1",
        "resetpass-no-info": "Морате бити пријављени да бисте приступили овој страници.",
        "resetpass-submit-loggedin": "Промени лозинку",
        "resetpass-submit-cancel": "Откажи",
        "diff-multi-sameuser": "({{PLURAL:$1|Једна међуревизија истог корисника није приказана|$1 међуревизија истог корисника нису приказане|$1 међуревизија истог корисника није приказано}})",
        "diff-multi-otherusers": "({{PLURAL:$1|Једна међуревизија|$1 међуревизије|$1 међуревизија}} од стране {{PLURAL:$2|још једног корисника није приказана|$2 корисника није приказано}})",
        "diff-multi-manyusers": "({{PLURAL:$1|Није приказана међуизмена|Нису приказане $1 међуизмене|Није приказано $1 међуизмена}} од више од $2 корисника)",
-       "diff-paragraph-moved-tonew": "Пасус је премештен. Кликните да пређете на његово ново место.",
-       "diff-paragraph-moved-toold": "Ð\9fаÑ\81Ñ\83Ñ\81 Ñ\98е Ð¿Ñ\80емеÑ\88Ñ\82ен. Ð\9aликниÑ\82е Ð´Ð° Ð¿Ñ\80еÑ\92еÑ\82е Ð½Ð° Ñ\9aегово Ñ\81Ñ\82аÑ\80о Ð¼ÐµÑ\81Ñ\82о.",
+       "diff-paragraph-moved-tonew": "Пасус је премештен. Кликните да пређете на нову локацију.",
+       "diff-paragraph-moved-toold": "Ð\9fаÑ\81Ñ\83Ñ\81 Ñ\98е Ð¿Ñ\80емеÑ\88Ñ\82ен. Ð\9aликниÑ\82е Ð´Ð° Ð¿Ñ\80еÑ\92еÑ\82е Ð½Ð° Ñ\81Ñ\82аÑ\80Ñ\83 Ð»Ð¾ÐºÐ°Ñ\86иÑ\98Ñ\83.",
        "difference-missing-revision": "{{PLURAL:$2|Једна ревизија|$2 ревизије}} ове разлике ($1) не {{PLURAL:$2|постоји|постоје}}.\n\nОво се обично дешава када пратите застарели линк до странице која је избрисана.\nДетаље можете да пронађете у [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} евиденцији брисања].",
        "searchresults": "Резултати претраге",
        "search-filter-title-prefix-reset": "Претражи све странице",
        "movepage-moved-redirect": "Преусмерење је направљено.",
        "movepage-moved-noredirect": "Стварање преусмерења је онемогућено.",
        "articleexists": "Страница са тим именом већ постоји или име које сте одабрали није важеће.\nОдаберите друго.",
-       "cantmove-titleprotected": "Не можете да преместите страницу на то место јер је жељени наслов заштићен од стварања",
+       "cantmove-titleprotected": "Не можете да преместите страницу на ову локацију јер је прављење новог наслова заштићено.",
        "movetalk": "Премести и страницу за разговор",
        "move-subpages": "Премести и подстранице (до $1)",
        "move-talk-subpages": "Премести подстранице странице за разговор (до $1)",
        "tooltip-n-currentevents": "Пронађите додатне информације о актуелностима",
        "tooltip-n-recentchanges": "Списак недавних промена на викију",
        "tooltip-n-randompage": "Учитајте случајну страницу",
-       "tooltip-n-help": "Ð\9cеÑ\81Ñ\82о Ð³Ð´Ðµ Ð¼Ð¾Ð¶ÐµÑ\82е Ð´Ð° Ð½Ð°Ñ\83Ñ\87иÑ\82е Ð½ÐµÑ\88Ñ\82о",
+       "tooltip-n-help": "Ð\9cеÑ\81Ñ\82о Ð³Ð´Ðµ Ð¼Ð¾Ð¶ÐµÑ\82е Ð½ÐµÑ\88Ñ\82о Ð´Ð° Ð½Ð°Ñ\83Ñ\87иÑ\82е",
        "tooltip-t-whatlinkshere": "Списак свих вики страница које воде овде",
        "tooltip-t-recentchangeslinked": "Недавне промене на страницама које су повезане с овом страницом",
        "tooltip-feed-rss": "RSS фид за ову страницу",
        "exif-ycbcrpositioning": "Положај Y и C",
        "exif-xresolution": "Водоравна резолуција",
        "exif-yresolution": "Усправна резолуција",
-       "exif-stripoffsets": "Ð\9cеÑ\81Ñ\82о Ð¿Ð¾Ð´Ð°Ñ\82ака",
+       "exif-stripoffsets": "Ð\9bокаÑ\86иÑ\98а Ð¿Ð¾Ð´Ð°Ñ\82ака Ñ\81лике",
        "exif-rowsperstrip": "Број редова по линији",
        "exif-stripbytecounts": "Бајтова по сажетом блоку",
        "exif-jpeginterchangeformat": "Почетак JPEG прегледа",
        "exif-urgency": "Хитност",
        "exif-fixtureidentifier": "Назив рубрике",
        "exif-locationdest": "Приказана локација",
-       "exif-locationdestcode": "Код приказаног места",
+       "exif-locationdestcode": "Кôд приказане локације",
        "exif-objectcycle": "Доба дана за који је медиј намењен",
        "exif-contact": "Подаци за контакт",
        "exif-writer": "Писац",
index 0616922..ab4cbfc 100644 (file)
        "filehist-filesize": "Dosya boyutu",
        "filehist-comment": "Açıklama",
        "imagelinks": "Dosya kullanımı",
-       "linkstoimage": "Bu görüntü dosyasına bağlantısı olan {{PLURAL:$1|sayfa|$1 sayfa}}:",
-       "linkstoimage-more": "$1'den fazla {{PLURAL:$1|sayfa|sayfa}} bu dosyaya bağlantı veriyor.\nSıradaki liste sadece bu dosyaya bağlantı veren {{PLURAL:$1|ilk dosyayı|ilk $1 dosyayı}} gösteriyor.\n[[Special:WhatLinksHere/$2|Tam bir liste]] mevcuttur.",
-       "nolinkstoimage": "Bu dosyaya bağlantı veren bir sayfa yok.",
+       "linkstoimage": "Aşağıdaki {{PLURAL:$1|sayfa|$1 sayfa}} bu dosyayı kullanmaktadır:",
+       "linkstoimage-more": "$1 {{PLURAL:$1|sayfadan|sayfadan}} fazlası bu dosyayı kullanıyor.\nAşağıdaki listede sadece bu dosyayı kullanan  {{PLURAL:$1|ilk sayfa|ilk $1 sayfa}} gösterilmektedir.\n[[Special:WhatLinksHere/$2|Tam listesi]] mevcuttur.",
+       "nolinkstoimage": "Bu dosyayı kullanan sayfa yok.",
        "morelinkstoimage": "Bu dosyaya [[Special:WhatLinksHere/$1|daha fazla bağlantıları]] gör.",
        "linkstoimage-redirect": "$1 (dosya yönlendirme) $2",
        "duplicatesoffile": "Şu {{PLURAL:$1|dosya|$1 dosya}}, bu dosyanın kopyası ([[Special:FileDuplicateSearch/$2|daha fazla ayrıntı]]):",
index 10922a6..ac26098 100644 (file)
        "action-delete": "删除本页",
        "action-deleterevision": "删除修订",
        "action-deletelogentry": "删除日志记录",
-       "action-deletedhistory": "查看页面被删除的历史",
+       "action-deletedhistory": "查看已被删除的页面历史",
        "action-deletedtext": "查看已删除的修订版本文字",
        "action-browsearchive": "搜索已被删除的页面",
        "action-undelete": "还原页面",
index a55372c..b1109ca 100644 (file)
        "action-delete": "刪除此頁面",
        "action-deleterevision": "刪除修訂",
        "action-deletelogentry": "刪除日誌項目",
-       "action-deletedhistory": "檢視頁面的刪除歷史",
+       "action-deletedhistory": "檢視已被刪除的頁面歷史",
        "action-deletedtext": "查看已刪除的修訂版本文字",
        "action-browsearchive": "搜尋已刪除頁面",
        "action-undelete": "取消刪除頁面",
index 7e9220c..bb88040 100644 (file)
@@ -165,26 +165,11 @@ FILE_PATTERNS          = *.c \
                          *.odl \
                          *.cs \
                          *.php \
-                         *.php5 \
                          *.inc \
                          *.m \
                          *.mm \
                          *.dox \
                          *.py \
-                         *.C \
-                         *.CC \
-                         *.C++ \
-                         *.II \
-                         *.I++ \
-                         *.H \
-                         *.HH \
-                         *.H++ \
-                         *.CS \
-                         *.PHP \
-                         *.PHP5 \
-                         *.M \
-                         *.MM \
-                         *.PY \
                          *.txt \
                          README
 RECURSIVE              = YES
diff --git a/maintenance/backup.inc b/maintenance/backup.inc
deleted file mode 100644 (file)
index 6eeb81b..0000000
+++ /dev/null
@@ -1,423 +0,0 @@
-<?php
-/**
- * Base classes for database dumpers
- *
- * Copyright © 2005 Brion Vibber <brion@pobox.com>
- * https://www.mediawiki.org/
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup Dump Maintenance
- */
-
-require_once __DIR__ . '/Maintenance.php';
-
-use MediaWiki\MediaWikiServices;
-use Wikimedia\Rdbms\LoadBalancer;
-use Wikimedia\Rdbms\IDatabase;
-
-/**
- * @ingroup Dump Maintenance
- */
-class BackupDumper extends Maintenance {
-       public $reporting = true;
-       public $pages = null; // all pages
-       public $skipHeader = false; // don't output <mediawiki> and <siteinfo>
-       public $skipFooter = false; // don't output </mediawiki>
-       public $startId = 0;
-       public $endId = 0;
-       public $revStartId = 0;
-       public $revEndId = 0;
-       public $dumpUploads = false;
-       public $dumpUploadFileContents = false;
-       public $orderRevs = false;
-
-       protected $reportingInterval = 100;
-       protected $pageCount = 0;
-       protected $revCount = 0;
-       protected $server = null; // use default
-       protected $sink = null; // Output filters
-       protected $lastTime = 0;
-       protected $pageCountLast = 0;
-       protected $revCountLast = 0;
-
-       protected $outputTypes = [];
-       protected $filterTypes = [];
-
-       protected $ID = 0;
-
-       /**
-        * The dependency-injected database to use.
-        *
-        * @var IDatabase|null
-        *
-        * @see self::setDB
-        */
-       protected $forcedDb = null;
-
-       /** @var LoadBalancer */
-       protected $lb;
-
-       // @todo Unused?
-       private $stubText = false; // include rev_text_id instead of text; for 2-pass dump
-
-       /**
-        * @param array|null $args For backward compatibility
-        */
-       function __construct( $args = null ) {
-               parent::__construct();
-               $this->stderr = fopen( "php://stderr", "wt" );
-
-               // Built-in output and filter plugins
-               $this->registerOutput( 'file', DumpFileOutput::class );
-               $this->registerOutput( 'gzip', DumpGZipOutput::class );
-               $this->registerOutput( 'bzip2', DumpBZip2Output::class );
-               $this->registerOutput( 'dbzip2', DumpDBZip2Output::class );
-               $this->registerOutput( '7zip', Dump7ZipOutput::class );
-
-               $this->registerFilter( 'latest', DumpLatestFilter::class );
-               $this->registerFilter( 'notalk', DumpNotalkFilter::class );
-               $this->registerFilter( 'namespace', DumpNamespaceFilter::class );
-
-               // These three can be specified multiple times
-               $this->addOption( 'plugin', 'Load a dump plugin class. Specify as <class>[:<file>].',
-                       false, true, false, true );
-               $this->addOption( 'output', 'Begin a filtered output stream; Specify as <type>:<file>. ' .
-                       '<type>s: file, gzip, bzip2, 7zip, dbzip2', false, true, false, true );
-               $this->addOption( 'filter', 'Add a filter on an output branch. Specify as ' .
-                       '<type>[:<options>]. <types>s: latest, notalk, namespace', false, true, false, true );
-               $this->addOption( 'report', 'Report position and speed after every n pages processed. ' .
-                       'Default: 100.', false, true );
-               $this->addOption( 'server', 'Force reading from MySQL server', false, true );
-               $this->addOption( '7ziplevel', '7zip compression level for all 7zip outputs. Used for ' .
-                       '-mx option to 7za command.', false, true );
-
-               if ( $args ) {
-                       // Args should be loaded and processed so that dump() can be called directly
-                       // instead of execute()
-                       $this->loadWithArgv( $args );
-                       $this->processOptions();
-               }
-       }
-
-       /**
-        * @param string $name
-        * @param string $class Name of output filter plugin class
-        */
-       function registerOutput( $name, $class ) {
-               $this->outputTypes[$name] = $class;
-       }
-
-       /**
-        * @param string $name
-        * @param string $class Name of filter plugin class
-        */
-       function registerFilter( $name, $class ) {
-               $this->filterTypes[$name] = $class;
-       }
-
-       /**
-        * Load a plugin and register it
-        *
-        * @param string $class Name of plugin class; must have a static 'register'
-        *   method that takes a BackupDumper as a parameter.
-        * @param string $file Full or relative path to the PHP file to load, or empty
-        */
-       function loadPlugin( $class, $file ) {
-               if ( $file != '' ) {
-                       require_once $file;
-               }
-               $register = [ $class, 'register' ];
-               $register( $this );
-       }
-
-       function execute() {
-               throw new MWException( 'execute() must be overridden in subclasses' );
-       }
-
-       /**
-        * Processes arguments and sets $this->$sink accordingly
-        */
-       function processOptions() {
-               $sink = null;
-               $sinks = [];
-
-               $options = $this->orderedOptions;
-               foreach ( $options as $arg ) {
-                       $opt = $arg[0];
-                       $param = $arg[1];
-
-                       switch ( $opt ) {
-                               case 'plugin':
-                                       $val = explode( ':', $param );
-
-                                       if ( count( $val ) === 1 ) {
-                                               $this->loadPlugin( $val[0], '' );
-                                       } elseif ( count( $val ) === 2 ) {
-                                               $this->loadPlugin( $val[0], $val[1] );
-                                       } else {
-                                               $this->fatalError( 'Invalid plugin parameter' );
-                                               return;
-                                       }
-
-                                       break;
-                               case 'output':
-                                       $split = explode( ':', $param, 2 );
-                                       if ( count( $split ) !== 2 ) {
-                                               $this->fatalError( 'Invalid output parameter' );
-                                       }
-                                       list( $type, $file ) = $split;
-                                       if ( !is_null( $sink ) ) {
-                                               $sinks[] = $sink;
-                                       }
-                                       if ( !isset( $this->outputTypes[$type] ) ) {
-                                               $this->fatalError( "Unrecognized output sink type '$type'" );
-                                       }
-                                       $class = $this->outputTypes[$type];
-                                       if ( $type === "7zip" ) {
-                                               $sink = new $class( $file, intval( $this->getOption( '7ziplevel' ) ) );
-                                       } else {
-                                               $sink = new $class( $file );
-                                       }
-
-                                       break;
-                               case 'filter':
-                                       if ( is_null( $sink ) ) {
-                                               $sink = new DumpOutput();
-                                       }
-
-                                       $split = explode( ':', $param );
-                                       $key = $split[0];
-
-                                       if ( !isset( $this->filterTypes[$key] ) ) {
-                                               $this->fatalError( "Unrecognized filter type '$key'" );
-                                       }
-
-                                       $type = $this->filterTypes[$key];
-
-                                       if ( count( $split ) === 1 ) {
-                                               $filter = new $type( $sink );
-                                       } elseif ( count( $split ) === 2 ) {
-                                               $filter = new $type( $sink, $split[1] );
-                                       } else {
-                                               $this->fatalError( 'Invalid filter parameter' );
-                                       }
-
-                                       // references are lame in php...
-                                       unset( $sink );
-                                       $sink = $filter;
-
-                                       break;
-                       }
-               }
-
-               if ( $this->hasOption( 'report' ) ) {
-                       $this->reportingInterval = intval( $this->getOption( 'report' ) );
-               }
-
-               if ( $this->hasOption( 'server' ) ) {
-                       $this->server = $this->getOption( 'server' );
-               }
-
-               if ( is_null( $sink ) ) {
-                       $sink = new DumpOutput();
-               }
-               $sinks[] = $sink;
-
-               if ( count( $sinks ) > 1 ) {
-                       $this->sink = new DumpMultiWriter( $sinks );
-               } else {
-                       $this->sink = $sink;
-               }
-       }
-
-       function dump( $history, $text = WikiExporter::TEXT ) {
-               # Notice messages will foul up your XML output even if they're
-               # relatively harmless.
-               if ( ini_get( 'display_errors' ) ) {
-                       ini_set( 'display_errors', 'stderr' );
-               }
-
-               $this->initProgress( $history );
-
-               $db = $this->backupDb();
-               $exporter = new WikiExporter( $db, $history, WikiExporter::STREAM, $text );
-               $exporter->dumpUploads = $this->dumpUploads;
-               $exporter->dumpUploadFileContents = $this->dumpUploadFileContents;
-
-               $wrapper = new ExportProgressFilter( $this->sink, $this );
-               $exporter->setOutputSink( $wrapper );
-
-               if ( !$this->skipHeader ) {
-                       $exporter->openStream();
-               }
-               # Log item dumps: all or by range
-               if ( $history & WikiExporter::LOGS ) {
-                       if ( $this->startId || $this->endId ) {
-                               $exporter->logsByRange( $this->startId, $this->endId );
-                       } else {
-                               $exporter->allLogs();
-                       }
-               } elseif ( is_null( $this->pages ) ) {
-                       # Page dumps: all or by page ID range
-                       if ( $this->startId || $this->endId ) {
-                               $exporter->pagesByRange( $this->startId, $this->endId, $this->orderRevs );
-                       } elseif ( $this->revStartId || $this->revEndId ) {
-                               $exporter->revsByRange( $this->revStartId, $this->revEndId );
-                       } else {
-                               $exporter->allPages();
-                       }
-               } else {
-                       # Dump of specific pages
-                       $exporter->pagesByName( $this->pages );
-               }
-
-               if ( !$this->skipFooter ) {
-                       $exporter->closeStream();
-               }
-
-               $this->report( true );
-       }
-
-       /**
-        * Initialise starting time and maximum revision count.
-        * We'll make ETA calculations based an progress, assuming relatively
-        * constant per-revision rate.
-        * @param int $history WikiExporter::CURRENT or WikiExporter::FULL
-        */
-       function initProgress( $history = WikiExporter::FULL ) {
-               $table = ( $history == WikiExporter::CURRENT ) ? 'page' : 'revision';
-               $field = ( $history == WikiExporter::CURRENT ) ? 'page_id' : 'rev_id';
-
-               $dbr = $this->forcedDb;
-               if ( $this->forcedDb === null ) {
-                       $dbr = wfGetDB( DB_REPLICA );
-               }
-               $this->maxCount = $dbr->selectField( $table, "MAX($field)", '', __METHOD__ );
-               $this->startTime = microtime( true );
-               $this->lastTime = $this->startTime;
-               $this->ID = getmypid();
-       }
-
-       /**
-        * @todo Fixme: the --server parameter is currently not respected, as it
-        * doesn't seem terribly easy to ask the load balancer for a particular
-        * connection by name.
-        * @return IDatabase
-        */
-       function backupDb() {
-               if ( $this->forcedDb !== null ) {
-                       return $this->forcedDb;
-               }
-
-               $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
-               $this->lb = $lbFactory->newMainLB();
-               $db = $this->lb->getConnection( DB_REPLICA, 'dump' );
-
-               // Discourage the server from disconnecting us if it takes a long time
-               // to read out the big ol' batch query.
-               $db->setSessionOptions( [ 'connTimeout' => 3600 * 24 ] );
-
-               return $db;
-       }
-
-       /**
-        * Force the dump to use the provided database connection for database
-        * operations, wherever possible.
-        *
-        * @param IDatabase|null $db (Optional) the database connection to use. If null, resort to
-        *   use the globally provided ways to get database connections.
-        */
-       function setDB( IDatabase $db = null ) {
-               parent::setDB( $db );
-               $this->forcedDb = $db;
-       }
-
-       function __destruct() {
-               if ( isset( $this->lb ) ) {
-                       $this->lb->closeAll();
-               }
-       }
-
-       function backupServer() {
-               global $wgDBserver;
-
-               return $this->server
-                       ? $this->server
-                       : $wgDBserver;
-       }
-
-       function reportPage() {
-               $this->pageCount++;
-       }
-
-       function revCount() {
-               $this->revCount++;
-               $this->report();
-       }
-
-       function report( $final = false ) {
-               if ( $final xor ( $this->revCount % $this->reportingInterval == 0 ) ) {
-                       $this->showReport();
-               }
-       }
-
-       function showReport() {
-               if ( $this->reporting ) {
-                       $now = wfTimestamp( TS_DB );
-                       $nowts = microtime( true );
-                       $deltaAll = $nowts - $this->startTime;
-                       $deltaPart = $nowts - $this->lastTime;
-                       $this->pageCountPart = $this->pageCount - $this->pageCountLast;
-                       $this->revCountPart = $this->revCount - $this->revCountLast;
-
-                       if ( $deltaAll ) {
-                               $portion = $this->revCount / $this->maxCount;
-                               $eta = $this->startTime + $deltaAll / $portion;
-                               $etats = wfTimestamp( TS_DB, intval( $eta ) );
-                               $pageRate = $this->pageCount / $deltaAll;
-                               $revRate = $this->revCount / $deltaAll;
-                       } else {
-                               $pageRate = '-';
-                               $revRate = '-';
-                               $etats = '-';
-                       }
-                       if ( $deltaPart ) {
-                               $pageRatePart = $this->pageCountPart / $deltaPart;
-                               $revRatePart = $this->revCountPart / $deltaPart;
-                       } else {
-                               $pageRatePart = '-';
-                               $revRatePart = '-';
-                       }
-                       $this->progress( sprintf(
-                               "%s: %s (ID %d) %d pages (%0.1f|%0.1f/sec all|curr), "
-                                       . "%d revs (%0.1f|%0.1f/sec all|curr), ETA %s [max %d]",
-                               $now, wfWikiID(), $this->ID, $this->pageCount, $pageRate,
-                               $pageRatePart, $this->revCount, $revRate, $revRatePart, $etats,
-                               $this->maxCount
-                       ) );
-                       $this->lastTime = $nowts;
-                       $this->revCountLast = $this->revCount;
-               }
-       }
-
-       function progress( $string ) {
-               if ( $this->reporting ) {
-                       fwrite( $this->stderr, $string . "\n" );
-               }
-       }
-}
diff --git a/maintenance/benchmarks/bench_strtr_str_replace.php b/maintenance/benchmarks/bench_strtr_str_replace.php
deleted file mode 100644 (file)
index 2c065f6..0000000
+++ /dev/null
@@ -1,74 +0,0 @@
-<?php
-/**
- * Benchmark for strtr() vs str_replace().
- *
- * This come from r75429 message.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- * http://www.gnu.org/copyleft/gpl.html
- *
- * @file
- * @ingroup Benchmark
- */
-
-require_once __DIR__ . '/Benchmarker.php';
-
-function bfNormalizeTitleStrTr( $str ) {
-       return strtr( $str, '_', ' ' );
-}
-
-function bfNormalizeTitleStrReplace( $str ) {
-       return str_replace( '_', ' ', $str );
-}
-
-/**
- * Maintenance script that benchmarks for strtr() vs str_replace().
- *
- * @ingroup Benchmark
- */
-class BenchStrtrStrReplace extends Benchmarker {
-       public function __construct() {
-               parent::__construct();
-               $this->addDescription( 'Benchmark for strtr() vs str_replace().' );
-       }
-
-       public function execute() {
-               $this->bench( [
-                       [ 'function' => [ $this, 'benchstrtr' ] ],
-                       [ 'function' => [ $this, 'benchstr_replace' ] ],
-                       [ 'function' => [ $this, 'benchstrtr_indirect' ] ],
-                       [ 'function' => [ $this, 'benchstr_replace_indirect' ] ],
-               ] );
-       }
-
-       protected function benchstrtr() {
-               strtr( "[[MediaWiki:Some_random_test_page]]", "_", " " );
-       }
-
-       protected function benchstr_replace() {
-               str_replace( "_", " ", "[[MediaWiki:Some_random_test_page]]" );
-       }
-
-       protected function benchstrtr_indirect() {
-               bfNormalizeTitleStrTr( "[[MediaWiki:Some_random_test_page]]" );
-       }
-
-       protected function benchstr_replace_indirect() {
-               bfNormalizeTitleStrReplace( "[[MediaWiki:Some_random_test_page]]" );
-       }
-}
-
-$maintClass = BenchStrtrStrReplace::class;
-require_once RUN_MAINTENANCE_IF_MAIN;
diff --git a/maintenance/benchmarks/benchmarkStringReplacement.php b/maintenance/benchmarks/benchmarkStringReplacement.php
new file mode 100644 (file)
index 0000000..6db024c
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+/**
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Benchmark
+ */
+
+require_once __DIR__ . '/Benchmarker.php';
+
+/**
+ * Maintenance script that benchmarks string replacement methods.
+ *
+ * @ingroup Benchmark
+ */
+class BenchmarkStringReplacement extends Benchmarker {
+       protected $defaultCount = 10000;
+       private $input = 'MediaWiki:Some_random_test_page';
+
+       public function __construct() {
+               parent::__construct();
+               $this->addDescription( 'Benchmark for string replacement methods.' );
+       }
+
+       public function execute() {
+               $this->bench( [
+                       'strtr' => [ $this, 'bench_strtr' ],
+                       'str_replace' => [ $this, 'bench_str_replace' ],
+               ] );
+       }
+
+       protected function bench_strtr() {
+               strtr( $this->input, "_", " " );
+       }
+
+       protected function bench_str_replace() {
+               str_replace( "_", " ", $this->input );
+       }
+}
+
+$maintClass = BenchmarkStringReplacement::class;
+require_once RUN_MAINTENANCE_IF_MAIN;
index dad79b0..2442caa 100644 (file)
@@ -35,6 +35,7 @@ class DeduplicateArchiveRevId extends LoggedUpdateMaintenance {
                $this->output( "Deduplicating ar_rev_id...\n" );
 
                $dbw = $this->getDB( DB_MASTER );
+               PopulateArchiveRevId::checkMysqlAutoIncrementBug( $dbw );
 
                $minId = $dbw->selectField( 'archive', 'MIN(ar_rev_id)', [], __METHOD__ );
                $maxId = $dbw->selectField( 'archive', 'MAX(ar_rev_id)', [], __METHOD__ );
index 6bbd86d..b942302 100644 (file)
  * http://www.gnu.org/copyleft/gpl.html
  *
  * @file
- * @ingroup Dump Maintenance
+ * @ingroup Dump
+ * @ingroup Maintenance
  */
 
-require_once __DIR__ . '/backup.inc';
+require_once __DIR__ . '/includes/BackupDumper.php';
 
 class DumpBackup extends BackupDumper {
        function __construct( $args = null ) {
index 05db622..512910c 100644 (file)
@@ -24,7 +24,7 @@
  * @ingroup Maintenance
  */
 
-require_once __DIR__ . '/backup.inc';
+require_once __DIR__ . '/includes/BackupDumper.php';
 require_once __DIR__ . '/7zip.inc';
 require_once __DIR__ . '/../includes/export/WikiExporter.php';
 
diff --git a/maintenance/includes/BackupDumper.php b/maintenance/includes/BackupDumper.php
new file mode 100644 (file)
index 0000000..e8993e4
--- /dev/null
@@ -0,0 +1,425 @@
+<?php
+/**
+ * Base classes for database-dumping maintenance scripts.
+ *
+ * Copyright © 2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ * @ingroup Dump
+ * @ingroup Maintenance
+ */
+
+require_once __DIR__ . '/../Maintenance.php';
+
+use MediaWiki\MediaWikiServices;
+use Wikimedia\Rdbms\LoadBalancer;
+use Wikimedia\Rdbms\IDatabase;
+
+/**
+ * @ingroup Dump
+ * @ingroup Maintenance
+ */
+abstract class BackupDumper extends Maintenance {
+       public $reporting = true;
+       public $pages = null; // all pages
+       public $skipHeader = false; // don't output <mediawiki> and <siteinfo>
+       public $skipFooter = false; // don't output </mediawiki>
+       public $startId = 0;
+       public $endId = 0;
+       public $revStartId = 0;
+       public $revEndId = 0;
+       public $dumpUploads = false;
+       public $dumpUploadFileContents = false;
+       public $orderRevs = false;
+
+       protected $reportingInterval = 100;
+       protected $pageCount = 0;
+       protected $revCount = 0;
+       protected $server = null; // use default
+       protected $sink = null; // Output filters
+       protected $lastTime = 0;
+       protected $pageCountLast = 0;
+       protected $revCountLast = 0;
+
+       protected $outputTypes = [];
+       protected $filterTypes = [];
+
+       protected $ID = 0;
+
+       /**
+        * The dependency-injected database to use.
+        *
+        * @var IDatabase|null
+        *
+        * @see self::setDB
+        */
+       protected $forcedDb = null;
+
+       /** @var LoadBalancer */
+       protected $lb;
+
+       // @todo Unused?
+       private $stubText = false; // include rev_text_id instead of text; for 2-pass dump
+
+       /**
+        * @param array|null $args For backward compatibility
+        */
+       function __construct( $args = null ) {
+               parent::__construct();
+               $this->stderr = fopen( "php://stderr", "wt" );
+
+               // Built-in output and filter plugins
+               $this->registerOutput( 'file', DumpFileOutput::class );
+               $this->registerOutput( 'gzip', DumpGZipOutput::class );
+               $this->registerOutput( 'bzip2', DumpBZip2Output::class );
+               $this->registerOutput( 'dbzip2', DumpDBZip2Output::class );
+               $this->registerOutput( '7zip', Dump7ZipOutput::class );
+
+               $this->registerFilter( 'latest', DumpLatestFilter::class );
+               $this->registerFilter( 'notalk', DumpNotalkFilter::class );
+               $this->registerFilter( 'namespace', DumpNamespaceFilter::class );
+
+               // These three can be specified multiple times
+               $this->addOption( 'plugin', 'Load a dump plugin class. Specify as <class>[:<file>].',
+                       false, true, false, true );
+               $this->addOption( 'output', 'Begin a filtered output stream; Specify as <type>:<file>. ' .
+                       '<type>s: file, gzip, bzip2, 7zip, dbzip2', false, true, false, true );
+               $this->addOption( 'filter', 'Add a filter on an output branch. Specify as ' .
+                       '<type>[:<options>]. <types>s: latest, notalk, namespace', false, true, false, true );
+               $this->addOption( 'report', 'Report position and speed after every n pages processed. ' .
+                       'Default: 100.', false, true );
+               $this->addOption( 'server', 'Force reading from MySQL server', false, true );
+               $this->addOption( '7ziplevel', '7zip compression level for all 7zip outputs. Used for ' .
+                       '-mx option to 7za command.', false, true );
+
+               if ( $args ) {
+                       // Args should be loaded and processed so that dump() can be called directly
+                       // instead of execute()
+                       $this->loadWithArgv( $args );
+                       $this->processOptions();
+               }
+       }
+
+       /**
+        * @param string $name
+        * @param string $class Name of output filter plugin class
+        */
+       function registerOutput( $name, $class ) {
+               $this->outputTypes[$name] = $class;
+       }
+
+       /**
+        * @param string $name
+        * @param string $class Name of filter plugin class
+        */
+       function registerFilter( $name, $class ) {
+               $this->filterTypes[$name] = $class;
+       }
+
+       /**
+        * Load a plugin and register it
+        *
+        * @param string $class Name of plugin class; must have a static 'register'
+        *   method that takes a BackupDumper as a parameter.
+        * @param string $file Full or relative path to the PHP file to load, or empty
+        */
+       function loadPlugin( $class, $file ) {
+               if ( $file != '' ) {
+                       require_once $file;
+               }
+               $register = [ $class, 'register' ];
+               $register( $this );
+       }
+
+       function execute() {
+               throw new MWException( 'execute() must be overridden in subclasses' );
+       }
+
+       /**
+        * Processes arguments and sets $this->$sink accordingly
+        */
+       function processOptions() {
+               $sink = null;
+               $sinks = [];
+
+               $options = $this->orderedOptions;
+               foreach ( $options as $arg ) {
+                       $opt = $arg[0];
+                       $param = $arg[1];
+
+                       switch ( $opt ) {
+                               case 'plugin':
+                                       $val = explode( ':', $param );
+
+                                       if ( count( $val ) === 1 ) {
+                                               $this->loadPlugin( $val[0], '' );
+                                       } elseif ( count( $val ) === 2 ) {
+                                               $this->loadPlugin( $val[0], $val[1] );
+                                       } else {
+                                               $this->fatalError( 'Invalid plugin parameter' );
+                                               return;
+                                       }
+
+                                       break;
+                               case 'output':
+                                       $split = explode( ':', $param, 2 );
+                                       if ( count( $split ) !== 2 ) {
+                                               $this->fatalError( 'Invalid output parameter' );
+                                       }
+                                       list( $type, $file ) = $split;
+                                       if ( !is_null( $sink ) ) {
+                                               $sinks[] = $sink;
+                                       }
+                                       if ( !isset( $this->outputTypes[$type] ) ) {
+                                               $this->fatalError( "Unrecognized output sink type '$type'" );
+                                       }
+                                       $class = $this->outputTypes[$type];
+                                       if ( $type === "7zip" ) {
+                                               $sink = new $class( $file, intval( $this->getOption( '7ziplevel' ) ) );
+                                       } else {
+                                               $sink = new $class( $file );
+                                       }
+
+                                       break;
+                               case 'filter':
+                                       if ( is_null( $sink ) ) {
+                                               $sink = new DumpOutput();
+                                       }
+
+                                       $split = explode( ':', $param );
+                                       $key = $split[0];
+
+                                       if ( !isset( $this->filterTypes[$key] ) ) {
+                                               $this->fatalError( "Unrecognized filter type '$key'" );
+                                       }
+
+                                       $type = $this->filterTypes[$key];
+
+                                       if ( count( $split ) === 1 ) {
+                                               $filter = new $type( $sink );
+                                       } elseif ( count( $split ) === 2 ) {
+                                               $filter = new $type( $sink, $split[1] );
+                                       } else {
+                                               $this->fatalError( 'Invalid filter parameter' );
+                                       }
+
+                                       // references are lame in php...
+                                       unset( $sink );
+                                       $sink = $filter;
+
+                                       break;
+                       }
+               }
+
+               if ( $this->hasOption( 'report' ) ) {
+                       $this->reportingInterval = intval( $this->getOption( 'report' ) );
+               }
+
+               if ( $this->hasOption( 'server' ) ) {
+                       $this->server = $this->getOption( 'server' );
+               }
+
+               if ( is_null( $sink ) ) {
+                       $sink = new DumpOutput();
+               }
+               $sinks[] = $sink;
+
+               if ( count( $sinks ) > 1 ) {
+                       $this->sink = new DumpMultiWriter( $sinks );
+               } else {
+                       $this->sink = $sink;
+               }
+       }
+
+       function dump( $history, $text = WikiExporter::TEXT ) {
+               # Notice messages will foul up your XML output even if they're
+               # relatively harmless.
+               if ( ini_get( 'display_errors' ) ) {
+                       ini_set( 'display_errors', 'stderr' );
+               }
+
+               $this->initProgress( $history );
+
+               $db = $this->backupDb();
+               $exporter = new WikiExporter( $db, $history, WikiExporter::STREAM, $text );
+               $exporter->dumpUploads = $this->dumpUploads;
+               $exporter->dumpUploadFileContents = $this->dumpUploadFileContents;
+
+               $wrapper = new ExportProgressFilter( $this->sink, $this );
+               $exporter->setOutputSink( $wrapper );
+
+               if ( !$this->skipHeader ) {
+                       $exporter->openStream();
+               }
+               # Log item dumps: all or by range
+               if ( $history & WikiExporter::LOGS ) {
+                       if ( $this->startId || $this->endId ) {
+                               $exporter->logsByRange( $this->startId, $this->endId );
+                       } else {
+                               $exporter->allLogs();
+                       }
+               } elseif ( is_null( $this->pages ) ) {
+                       # Page dumps: all or by page ID range
+                       if ( $this->startId || $this->endId ) {
+                               $exporter->pagesByRange( $this->startId, $this->endId, $this->orderRevs );
+                       } elseif ( $this->revStartId || $this->revEndId ) {
+                               $exporter->revsByRange( $this->revStartId, $this->revEndId );
+                       } else {
+                               $exporter->allPages();
+                       }
+               } else {
+                       # Dump of specific pages
+                       $exporter->pagesByName( $this->pages );
+               }
+
+               if ( !$this->skipFooter ) {
+                       $exporter->closeStream();
+               }
+
+               $this->report( true );
+       }
+
+       /**
+        * Initialise starting time and maximum revision count.
+        * We'll make ETA calculations based an progress, assuming relatively
+        * constant per-revision rate.
+        * @param int $history WikiExporter::CURRENT or WikiExporter::FULL
+        */
+       function initProgress( $history = WikiExporter::FULL ) {
+               $table = ( $history == WikiExporter::CURRENT ) ? 'page' : 'revision';
+               $field = ( $history == WikiExporter::CURRENT ) ? 'page_id' : 'rev_id';
+
+               $dbr = $this->forcedDb;
+               if ( $this->forcedDb === null ) {
+                       $dbr = wfGetDB( DB_REPLICA );
+               }
+               $this->maxCount = $dbr->selectField( $table, "MAX($field)", '', __METHOD__ );
+               $this->startTime = microtime( true );
+               $this->lastTime = $this->startTime;
+               $this->ID = getmypid();
+       }
+
+       /**
+        * @todo Fixme: the --server parameter is currently not respected, as it
+        * doesn't seem terribly easy to ask the load balancer for a particular
+        * connection by name.
+        * @return IDatabase
+        */
+       function backupDb() {
+               if ( $this->forcedDb !== null ) {
+                       return $this->forcedDb;
+               }
+
+               $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
+               $this->lb = $lbFactory->newMainLB();
+               $db = $this->lb->getConnection( DB_REPLICA, 'dump' );
+
+               // Discourage the server from disconnecting us if it takes a long time
+               // to read out the big ol' batch query.
+               $db->setSessionOptions( [ 'connTimeout' => 3600 * 24 ] );
+
+               return $db;
+       }
+
+       /**
+        * Force the dump to use the provided database connection for database
+        * operations, wherever possible.
+        *
+        * @param IDatabase|null $db (Optional) the database connection to use. If null, resort to
+        *   use the globally provided ways to get database connections.
+        */
+       function setDB( IDatabase $db = null ) {
+               parent::setDB( $db );
+               $this->forcedDb = $db;
+       }
+
+       function __destruct() {
+               if ( isset( $this->lb ) ) {
+                       $this->lb->closeAll();
+               }
+       }
+
+       function backupServer() {
+               global $wgDBserver;
+
+               return $this->server
+                       ? $this->server
+                       : $wgDBserver;
+       }
+
+       function reportPage() {
+               $this->pageCount++;
+       }
+
+       function revCount() {
+               $this->revCount++;
+               $this->report();
+       }
+
+       function report( $final = false ) {
+               if ( $final xor ( $this->revCount % $this->reportingInterval == 0 ) ) {
+                       $this->showReport();
+               }
+       }
+
+       function showReport() {
+               if ( $this->reporting ) {
+                       $now = wfTimestamp( TS_DB );
+                       $nowts = microtime( true );
+                       $deltaAll = $nowts - $this->startTime;
+                       $deltaPart = $nowts - $this->lastTime;
+                       $this->pageCountPart = $this->pageCount - $this->pageCountLast;
+                       $this->revCountPart = $this->revCount - $this->revCountLast;
+
+                       if ( $deltaAll ) {
+                               $portion = $this->revCount / $this->maxCount;
+                               $eta = $this->startTime + $deltaAll / $portion;
+                               $etats = wfTimestamp( TS_DB, intval( $eta ) );
+                               $pageRate = $this->pageCount / $deltaAll;
+                               $revRate = $this->revCount / $deltaAll;
+                       } else {
+                               $pageRate = '-';
+                               $revRate = '-';
+                               $etats = '-';
+                       }
+                       if ( $deltaPart ) {
+                               $pageRatePart = $this->pageCountPart / $deltaPart;
+                               $revRatePart = $this->revCountPart / $deltaPart;
+                       } else {
+                               $pageRatePart = '-';
+                               $revRatePart = '-';
+                       }
+                       $this->progress( sprintf(
+                               "%s: %s (ID %d) %d pages (%0.1f|%0.1f/sec all|curr), "
+                                       . "%d revs (%0.1f|%0.1f/sec all|curr), ETA %s [max %d]",
+                               $now, wfWikiID(), $this->ID, $this->pageCount, $pageRate,
+                               $pageRatePart, $this->revCount, $revRate, $revRatePart, $etats,
+                               $this->maxCount
+                       ) );
+                       $this->lastTime = $nowts;
+                       $this->revCountLast = $this->revCount;
+               }
+       }
+
+       function progress( $string ) {
+               if ( $this->reporting ) {
+                       fwrite( $this->stderr, $string . "\n" );
+               }
+       }
+}
index e493506..60f5e8a 100644 (file)
@@ -21,6 +21,7 @@
  * @ingroup Maintenance
  */
 
+use Wikimedia\Rdbms\DBQueryError;
 use Wikimedia\Rdbms\IDatabase;
 
 require_once __DIR__ . '/Maintenance.php';
@@ -49,6 +50,7 @@ class PopulateArchiveRevId extends LoggedUpdateMaintenance {
        protected function doDBUpdates() {
                $this->output( "Populating ar_rev_id...\n" );
                $dbw = $this->getDB( DB_MASTER );
+               self::checkMysqlAutoIncrementBug( $dbw );
 
                // Quick exit if there are no rows needing updates.
                $any = $dbw->selectField(
@@ -86,6 +88,53 @@ class PopulateArchiveRevId extends LoggedUpdateMaintenance {
                }
        }
 
+       /**
+        * Check for (and work around) a MySQL auto-increment bug
+        *
+        * (T202032) MySQL until 8.0 and MariaDB until some version after 10.1.34
+        * don't save the auto-increment value to disk, so on server restart it
+        * might reuse IDs from deleted revisions. We can fix that with an insert
+        * with an explicit rev_id value, if necessary.
+        *
+        * @param IDatabase $dbw
+        */
+       public static function checkMysqlAutoIncrementBug( IDatabase $dbw ) {
+               if ( $dbw->getType() !== 'mysql' ) {
+                       return;
+               }
+
+               if ( !self::$dummyRev ) {
+                       self::$dummyRev = self::makeDummyRevisionRow( $dbw );
+               }
+
+               $ok = false;
+               while ( !$ok ) {
+                       try {
+                               $dbw->doAtomicSection( __METHOD__, function ( $dbw, $fname ) {
+                                       $dbw->insert( 'revision', self::$dummyRev, $fname );
+                                       $id = $dbw->insertId();
+                                       $toDelete[] = $id;
+
+                                       $maxId = max(
+                                               (int)$dbw->selectField( 'archive', 'MAX(ar_rev_id)', [], __METHOD__ ),
+                                               (int)$dbw->selectField( 'slots', 'MAX(slot_revision_id)', [], __METHOD__ )
+                                       );
+                                       if ( $id <= $maxId ) {
+                                               $dbw->insert( 'revision', [ 'rev_id' => $maxId + 1 ] + self::$dummyRev, $fname );
+                                               $toDelete[] = $maxId + 1;
+                                       }
+
+                                       $dbw->delete( 'revision', [ 'rev_id' => $toDelete ], $fname );
+                               } );
+                               $ok = true;
+                       } catch ( DBQueryError $e ) {
+                               if ( $e->errno != 1062 ) { // 1062 is "duplicate entry", ignore it and retry
+                                       throw $e;
+                               }
+                       }
+               }
+       }
+
        /**
         * Assign new ar_rev_ids to a set of ar_ids.
         * @param IDatabase $dbw
index 91c28bd..c40ef93 100644 (file)
@@ -2629,6 +2629,15 @@ return [
                ],
                'targets' => [ 'desktop', 'mobile' ],
        ],
+       'mediawiki.widgets.CheckMatrixWidget' => [
+               'scripts' => [
+                       'resources/src/mediawiki.widgets/mw.widgets.CheckMatrixWidget.js',
+               ],
+               'dependencies' => [
+                       'oojs-ui-core',
+               ],
+               'targets' => [ 'desktop', 'mobile' ],
+       ],
        'mediawiki.widgets.CategoryMultiselectWidget' => [
                'scripts' => [
                        'resources/src/mediawiki.widgets/mw.widgets.CategoryTagItemWidget.js',
diff --git a/resources/src/mediawiki.widgets/mw.widgets.CheckMatrixWidget.js b/resources/src/mediawiki.widgets/mw.widgets.CheckMatrixWidget.js
new file mode 100644 (file)
index 0000000..e13c6fa
--- /dev/null
@@ -0,0 +1,142 @@
+( function ( $, mw ) {
+       /**
+        * A JavaScript version of CheckMatrixWidget.
+        *
+        * @class
+        * @extends OO.ui.Widget
+        *
+        * @constructor
+        * @param {Object} [config] Configuration options
+        * @cfg {Object} columns Required object representing the column labels and associated
+        *  tags in the matrix.
+        * @cfg {Object} rows Required object representing the row labels and associated
+        *  tags in the matrix.
+        * @cfg {string[]} [forcedOn] An array of column-row tags to be displayed as
+        *  enabled but unavailable to change
+        * @cfg {string[]} [forcedOff] An array of column-row tags to be displayed as
+        *  disnabled but unavailable to change
+        * @cfg {Object} Object mapping row label to tooltip content
+        */
+       mw.widgets.CheckMatrixWidget = function MWWCheckMatrixWidget( config ) {
+               var $headRow = $( '<tr>' ),
+                       $table = $( '<table>' ),
+                       widget = this;
+               config = config || {};
+
+               // Parent constructor
+               mw.widgets.CheckMatrixWidget.parent.call( this, config );
+               this.checkboxes = {};
+               this.name = config.name;
+               this.id = config.id;
+               this.rows = config.rows || {};
+               this.columns = config.columns || {};
+               this.tooltips = config.tooltips || [];
+               this.values = config.values || [];
+               this.forcedOn = config.forcedOn || [];
+               this.forcedOff = config.forcedOff || [];
+
+               // Build header
+               $headRow.append( $( '<td>' ).html( '&#160;' ) );
+
+               // Iterate over the columns object (ignore the value)
+               $.each( this.columns, function ( columnLabel ) {
+                       $headRow.append( $( '<td>' ).text( columnLabel ) );
+               } );
+               $table.append( $headRow );
+
+               // Build table
+               $.each( this.rows, function ( rowLabel, rowTag ) {
+                       var $row = $( '<tr>' ),
+                               labelField = new OO.ui.FieldLayout(
+                                       new OO.ui.Widget(), // Empty widget, since we don't have the checkboxes here
+                                       {
+                                               label: rowLabel,
+                                               help: widget.tooltips[ rowLabel ],
+                                               align: 'inline'
+                                       }
+                               );
+
+                       // Label
+                       $row.append( $( '<td>' ).append( labelField.$element ) );
+
+                       // Columns
+                       $.each( widget.columns, function ( columnLabel, columnTag ) {
+                               var thisTag = columnTag + '-' + rowTag,
+                                       checkbox = new OO.ui.CheckboxInputWidget( {
+                                               value: thisTag,
+                                               name: widget.name ? widget.name + '[]' : undefined,
+                                               id: widget.id ? widget.id + '-' + thisTag : undefined,
+                                               selected: widget.isTagSelected( thisTag ),
+                                               disabled: widget.isTagDisabled( thisTag )
+                                       } );
+
+                               widget.checkboxes[ thisTag ] = checkbox;
+                               $row.append( $( '<td>' ).append( checkbox.$element ) );
+                       } );
+
+                       $table.append( $row );
+               } );
+
+               this.$element
+                       .addClass( 'mw-widget-checkMatrixWidget' )
+                       .append( $table );
+       };
+
+       /* Setup */
+
+       OO.inheritClass( mw.widgets.CheckMatrixWidget, OO.ui.Widget );
+
+       /* Methods */
+
+       /**
+        * Check whether the given tag is selected
+        *
+        * @param {string} tagName Tag name
+        * @return {boolean} Tag is selected
+        */
+       mw.widgets.CheckMatrixWidget.prototype.isTagSelected = function ( tagName ) {
+               return (
+                       // If tag is not forced off
+                       this.forcedOff.indexOf( tagName ) === -1 &&
+                       (
+                               // If tag is in values
+                               this.values.indexOf( tagName ) > -1 ||
+                               // If tag is forced on
+                               this.forcedOn.indexOf( tagName ) > -1
+                       )
+               );
+       };
+
+       /**
+        * Check whether the given tag is disabled
+        *
+        * @param {string} tagName Tag name
+        * @return {boolean} Tag is disabled
+        */
+       mw.widgets.CheckMatrixWidget.prototype.isTagDisabled = function ( tagName ) {
+               return (
+                       // If the entire widget is disabled
+                       this.isDisabled() ||
+                       // If tag is forced off or forced on
+                       this.forcedOff.indexOf( tagName ) > -1 ||
+                       this.forcedOn.indexOf( tagName ) > -1
+               );
+       };
+       /**
+        * @inheritdoc
+        */
+       mw.widgets.CheckMatrixWidget.prototype.setDisabled = function ( isDisabled ) {
+               var widget = this;
+
+               // Parent method
+               mw.widgets.CheckMatrixWidget.parent.prototype.setDisabled.call( this, isDisabled );
+
+               // setDisabled sometimes gets called before the widget is ready
+               if ( this.checkboxes && Object.keys( this.checkboxes ).length > 0 ) {
+                       // Propagate to all checkboxes and update their disabled state
+                       $.each( this.checkboxes, function ( name, checkbox ) {
+                               checkbox.setDisabled( widget.isTagDisabled( name ) );
+                       } );
+               }
+       };
+}( jQuery, mediaWiki ) );
index 0757b34..067905e 100644 (file)
@@ -17,4 +17,8 @@ class ParserTestMockParser {
        ) {
                return new ParserOutput;
        }
+
+       public function getOutput() {
+               return new ParserOutput;
+       }
 }
index bcb3379..f76b1e3 100644 (file)
@@ -44,7 +44,6 @@ return [
                class_exists( PHPUnit_TextUI_Command::class ) ? [] : [ 'tests/phan/stubs/phpunit4.php' ],
                [
                        'maintenance/7zip.inc',
-                       'maintenance/backup.inc',
                        'maintenance/cleanupTable.inc',
                        'maintenance/CodeCleanerGlobalsPass.inc',
                        'maintenance/commandLine.inc',
index 9a118d7..649e692 100644 (file)
@@ -3,6 +3,7 @@ namespace MediaWiki\Tests\Storage;
 
 use CommentStoreComment;
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\MutableRevisionRecord;
 use MediaWiki\Storage\RevisionRecord;
 use MediaWiki\Storage\SlotRecord;
 use TextContent;
@@ -239,4 +240,56 @@ class McrRevisionStoreDbTest extends RevisionStoreDbTestBase {
                ];
        }
 
+       /**
+        * @covers \MediaWiki\Storage\RevisionStore::insertRevisionOn
+        * @covers \MediaWiki\Storage\RevisionStore::insertSlotRowOn
+        * @covers \MediaWiki\Storage\RevisionStore::insertContentRowOn
+        */
+       public function testInsertRevisionOn_T202032() {
+               // This test only makes sense for MySQL
+               if ( $this->db->getType() !== 'mysql' ) {
+                       $this->assertTrue( true );
+                       return;
+               }
+
+               // NOTE: must be done before checking MAX(rev_id)
+               $page = $this->getTestPage();
+
+               $maxRevId = $this->db->selectField( 'revision', 'MAX(rev_id)' );
+
+               // Construct a slot row that will conflict with the insertion of the next revision ID,
+               // to emulate the failure mode described in T202032. Nothing will ever read this row,
+               // we just need it to trigger a primary key conflict.
+               $this->db->insert( 'slots', [
+                       'slot_revision_id' => $maxRevId + 1,
+                       'slot_role_id' => 1,
+                       'slot_content_id' => 0,
+                       'slot_origin' => 0
+               ], __METHOD__ );
+
+               $rev = new MutableRevisionRecord( $page->getTitle() );
+               $rev->setTimestamp( '20180101000000' );
+               $rev->setComment( CommentStoreComment::newUnsavedComment( 'test' ) );
+               $rev->setUser( $this->getTestUser()->getUser() );
+               $rev->setContent( 'main', new WikitextContent( 'Text' ) );
+               $rev->setPageId( $page->getId() );
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $return = $store->insertRevisionOn( $rev, $this->db );
+
+               $this->assertSame( $maxRevId + 2, $return->getId() );
+
+               // is the new revision correct?
+               $this->assertRevisionCompleteness( $return );
+               $this->assertRevisionRecordsEqual( $rev, $return );
+
+               // can we find it directly in the database?
+               $this->assertRevisionExistsInDatabase( $return );
+
+               // can we load it from the store?
+               $loaded = $store->getRevisionById( $return->getId() );
+               $this->assertRevisionCompleteness( $loaded );
+               $this->assertRevisionRecordsEqual( $return, $loaded );
+       }
+
 }
index ad1e013..8137b27 100644 (file)
@@ -244,14 +244,14 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
                $this->assertSame( 0, $count );
        }
 
-       private function assertLinkTargetsEqual( LinkTarget $l1, LinkTarget $l2 ) {
+       protected function assertLinkTargetsEqual( LinkTarget $l1, LinkTarget $l2 ) {
                $this->assertEquals( $l1->getDBkey(), $l2->getDBkey() );
                $this->assertEquals( $l1->getNamespace(), $l2->getNamespace() );
                $this->assertEquals( $l1->getFragment(), $l2->getFragment() );
                $this->assertEquals( $l1->getInterwiki(), $l2->getInterwiki() );
        }
 
-       private function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) {
+       protected function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) {
                $this->assertEquals(
                        $r1->getPageAsLinkTarget()->getNamespace(),
                        $r2->getPageAsLinkTarget()->getNamespace()
@@ -291,7 +291,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
                }
        }
 
-       private function assertSlotRecordsEqual( SlotRecord $s1, SlotRecord $s2 ) {
+       protected function assertSlotRecordsEqual( SlotRecord $s1, SlotRecord $s2 ) {
                $this->assertSame( $s1->getRole(), $s2->getRole() );
                $this->assertSame( $s1->getModel(), $s2->getModel() );
                $this->assertSame( $s1->getFormat(), $s2->getFormat() );
@@ -303,7 +303,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
                $s1->hasAddress() ? $this->assertSame( $s1->hasAddress(), $s2->hasAddress() ) : null;
        }
 
-       private function assertRevisionCompleteness( RevisionRecord $r ) {
+       protected function assertRevisionCompleteness( RevisionRecord $r ) {
                $this->assertTrue( $r->hasSlot( 'main' ) );
                $this->assertInstanceOf( SlotRecord::class, $r->getSlot( 'main' ) );
                $this->assertInstanceOf( Content::class, $r->getContent( 'main' ) );
@@ -313,7 +313,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
                }
        }
 
-       private function assertSlotCompleteness( RevisionRecord $r, SlotRecord $slot ) {
+       protected function assertSlotCompleteness( RevisionRecord $r, SlotRecord $slot ) {
                $this->assertTrue( $slot->hasAddress() );
                $this->assertSame( $r->getId(), $slot->getRevision() );
 
index 0428335..30e1d0c 100644 (file)
@@ -73,6 +73,9 @@ class ApiComparePagesTest extends ApiTestCase {
                self::$repl['revF1'] = $this->addPage( 'F', "== Section 1 ==\nF 1.1\n\n== Section 2 ==\nF 1.2" );
                self::$repl['pageF'] = Title::newFromText( 'ApiComparePagesTest F' )->getArticleId();
 
+               self::$repl['revG1'] = $this->addPage( 'G', "== Section 1 ==\nG 1.1", CONTENT_MODEL_TEXT );
+               self::$repl['pageG'] = Title::newFromText( 'ApiComparePagesTest G' )->getArticleId();
+
                WikiPage::factory( Title::newFromText( 'ApiComparePagesTest C' ) )
                        ->doDeleteArticleReal( 'Test for ApiComparePagesTest' );
 
@@ -132,6 +135,7 @@ class ApiComparePagesTest extends ApiTestCase {
 
                $params += [
                        'action' => 'compare',
+                       'errorformat' => 'none',
                ];
 
                $user = $sysop
@@ -153,6 +157,25 @@ class ApiComparePagesTest extends ApiTestCase {
                }
        }
 
+       private static function makeDeprecationWarnings( ...$params ) {
+               $warn = [];
+               foreach ( $params as $p ) {
+                       $warn[] = [
+                               'code' => 'deprecation',
+                               'data' => [ 'feature' => "action=compare&{$p}" ],
+                               'module' => 'compare',
+                       ];
+                       if ( count( $warn ) === 1 ) {
+                               $warn[] = [
+                                       'code' => 'deprecation-help',
+                                       'module' => 'main',
+                               ];
+                       }
+               }
+
+               return $warn;
+       }
+
        public static function provideDiff() {
                // phpcs:disable Generic.Files.LineLength.TooLong
                return [
@@ -269,10 +292,12 @@ class ApiComparePagesTest extends ApiTestCase {
                        ],
                        'Basic diff, text' => [
                                [
-                                       'fromtext' => 'From text',
-                                       'fromcontentmodel' => 'wikitext',
-                                       'totext' => 'To text {{subst:PAGENAME}}',
-                                       'tocontentmodel' => 'wikitext',
+                                       'fromslots' => 'main',
+                                       'fromtext-main' => 'From text',
+                                       'fromcontentmodel-main' => 'wikitext',
+                                       'toslots' => 'main',
+                                       'totext-main' => 'To text {{subst:PAGENAME}}',
+                                       'tocontentmodel-main' => 'wikitext',
                                ],
                                [
                                        'compare' => [
@@ -284,9 +309,11 @@ class ApiComparePagesTest extends ApiTestCase {
                        ],
                        'Basic diff, text 2' => [
                                [
-                                       'fromtext' => 'From text',
-                                       'totext' => 'To text {{subst:PAGENAME}}',
-                                       'tocontentmodel' => 'wikitext',
+                                       'fromslots' => 'main',
+                                       'fromtext-main' => 'From text',
+                                       'toslots' => 'main',
+                                       'totext-main' => 'To text {{subst:PAGENAME}}',
+                                       'tocontentmodel-main' => 'wikitext',
                                ],
                                [
                                        'compare' => [
@@ -298,15 +325,13 @@ class ApiComparePagesTest extends ApiTestCase {
                        ],
                        'Basic diff, guessed model' => [
                                [
-                                       'fromtext' => 'From text',
-                                       'totext' => 'To text',
+                                       'fromslots' => 'main',
+                                       'fromtext-main' => 'From text',
+                                       'toslots' => 'main',
+                                       'totext-main' => 'To text',
                                ],
                                [
-                                       'warnings' => [
-                                               'compare' => [
-                                                       'warnings' => 'No content model could be determined, assuming wikitext.',
-                                               ],
-                                       ],
+                                       'warnings' => [ [ 'code' => 'compare-nocontentmodel', 'module' => 'compare' ] ],
                                        'compare' => [
                                                'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
                                                        . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
@@ -316,9 +341,11 @@ class ApiComparePagesTest extends ApiTestCase {
                        ],
                        'Basic diff, text with title and PST' => [
                                [
-                                       'fromtext' => 'From text',
+                                       'fromslots' => 'main',
+                                       'fromtext-main' => 'From text',
                                        'totitle' => 'Test',
-                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'toslots' => 'main',
+                                       'totext-main' => 'To text {{subst:PAGENAME}}',
                                        'topst' => true,
                                ],
                                [
@@ -331,9 +358,11 @@ class ApiComparePagesTest extends ApiTestCase {
                        ],
                        'Basic diff, text with page ID and PST' => [
                                [
-                                       'fromtext' => 'From text',
+                                       'fromslots' => 'main',
+                                       'fromtext-main' => 'From text',
                                        'toid' => '{{REPL:pageB}}',
-                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'toslots' => 'main',
+                                       'totext-main' => 'To text {{subst:PAGENAME}}',
                                        'topst' => true,
                                ],
                                [
@@ -346,9 +375,11 @@ class ApiComparePagesTest extends ApiTestCase {
                        ],
                        'Basic diff, text with revision and PST' => [
                                [
-                                       'fromtext' => 'From text',
+                                       'fromslots' => 'main',
+                                       'fromtext-main' => 'From text',
                                        'torev' => '{{REPL:revB2}}',
-                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'toslots' => 'main',
+                                       'totext-main' => 'To text {{subst:PAGENAME}}',
                                        'topst' => true,
                                ],
                                [
@@ -361,9 +392,11 @@ class ApiComparePagesTest extends ApiTestCase {
                        ],
                        'Basic diff, text with deleted revision and PST' => [
                                [
-                                       'fromtext' => 'From text',
+                                       'fromslots' => 'main',
+                                       'fromtext-main' => 'From text',
                                        'torev' => '{{REPL:revC2}}',
-                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'toslots' => 'main',
+                                       'totext-main' => 'To text {{subst:PAGENAME}}',
                                        'topst' => true,
                                ],
                                [
@@ -378,20 +411,23 @@ class ApiComparePagesTest extends ApiTestCase {
                        'Basic diff, test with sections' => [
                                [
                                        'fromtitle' => 'ApiComparePagesTest F',
-                                       'fromsection' => 1,
-                                       'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
-                                       'tosection' => 2,
+                                       'fromslots' => 'main',
+                                       'fromtext-main' => "== Section 2 ==\nFrom text?",
+                                       'fromsection-main' => 2,
+                                       'totitle' => 'ApiComparePagesTest F',
+                                       'toslots' => 'main',
+                                       'totext-main' => "== Section 1 ==\nTo text?",
+                                       'tosection-main' => 1,
                                ],
                                [
                                        'compare' => [
                                                'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
                                                        . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
-                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>== Section <del class="diffchange diffchange-inline">1 </del>==</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>== Section <ins class="diffchange diffchange-inline">2 </ins>==</div></td></tr>' . "\n"
-                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">F 1.1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To text?</ins></div></td></tr>' . "\n",
-                                               'fromid' => '{{REPL:pageF}}',
-                                               'fromrevid' => '{{REPL:revF1}}',
-                                               'fromns' => '0',
-                                               'fromtitle' => 'ApiComparePagesTest F',
+                                                       . '<tr><td class=\'diff-marker\'> </td><td class=\'diff-context\'><div>== Section 1 ==</div></td><td class=\'diff-marker\'> </td><td class=\'diff-context\'><div>== Section 1 ==</div></td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">F 1.1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To text?</ins></div></td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'> </td><td class=\'diff-context\'></td><td class=\'diff-marker\'> </td><td class=\'diff-context\'></td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'> </td><td class=\'diff-context\'><div>== Section 2 ==</div></td><td class=\'diff-marker\'> </td><td class=\'diff-context\'><div>== Section 2 ==</div></td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From text?</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">F 1.2</ins></div></td></tr>' . "\n",
                                        ]
                                ],
                        ],
@@ -517,6 +553,197 @@ class ApiComparePagesTest extends ApiTestCase {
                                        ]
                                ],
                        ],
+                       'Diff for specific slots' => [
+                               // @todo Use a page with multiple slots here
+                               [
+                                       'fromrev' => '{{REPL:revA1}}',
+                                       'torev' => '{{REPL:revA3}}',
+                                       'prop' => 'diff',
+                                       'slots' => 'main',
+                               ],
+                               [
+                                       'compare' => [
+                                               'bodies' => [
+                                                       'main' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                               . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                               . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>A <del class="diffchange diffchange-inline">1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>A <ins class="diffchange diffchange-inline">3</ins></div></td></tr>' . "\n",
+                                               ],
+                                       ],
+                               ],
+                       ],
+                       // @todo Add a test for diffing with a deleted slot. Deleting 'main' doesn't work.
+
+                       'Basic diff, deprecated text' => [
+                               [
+                                       'fromtext' => 'From text',
+                                       'fromcontentmodel' => 'wikitext',
+                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'tocontentmodel' => 'wikitext',
+                               ],
+                               [
+                                       'warnings' => self::makeDeprecationWarnings( 'fromtext', 'fromcontentmodel', 'totext', 'tocontentmodel' ),
+                                       'compare' => [
+                                               'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                       . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
+                                       ]
+                               ],
+                       ],
+                       'Basic diff, deprecated text 2' => [
+                               [
+                                       'fromtext' => 'From text',
+                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'tocontentmodel' => 'wikitext',
+                               ],
+                               [
+                                       'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext', 'tocontentmodel' ),
+                                       'compare' => [
+                                               'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                       . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">{{subst:PAGENAME}}</ins></div></td></tr>' . "\n",
+                                       ]
+                               ],
+                       ],
+                       'Basic diff, deprecated text, guessed model' => [
+                               [
+                                       'fromtext' => 'From text',
+                                       'totext' => 'To text',
+                               ],
+                               [
+                                       'warnings' => array_merge( self::makeDeprecationWarnings( 'fromtext', 'totext' ), [
+                                               [ 'code' => 'compare-nocontentmodel', 'module' => 'compare' ],
+                                       ] ),
+                                       'compare' => [
+                                               'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                       . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text</div></td></tr>' . "\n",
+                                       ]
+                               ],
+                       ],
+                       'Basic diff, deprecated text with title and PST' => [
+                               [
+                                       'fromtext' => 'From text',
+                                       'totitle' => 'Test',
+                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'topst' => true,
+                               ],
+                               [
+                                       'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ),
+                                       'compare' => [
+                                               'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                       . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">Test</ins></div></td></tr>' . "\n",
+                                       ]
+                               ],
+                       ],
+                       'Basic diff, deprecated text with page ID and PST' => [
+                               [
+                                       'fromtext' => 'From text',
+                                       'toid' => '{{REPL:pageB}}',
+                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'topst' => true,
+                               ],
+                               [
+                                       'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ),
+                                       'compare' => [
+                                               'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                       . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
+                                       ]
+                               ],
+                       ],
+                       'Basic diff, deprecated text with revision and PST' => [
+                               [
+                                       'fromtext' => 'From text',
+                                       'torev' => '{{REPL:revB2}}',
+                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'topst' => true,
+                               ],
+                               [
+                                       'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ),
+                                       'compare' => [
+                                               'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                       . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest B</ins></div></td></tr>' . "\n",
+                                       ]
+                               ],
+                       ],
+                       'Basic diff, deprecated text with deleted revision and PST' => [
+                               [
+                                       'fromtext' => 'From text',
+                                       'torev' => '{{REPL:revC2}}',
+                                       'totext' => 'To text {{subst:PAGENAME}}',
+                                       'topst' => true,
+                               ],
+                               [
+                                       'warnings' => self::makeDeprecationWarnings( 'fromtext', 'totext' ),
+                                       'compare' => [
+                                               'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                       . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">From </del>text</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To </ins>text <ins class="diffchange diffchange-inline">ApiComparePagesTest C</ins></div></td></tr>' . "\n",
+                                       ]
+                               ],
+                               false, true
+                       ],
+                       'Basic diff, test with deprecated sections' => [
+                               [
+                                       'fromtitle' => 'ApiComparePagesTest F',
+                                       'fromsection' => 1,
+                                       'totext' => "== Section 1 ==\nTo text\n\n== Section 2 ==\nTo text?",
+                                       'tosection' => 2,
+                               ],
+                               [
+                                       'warnings' => self::makeDeprecationWarnings( 'fromsection', 'totext', 'tosection' ),
+                                       'compare' => [
+                                               'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                       . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>== Section <del class="diffchange diffchange-inline">1 </del>==</div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>== Section <ins class="diffchange diffchange-inline">2 </ins>==</div></td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div><del class="diffchange diffchange-inline">F 1.1</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div><ins class="diffchange diffchange-inline">To text?</ins></div></td></tr>' . "\n",
+                                               'fromid' => '{{REPL:pageF}}',
+                                               'fromrevid' => '{{REPL:revF1}}',
+                                               'fromns' => '0',
+                                               'fromtitle' => 'ApiComparePagesTest F',
+                                       ]
+                               ],
+                       ],
+                       'Basic diff, test with deprecated sections and revdel, non-sysop' => [
+                               [
+                                       'fromrev' => '{{REPL:revB2}}',
+                                       'fromsection' => 0,
+                                       'torev' => '{{REPL:revB4}}',
+                                       'tosection' => 0,
+                               ],
+                               [],
+                               'missingcontent'
+                       ],
+                       'Basic diff, test with deprecated sections and revdel, sysop' => [
+                               [
+                                       'fromrev' => '{{REPL:revB2}}',
+                                       'fromsection' => 0,
+                                       'torev' => '{{REPL:revB4}}',
+                                       'tosection' => 0,
+                               ],
+                               [
+                                       'warnings' => self::makeDeprecationWarnings( 'fromsection', 'tosection' ),
+                                       'compare' => [
+                                               'body' => '<tr><td colspan="2" class="diff-lineno" id="mw-diff-left-l1" >Line 1:</td>' . "\n"
+                                                       . '<td colspan="2" class="diff-lineno">Line 1:</td></tr>' . "\n"
+                                                       . '<tr><td class=\'diff-marker\'>−</td><td class=\'diff-deletedline\'><div>B <del class="diffchange diffchange-inline">2</del></div></td><td class=\'diff-marker\'>+</td><td class=\'diff-addedline\'><div>B <ins class="diffchange diffchange-inline">4</ins></div></td></tr>' . "\n",
+                                               'fromid' => '{{REPL:pageB}}',
+                                               'fromrevid' => '{{REPL:revB2}}',
+                                               'fromns' => 0,
+                                               'fromtitle' => 'ApiComparePagesTest B',
+                                               'fromtexthidden' => true,
+                                               'fromuserhidden' => true,
+                                               'fromcommenthidden' => true,
+                                               'toid' => '{{REPL:pageB}}',
+                                               'torevid' => '{{REPL:revB4}}',
+                                               'tons' => 0,
+                                               'totitle' => 'ApiComparePagesTest B',
+                                       ]
+                               ],
+                               false, true,
+                       ],
 
                        'Error, missing title' => [
                                [
@@ -647,6 +874,68 @@ class ApiComparePagesTest extends ApiTestCase {
                                [],
                                'missingcontent'
                        ],
+                       'Error, Relative diff, no prev' => [
+                               [
+                                       'fromrev' => '{{REPL:revA1}}',
+                                       'torelative' => 'prev',
+                                       'prop' => 'ids',
+                               ],
+                               [],
+                               'baddiff'
+                       ],
+                       'Error, Relative diff, no next' => [
+                               [
+                                       'fromrev' => '{{REPL:revA4}}',
+                                       'torelative' => 'next',
+                                       'prop' => 'ids',
+                               ],
+                               [],
+                               'baddiff'
+                       ],
+                       'Error, section diff with no revision' => [
+                               [
+                                       'fromtitle' => 'ApiComparePagesTest F',
+                                       'toslots' => 'main',
+                                       'totext-main' => "== Section 1 ==\nTo text?",
+                                       'tosection-main' => 1,
+                               ],
+                               [],
+                               'compare-notorevision',
+                       ],
+                       'Error, section diff with revdeleted revision' => [
+                               [
+                                       'fromtitle' => 'ApiComparePagesTest F',
+                                       'torev' => '{{REPL:revB2}}',
+                                       'toslots' => 'main',
+                                       'totext-main' => "== Section 1 ==\nTo text?",
+                                       'tosection-main' => 1,
+                               ],
+                               [],
+                               'missingcontent',
+                       ],
+                       'Error, section diff with a content model not supporting sections' => [
+                               [
+                                       'fromtitle' => 'ApiComparePagesTest G',
+                                       'torev' => '{{REPL:revG1}}',
+                                       'toslots' => 'main',
+                                       'totext-main' => "== Section 1 ==\nTo text?",
+                                       'tosection-main' => 1,
+                               ],
+                               [],
+                               'sectionsnotsupported',
+                       ],
+                       'Error, section diff with bad content model' => [
+                               [
+                                       'fromtitle' => 'ApiComparePagesTest F',
+                                       'torev' => '{{REPL:revF1}}',
+                                       'toslots' => 'main',
+                                       'totext-main' => "== Section 1 ==\nTo text?",
+                                       'tosection-main' => 1,
+                                       'tocontentmodel-main' => CONTENT_MODEL_TEXT,
+                               ],
+                               [],
+                               'sectionreplacefailed',
+                       ],
                ];
                // phpcs:enable
        }
index 60cda09..d5d33fb 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use Wikimedia\TestingAccessWrapper;
+
 /**
  * @covers ApiStashEdit
  * @group API
  * @group Database
  */
 class ApiStashEditTest extends ApiTestCase {
+       public function setUp() {
+               parent::setUp();
+
+               // We need caching here, but note that the cache gets cleared in between tests, so it
+               // doesn't work with @depends
+               $this->setMwGlobals( 'wgMainCacheType', 'hash' );
+       }
+
+       /**
+        * Make a stashedit API call with suitable default parameters
+        *
+        * @param array $params Query parameters for API request.  All are optional and will have
+        *   sensible defaults filled in.  To make a parameter actually not passed, set to null.
+        * @param User $user User to do the request
+        * @param string $expectedResult 'stashed', 'editconflict'
+        */
+       protected function doStash(
+               array $params = [], User $user = null, $expectedResult = 'stashed'
+       ) {
+               $params = array_merge( [
+                       'action' => 'stashedit',
+                       'title' => __CLASS__,
+                       'contentmodel' => 'wikitext',
+                       'contentformat' => 'text/x-wiki',
+                       'baserevid' => 0,
+               ], $params );
+               if ( !array_key_exists( 'text', $params ) &&
+                       !array_key_exists( 'stashedtexthash', $params )
+               ) {
+                       $params['text'] = 'Content';
+               }
+               foreach ( $params as $key => $val ) {
+                       if ( $val === null ) {
+                               unset( $params[$key] );
+                       }
+               }
+
+               if ( isset( $params['text'] ) ) {
+                       $expectedText = $params['text'];
+               } elseif ( isset( $params['stashedtexthash'] ) ) {
+                       $expectedText = $this->getStashedText( $params['stashedtexthash'] );
+               }
+               if ( isset( $expectedText ) ) {
+                       $expectedText = rtrim( str_replace( "\r\n", "\n", $expectedText ) );
+                       $expectedHash = sha1( $expectedText );
+                       $origText = $this->getStashedText( $expectedHash );
+               }
+
+               $res = $this->doApiRequestWithToken( $params, null, $user );
+
+               $this->assertSame( $expectedResult, $res[0]['stashedit']['status'] );
+               $this->assertCount( $expectedResult === 'stashed' ? 2 : 1, $res[0]['stashedit'] );
+
+               if ( $expectedResult === 'stashed' ) {
+                       $hash = $res[0]['stashedit']['texthash'];
+
+                       $this->assertSame( $expectedText, $this->getStashedText( $hash ) );
+
+                       $this->assertSame( $expectedHash, $hash );
+
+                       if ( isset( $params['stashedtexthash'] ) ) {
+                               $this->assertSame( $params['stashedtexthash'], $expectedHash, 'Sanity' );
+                       }
+               } else {
+                       $this->assertSame( $origText, $this->getStashedText( $expectedHash ) );
+               }
+
+               $this->assertArrayNotHasKey( 'warnings', $res[0] );
+
+               return $res;
+       }
+
+       /**
+        * Return the text stashed for $hash.
+        *
+        * @param string $hash
+        * @return string
+        */
+       protected function getStashedText( $hash ) {
+               $cache = ObjectCache::getLocalClusterInstance();
+               $key = $cache->makeKey( 'stashedit', 'text', $hash );
+               return $cache->get( $key );
+       }
+
+       /**
+        * Return a key that can be passed to the cache to obtain a PreparedEdit object.
+        *
+        * @param string $title Title of page
+        * @param string Content $text Content of edit
+        * @param User $user User who made edit
+        * @return string
+        */
+       protected function getStashKey( $title = __CLASS__, $text = 'Content', User $user = null ) {
+               $titleObj = Title::newFromText( $title );
+               $content = new WikitextContent( $text );
+               if ( !$user ) {
+                       $user = $this->getTestSysop()->getUser();
+               }
+               $wrapper = TestingAccessWrapper::newFromClass( ApiStashEdit::class );
+               return $wrapper->getStashKey( $titleObj, $wrapper->getContentHash( $content ), $user );
+       }
 
        public function testBasicEdit() {
-               $apiResult = $this->doApiRequestWithToken(
-                       [
-                               'action' => 'stashedit',
-                               'title' => 'ApistashEdit_Page',
-                               'contentmodel' => 'wikitext',
-                               'contentformat' => 'text/x-wiki',
-                               'text' => 'Text for ' . __METHOD__ . ' page',
-                               'baserevid' => 0,
-                       ]
+               $this->doStash();
+       }
+
+       public function testBot() {
+               // @todo This restriction seems arbitrary, is there any good reason to keep it?
+               $this->setExpectedApiException( 'apierror-botsnotsupported' );
+
+               $this->doStash( [], $this->getTestUser( [ 'bot' ] )->getUser() );
+       }
+
+       public function testUnrecognizedFormat() {
+               $this->setExpectedApiException(
+                       [ 'apierror-badformat-generic', 'application/json', 'wikitext' ] );
+
+               $this->doStash( [ 'contentformat' => 'application/json' ] );
+       }
+
+       public function testMissingTextAndStashedTextHash() {
+               $this->setExpectedApiException( [
+                       'apierror-missingparam-one-of',
+                       Message::listParam( [ '<var>stashedtexthash</var>', '<var>text</var>' ] ),
+                       2
+               ] );
+               $this->doStash( [ 'text' => null ] );
+       }
+
+       public function testStashedTextHash() {
+               $res = $this->doStash();
+
+               $this->doStash( [ 'stashedtexthash' => $res[0]['stashedit']['texthash'] ] );
+       }
+
+       public function testMalformedStashedTextHash() {
+               $this->setExpectedApiException( 'apierror-stashedit-missingtext' );
+               $this->doStash( [ 'stashedtexthash' => 'abc' ] );
+       }
+
+       public function testMissingStashedTextHash() {
+               $this->setExpectedApiException( 'apierror-stashedit-missingtext' );
+               $this->doStash( [ 'stashedtexthash' => str_repeat( '0', 40 ) ] );
+       }
+
+       public function testHashNormalization() {
+               $res1 = $this->doStash( [ 'text' => "a\r\nb\rc\nd \t\n\r" ] );
+               $res2 = $this->doStash( [ 'text' => "a\nb\rc\nd" ] );
+
+               $this->assertSame( $res1[0]['stashedit']['texthash'], $res2[0]['stashedit']['texthash'] );
+               $this->assertSame( "a\nb\rc\nd",
+                       $this->getStashedText( $res1[0]['stashedit']['texthash'] ) );
+       }
+
+       public function testNonexistentBaseRevId() {
+               $this->setExpectedApiException( [ 'apierror-nosuchrevid', pow( 2, 31 ) - 1 ] );
+
+               $name = ucfirst( __FUNCTION__ );
+               $this->editPage( $name, '' );
+               $this->doStash( [ 'title' => $name, 'baserevid' => pow( 2, 31 ) - 1 ] );
+       }
+
+       public function testPageWithNoRevisions() {
+               $name = ucfirst( __FUNCTION__ );
+               $rev = $this->editPage( $name, '' )->value['revision'];
+
+               $this->setExpectedApiException( [ 'apierror-missingrev-pageid', $rev->getPage() ] );
+
+               // Corrupt the database.  @todo Does the API really need to fail gracefully for this case?
+               $dbw = wfGetDB( DB_MASTER );
+               $dbw->update(
+                       'page',
+                       [ 'page_latest' => 0 ],
+                       [ 'page_id' => $rev->getPage() ],
+                       __METHOD__
                );
-               $apiResult = $apiResult[0];
-               $this->assertArrayHasKey( 'stashedit', $apiResult );
-               $this->assertEquals( 'stashed', $apiResult['stashedit']['status'] );
+
+               $this->doStash( [ 'title' => $name, 'baserevid' => $rev->getId() ] );
+       }
+
+       public function testExistingPage() {
+               $name = ucfirst( __FUNCTION__ );
+               $rev = $this->editPage( $name, '' )->value['revision'];
+
+               $this->doStash( [ 'title' => $name, 'baserevid' => $rev->getId() ] );
+       }
+
+       public function testInterveningEdit() {
+               $name = ucfirst( __FUNCTION__ );
+               $oldRev = $this->editPage( $name, "A\n\nB" )->value['revision'];
+               $this->editPage( $name, "A\n\nC" );
+
+               $this->doStash( [
+                       'title' => $name,
+                       'baserevid' => $oldRev->getId(),
+                       'text' => "D\n\nB",
+               ] );
+       }
+
+       public function testEditConflict() {
+               $name = ucfirst( __FUNCTION__ );
+               $oldRev = $this->editPage( $name, 'A' )->value['revision'];
+               $this->editPage( $name, 'B' );
+
+               $this->doStash( [
+                       'title' => $name,
+                       'baserevid' => $oldRev->getId(),
+                       'text' => 'C',
+               ], null, 'editconflict' );
+       }
+
+       public function testDeletedRevision() {
+               $name = ucfirst( __FUNCTION__ );
+               $oldRev = $this->editPage( $name, 'A' )->value['revision'];
+               $this->editPage( $name, 'B' );
+
+               $this->setExpectedApiException( [ 'apierror-missingcontent-pageid', $oldRev->getPage() ] );
+
+               $this->revisionDelete( $oldRev );
+
+               $this->doStash( [
+                       'title' => $name,
+                       'baserevid' => $oldRev->getId(),
+                       'text' => 'C',
+               ] );
        }
 
+       public function testDeletedRevisionSection() {
+               $name = ucfirst( __FUNCTION__ );
+               $oldRev = $this->editPage( $name, 'A' )->value['revision'];
+               $this->editPage( $name, 'B' );
+
+               $this->setExpectedApiException( 'apierror-sectionreplacefailed' );
+
+               $this->revisionDelete( $oldRev );
+
+               $this->doStash( [
+                       'title' => $name,
+                       'baserevid' => $oldRev->getId(),
+                       'text' => 'C',
+                       'section' => '1',
+               ] );
+       }
+
+       public function testPingLimiter() {
+               global $wgRateLimits;
+
+               $this->stashMwGlobals( 'wgRateLimits' );
+               $wgRateLimits['stashedit'] = [ '&can-bypass' => false, 'user' => [ 1, 60 ] ];
+
+               $this->doStash( [ 'text' => 'A' ] );
+
+               $this->doStash( [ 'text' => 'B' ], null, 'ratelimited' );
+       }
+
+       /**
+        * Shortcut for calling ApiStashEdit::checkCache() without having to create Titles and Contents
+        * in every test.
+        *
+        * @param User $user
+        * @param string $text The text of the article
+        * @return stdClass|bool Return value of ApiStashEdit::checkCache(), false if not in cache
+        */
+       protected function doCheckCache( User $user, $text = 'Content' ) {
+               return ApiStashEdit::checkCache(
+                       Title::newFromText( __CLASS__ ),
+                       new WikitextContent( $text ),
+                       $user
+               );
+       }
+
+       public function testCheckCache() {
+               $user = $this->getMutableTestUser()->getUser();
+
+               $this->doStash( [], $user );
+
+               $this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) );
+
+               // Another user doesn't see the cache
+               $this->assertFalse(
+                       $this->doCheckCache( $this->getTestUser()->getUser() ),
+                       'Cache is user-specific'
+               );
+
+               // Nor does the original one if they become a bot
+               $user->addGroup( 'bot' );
+               $this->assertFalse(
+                       $this->doCheckCache( $user ),
+                       "We assume bots don't have cache entries"
+               );
+
+               // But other groups are okay
+               $user->removeGroup( 'bot' );
+               $user->addGroup( 'sysop' );
+               $this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) );
+       }
+
+       public function testCheckCacheAnon() {
+               $user = new User();
+
+               $this->doStash( [], $user );
+
+               $this->assertInstanceOf( stdClass::class, $this->docheckCache( $user ) );
+       }
+
+       /**
+        * Stash an edit some time in the past, for testing expiry and freshness logic.
+        *
+        * @param User $user Who's doing the editing
+        * @param string $text What text should be cached
+        * @param int $howOld How many seconds is "old" (we actually set it one second before this)
+        */
+       protected function doStashOld(
+               User $user, $text = 'Content', $howOld = ApiStashEdit::PRESUME_FRESH_TTL_SEC
+       ) {
+               $this->doStash( [ 'text' => $text ], $user );
+
+               // Monkey with the cache to make the edit look old.  @todo Is there a less fragile way to
+               // fake the time?
+               $key = $this->getStashKey( __CLASS__, $text, $user );
+
+               $cache = ObjectCache::getLocalClusterInstance();
+
+               $editInfo = $cache->get( $key );
+               $editInfo->output->setCacheTime( wfTimestamp( TS_MW,
+                       wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() ) - $howOld - 1 ) );
+
+               $cache->set( $key, $editInfo );
+       }
+
+       public function testCheckCacheOldNoEdits() {
+               $user = $this->getTestSysop()->getUser();
+
+               $this->doStashOld( $user );
+
+               // Should still be good, because no intervening edits
+               $this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) );
+       }
+
+       public function testCheckCacheOldNoEditsAnon() {
+               // Specify a made-up IP address to make sure no edits are lying around
+               $user = User::newFromName( '192.0.2.77', false );
+
+               $this->doStashOld( $user );
+
+               // Should still be good, because no intervening edits
+               $this->assertInstanceOf( stdClass::class, $this->doCheckCache( $user ) );
+       }
+
+       public function testCheckCacheInterveningEdits() {
+               $user = $this->getTestSysop()->getUser();
+
+               $this->doStashOld( $user );
+
+               // Now let's also increment our editcount
+               $this->editPage( ucfirst( __FUNCTION__ ), '' );
+
+               $this->assertFalse( $this->doCheckCache( $user ),
+                       "Cache should be invalidated when it's old and the user has an intervening edit" );
+       }
+
+       /**
+        * @dataProvider signatureProvider
+        * @param string $text Which signature to test (~~~, ~~~~, or ~~~~~)
+        * @param int $ttl Expected TTL in seconds
+        */
+       public function testSignatureTtl( $text, $ttl ) {
+               $this->doStash( [ 'text' => $text ] );
+
+               $cache = ObjectCache::getLocalClusterInstance();
+               $key = $this->getStashKey( __CLASS__, $text );
+
+               $wrapper = TestingAccessWrapper::newFromObject( $cache );
+
+               $this->assertEquals( $ttl, $wrapper->bag[$key][HashBagOStuff::KEY_EXP] - time(), '', 1 );
+       }
+
+       public function signatureProvider() {
+               return [
+                       '~~~' => [ '~~~', ApiStashEdit::MAX_SIGNATURE_TTL ],
+                       '~~~~' => [ '~~~~', ApiStashEdit::MAX_SIGNATURE_TTL ],
+                       '~~~~~' => [ '~~~~~', ApiStashEdit::MAX_SIGNATURE_TTL ],
+               ];
+       }
+
+       public function testIsInternal() {
+               $res = $this->doApiRequest( [
+                       'action' => 'paraminfo',
+                       'modules' => 'stashedit',
+               ] );
+
+               $this->assertCount( 1, $res[0]['paraminfo']['modules'] );
+               $this->assertSame( true, $res[0]['paraminfo']['modules'][0]['internal'] );
+       }
+
+       public function testBusy() {
+               // @todo This doesn't work because both lock acquisitions are in the same MySQL session, so
+               // they don't conflict.  How do I open a different session?
+               $this->markTestSkipped();
+
+               $key = $this->getStashKey();
+               $this->db->lock( $key, __METHOD__, 0 );
+               try {
+                       $this->doStash( [], null, 'busy' );
+               } finally {
+                       $this->db->unlock( $key, __METHOD__ );
+               }
+       }
 }
index e1b98ec..20f0039 100644 (file)
@@ -164,7 +164,7 @@ class MediaWikiTitleCodecTest extends MediaWikiTestCase {
                        // getGenderCache() provides a mock that considers first
                        // names ending in "a" to be female.
                        [ NS_USER, 'Lisa_Müller', '', 'de', 'Benutzerin:Lisa Müller' ],
-                       [ 1000000, 'Invalid_namespace', '', 'en', ':Invalid namespace' ],
+                       [ 1000000, 'Invalid_namespace', '', 'en', 'Special:Badtitle/NS1000000:Invalid namespace' ],
                ];
        }
 
@@ -195,7 +195,7 @@ class MediaWikiTitleCodecTest extends MediaWikiTestCase {
                        [ NS_MAIN, 'Remote_page', '', 'remotetestiw', 'en', 'remotetestiw:Remote_page' ],
 
                        // non-existent namespace
-                       [ 10000000, 'Foobar', '', '', 'en', ':Foobar' ],
+                       [ 10000000, 'Foobar', '', '', 'en', 'Special:Badtitle/NS10000000:Foobar' ],
                ];
        }