Merge "Add a hook to allow changing the query of Special:AncientPages in extensions"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Sat, 1 Sep 2018 11:08:26 +0000 (11:08 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Sat, 1 Sep 2018 11:08:26 +0000 (11:08 +0000)
45 files changed:
autoload.php
includes/DefaultSettings.php
includes/Linker.php
includes/MediaWikiServices.php
includes/Revision/RenderedRevision.php [new file with mode: 0644]
includes/Revision/RevisionRenderer.php [new file with mode: 0644]
includes/ServiceWiring.php
includes/Storage/DerivedPageDataUpdater.php
includes/Storage/MutableRevisionRecord.php
includes/Storage/PageUpdater.php
includes/Title.php
includes/api/i18n/zh-hant.json
includes/content/AbstractContent.php
includes/htmlform/HTMLForm.php
includes/page/WikiPage.php
includes/parser/Parser.php
includes/parser/ParserCache.php
includes/parser/ParserOptions.php
includes/parser/ParserOutput.php
languages/i18n/be-tarask.json
languages/i18n/bn.json
languages/i18n/el.json
languages/i18n/et.json
languages/i18n/fy.json
languages/i18n/gor.json
languages/i18n/hyw.json
languages/i18n/sd.json
languages/i18n/yue.json
languages/messages/MessagesSr_ec.php
maintenance/resources/foreign-resources.yaml
resources/src/mediawiki.user.js
resources/src/startup/mediawiki.js
resources/src/startup/startup.js
tests/phpunit/includes/GlobalFunctions/GlobalTest.php
tests/phpunit/includes/Revision/RenderedRevisionTest.php [new file with mode: 0644]
tests/phpunit/includes/Revision/RevisionRendererTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php
tests/phpunit/includes/Storage/MutableRevisionRecordTest.php
tests/phpunit/includes/Storage/PageUpdaterTest.php
tests/phpunit/includes/TitleTest.php
tests/phpunit/includes/parser/ParserOptionsTest.php
tests/phpunit/includes/parser/ParserOutputTest.php
tests/phpunit/maintenance/DumpTestCase.php
tests/phpunit/maintenance/backupTextPassTest.php
tests/qunit/suites/resources/mediawiki/mediawiki.user.test.js

index 10aab64..cc2b428 100644 (file)
@@ -931,6 +931,8 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\MediaWikiServices' => __DIR__ . '/includes/MediaWikiServices.php',
        'MediaWiki\\OutputHandler' => __DIR__ . '/includes/OutputHandler.php',
        'MediaWiki\\ProcOpenError' => __DIR__ . '/includes/exception/ProcOpenError.php',
+       'MediaWiki\\Revision\\RenderedRevision' => __DIR__ . '/includes/Revision/RenderedRevision.php',
+       'MediaWiki\\Revision\\RevisionRenderer' => __DIR__ . '/includes/Revision/RevisionRenderer.php',
        'MediaWiki\\Search\\ParserOutputSearchDataExtractor' => __DIR__ . '/includes/search/ParserOutputSearchDataExtractor.php',
        'MediaWiki\\ShellDisabledError' => __DIR__ . '/includes/exception/ShellDisabledError.php',
        'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php',
index 4ddc23e..928d875 100644 (file)
@@ -8848,6 +8848,8 @@ $wgCSPReportOnlyHeader = false;
  * Extensions should add their messages here. The list is used for access control:
  * changing messages listed here will require editsitecss and editsitejs rights.
  *
+ * Message names must be given with underscores rather than spaces and with lowercase first letter.
+ *
  * @since 1.32
  * @var string[]
  */
index 0aa8ec5..0ee6c92 100644 (file)
@@ -1213,7 +1213,7 @@ class Linker {
         *  as used by WikiMap.
         *
         * @return string HTML
-        * @return-taint escapes_html
+        * @return-taint onlysafefor_html
         */
        public static function formatLinksInComment(
                $comment, $title = null, $local = false, $wikiId = null
index 4a6046d..5b53ad1 100644 (file)
@@ -16,6 +16,7 @@ use Liuggio\StatsdClient\Factory\StatsdDataFactoryInterface;
 use MediaWiki\Http\HttpRequestFactory;
 use MediaWiki\Preferences\PreferencesFactory;
 use MediaWiki\Shell\CommandFactory;
+use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Special\SpecialPageFactory;
 use MediaWiki\Storage\BlobStore;
 use MediaWiki\Storage\BlobStoreFactory;
@@ -749,6 +750,14 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'RevisionLookup' );
        }
 
+       /**
+        * @since 1.32
+        * @return RevisionRenderer
+        */
+       public function getRevisionRenderer() {
+               return $this->getService( 'RevisionRenderer' );
+       }
+
        /**
         * @since 1.31
         * @return RevisionStore
diff --git a/includes/Revision/RenderedRevision.php b/includes/Revision/RenderedRevision.php
new file mode 100644 (file)
index 0000000..0c052d1
--- /dev/null
@@ -0,0 +1,345 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Revision;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * RenderedRevision represents the rendered representation of a revision. It acts as a lazy provider
+ * of ParserOutput objects for the revision's individual slots, as well as a combined ParserOutput
+ * of all slots.
+ *
+ * @since 1.32
+ */
+class RenderedRevision {
+
+       /**
+        * @var Title
+        */
+       private $title;
+
+       /** @var RevisionRecord */
+       private $revision;
+
+       /**
+        * @var ParserOptions
+        */
+       private $options;
+
+       /**
+        * @var int Audience to check when accessing content.
+        */
+       private $audience = RevisionRecord::FOR_PUBLIC;
+
+       /**
+        * @var User|null The user to use for audience checks during content access.
+        */
+       private $forUser = null;
+
+       /**
+        * @var ParserOutput|null The combined ParserOutput for the revision,
+        *      initialized lazily by getRevisionParserOutput().
+        */
+       private $revisionOutput = null;
+
+       /**
+        * @var ParserOutput[] The ParserOutput for each slot,
+        *      initialized lazily by getSlotParserOutput().
+        */
+       private $slotsOutput = [];
+
+       /**
+        * @var callable Callback for combining slot output into revision output.
+        *      Signature: function ( RenderedRevision $this ): ParserOutput.
+        */
+       private $combineOutput;
+
+       /**
+        * @var LoggerInterface For profiling ParserOutput re-use.
+        */
+       private $saveParseLogger;
+
+       /**
+        * @note Application logic should not instantiate RenderedRevision instances directly,
+        * but should use a RevisionRenderer instead.
+        *
+        * @param Title $title
+        * @param RevisionRecord $revision
+        * @param ParserOptions $options
+        * @param callable $combineOutput Callback for combining slot output into revision output.
+        *        Signature: function ( RenderedRevision $this ): ParserOutput.
+        * @param int $audience Use RevisionRecord::FOR_PUBLIC, FOR_THIS_USER, or RAW.
+        * @param User|null $forUser Required if $audience is FOR_THIS_USER.
+        */
+       public function __construct(
+               Title $title,
+               RevisionRecord $revision,
+               ParserOptions $options,
+               callable $combineOutput,
+               $audience = RevisionRecord::FOR_PUBLIC,
+               User $forUser = null
+       ) {
+               $this->title = $title;
+               $this->options = $options;
+
+               $this->setRevisionInternal( $revision );
+
+               $this->combineOutput = $combineOutput;
+               $this->saveParseLogger = new NullLogger();
+
+               if ( $audience === RevisionRecord::FOR_THIS_USER && !$forUser ) {
+                       throw new InvalidArgumentException(
+                               'User must be specified when setting audience to FOR_THIS_USER'
+                       );
+               }
+
+               $this->audience = $audience;
+               $this->forUser = $forUser;
+       }
+
+       /**
+        * @param LoggerInterface $saveParseLogger
+        */
+       public function setSaveParseLogger( LoggerInterface $saveParseLogger ) {
+               $this->saveParseLogger = $saveParseLogger;
+       }
+
+       /**
+        * @return bool Whether the revision's content has been hidden from unprivileged users.
+        */
+       public function isContentDeleted() {
+               return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
+       }
+
+       /**
+        * @return RevisionRecord
+        */
+       public function getRevision() {
+               return $this->revision;
+       }
+
+       /**
+        * @return ParserOptions
+        */
+       public function getOptions() {
+               return $this->options;
+       }
+
+       /**
+        * @param array $hints Hints given as an associative array. Known keys:
+        *      - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
+        *        to just meta-data). Default is to generate HTML.
+        *
+        * @return ParserOutput
+        */
+       public function getRevisionParserOutput( array $hints = [] ) {
+               $withHtml = $hints['generate-html'] ?? true;
+
+               if ( !$this->revisionOutput
+                       || ( $withHtml && !$this->revisionOutput->hasText() )
+               ) {
+                       $output = call_user_func( $this->combineOutput, $this, $hints );
+
+                       Assert::postcondition(
+                               $output instanceof ParserOutput,
+                               'Callback did not return a ParserOutput object!'
+                       );
+
+                       $this->revisionOutput = $output;
+               }
+
+               return $this->revisionOutput;
+       }
+
+       /**
+        * @param string $role
+        * @param array $hints Hints given as an associative array. Known keys:
+        *      - 'generate-html' => bool: Whether the caller is interested in output HTML (as opposed
+        *        to just meta-data). Default is to generate HTML.
+        *
+        * @throws SuppressedDataException if the content is not accessible for the audience
+        *         specified in the constructor.
+        * @return ParserOutput
+        */
+       public function getSlotParserOutput( $role, array $hints = [] ) {
+               $withHtml = $hints['generate-html'] ?? true;
+
+               if ( !isset( $this->slotsOutput[ $role ] )
+                       || ( $withHtml && !$this->slotsOutput[ $role ]->hasText() )
+               ) {
+                       $content = $this->revision->getContent( $role, $this->audience, $this->forUser );
+
+                       if ( !$content ) {
+                               throw new SuppressedDataException(
+                                       'Access to the content has been suppressed for this audience'
+                               );
+                       } else {
+                               $output = $content->getParserOutput(
+                                       $this->title,
+                                       $this->revision->getId(),
+                                       $this->options,
+                                       $withHtml
+                               );
+
+                               if ( $withHtml && !$output->hasText() ) {
+                                       throw new LogicException(
+                                               'HTML generation was requested, but '
+                                               . get_class( $content )
+                                               . '::getParserOutput() returns a ParserOutput with no text set.'
+                                       );
+                               }
+
+                               // Detach watcher, to ensure option use is not recorded in the wrong ParserOutput.
+                               $this->options->registerWatcher( null );
+                       }
+
+                       $this->slotsOutput[ $role ] = $output;
+               }
+
+               return $this->slotsOutput[$role];
+       }
+
+       /**
+        * Updates the RevisionRecord after the revision has been saved. This can be used to discard
+        * and cached ParserOutput so parser functions like {{REVISIONTIMESTAMP}} or {{REVISIONID}}
+        * are re-evaluated.
+        *
+        * @note There should be no need to call this for null-edits.
+        *
+        * @param RevisionRecord $rev
+        */
+       public function updateRevision( RevisionRecord $rev ) {
+               if ( $rev->getId() === $this->revision->getId() ) {
+                       return;
+               }
+
+               if ( $this->revision->getId() ) {
+                       throw new LogicException( 'RenderedRevision already has a revision with ID '
+                               . $this->revision->getId(), ', can\'t update to revision with ID ' . $rev->getId() );
+               }
+
+               if ( !$this->revision->getSlots()->hasSameContent( $rev->getSlots() ) ) {
+                       throw new LogicException( 'Cannot update to a revision with different content!' );
+               }
+
+               $this->setRevisionInternal( $rev );
+
+               $this->pruneRevisionSensitiveOutput( $this->revision->getId() );
+       }
+
+       /**
+        * Prune any output that depends on the revision ID.
+        *
+        * @param int|bool  $actualRevId The actual rev id, to check the used speculative rev ID
+        *        against, or false to not purge on vary-revision-id, or true to purge on
+        *        vary-revision-id unconditionally.
+        */
+       private function pruneRevisionSensitiveOutput( $actualRevId ) {
+               if ( $this->revisionOutput ) {
+                       if ( $this->outputVariesOnRevisionMetaData( $this->revisionOutput, $actualRevId ) ) {
+                               $this->revisionOutput = null;
+                       }
+               } else {
+                       $this->saveParseLogger->debug( __METHOD__ . ": no prepared revision output...\n" );
+               }
+
+               foreach ( $this->slotsOutput as $role => $output ) {
+                       if ( $this->outputVariesOnRevisionMetaData( $output, $actualRevId ) ) {
+                               unset( $this->slotsOutput[$role] );
+                       }
+               }
+       }
+
+       /**
+        * @param RevisionRecord $revision
+        */
+       private function setRevisionInternal( RevisionRecord $revision ) {
+               $this->revision = $revision;
+
+               // Make sure the parser uses the correct Revision object
+               $title = $this->title;
+               $oldCallback = $this->options->getCurrentRevisionCallback();
+               $this->options->setCurrentRevisionCallback(
+                       function ( Title $parserTitle, $parser = false ) use ( $title, $oldCallback ) {
+                               if ( $parserTitle->equals( $title ) ) {
+                                       $legacyRevision = new Revision( $this->revision );
+                                       return $legacyRevision;
+                               } else {
+                                       return call_user_func( $oldCallback, $parserTitle, $parser );
+                               }
+                       }
+               );
+       }
+
+       /**
+        * @param ParserOutput $out
+        * @param int|bool  $actualRevId The actual rev id, to check the used speculative rev ID
+        *        against, or false to not purge on vary-revision-id, or true to purge on
+        *        vary-revision-id unconditionally.
+        * @return bool
+        */
+       private function outputVariesOnRevisionMetaData( ParserOutput $out, $actualRevId ) {
+               $method = __METHOD__;
+
+               if ( $out->getFlag( 'vary-revision' ) ) {
+                       // XXX: Would be just keep the output if the speculative revision ID was correct,
+                       // but that can go wrong for some edge cases, like {{PAGEID}} during page creation.
+                       // For that specific case, it would perhaps nice to have a vary-page flag.
+                       $this->saveParseLogger->info(
+                               "$method: Prepared output has vary-revision...\n"
+                       );
+                       return true;
+               } elseif ( $out->getFlag( 'vary-revision-id' )
+                       && $actualRevId !== false
+                       && ( $actualRevId === true || $out->getSpeculativeRevIdUsed() !== $actualRevId )
+               ) {
+                       $this->saveParseLogger->info(
+                               "$method: Prepared output has vary-revision-id with wrong ID...\n"
+                       );
+                       return true;
+               } else {
+                       // NOTE: In the original fix for T135261, the output was discarded if 'vary-user' was
+                       // set for a null-edit. The reason was that the original rendering in that case was
+                       // targeting the user making the null-edit, not the user who made the original edit,
+                       // causing {{REVISIONUSER}} to return the wrong name.
+                       // This case is now expected to be handled by the code in RevisionRenderer that
+                       // constructs the ParserOptions: For a null-edit, setCurrentRevisionCallback is called
+                       // with the old, existing revision.
+
+                       wfDebug( "$method: Keeping prepared output...\n" );
+                       return false;
+               }
+       }
+
+}
diff --git a/includes/Revision/RevisionRenderer.php b/includes/Revision/RevisionRenderer.php
new file mode 100644 (file)
index 0000000..f71f9e7
--- /dev/null
@@ -0,0 +1,218 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use Html;
+use InvalidArgumentException;
+use MediaWiki\Storage\RevisionRecord;
+use ParserOptions;
+use ParserOutput;
+use Psr\Log\LoggerInterface;
+use Psr\Log\NullLogger;
+use Title;
+use User;
+use Wikimedia\Rdbms\ILoadBalancer;
+
+/**
+ * The RevisionRenderer service provides access to rendered output for revisions.
+ * It does so be acting as a factory for RenderedRevision instances, which in turn
+ * provide lazy access to ParserOutput objects.
+ *
+ * One key responsibility of RevisionRenderer is implementing the layout used to combine
+ * the output of multiple slots.
+ *
+ * @since 1.32
+ */
+class RevisionRenderer {
+
+       /** @var LoggerInterface */
+       private $saveParseLogger;
+
+       /** @var ILoadBalancer */
+       private $loadBalancer;
+
+       /** @var string|bool */
+       private $wikiId;
+
+       /**
+        * @param ILoadBalancer $loadBalancer
+        * @param bool|string $wikiId
+        */
+       public function __construct( ILoadBalancer $loadBalancer, $wikiId = false ) {
+               $this->loadBalancer = $loadBalancer;
+               $this->wikiId = $wikiId;
+
+               $this->saveParseLogger = new NullLogger();
+       }
+
+       /**
+        * @param RevisionRecord $rev
+        * @param ParserOptions|null $options
+        * @param User|null $forUser User for privileged access. Default is unprivileged (public)
+        *        access, unless the 'audience' hint is set to something else RevisionRecord::RAW.
+        * @param array $hints Hints given as an associative array. Known keys:
+        *      - 'use-master' Use master when rendering for the parser cache during save.
+        *        Default is to use a replica.
+        *      - 'audience' the audience to use for content access. Default is
+        *        RevisionRecord::FOR_PUBLIC if $forUser is not set, RevisionRecord::FOR_THIS_USER
+        *        if $forUser is set. Can be set to RevisionRecord::RAW to disable audience checks.
+        *
+        * @return RenderedRevision|null The rendered revision, or null if the audience checks fails.
+        */
+       public function getRenderedRevision(
+               RevisionRecord $rev,
+               ParserOptions $options = null,
+               User $forUser = null,
+               array $hints = []
+       ) {
+               if ( $rev->getWikiId() !== $this->wikiId ) {
+                       throw new InvalidArgumentException( 'Mismatching wiki ID ' . $rev->getWikiId() );
+               }
+
+               $audience = $hints['audience']
+                       ?? ( $forUser ? RevisionRecord::FOR_THIS_USER : RevisionRecord::FOR_PUBLIC );
+
+               if ( !$rev->audienceCan( RevisionRecord::DELETED_TEXT, $audience, $forUser ) ) {
+                       // Returning null here is awkward, but consist with the signature of
+                       // Revision::getContent() and RevisionRecord::getContent().
+                       return null;
+               }
+
+               if ( !$options ) {
+                       $options = ParserOptions::newCanonical( $forUser ?: 'canonical' );
+               }
+
+               $useMaster = $hints['use-master'] ?? false;
+
+               $dbIndex = $useMaster
+                       ? DB_MASTER // use latest values
+                       : DB_REPLICA; // T154554
+
+               $options->setSpeculativeRevIdCallback( function () use ( $dbIndex ) {
+                       return $this->getSpeculativeRevId( $dbIndex );
+               } );
+
+               $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
+
+               $renderedRevision = new RenderedRevision(
+                       $title,
+                       $rev,
+                       $options,
+                       function ( RenderedRevision $rrev, array $hints ) {
+                               return $this->combineSlotOutput( $rrev, $hints );
+                       },
+                       $audience,
+                       $forUser
+               );
+
+               $renderedRevision->setSaveParseLogger( $this->saveParseLogger );
+
+               return $renderedRevision;
+       }
+
+       private function getSpeculativeRevId( $dbIndex ) {
+               // Use a fresh master connection in order to see the latest data, by avoiding
+               // stale data from REPEATABLE-READ snapshots.
+               // HACK: But don't use a fresh connection in unit tests, since it would not have
+               // the fake tables. This should be handled by the LoadBalancer!
+               $flags = defined( 'MW_PHPUNIT_TEST' ) || $dbIndex === DB_REPLICA
+                       ? 0 : ILoadBalancer::CONN_TRX_AUTOCOMMIT;
+
+               $db = $this->loadBalancer->getConnectionRef( $dbIndex, [], $this->wikiId, $flags );
+
+               return 1 + (int)$db->selectField(
+                       'revision',
+                       'MAX(rev_id)',
+                       [],
+                       __METHOD__
+               );
+       }
+
+       /**
+        * This implements the layout for combining the output of multiple slots.
+        *
+        * @todo Use placement hints from SlotRoleHandlers instead of hard-coding the layout.
+        *
+        * @param RenderedRevision $rrev
+        * @param array $hints see RenderedRevision::getRevisionParserOutput()
+        *
+        * @return ParserOutput
+        */
+       private function combineSlotOutput( RenderedRevision $rrev, array $hints = [] ) {
+               $revision = $rrev->getRevision();
+               $slots = $revision->getSlots()->getSlots();
+
+               $withHtml = $hints['generate-html'] ?? true;
+
+               // short circuit if there is only the main slot
+               if ( array_keys( $slots ) === [ 'main' ] ) {
+                       return $rrev->getSlotParserOutput( 'main' );
+               }
+
+               // TODO: put fancy layout logic here, see T200915.
+
+               // move main slot to front
+               if ( isset( $slots['main'] ) ) {
+                       $slots = [ 'main' => $slots['main'] ] + $slots;
+               }
+
+               $combinedOutput = new ParserOutput( null );
+               $slotOutput = [];
+
+               $options = $rrev->getOptions();
+               $options->registerWatcher( [ $combinedOutput, 'recordOption' ] );
+
+               foreach ( $slots as $role => $slot ) {
+                       $out = $rrev->getSlotParserOutput( $role, $hints );
+                       $slotOutput[$role] = $out;
+
+                       $combinedOutput->mergeInternalMetaDataFrom( $out, $role );
+                       $combinedOutput->mergeTrackingMetaDataFrom( $out );
+               }
+
+               if ( $withHtml ) {
+                       $html = '';
+                       $first = true;
+                       /** @var ParserOutput $out */
+                       foreach ( $slotOutput as $role => $out ) {
+                               if ( $first ) {
+                                       // skip header for the first slot
+                                       $first = false;
+                               } else {
+                                       // NOTE: this placeholder is hydrated by ParserOutput::getText().
+                                       $headText = Html::element( 'mw:slotheader', [], $role );
+                                       $html .= Html::rawElement( 'h1', [ 'class' => 'mw-slot-header' ], $headText );
+                               }
+
+                               $html .= $out->getRawText();
+                               $combinedOutput->mergeHtmlMetaDataFrom( $out );
+                       }
+
+                       $combinedOutput->setText( $html );
+               }
+
+               $options->registerWatcher( null );
+               return $combinedOutput;
+       }
+
+}
index 99b2942..59cdec9 100644 (file)
@@ -51,6 +51,7 @@ use MediaWiki\Preferences\DefaultPreferencesFactory;
 use MediaWiki\Shell\CommandFactory;
 use MediaWiki\Special\SpecialPageFactory;
 use MediaWiki\Storage\BlobStore;
+use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Storage\BlobStoreFactory;
 use MediaWiki\Storage\NameTableStore;
 use MediaWiki\Storage\RevisionFactory;
@@ -445,6 +446,10 @@ return [
                return $services->getRevisionStore();
        },
 
+       'RevisionRenderer' => function ( MediaWikiServices $services ) : RevisionRenderer {
+               return new RevisionRenderer( $services->getDBLoadBalancer() );
+       },
+
        'RevisionStore' => function ( MediaWikiServices $services ) : RevisionStore {
                return $services->getRevisionStoreFactory()->getRevisionStore();
        },
index dacec96..2df1670 100644 (file)
@@ -36,14 +36,13 @@ use Language;
 use LinksUpdate;
 use LogicException;
 use MediaWiki\Edit\PreparedEdit;
-use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RenderedRevision;
+use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\User\UserIdentity;
 use MessageCache;
 use ParserCache;
 use ParserOptions;
 use ParserOutput;
-use Psr\Log\LoggerInterface;
-use Psr\Log\NullLogger;
 use RecentChangesUpdateJob;
 use ResourceLoaderWikiModule;
 use Revision;
@@ -112,11 +111,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         */
        private $contLang;
 
-       /**
-        * @var LoggerInterface
-        */
-       private $saveParseLogger;
-
        /**
         * @var JobQueueGroup
         */
@@ -177,31 +171,19 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        private $slotsUpdate = null;
 
        /**
-        * @var MutableRevisionSlots|null
-        */
-       private $pstContentSlots = null;
-
-       /**
-        * @var object[] anonymous objects with two fields, using slot roles as keys:
-        *  - hasHtml: whether the output contains HTML
-        *  - ParserOutput: the slot's parser output
-        */
-       private $slotsOutput = [];
-
-       /**
-        * @var ParserOutput|null
+        * @var RevisionRecord|null
         */
-       private $canonicalParserOutput = null;
+       private $revision = null;
 
        /**
-        * @var ParserOptions|null
+        * @var RenderedRevision
         */
-       private $canonicalParserOptions = null;
+       private $renderedRevision = null;
 
        /**
-        * @var RevisionRecord
+        * @var RevisionRenderer
         */
-       private $revision = null;
+       private $revisionRenderer;
 
        /**
         * A stage identifier for managing the life cycle of this instance.
@@ -248,31 +230,29 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        /**
         * @param WikiPage $wikiPage ,
         * @param RevisionStore $revisionStore
+        * @param RevisionRenderer $revisionRenderer
         * @param ParserCache $parserCache
         * @param JobQueueGroup $jobQueueGroup
         * @param MessageCache $messageCache
         * @param Language $contLang
-        * @param LoggerInterface|null $saveParseLogger
         */
        public function __construct(
                WikiPage $wikiPage,
                RevisionStore $revisionStore,
+               RevisionRenderer $revisionRenderer,
                ParserCache $parserCache,
                JobQueueGroup $jobQueueGroup,
                MessageCache $messageCache,
-               Language $contLang,
-               LoggerInterface $saveParseLogger = null
+               Language $contLang
        ) {
                $this->wikiPage = $wikiPage;
 
                $this->parserCache = $parserCache;
                $this->revisionStore = $revisionStore;
+               $this->revisionRenderer = $revisionRenderer;
                $this->jobQueueGroup = $jobQueueGroup;
                $this->messageCache = $messageCache;
                $this->contLang = $contLang;
-
-               // XXX: replace all wfDebug calls with a Logger. Do we nede more than one logger here?
-               $this->saveParseLogger = $saveParseLogger ?: new NullLogger();
        }
 
        /**
@@ -353,7 +333,9 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        return false;
                }
 
-               if ( $revision && $this->revision && $this->revision->getId() !== $revision->getId() ) {
+               if ( $revision && $this->revision && $this->revision->getId()
+                       && $this->revision->getId() !== $revision->getId()
+               ) {
                        return false;
                }
 
@@ -378,6 +360,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                if ( $this->revision
                        && $user
+                       && $this->revision->getUser( RevisionRecord::RAW )
                        && $this->revision->getUser( RevisionRecord::RAW )->getName() !== $user->getName()
                ) {
                        return false;
@@ -385,6 +368,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                if ( $revision
                        && $this->user
+                       && $this->revision->getUser( RevisionRecord::RAW )
                        && $revision->getUser( RevisionRecord::RAW )->getName() !== $this->user->getName()
                ) {
                        return false;
@@ -398,9 +382,9 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        return false;
                }
 
-               if ( $this->pstContentSlots
-                       && $revision
-                       && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+               if ( $revision
+                       && $this->revision
+                       && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
                ) {
                        return false;
                }
@@ -533,16 +517,18 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         * @return bool
         */
        public function isContentPrepared() {
-               return $this->pstContentSlots !== null;
+               return $this->revision !== null;
        }
 
        /**
         * Whether prepareUpdate() has been called on this instance.
         *
+        * @note will also return null in case of a null-edit!
+        *
         * @return bool
         */
        public function isUpdatePrepared() {
-               return $this->revision !== null;
+               return $this->revision !== null && $this->revision->getId() !== null;
        }
 
        /**
@@ -562,17 +548,17 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        }
 
        /**
-        * Whether the content of the target revision is publicly visible.
+        * Whether the content is deleted and thus not visible to the public.
         *
         * @return bool
         */
-       public function isContentPublic() {
+       public function isContentDeleted() {
                if ( $this->revision ) {
-                       // XXX: if that revision is the current revision, this can be skipped
-                       return !$this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
+                       // XXX: if that revision is the current revision, this should be skipped
+                       return $this->revision->isDeleted( RevisionRecord::DELETED_TEXT );
                } else {
-                       // If the content has not been saved yet, it cannot have been suppressed yet.
-                       return true;
+                       // If the content has not been saved yet, it cannot have been deleted yet.
+                       return false;
                }
        }
 
@@ -635,7 +621,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        return false;
                }
 
-               if ( !$this->isContentPublic() ) {
+               if ( $this->isContentDeleted() ) {
                        // This should be irrelevant: countability only applies to the current revision,
                        // and the current revision is never suppressed.
                        return false;
@@ -739,7 +725,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                $this->slotsOutput = [];
                $this->canonicalParserOutput = null;
-               $this->canonicalParserOptions = null;
 
                // The edit may have already been prepared via api.php?action=stashedit
                $stashedEdit = false;
@@ -769,14 +754,13 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                $this->slotsUpdate = $slotsUpdate;
 
                if ( $parentRevision ) {
-                       // start out by inheriting all parent slots
-                       $this->pstContentSlots = MutableRevisionSlots::newFromParentRevisionSlots(
-                               $parentRevision->getSlots()->getSlots()
-                       );
+                       $this->revision = MutableRevisionRecord::newFromParentRevision( $parentRevision );
                } else {
-                       $this->pstContentSlots = new MutableRevisionSlots();
+                       $this->revision = new MutableRevisionRecord( $title );
                }
 
+               $pstContentSlots = $this->revision->getSlots();
+
                foreach ( $slotsUpdate->getModifiedRoles() as $role ) {
                        $slot = $slotsUpdate->getModifiedSlot( $role );
 
@@ -793,18 +777,78 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                                $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
                        }
 
-                       $this->pstContentSlots->setSlot( $pstSlot );
+                       $pstContentSlots->setSlot( $pstSlot );
                }
 
                foreach ( $slotsUpdate->getRemovedRoles() as $role ) {
-                       $this->pstContentSlots->removeSlot( $role );
+                       $pstContentSlots->removeSlot( $role );
                }
 
                $this->options['created'] = ( $parentRevision === null );
                $this->options['changed'] = ( $parentRevision === null
-                       || !$this->pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
+                       || !$pstContentSlots->hasSameContent( $parentRevision->getSlots() ) );
 
                $this->doTransition( 'has-content' );
+
+               if ( !$this->options['changed'] ) {
+                       // null-edit!
+
+                       // TODO: move this into MutableRevisionRecord
+                       // TODO: This needs to behave differently for a forced dummy edit!
+                       $this->revision->setId( $parentRevision->getId() );
+                       $this->revision->setTimestamp( $parentRevision->getTimestamp() );
+                       $this->revision->setPageId( $parentRevision->getPageId() );
+                       $this->revision->setParentId( $parentRevision->getParentId() );
+                       $this->revision->setUser( $parentRevision->getUser( RevisionRecord::RAW ) );
+                       $this->revision->setComment( $parentRevision->getComment( RevisionRecord::RAW ) );
+                       $this->revision->setMinorEdit( $parentRevision->isMinor() );
+                       $this->revision->setVisibility( $parentRevision->getVisibility() );
+
+                       // prepareUpdate() is redundant for null-edits
+                       $this->doTransition( 'has-revision' );
+               } else {
+                       $this->revision->setUser( $user );
+               }
+       }
+
+       /**
+        * Returns the update's target revision - that is, the revision that will be the current
+        * revision after the update.
+        *
+        * @note Callers must treat the returned RevisionRecord's content as immutable, even
+        * if it is a MutableRevisionRecord instance. Other aspects of a MutableRevisionRecord
+        * returned from here, such as the user or the comment, may be changed, but may not
+        * be reflected in ParserOutput until after prepareUpdate() has been called.
+        *
+        * @todo This is currently used by PageUpdater::makeNewRevision() to construct an unsaved
+        * MutableRevisionRecord instance. Introduce something like an UnsavedRevisionFactory service
+        * for that purpose instead!
+        *
+        * @return RevisionRecord
+        */
+       public function getRevision() {
+               $this->assertPrepared( __METHOD__ );
+               return $this->revision;
+       }
+
+       /**
+        * @return RenderedRevision
+        */
+       public function getRenderedRevision() {
+               if ( !$this->renderedRevision ) {
+                       $this->assertPrepared( __METHOD__ );
+
+                       // NOTE: we want a canonical rendering, so don't pass $this->user or ParserOptions
+                       // NOTE: the revision is either new or current, so we can bypass audience checks.
+                       $this->renderedRevision = $this->revisionRenderer->getRenderedRevision(
+                               $this->revision,
+                               null,
+                               null,
+                               [ 'use-master' => $this->useMaster(), 'audience' => RevisionRecord::RAW ]
+                       );
+               }
+
+               return $this->renderedRevision;
        }
 
        private function assertHasPageState( $method ) {
@@ -817,7 +861,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        }
 
        private function assertPrepared( $method ) {
-               if ( !$this->pstContentSlots ) {
+               if ( !$this->revision ) {
                        throw new LogicException(
                                'Must call prepareContent() or prepareUpdate() before calling ' . $method
                        );
@@ -872,11 +916,14 @@ class DerivedPageDataUpdater implements IDBAccessObject {
        /**
         * Returns the slots of the target revision, after PST.
         *
+        * @note Callers must treat the returned RevisionSlots instance as immutable, even
+        * if it is a MutableRevisionSlots instance.
+        *
         * @return RevisionSlots
         */
        public function getSlots() {
                $this->assertPrepared( __METHOD__ );
-               return $this->pstContentSlots;
+               return $this->revision->getSlots();
        }
 
        /**
@@ -888,12 +935,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                $this->assertPrepared( __METHOD__ );
 
                if ( !$this->slotsUpdate ) {
-                       if ( !$this->revision ) {
-                               // This should not be possible: if assertPrepared() returns true,
-                               // at least one of $this->slotsUpdate or $this->revision should be set.
-                               throw new LogicException( 'No revision nor a slots update is known!' );
-                       }
-
                        $old = $this->getOldRevision();
                        $this->slotsUpdate = RevisionSlotsUpdate::newFromRevisionSlots(
                                $this->revision->getSlots(),
@@ -957,7 +998,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         * - moved: bool, whether the page was moved (default false)
         * - restored: bool, whether the page was undeleted (default false)
         * - oldrevision: Revision object for the pre-update revision (default null)
-        * - parseroutput: The canonical ParserOutput of $revision (default null)
         * - triggeringuser: The user triggering the update (UserIdentity, default null)
         * - oldredirect: bool, null, or string 'no-change' (default null):
         *    - bool: whether the page was counted as a redirect before that
@@ -979,12 +1019,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        '$options["oldrevision"]',
                        'must be a RevisionRecord (or Revision)'
                );
-               Assert::parameter(
-                       !isset( $options['parseroutput'] )
-                       || $options['parseroutput'] instanceof ParserOutput,
-                       '$options["parseroutput"]',
-                       'must be a ParserOutput'
-               );
                Assert::parameter(
                        !isset( $options['triggeringuser'] )
                        || $options['triggeringuser'] instanceof UserIdentity,
@@ -998,7 +1032,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        );
                }
 
-               if ( $this->revision ) {
+               if ( $this->revision && $this->revision->getId() ) {
                        if ( $this->revision->getId() === $revision->getId() ) {
                                return; // nothing to do!
                        } else {
@@ -1011,8 +1045,8 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                        }
                }
 
-               if ( $this->pstContentSlots
-                       && !$this->pstContentSlots->hasSameContent( $revision->getSlots() )
+               if ( $this->revision
+                       && !$this->revision->getSlots()->hasSameContent( $revision->getSlots() )
                ) {
                        throw new LogicException(
                                'The Revision provided has mismatching content!'
@@ -1107,7 +1141,6 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                $this->options['created'] = ( $this->pageState['oldId'] === 0 );
 
                $this->revision = $revision;
-               $this->pstContentSlots = $revision->getSlots();
 
                $this->doTransition( 'has-revision' );
 
@@ -1118,74 +1151,14 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                }
 
                // Prune any output that depends on the revision ID.
-               if ( $this->canonicalParserOutput ) {
-                       if ( $this->outputVariesOnRevisionMetaData( $this->canonicalParserOutput, __METHOD__ ) ) {
-                               $this->canonicalParserOutput = null;
-                       }
-               } else {
-                       $this->saveParseLogger->debug( __METHOD__ . ": No prepared canonical output...\n" );
-               }
-
-               if ( $this->slotsOutput ) {
-                       foreach ( $this->slotsOutput as $role => $prep ) {
-                               if ( $this->outputVariesOnRevisionMetaData( $prep->output, __METHOD__ ) ) {
-                                       unset( $this->slotsOutput[$role] );
-                               }
-                       }
-               } else {
-                       $this->saveParseLogger->debug( __METHOD__ . ": No prepared output...\n" );
-               }
-
-               // reset ParserOptions, so the actual revision ID is used in future ParserOutput generation
-               $this->canonicalParserOptions = null;
-
-               // Avoid re-generating the canonical ParserOutput if it's known.
-               // We just trust that the caller is passing the correct ParserOutput!
-               if ( isset( $options['parseroutput'] ) ) {
-                       $this->canonicalParserOutput = $options['parseroutput'];
+               if ( $this->renderedRevision ) {
+                       $this->renderedRevision->updateRevision( $revision );
                }
 
                // TODO: optionally get ParserOutput from the ParserCache here.
                // Move the logic used by RefreshLinksJob here!
        }
 
-       /**
-        * @param ParserOutput $out
-        * @param string $method
-        * @return bool
-        */
-       private function outputVariesOnRevisionMetaData( ParserOutput $out, $method = __METHOD__ ) {
-               if ( $out->getFlag( 'vary-revision' ) ) {
-                       // XXX: Just keep the output if the speculative revision ID was correct, like below?
-                       $this->saveParseLogger->info(
-                               "$method: Prepared output has vary-revision...\n"
-                       );
-                       return true;
-               } elseif ( $out->getFlag( 'vary-revision-id' )
-                       && $out->getSpeculativeRevIdUsed() !== $this->revision->getId()
-               ) {
-                       $this->saveParseLogger->info(
-                               "$method: Prepared output has vary-revision-id with wrong ID...\n"
-                       );
-                       return true;
-               } elseif ( $out->getFlag( 'vary-user' )
-                       && !$this->options['changed']
-               ) {
-                       // When Alice makes a null-edit on top of Bob's edit,
-                       // {{REVISIONUSER}} must resolve to "Bob", not "Alice", see T135261.
-                       // TODO: to avoid this, we should check for null-edits in makeCanonicalparserOptions,
-                       // and set setCurrentRevisionCallback to return the existing revision when appropriate.
-                       // See also the comment there [dk 2018-05]
-                       $this->saveParseLogger->info(
-                               "$method: Prepared output has vary-user and is null-edit...\n"
-                       );
-                       return true;
-               } else {
-                       wfDebug( "$method: Keeping prepared output...\n" );
-                       return false;
-               }
-       }
-
        /**
         * @deprecated This only exists for B/C, use the getters on DerivedPageDataUpdater directly!
         * @return PreparedEdit
@@ -1198,11 +1171,11 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                $preparedEdit->popts = $this->getCanonicalParserOptions();
                $preparedEdit->output = $this->getCanonicalParserOutput();
-               $preparedEdit->pstContent = $this->pstContentSlots->getContent( 'main' );
+               $preparedEdit->pstContent = $this->revision->getContent( 'main' );
                $preparedEdit->newContent =
                        $slotsUpdate->isModifiedSlot( 'main' )
                        ? $slotsUpdate->getModifiedSlot( 'main' )->getContent()
-                       : $this->pstContentSlots->getContent( 'main' ); // XXX: can we just remove this?
+                       : $this->revision->getContent( 'main' ); // XXX: can we just remove this?
                $preparedEdit->oldContent = null; // unused. // XXX: could get this from the parent revision
                $preparedEdit->revid = $this->revision ? $this->revision->getId() : null;
                $preparedEdit->timestamp = $preparedEdit->output->getCacheTime();
@@ -1211,130 +1184,30 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                return $preparedEdit;
        }
 
-       /**
-        * @return bool
-        */
-       private function isContentAccessible() {
-               // XXX: when we move this to a RevisionHtmlProvider, the audience may be configurable!
-               return $this->isContentPublic();
-       }
-
        /**
         * @param string $role
         * @param bool $generateHtml
         * @return ParserOutput
         */
        public function getSlotParserOutput( $role, $generateHtml = true ) {
-               // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
-
-               $this->assertPrepared( __METHOD__ );
-
-               if ( isset( $this->slotsOutput[$role] ) ) {
-                       $entry = $this->slotsOutput[$role];
-
-                       if ( $entry->hasHtml || !$generateHtml ) {
-                               return $entry->output;
-                       }
-               }
-
-               if ( !$this->isContentAccessible() ) {
-                       // empty output
-                       $output = new ParserOutput();
-               } else {
-                       $content = $this->getRawContent( $role );
-
-                       $output = $content->getParserOutput(
-                               $this->getTitle(),
-                               $this->revision ? $this->revision->getId() : null,
-                               $this->getCanonicalParserOptions(),
-                               $generateHtml
-                       );
-               }
-
-               $this->slotsOutput[$role] = (object)[
-                       'output' => $output,
-                       'hasHtml' => $generateHtml,
-               ];
-
-               $output->setCacheTime( $this->getTimestampNow() );
-
-               return $output;
+               return $this->getRenderedRevision()->getSlotParserOutput(
+                       $role,
+                       [ 'generate-html' => $generateHtml ]
+               );
        }
 
        /**
         * @return ParserOutput
         */
        public function getCanonicalParserOutput() {
-               if ( $this->canonicalParserOutput ) {
-                       return $this->canonicalParserOutput;
-               }
-
-               // TODO: MCR: logic for combining the output of multiple slot goes here!
-               // TODO: factor this out into a RevisionHtmlProvider that can also be used for viewing.
-               $this->canonicalParserOutput = $this->getSlotParserOutput( 'main' );
-
-               return $this->canonicalParserOutput;
+               return $this->getRenderedRevision()->getRevisionParserOutput();
        }
 
        /**
         * @return ParserOptions
         */
        public function getCanonicalParserOptions() {
-               if ( $this->canonicalParserOptions ) {
-                       return $this->canonicalParserOptions;
-               }
-
-               // TODO: ParserOptions should *not* be controlled by the ContentHandler!
-               // See T190712 for how to fix this for Wikibase.
-               $this->canonicalParserOptions = $this->wikiPage->makeParserOptions( 'canonical' );
-
-               //TODO: if $this->revision is not set but we already know that we pending update is a
-               // null-edit, we should probably use the page's current revision here.
-               // That would avoid the need for the !$this->options['changed'] branch in
-               // outputVariesOnRevisionMetaData [dk 2018-05]
-
-               if ( $this->revision ) {
-                       // Make sure we use the appropriate revision ID when generating output
-                       $title = $this->getTitle();
-                       $oldCallback = $this->canonicalParserOptions->getCurrentRevisionCallback();
-                       $this->canonicalParserOptions->setCurrentRevisionCallback(
-                               function ( Title $parserTitle, $parser = false ) use ( $title, &$oldCallback ) {
-                                       if ( $parserTitle->equals( $title ) ) {
-                                               $legacyRevision = new Revision( $this->revision );
-                                               return $legacyRevision;
-                                       } else {
-                                               return call_user_func( $oldCallback, $parserTitle, $parser );
-                                       }
-                               }
-                       );
-               } else {
-                       // NOTE: we only get here without READ_LATEST if called directly by application logic
-                       $dbIndex = $this->useMaster()
-                               ? DB_MASTER // use the best possible guess
-                               : DB_REPLICA; // T154554
-
-                       $this->canonicalParserOptions->setSpeculativeRevIdCallback(
-                               function () use ( $dbIndex ) {
-                                       // TODO: inject LoadBalancer!
-                                       $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
-                                       // Use a fresh connection in order to see the latest data, by avoiding
-                                       // stale data from REPEATABLE-READ snapshots.
-                                       // HACK: But don't use a fresh connection in unit tests, since it would not have
-                                       // the fake tables. This should be handled by the LoadBalancer!
-                                       $flags = defined( 'MW_PHPUNIT_TEST' ) ? 0 : $lb::CONN_TRX_AUTOCOMMIT;
-                                       $db = $lb->getConnectionRef( $dbIndex, [], $this->getWikiId(), $flags );
-
-                                       return 1 + (int)$db->selectField(
-                                               'revision',
-                                               'MAX(rev_id)',
-                                               [],
-                                               __METHOD__
-                                       );
-                               }
-                       );
-               }
-
-               return $this->canonicalParserOptions;
+               return $this->getRenderedRevision()->getOptions();
        }
 
        /**
@@ -1490,7 +1363,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
 
                // TODO: make search infrastructure aware of slots!
                $mainSlot = $this->revision->getSlot( 'main' );
-               if ( !$mainSlot->isInherited() && $this->isContentPublic() ) {
+               if ( !$mainSlot->isInherited() && !$this->isContentDeleted() ) {
                        DeferredUpdates::addUpdate( new SearchUpdate( $id, $dbKey, $mainSlot->getContent() ) );
                }
 
@@ -1525,7 +1398,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                if ( $title->getNamespace() == NS_MEDIAWIKI
                        && $this->getRevisionSlotsUpdate()->isModifiedSlot( 'main' )
                ) {
-                       $mainContent = $this->isContentPublic() ? $this->getRawContent( 'main' ) : null;
+                       $mainContent = $this->isContentDeleted() ? null : $this->getRawContent( 'main' );
 
                        $this->messageCache->updateMessageOverride( $title, $mainContent );
                }
index 1aa1165..72d6547 100644 (file)
@@ -314,6 +314,17 @@ class MutableRevisionRecord extends RevisionRecord {
                return $this->mSha1;
        }
 
+       /**
+        * Returns the slots defined for this revision as a MutableRevisionSlots instance,
+        * which can be modified to defined the slots for this revision.
+        *
+        * @return MutableRevisionSlots
+        */
+       public function getSlots() {
+               // Overwritten just guarantee the more narrow return type.
+               return parent::getSlots();
+       }
+
        /**
         * Invalidate cached aggregate values such as hash and size.
         */
index 838efcd..9d2f209 100644 (file)
@@ -344,10 +344,6 @@ class PageUpdater {
                // TODO: MCR: check the role and the content's model against the list of supported
                // roles, see T194046.
 
-               if ( $role !== 'main' ) {
-                       throw new InvalidArgumentException( 'Only the main slot is presently supported' );
-               }
-
                $this->slotsUpdate->modifyContent( $role, $content );
        }
 
@@ -861,7 +857,11 @@ class PageUpdater {
                $title = $this->getTitle();
                $parent = $this->grabParentRevision();
 
-               $rev = new MutableRevisionRecord( $title, $this->getWikiId() );
+               // XXX: we expect to get a MutableRevisionRecord here, but that's a bit brittle!
+               // TODO: introduce something like an UnsavedRevisionFactory service instead!
+               /** @var MutableRevisionRecord $rev */
+               $rev = $this->derivedDataUpdater->getRevision();
+
                $rev->setPageId( $title->getArticleID() );
 
                if ( $parent ) {
@@ -876,17 +876,13 @@ class PageUpdater {
                $rev->setTimestamp( $timestamp );
                $rev->setMinorEdit( ( $flags & EDIT_MINOR ) > 0 );
 
-               foreach ( $this->derivedDataUpdater->getSlots()->getSlots() as $slot ) {
+               foreach ( $rev->getSlots()->getSlots() as $slot ) {
                        $content = $slot->getContent();
 
                        // XXX: We may push this up to the "edit controller" level, see T192777.
                        // TODO: change the signature of PrepareSave to not take a WikiPage!
                        $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
 
-                       if ( $prepStatus->isOK() ) {
-                               $rev->setSlot( $slot );
-                       }
-
                        // TODO: MCR: record which problem arose in which slot.
                        $status->merge( $prepStatus );
                }
index 895cc0e..ca62e0e 100644 (file)
@@ -1489,10 +1489,10 @@ class Title implements LinkTarget {
        public function isRawHtmlMessage() {
                global $wgRawHtmlMessages;
 
-               if ( $this->inNamespace( NS_MEDIAWIKI ) ) {
+               if ( !$this->inNamespace( NS_MEDIAWIKI ) ) {
                        return false;
                }
-               $message = lcfirst( $this->getRootText() );
+               $message = lcfirst( $this->getRootTitle()->getDBkey() );
                return in_array( $message, $wgRawHtmlMessages, true );
        }
 
index 28d331b..3694186 100644 (file)
        "apihelp-protect-param-watch": "如果被設定,就將被(解除)保護的頁面加至目前使用者的監視列表。",
        "apihelp-protect-param-watchlist": "無條件地將該頁面加入至或移除自目前使用者的監視列表、使用偏好設定或不更改監視。",
        "apihelp-protect-example-protect": "保護一個頁面。",
+       "apihelp-protect-example-unprotect": "透過設定為 <kbd>all</kbd>(註:代表任何人都可以執行操作),來解除對頁面的保護。",
+       "apihelp-protect-example-unprotect2": "透過設定為沒有限制,來解除對頁面的保護。",
        "apihelp-purge-summary": "為指定標題清除快取。",
        "apihelp-purge-param-forcelinkupdate": "更新連結表格。",
        "apihelp-purge-example-generator": "重新整理主要命名空間的前10個頁面。",
        "apihelp-query-param-prop": "替已查詢頁面所要取得的屬性。",
        "apihelp-query-param-list": "要取得的清單。",
        "apihelp-query-param-meta": "要取得的詮釋資料。",
+       "apihelp-query-param-rawcontinue": "回傳原始的 <samp>query-continue</samp> 資料來繼續。",
        "apihelp-query-example-allpages": "索取以 <kbd>API/</kbd> 為開頭的頁面修訂。",
        "apihelp-query+allcategories-summary": "列舉所有分類。",
        "apihelp-query+allcategories-param-from": "起始列舉的分類。",
index b6211b0..733d85a 100644 (file)
@@ -505,6 +505,7 @@ abstract class AbstractContent implements Content {
                }
 
                $po = new ParserOutput();
+               $options->registerWatcher( [ $po, 'recordOption' ] );
 
                if ( Hooks::run( 'ContentGetParserOutput',
                        [ $this, $title, $revId, $options, $generateHtml, &$po ] )
@@ -518,6 +519,7 @@ abstract class AbstractContent implements Content {
                }
 
                Hooks::run( 'ContentAlterParserOutput', [ $this, $title, $po ] );
+               $options->registerWatcher( null );
 
                return $po;
        }
index 8e78459..9a78fd8 100644 (file)
@@ -1029,6 +1029,7 @@ class HTMLForm extends ContextSource {
         * @param bool|string|array|Status $submitResult Output from HTMLForm::trySubmit()
         *
         * @return string HTML
+        * @return-taint escaped
         */
        public function getHTML( $submitResult ) {
                # For good measure (it is the default)
index 24cc8b5..b609d7b 100644 (file)
@@ -23,6 +23,7 @@
 use MediaWiki\Edit\PreparedEdit;
 use MediaWiki\Logger\LoggerFactory;
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Storage\DerivedPageDataUpdater;
 use MediaWiki\Storage\PageUpdater;
 use MediaWiki\Storage\RevisionRecord;
@@ -223,6 +224,13 @@ class WikiPage implements Page, IDBAccessObject {
                return MediaWikiServices::getInstance()->getRevisionStore();
        }
 
+       /**
+        * @return RevisionRenderer
+        */
+       private function getRevisionRenderer() {
+               return MediaWikiServices::getInstance()->getRevisionRenderer();
+       }
+
        /**
         * @return ParserCache
         */
@@ -931,6 +939,7 @@ class WikiPage implements Page, IDBAccessObject {
                                // links.
                                $hasLinks = (bool)count( $editInfo->output->getLinks() );
                        } else {
+                               // NOTE: keep in sync with revisionRenderer::getLinkCount
                                $hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1,
                                        [ 'pl_from' => $this->getId() ], __METHOD__ );
                        }
@@ -1630,6 +1639,7 @@ class WikiPage implements Page, IDBAccessObject {
                $derivedDataUpdater = new DerivedPageDataUpdater(
                        $this, // NOTE: eventually, PageUpdater should not know about WikiPage
                        $this->getRevisionStore(),
+                       $this->getRevisionRenderer(),
                        $this->getParserCache(),
                        JobQueueGroup::singleton(),
                        MessageCache::singleton(),
index 5f80e27..51c04ea 100644 (file)
@@ -425,12 +425,14 @@ class Parser {
         * Do not call this function recursively.
         *
         * @param string $text Text we want to parse
+        * @param-taint $text escapes_htmlnoent
         * @param Title $title
         * @param ParserOptions $options
         * @param bool $linestart
         * @param bool $clearState
         * @param int|null $revid Number to pass in {{REVISIONID}}
         * @return ParserOutput A ParserOutput
+        * @return-taint escaped
         */
        public function parse(
                $text, Title $title, ParserOptions $options,
@@ -671,8 +673,10 @@ class Parser {
         * $text are not expanded
         *
         * @param string $text Text extension wants to have parsed
+        * @param-taint $text escapes_htmlnoent
         * @param bool|PPFrame $frame The frame to use for expanding any template variables
         * @return string UNSAFE half-parsed HTML
+        * @return-taint escaped
         */
        public function recursiveTagParse( $text, $frame = false ) {
                // Avoid PHP 7.1 warning from passing $this by reference
@@ -697,8 +701,10 @@ class Parser {
         * @since 1.25
         *
         * @param string $text Text extension wants to have parsed
+        * @param-taint $text escapes_htmlnoent
         * @param bool|PPFrame $frame The frame to use for expanding any template variables
         * @return string Fully parsed HTML
+        * @return-taint escaped
         */
        public function recursiveTagParseFully( $text, $frame = false ) {
                $text = $this->recursiveTagParse( $text, $frame );
@@ -2693,9 +2699,19 @@ class Parser {
                                $this->mOutput->setFlag( 'vary-revision-id' );
                                wfDebug( __METHOD__ . ": {{REVISIONID}} used, setting vary-revision-id...\n" );
                                $value = $this->mRevisionId;
-                               if ( !$value && $this->mOptions->getSpeculativeRevIdCallback() ) {
-                                       $value = call_user_func( $this->mOptions->getSpeculativeRevIdCallback() );
-                                       $this->mOutput->setSpeculativeRevIdUsed( $value );
+
+                               if ( !$value ) {
+                                       $rev = $this->getRevisionObject();
+                                       if ( $rev ) {
+                                               $value = $rev->getId();
+                                       }
+                               }
+
+                               if ( !$value ) {
+                                       $value = $this->mOptions->getSpeculativeRevId();
+                                       if ( $value ) {
+                                               $this->mOutput->setSpeculativeRevIdUsed( $value );
+                                       }
                                }
                                break;
                        case 'revisionday':
@@ -5751,10 +5767,9 @@ class Parser {
                if ( !is_null( $this->mRevisionObject ) ) {
                        return $this->mRevisionObject;
                }
-               if ( is_null( $this->mRevisionId ) ) {
-                       return null;
-               }
 
+               // NOTE: try to get the RevisionObject even if mRevisionId is null.
+               // This is useful when parsing revision that has not yet been saved.
                $rev = call_user_func(
                        $this->mOptions->getCurrentRevisionCallback(), $this->getTitle(), $this
                );
@@ -5762,7 +5777,7 @@ class Parser {
                # If the parse is for a new revision, then the callback should have
                # already been set to force the object and should match mRevisionId.
                # If not, try to fetch by mRevisionId for sanity.
-               if ( $rev && $rev->getId() != $this->mRevisionId ) {
+               if ( $this->mRevisionId && $rev && $rev->getId() != $this->mRevisionId ) {
                        $rev = Revision::newFromId( $this->mRevisionId );
                }
 
index 5e6081d..43c72b1 100644 (file)
@@ -301,6 +301,10 @@ class ParserCache {
                $cacheTime = null,
                $revId = null
        ) {
+               if ( !$parserOutput->hasText() ) {
+                       throw new InvalidArgumentException( 'Attempt to cache a ParserOutput with no text set!' );
+               }
+
                $expire = $parserOutput->getCacheExpiry();
                if ( $expire > 0 && !$this->mMemc instanceof EmptyBagOStuff ) {
                        $cacheTime = $cacheTime ?: wfTimestampNow();
index b30c116..a8da3ce 100644 (file)
@@ -61,6 +61,7 @@ class ParserOptions {
         */
        private static $lazyOptions = [
                'dateformat' => [ __CLASS__, 'initDateFormat' ],
+               'speculativeRevId' => [ __CLASS__, 'initSpeculativeRevId' ],
        ];
 
        /**
@@ -831,9 +832,38 @@ class ParserOptions {
                return $this->setOptionLegacy( 'templateCallback', $x );
        }
 
+       /**
+        * A guess for {{REVISIONID}}, calculated using the callback provided via
+        * setSpeculativeRevIdCallback(). For consistency, the value will be calculated upon the
+        * first call of this method, and re-used for subsequent calls.
+        *
+        * If no callback was defined via setSpeculativeRevIdCallback(), this method will return false.
+        *
+        * @since 1.32
+        * @return int|false
+        */
+       public function getSpeculativeRevId() {
+               return $this->getOption( 'speculativeRevId' );
+       }
+
+       /**
+        * Callback registered with ParserOptions::$lazyOptions, triggered by getSpeculativeRevId().
+        *
+        * @param ParserOptions $popt
+        * @return bool|false
+        */
+       private static function initSpeculativeRevId( ParserOptions $popt ) {
+               $cb = $popt->getOption( 'speculativeRevIdCallback' );
+               $id = $cb ? $cb() : null;
+
+               // returning null would result in this being re-called every access
+               return $id ?? false;
+       }
+
        /**
         * Callback to generate a guess for {{REVISIONID}}
         * @since 1.28
+        * @deprecated since 1.32, use getSpeculativeRevId() instead!
         * @return callable|null
         */
        public function getSpeculativeRevIdCallback() {
@@ -847,6 +877,7 @@ class ParserOptions {
         * @return callable|null Old value
         */
        public function setSpeculativeRevIdCallback( $x ) {
+               $this->setOption( 'speculativeRevId', null ); // reset
                return $this->setOptionLegacy( 'speculativeRevIdCallback', $x );
        }
 
@@ -1081,6 +1112,7 @@ class ParserOptions {
                                'currentRevisionCallback' => [ Parser::class, 'statelessFetchRevision' ],
                                'templateCallback' => [ Parser::class, 'statelessFetchTemplate' ],
                                'speculativeRevIdCallback' => null,
+                               'speculativeRevId' => null,
                        ];
 
                        Hooks::run( 'ParserOptionsRegister', [
index fe9913d..48ba111 100644 (file)
@@ -31,9 +31,9 @@ class ParserOutput extends CacheTime {
        const SUPPORTS_UNWRAP_TRANSFORM = 1;
 
        /**
-        * @var string $mText The output text
+        * @var string|null $mText The output text
         */
-       public $mText;
+       public $mText = null;
 
        /**
         * @var array $mLanguageLinks List of the full text of language links,
@@ -232,6 +232,15 @@ class ParserOutput extends CacheTime {
        const SLOW_AR_TTL = 3600; // adaptive TTL for "slow" pages
        const MIN_AR_TTL = 15; // min adaptive TTL (for sanity, pool counter, and edit stashing)
 
+       /**
+        * @param string|null $text HTML. Use null to indicate that this ParserOutput contains only
+        *        meta-data, and the HTML output is undetermined, as opposed to empty. Passing null
+        *        here causes hasText() to return false.
+        * @param array $languageLinks
+        * @param array $categoryLinks
+        * @param bool $unused
+        * @param string $titletext
+        */
        public function __construct( $text = '', $languageLinks = [], $categoryLinks = [],
                $unused = false, $titletext = ''
        ) {
@@ -241,6 +250,20 @@ class ParserOutput extends CacheTime {
                $this->mTitleText = $titletext;
        }
 
+       /**
+        * Returns true if text was passed to the constructor, or set using setText(). Returns false
+        * if null was passed to the $text parameter of the constructor to indicate that this
+        * ParserOutput only contains meta-data, and the HTML output is undetermined.
+        *
+        * @since 1.32
+        *
+        * @return bool Whether this ParserOutput contains rendered text. If this returns false, the
+        *         ParserOutput contains meta-data only.
+        */
+       public function hasText() {
+               return ( $this->mText !== null );
+       }
+
        /**
         * Get the cacheable text with <mw:editsection> markers still in it. The
         * return value is suitable for writing back via setText() but is not valid
@@ -250,6 +273,10 @@ class ParserOutput extends CacheTime {
         * @since 1.27
         */
        public function getRawText() {
+               if ( $this->mText === null ) {
+                       throw new LogicException( 'This ParserOutput contains no text!' );
+               }
+
                return $this->mText;
        }
 
@@ -276,6 +303,7 @@ class ParserOutput extends CacheTime {
         *    the scheme-specific-part of the href is the (percent-encoded) value
         *    of the `data-mw-deduplicate` attribute.
         * @return string HTML
+        * @return-taint escaped
         */
        public function getText( $options = [] ) {
                $options += [
@@ -285,7 +313,7 @@ class ParserOutput extends CacheTime {
                        'deduplicateStyles' => true,
                        'wrapperDivClass' => $this->getWrapperDivClass(),
                ];
-               $text = $this->mText;
+               $text = $this->getRawText();
 
                Hooks::runWithoutAbort( 'ParserOutputPostCacheTransform', [ $this, &$text, &$options ] );
 
@@ -358,6 +386,17 @@ class ParserOutput extends CacheTime {
                        );
                }
 
+               // Hydrate slot section header placeholders generated by RevisionRenderer.
+               $text = preg_replace_callback(
+                       '#<mw:slotheader>(.*?)</mw:slotheader>#',
+                       function ( $m ) {
+                               $role = htmlspecialchars_decode( $m[1] );
+                               // TODO: map to message, using the interface language. Set lang="xyz" accordingly.
+                               $headerText = $role;
+                               return $headerText;
+                       },
+                       $text
+               );
                return $text;
        }
 
@@ -469,6 +508,9 @@ class ParserOutput extends CacheTime {
                return $this->mExternalLinks;
        }
 
+       public function setNoGallery( $value ) {
+               $this->mNoGallery = (bool)$value;
+       }
        public function getNoGallery() {
                return $this->mNoGallery;
        }
@@ -1247,4 +1289,211 @@ class ParserOutput extends CacheTime {
                        [ 'mParseStartTime' ]
                );
        }
+
+       /**
+        * Merges internal metadata such as flags, accessed options, and profiling info
+        * from $source into this ParserOutput. This should be used whenever the state of $source
+        * has any impact on the state of this ParserOutput.
+        *
+        * @param ParserOutput $source
+        */
+       public function mergeInternalMetaDataFrom( ParserOutput $source ) {
+               $this->mOutputHooks = self::mergeList( $this->mOutputHooks, $source->getOutputHooks() );
+               $this->mWarnings = self::mergeMap( $this->mWarnings, $source->mWarnings ); // don't use getter
+               $this->mTimestamp = $this->useMaxValue( $this->mTimestamp, $source->getTimestamp() );
+
+               if ( $this->mSpeculativeRevId && $source->mSpeculativeRevId
+                       && $this->mSpeculativeRevId !== $source->mSpeculativeRevId
+               ) {
+                       wfLogWarning(
+                               'Inconsistent speculative revision ID encountered while merging parser output!'
+                       );
+               }
+
+               $this->mSpeculativeRevId = $this->useMaxValue(
+                       $this->mSpeculativeRevId,
+                       $source->getSpeculativeRevIdUsed()
+               );
+               $this->mParseStartTime = $this->useEachMinValue(
+                       $this->mParseStartTime,
+                       $source->mParseStartTime
+               );
+
+               $this->mFlags = self::mergeMap( $this->mFlags, $source->mFlags );
+               $this->mAccessedOptions = self::mergeMap( $this->mAccessedOptions, $source->mAccessedOptions );
+
+               // TODO: maintain per-slot limit reports!
+               if ( empty( $this->mLimitReportData ) ) {
+                       $this->mLimitReportData = $source->mLimitReportData;
+               }
+               if ( empty( $this->mLimitReportJSData ) ) {
+                       $this->mLimitReportJSData = $source->mLimitReportJSData;
+               }
+       }
+
+       /**
+        * Merges HTML metadata such as head items, JS config vars, and HTTP cache control info
+        * from $source into this ParserOutput. This should be used whenever the HTML in $source
+        * has been somehow mered into the HTML of this ParserOutput.
+        *
+        * @param ParserOutput $source
+        */
+       public function mergeHtmlMetaDataFrom( ParserOutput $source ) {
+               // HTML and HTTP
+               $this->mHeadItems = self::mergeMixedList( $this->mHeadItems, $source->getHeadItems() );
+               $this->mModules = self::mergeList( $this->mModules, $source->getModules() );
+               $this->mModuleScripts = self::mergeList( $this->mModuleScripts, $source->getModuleScripts() );
+               $this->mModuleStyles = self::mergeList( $this->mModuleStyles, $source->getModuleStyles() );
+               $this->mJsConfigVars = self::mergeMap( $this->mJsConfigVars, $source->getJsConfigVars() );
+               $this->mMaxAdaptiveExpiry = min( $this->mMaxAdaptiveExpiry, $source->mMaxAdaptiveExpiry );
+
+               // "noindex" always wins!
+               if ( $this->mIndexPolicy === 'noindex' || $source->mIndexPolicy === 'noindex' ) {
+                       $this->mIndexPolicy = 'noindex';
+               } elseif ( $this->mIndexPolicy !== 'index' ) {
+                       $this->mIndexPolicy = $source->mIndexPolicy;
+               }
+
+               // Skin control
+               $this->mNewSection = $this->mNewSection || $source->getNewSection();
+               $this->mHideNewSection = $this->mHideNewSection || $source->getHideNewSection();
+               $this->mNoGallery = $this->mNoGallery || $source->getNoGallery();
+               $this->mEnableOOUI = $this->mEnableOOUI || $source->getEnableOOUI();
+               $this->mPreventClickjacking = $this->mPreventClickjacking || $source->preventClickjacking();
+
+               // TODO: we'll have to be smarter about this!
+               $this->mSections = array_merge( $this->mSections, $source->getSections() );
+               $this->mTOCHTML = $this->mTOCHTML . $source->mTOCHTML;
+
+               // XXX: we don't want to concatenate title text, so first write wins.
+               // We should use the first *modified* title text, but we don't have the original to check.
+               if ( $this->mTitleText === null || $this->mTitleText === '' ) {
+                       $this->mTitleText = $source->mTitleText;
+               }
+
+               // class names are stored in array keys
+               $this->mWrapperDivClasses = self::mergeMap(
+                       $this->mWrapperDivClasses,
+                       $source->mWrapperDivClasses
+               );
+
+               // NOTE: last write wins, same as within one ParserOutput
+               $this->mIndicators = self::mergeMap( $this->mIndicators, $source->getIndicators() );
+
+               // NOTE: include extension data in "tracking meta data" as well as "html meta data"!
+               // TODO: add a $mergeStrategy parameter to setExtensionData to allow different
+               // kinds of extension data to be merged in different ways.
+               $this->mExtensionData = self::mergeMap(
+                       $this->mExtensionData,
+                       $source->mExtensionData
+               );
+       }
+
+       /**
+        * Merges dependency tracking metadata such as backlinks, images used, and extension data
+        * from $source into this ParserOutput. This allows dependency tracking to be done for the
+        * combined output of multiple content slots.
+        *
+        * @param ParserOutput $source
+        */
+       public function mergeTrackingMetaDataFrom( ParserOutput $source ) {
+               $this->mLanguageLinks = self::mergeList( $this->mLanguageLinks, $source->getLanguageLinks() );
+               $this->mCategories = self::mergeMap( $this->mCategories, $source->getCategories() );
+               $this->mLinks = self::merge2D( $this->mLinks, $source->getLinks() );
+               $this->mTemplates = self::merge2D( $this->mTemplates, $source->getTemplates() );
+               $this->mTemplateIds = self::merge2D( $this->mTemplateIds, $source->getTemplateIds() );
+               $this->mImages = self::mergeMap( $this->mImages, $source->getImages() );
+               $this->mFileSearchOptions = self::mergeMap(
+                       $this->mFileSearchOptions,
+                       $source->getFileSearchOptions()
+               );
+               $this->mExternalLinks = self::mergeMap( $this->mExternalLinks, $source->getExternalLinks() );
+               $this->mInterwikiLinks = self::merge2D(
+                       $this->mInterwikiLinks,
+                       $source->getInterwikiLinks()
+               );
+
+               // TODO: add a $mergeStrategy parameter to setProperty to allow different
+               // kinds of properties to be merged in different ways.
+               $this->mProperties = self::mergeMap( $this->mProperties, $source->getProperties() );
+
+               // NOTE: include extension data in "tracking meta data" as well as "html meta data"!
+               // TODO: add a $mergeStrategy parameter to setExtensionData to allow different
+               // kinds of extension data to be merged in different ways.
+               $this->mExtensionData = self::mergeMap(
+                       $this->mExtensionData,
+                       $source->mExtensionData
+               );
+       }
+
+       private static function mergeMixedList( array $a, array $b ) {
+               return array_unique( array_merge( $a, $b ), SORT_REGULAR );
+       }
+
+       private static function mergeList( array $a, array $b ) {
+               return array_values( array_unique( array_merge( $a, $b ), SORT_REGULAR ) );
+       }
+
+       private static function mergeMap( array $a, array $b ) {
+               return array_replace( $a, $b );
+       }
+
+       private static function merge2D( array $a, array $b ) {
+               $values = [];
+               $keys = array_merge( array_keys( $a ), array_keys( $b ) );
+
+               foreach ( $keys as $k ) {
+                       if ( empty( $a[$k] ) ) {
+                               $values[$k] = $b[$k];
+                       } elseif ( empty( $b[$k] ) ) {
+                               $values[$k] = $a[$k];
+                       } elseif ( is_array( $a[$k] ) && is_array( $b[$k] ) ) {
+                               $values[$k] = array_replace( $a[$k], $b[$k] );
+                       } else {
+                               $values[$k] = $b[$k];
+                       }
+               }
+
+               return $values;
+       }
+
+       private static function useEachMinValue( array $a, array $b ) {
+               $values = [];
+               $keys = array_merge( array_keys( $a ), array_keys( $b ) );
+
+               foreach ( $keys as $k ) {
+                       if ( is_array( $a[$k] ?? null ) && is_array( $b[$k] ?? null ) ) {
+                               $values[$k] = self::useEachMinValue( $a[$k], $b[$k] );
+                       } else {
+                               $values[$k] = self::useMinValue( $a[$k] ?? null, $b[$k] ?? null );
+                       }
+               }
+
+               return $values;
+       }
+
+       private static function useMinValue( $a, $b ) {
+               if ( $a === null ) {
+                       return $b;
+               }
+
+               if ( $b === null ) {
+                       return $a;
+               }
+
+               return min( $a, $b );
+       }
+
+       private static function useMaxValue( $a, $b ) {
+               if ( $a === null ) {
+                       return $b;
+               }
+
+               if ( $b === null ) {
+                       return $a;
+               }
+
+               return max( $a, $b );
+       }
+
 }
index 1ae3dee..31da686 100644 (file)
        "backend-fail-invalidpath": "«$1» не зьяўляецца слушным шляхам да сховішча.",
        "backend-fail-delete": "Немагчыма выдаліць файл «$1».",
        "backend-fail-describe": "Не атрымалася зьмяніць мэтазьвесткі для файлу «$1».",
-       "backend-fail-alreadyexists": "Файл $1 ужо існуе.",
+       "backend-fail-alreadyexists": "Файл «$1» ужо існуе.",
        "backend-fail-store": "Немагчыма захаваць файл $1 у $2.",
        "backend-fail-copy": "Немагчыма скапіяваць файл $1 у $2.",
        "backend-fail-move": "Немагчыма перанесьці файл $1 у $2.",
index 2222d42..18f0515 100644 (file)
        "diff-paragraph-moved-toold": "অনুচ্ছেদ স্থানান্তর করা হয়েছে। পুরনো অবস্থানে যাওয়ার জন্য ক্লিক করুন।",
        "difference-missing-revision": "এই পার্থক্যের ($1) অন্তর্গত {{PLURAL:$2|একটি সংশোধিত সংস্করণ|$2টি সংশোধিত সংস্করণ}} খুঁজে পাওয়া যাচ্ছে না।\n\nসাধারণত মুছে ফেলা হয়েছে এমন পাতার মেয়াদ উত্তীর্ণ ইতিহাস পাতার লিংক খোলার কারণে এটি হতে পারে। \n[{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} পাতা অবলুপ্তি লগে] বিস্তারিত তথ্য জানা যাবে।",
        "searchresults": "অনুসন্ধানের ফলাফল",
-       "search-filter-title-prefix": "শুধুমাত্র \"$1\" শিরোনাম দিয়ে শুরু হওয়া পাতাগুলি খোঁজা হচ্ছে",
+       "search-filter-title-prefix": "শুধুমাত্র \"$1\" শিরোনাম দিয়ে শুরু হওয়া পাতাগুলিতে খোঁজা হচ্ছে",
        "search-filter-title-prefix-reset": "সব পাতা অনুসন্ধান করুন",
        "searchresults-title": "\"$1\" অনুসন্ধানের ফলাফল",
        "titlematches": "নিবন্ধের শিরোনাম মিলেছে",
index 20bb78a..c66aa07 100644 (file)
        "search-nonefound-thiswiki": "Δεν υπάρχουν αποτελέσματα που να ικανοποιούν το ερώτημα σε αυτόν τον ιστότοπο.",
        "powersearch-legend": "Αναλυτική αναζήτηση",
        "powersearch-ns": "Αναζήτηση στους ονοματοχώρους:",
-       "powersearch-togglelabel": "Î\88λεγÏ\87οÏ\82:",
+       "powersearch-togglelabel": "Î\95Ï\80ιλογή:",
        "powersearch-toggleall": "Όλοι",
        "powersearch-togglenone": "Κανένας",
        "powersearch-remember": "Διατήρηση επιλογής για μελλοντικές αναζητήσεις",
index a67c238..7131875 100644 (file)
        "filehist-filesize": "Faili suurus",
        "filehist-comment": "Kommentaar",
        "imagelinks": "Failikasutus",
-       "linkstoimage": "Sellele failile {{PLURAL:$1|viitab järgmine lehekülg|viitavad järgmised $1 leheküljed}}:",
-       "linkstoimage-more": "Sellele failile viitab enam kui {{PLURAL:$1|üks lehekülg|$1 lehekülge}}.\nJärgmises loendis on näidatud ainult {{PLURAL:$1|esimene viitav lehekülg|esimesed $1 viitavat lehekülge}}.\n[[Special:WhatLinksHere/$2|Kogu loetelu]] on saadaval.",
-       "nolinkstoimage": "Sellele failile ei viita ükski lehekülg.",
+       "linkstoimage": "Seda faili {{PLURAL:$1|kasutab järgmine lehekülg|kasutavad järgmised $1 lehekülge}}:",
+       "linkstoimage-more": "Seda faili kasutab enam kui {{PLURAL:$1|üks lehekülg|$1 lehekülge}}.\nJärgmises loendis on näidatud ainult {{PLURAL:$1|esimene lehekülg|esimesed $1 lehekülge}}, mis faili {{PLURAL:$1|kasutab|kasutavad}}.\n[[Special:WhatLinksHere/$2|Kogu loetelu]] on saadaval.",
+       "nolinkstoimage": "Seda faili ei kasuta ükski lehekülg.",
        "morelinkstoimage": "Vaata [[Special:WhatLinksHere/$1|veel linke]], mis sellele failile viitavad.",
        "linkstoimage-redirect": "$1 (failiümbersuunamine) $2",
        "duplicatesoffile": "{{PLURAL:$1|Järgmine fail|Järgmised $1 faili}} on selle faili {{PLURAL:$1|duplikaat|duplikaadid}} ([[Special:FileDuplicateSearch/$2|üksikasjad]]):",
        "cachedspecial-refresh-now": "Vaata uusimat versiooni.",
        "categories": "Kategooriad",
        "categories-submit": "Näita",
-       "categoriespagetext": "Vikis on {{PLURAL:$1|järgmine kategooria|järgmised kategooriad}}.\nSiin ei näidata [[Special:UnusedCategories|kasutamata kategooriaid]].\nVaata ka [[Special:WantedCategories|puuduvaid kategooriaid]].",
+       "categoriespagetext": "Vikis on {{PLURAL:$1|järgmine kategooria. See|järgmised kategooriad. Need}} ei pruugi olla kasutusel.\nVaata ka [[Special:WantedCategories|puuduvaid kategooriaid]].",
        "categoriesfrom": "Näita kategooriaid alates:",
        "deletedcontributions": "Kustutatud kaastöö",
        "deletedcontributions-title": "Kasutaja kustutatud kaastöö",
index a97e832..749a26c 100644 (file)
        "summary-preview": "Gearfetting sa at dy brûkt wurdt:",
        "subject-preview": "Neisjen ûnderwerp/kop:",
        "blockedtitle": "Meidogger is útsletten troch",
-       "blockedtext": "<strong>Jo meidochnamme of ynternet-adres is útsletten.</strong>\n\nDe útsluting is útfierd troch $1.\nAs reden is opjûn <em>$2</em>.\n\n* Begjin útsluting: $8\n* Ein útsluting: $6\n* Bedoeld út te sluten: $7\n\nAs jo wolle, kinne jo kontakt opnimme mei $1 of in oare [[{{MediaWiki:Grouppage-sysop}}|behearder]] en besprekke de útsluting.\nJo kinne de funksje 'Skriuw dizze meidogger' net brûke, of it moast wêze dat jo in jildich e-mailadres opjûn hawwe by jo [[Special:Preferences|ynstellings]] en net útsletten binne dat te brûken.\nJo hjoeddeisk ynternet-adres is $3, en it útslútnûmer is #$5.\nNim alle boppesteande gegevens op yn jo reäksjes.\n\n(Om't in ynternet-adres faak mar foar ien sesje tawiisd wurdt, kin it wêze dat it om in oar giet, dy't deselde tagongkedizer hat as jo hawwe. As it jo net oanbelanget, besykje dan earst of it noch sa is as jo in skoftke gjin ynternet-ferbining hân hawwe. As it in probleem bliuwt, skriuw dan in behearder. Sorry, foar it ûngemak.)",
-       "autoblockedtext": "Jo IP-adres is automatysk útsletten om't brûkt is troch in oare brûker, dy't útsletten is troch $1.\nDe opjûne reden is:\n\n:''$2''\n\n* Begjin útsluting : $8\n* Ein útsluting : $6\n* Bedoeld út te sluten: $7\n\nJo kinne kontakt opnimme mei $1 of in oare [[{{MediaWiki:Grouppage-sysop}}|behearder]] om de útsluting te besprekken.\nJo kinne gjin gebrûk meitsje fan 'e funksje 'Skriuw meidogger', of jo moatte in jildich e-postadres opjûn hawwe yn jo [[Special:Preferences|foarkarren]] en it gebrûk fan dy funksje moat net útsletten wêze.\nJo tsjintwurdich e-postadres is $3 en it útsletnûmer is #$5. Neam beide gegevens as jo earne op dizze útsluting reagearje.",
+       "blockedtext": "<strong>Jo meidochnamme of ynternet-adres is útsletten.</strong>\n\nDe útsluting is útfierd troch $1.\nAs reden is opjûn <em>$2</em>.\n\n* Begjin útsluting: $8\n* Ein útsluting: $6\n* Bedoeld út te sluten: $7\n\nAs jo wolle, kinne jo kontakt opnimme mei $1 of in oare [[{{MediaWiki:Grouppage-sysop}}|behearder]] en besprekke de útsluting.\nJo kinne de funksje \"{{int:emailuser}}\" net brûke, of it moast wêze dat jo in jildich e-mailadres opjûn hawwe by jo [[Special:Preferences|ynstellings]] en net útsletten binne dat te brûken.\nJo hjoeddeisk ynternet-adres is $3, en it útslútnûmer is #$5.\nNim alle boppesteande gegevens op yn jo reäksjes.\n\n(Om't in ynternet-adres faak mar foar ien sesje tawiisd wurdt, kin it wêze dat it om in oar giet, dy't deselde tagongkedizer hat as jo hawwe. As it jo net oanbelanget, besykje dan earst of it noch sa is as jo in skoftke gjin ynternet-ferbining hân hawwe. As it in probleem bliuwt, skriuw dan in behearder. Sorry, foar it ûngemak.)",
+       "autoblockedtext": "Jo ynternet-adres is automatysk útsletten, om't it brûkt is troch in oare meidogger dy't útsletten is troch $1.\nAs reden is opjûn:\n\n:<em>$2</em>\n\n* Begjin útsluting: $8\n* Ein útsluting: $6\n* Bedoeld út te sluten: $7\n\nAs jo wolle, meie jo kontakt opnimme mei $1 of ien fan de oare [[{{MediaWiki:Grouppage-sysop}}|behearders]] en besprekke de útsluting.\n\nTink derom dat jo de funksje \"{{int:emailuser}}\" net brûke kinne, of it moast wêze dat jo in jildich e-mailadres fêstlein hawwe yn jo [[Special:Preferences|ynstellings]] en net útsletten binne dat te brûken.\n\nJo hjoeddeisk ynternet-adres is $3, en it útslútnûmer is #$5.\nNim alle boppesteande gegevens op yn jo reäksjes.",
+       "systemblockedtext": "Jo meidochnamme of ynternet-adres is automatysk útsletten troch MediaWiki.\nAs reden is opjûn:\n\n:<em>$2</em>\n\n* Begjin útsluting: $8\n* Ein útsluting: $6\n* Bedoeld út te sluten: $7\n\nJo hjoeddeisk ynternet-adres is $3.\nNim alle boppesteande gegevens op yn jo reäksjes.",
        "blockednoreason": "gjin reden opjûn",
        "whitelistedittext": "Jo moatte $1 om siden te bewurkjen.",
        "confirmedittext": "Jo moatte jo e-mailadres befêstichje foar't jo siden feroarje kinne.\nFier in e-mailadres yn by jo [[Special:Preferences|foarkarren]] en befêstichje it.",
        "timezoneregion-europe": "Jeropa",
        "timezoneregion-indian": "Yndyske Oseaan",
        "timezoneregion-pacific": "Stille Oseaan",
-       "allowemail": "Lit my ek e-mail fan oare meidoggers ûntfange",
+       "allowemail": "Stean oare meidoggers ta my te e-mailen",
        "prefs-searchoptions": "Sykje",
        "prefs-namespaces": "Nammeromten",
        "default": "standert",
        "recentchanges-feed-description": "Mei dizze feed kinne jo de nijste feroarings yn dizze wiki besjen.",
        "recentchanges-label-newpage": "Mei dizze wiziging is in nije side makke",
        "recentchanges-label-minor": "Dit is in tekstwiziging",
-       "recentchanges-label-bot": "Dizze wiziging is troch in robot makke",
+       "recentchanges-label-bot": "Dizze bewurking is troch in bot útfierd",
        "recentchanges-label-unpatrolled": "Dizze wiziging is noch net neisjûn",
        "recentchanges-label-plusminus": "De sidegrutte is mei dit oantal bytes wizige",
        "recentchanges-legend-heading": "<strong>Leginda:</strong>",
        "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} (sjoch ek de [[Special:NewPages|list mei nije siden]])",
+       "rcfilters-legend-heading": "<strong>List fan ôfkoartings:</strong>",
        "rcnotefrom": "Dit binne de feroarings sûnt <b>$2</b> (maksimaal <b>$1</b>).",
        "rclistfrom": "Jou nije feroarings, begjinnende mei $3 $2",
        "rcshowhideminor": "$1 tekstwizigings",
        "usereditcount": "$1 {{PLURAL:$1|bewurking|bewurkings}}",
        "newpages": "Nije siden",
        "newpages-username": "Brûkersnamme:",
-       "ancientpages": "Alde siden",
+       "ancientpages": "Aldste siden",
        "move": "Omneame",
        "movethispage": "Dizze side omneame",
        "unusedimagestext": "<p>Tink derom dat oare websiden fan oare parten fan it meartalige projekt mooglik in keppeling nei it URL fan it ôfbyld makke hawwe. Sokke ôfbylden wurde wol brûkt, mar steane dochs op dizze list.",
        "allpagesfrom": "Begjin list by",
        "allpagesto": "Siden besjen oant:",
        "allarticles": "Alle siden",
-       "allinnamespace": "Alle siden (yn de $1-nammeromte)",
+       "allinnamespace": "Alle siden (yn nammeromte $1)",
        "allpagessubmit": "Los!",
        "allpagesprefix": "Siden sjen litte dy't begjinne mei:",
        "allpagesbadtitle": "De opjûne sidenamme is ûnjildich of hat in yntertaal- of ynterwikifoarheaksel.\nMûglik befettet de namme karakters dy't net brûkt wurde meie yn sidenammen.",
        "trackingcategories-name": "Berjochtnamme",
        "mailnologin": "Gjin adres beskikber",
        "mailnologintext": "Jo moatte [[Special:UserLogin|oanmelden]] wêze, en in jildich e-postadres [[Special:Preferences|ynsteld]] hawwe, om oan oare meidoggers e-post stjoere te kinnen.",
-       "emailuser": "E-mail meidogger",
+       "emailuser": "E-mail dizze meidogger",
        "emailuser-title-notarget": "E-mail nei meidogger",
        "emailpagetext": "Fia dit berjocht kinne jo in e-mail oan dizze brûker ferstjoere.\nIt e-mailadres dat jo opjûn hawwe by [[Special:Preferences|jo foarkarren]] wurdt as ôfstjoerder  brûkt.\nDe ûntfanger kin dus daliks nei jo reagearje.",
        "defemailsubject": "E-mail fan {{SITENAME}}-brûker \"$1\"",
        "undelete-show-file-submit": "Ja",
        "namespace": "Nammeromte:",
        "invert": "Seleksje útsein",
-       "blanknamespace": "(Ensyklopedy)",
+       "blanknamespace": "(Haad)",
        "contributions": "{{GENDER:$1|Meidogger}}-bydragen",
        "contributions-title": "Bydragen fan $1",
        "mycontris": "Bydragen",
        "change-blocklink": "blokkade feroarje",
        "contribslink": "bydragen",
        "emaillink": "e-mail stjoere:",
-       "autoblocker": "Jo wiene útsletten om't jo ynternet-adres oerienkomt mei dat fan \"[[User:$1|$1]]\". Foar it útsluten fan dy meidogger waard dizze reden jûn: \"$2\".",
+       "autoblocker": "Automatysk útsletten om't jo ynternet-adres okkerdeis brûkt is troch \"[[User:$1|$1]]\".\nAs reden foar de útsluting fan $1 is opjûn \"$2\"",
        "blocklogpage": "Utslútloch",
        "blocklogentry": "\"[[$1]]\" útsletten foar $2 $3",
        "blocklogtext": "Dit is in loch fan it útsluten en talitten fan meidoggers. Fansels útsletten IP-adressen binne net opnaam. Sjoch de [[Special:BlockList|útslútlist]] foar de no jildende útslutings.",
        "tooltip-feed-rss": "RSS-feed foar dizze side",
        "tooltip-feed-atom": "Atom-feed foar dizze side",
        "tooltip-t-contributions": "Bydragen fan dizze brûker",
-       "tooltip-t-emailuser": "Stjoer in e-mail nei dizze brûker",
+       "tooltip-t-emailuser": "Stjoer in e-mail nei {{GENDER:$1|dizze meidogger}}",
        "tooltip-t-upload": "Triemmen oplade",
        "tooltip-t-specialpages": "List fan alle spesjale siden",
        "tooltip-ca-nstab-user": "Brûkersside sjen litte",
index 89892eb..ceec1fe 100644 (file)
@@ -39,8 +39,8 @@
        "tog-oldsig": "Pali lo ulu'umu masatiya",
        "tog-fancysig": "Popopasiya pali lo'ulu'u odelo tuladuwiki (diyalu tuwawu wumbuta otomatis)",
        "tog-uselivepreview": "Popobilohe pratayang wawu ja detohe ulangi halaman",
-       "tog-forceeditsummary": "Popo'eelawa wa'u wonu dosi momoli'o diipo otuwa",
-       "tog-watchlisthideown": "Wantoa u biloli'u'u to daputari lo he'awasiyalo",
+       "tog-forceeditsummary": "Popo'ēlawa wa'u wonu dosi momoli'o dīpo otuwa",
+       "tog-watchlisthideown": "Wanto'a u biloli'u'u to daputari lo he'awasiyalo",
        "tog-watchlisthidebots": "Wanto'a u biloli'o bot to daputari lo he'awasiyalo",
        "tog-watchlisthideminor": "Wanto'a u loboli'a ngo'idi to daputari lo he'awasiyalo",
        "tog-watchlisthideliu": "Wanto'a u biloli'o ta ohu'uwo tilumuwoto log to daputari he awasiyalo",
@@ -48,9 +48,9 @@
        "tog-watchlisthideanons": "Wanto'a u bilo;i'o ta ohu'uwo anonim monto daputari he awasiyalo",
        "tog-watchlisthidepatrolled": "Wanto'a u biloli'o patroli monto daputari he'awasiyalo",
        "tog-watchlisthidecategorization": "Wanto'a dalala lo halaman",
-       "tog-ccmeonemails": "Lawoli wa'u wami lo surel u yilawou to tawu",
+       "tog-ccmeonemails": "Lawoli wa'u wami lo surel u yilawo'u to tawu",
        "tog-diffonly": "Ja popobilohe tuwango halaman u hihihede",
-       "tog-showhiddencats": "Popobilehe dalala u hewanto'a",
+       "tog-showhiddencats": "Popobilohe dalala u hewanto'a",
        "tog-norollbackdiff": "Japopobilohe hihedeliyo to'u yilapato pilopohuwalingo",
        "tog-useeditwarning": "Popo'eelawa wa'u wonu molola halaman heboli'olo wonu dipo tilahu",
        "tog-prefershttps": "Layito momake koneksi amani wonu tumuwoto log",
        "article": "Tuwango halaman",
        "newwindow": "hu'owa to tutulowa bohu",
        "cancel": "Batali",
-       "moredotdotdot": "Uweewo",
-       "morenotlisted": "Daputari boti kira-kira diipo ganapu",
+       "moredotdotdot": "Uwēwo",
+       "morenotlisted": "Daputari botiya kira-kira dīpo ganapu",
        "mypage": "Halaman",
        "mytalk": "Lo'iya",
        "anontalk": "Lo'iya",
        "namespaces": "Huwali lo tanggulo",
        "variants": "Varian",
        "navigation-heading": "Menu navigasi",
-       "errorpagetitle": "Lotaalawa",
+       "errorpagetitle": "Tilala",
        "returnto": "Mohuwalingo ode $1",
        "tagline": "Lonto {{SITENAME}}",
        "help": "Wubodu",
        "searchbutton": "Lolohe",
        "go": "Ntali",
        "searcharticle": "Ntali",
-       "history": "Riwayati lo halaman",
-       "history_short": "Riwayati",
-       "history_small": "riwayati",
+       "history": "Riwāyati lo halaman",
+       "history_short": "Riwāyati",
+       "history_small": "riwāyati",
        "updatedmarker": "biloli'o to'u bililohe pulitiyo",
        "printableversion": "Persi cetak",
-       "permalink": "Wumbuta kakali",
-       "print": "Cetaki",
+       "permalink": "Wūmbuta kakali",
+       "print": "Cetakiya",
        "view": "Bilohi",
        "view-foreign": "Bilohi to $1",
        "edit": "Boli'o",
        "personaltools": "Pilaakasi lo hihilawo",
        "talk": "Lo'iya",
        "views": "Bibilohu",
-       "toolbox": "Pilaakasi",
-       "tool-link-userrights": "Boli'a lembo'a {{GENDER:$1|ta ohu'uwo}}",
-       "tool-link-userrights-readonly": "Bilohi lembo'a {{GENDER:$1|ta ohu'uwo}}",
+       "toolbox": "Pilākasi",
+       "tool-link-userrights": "Boli'a lēmbo'a {{GENDER:$1|ta ohu'uwo}}",
+       "tool-link-userrights-readonly": "Bilohi lēmbo'a {{GENDER:$1|ta ohu'uwo}}",
        "tool-link-emailuser": "Lawoli surel ode {{GENDER:$1|ta ohu'uwo}}",
        "imagepage": "Bilohi halaman berkas",
        "mediawikipage": "Bilohi halaman tahuli",
        "viewhelppage": "Bilohi halaman wubodu",
        "categorypage": "Bilohi dalala lo halaman",
        "viewtalkpage": "Bilohi u lo'iya",
-       "otherlanguages": "To bahasa uweewo",
+       "otherlanguages": "To bahasa uwēwo",
        "redirectedfrom": "Pilobale lonto $1",
        "redirectpagesub": "Halaman pilobaleya",
        "redirectto": "Mobale ode",
        "pool-servererror": "Ta hemorekeni pool botiye diya'a: $1",
        "poolcounter-usage-error": "Tilala lopohuna:$1",
        "aboutsite": "Tomimbihu {{SITENAME}}",
-       "aboutpage": "Proyek:Tomimbihu",
+       "aboutpage": "Project:Tomimbihu",
        "copyright": "Tuwanga botiya sadi-sadia odelo to tibawa $1",
        "copyrightpage": "{{ns:project}}:Haku lohutu",
-       "currentevents": "U yilowali baharu",
-       "currentevents-url": "Project:U yilowali baharu",
-       "disclaimers": "Momaahu",
-       "disclaimerpage": "Project:Momaahu umum",
+       "currentevents": "U yilowali bahāru",
+       "currentevents-url": "Project:U yilowali bahāru",
+       "disclaimers": "Momāhu",
+       "disclaimerpage": "Project:Momāhu umum",
        "edithelp": "Wubodu momoli'o",
        "helppage-top-gethelp": "Wubodu",
        "mainpage": "Halaman Bungaliyo",
        "mainpage-description": "Halaman bungaliyo",
        "policy-url": "Project:Tinepo",
-       "portal": "Buubu'a leembo'a",
-       "portal-url": "Project:Buubu'a lembo'a",
+       "portal": "Būbu'a lēmbo'a",
+       "portal-url": "Project:Būbu'a lēmbo'a",
        "privacy": "Tinepo privasi",
        "privacypage": "Project:Tinepo privasi",
        "badaccess": "Tilala haku momu'o",
        "thisisdeleted": "Bilohi meyalo pohuwalinga $1",
        "viewdeleted": "Bilohi $1",
        "restorelink": "{{PLURAL:$1|tuwawu biloli'o ma yiluluto}}",
-       "feedlinks": "Paalo",
-       "feed-invalid": "Hihile tayadu paalo dila banari.",
-       "feed-unavailable": "Paalo sindikasi diyaluwo",
-       "site-rss-feed": "Paalo $1 RSS",
-       "site-atom-feed": "Paalo $1 Atom",
-       "page-rss-feed": "Paalo $1 RSS",
-       "page-atom-feed": "Paalo $1 Atom",
-       "red-link-title": "$1 (halaman dipoluwo)",
+       "feedlinks": "Pālo",
+       "feed-invalid": "Hihile tayadu pālo ja banari.",
+       "feed-unavailable": "Pālo sindikasi diyāluwo",
+       "site-rss-feed": "Pālo $1 RSS",
+       "site-atom-feed": "Pālo $1 Atom",
+       "page-rss-feed": "Pālo $1 RSS",
+       "page-atom-feed": "Pālo $1 Atom",
+       "red-link-title": "$1 (halaman dipōluwo)",
        "sort-descending": "Boluti ode tibawa",
-       "sort-ascending": "Boluti ode yitaato",
+       "sort-ascending": "Boluti ode yitāto",
        "nstab-main": "Halaman",
        "nstab-user": "Halaman lo ta ohu'uwo",
        "nstab-media": "Halaman media",
        "nosuchactiontext": "Huhutu u hepohile lo URL ja valid.\nYi'o lotalawa lopotuwoto lo URL, meyalo lodudu'a wumbuta u ja banari.\nUtiye olo kira-kira tuwotiyo woluwo bug to pilaakasi u hepomake {{SITENAME}}",
        "nosuchspecialpage": "Diya'a halaman istimewa boyito",
        "nospecialpagetext": "<strong>Yi'o hemohile halaman istimewa u ja sah.</strong>\n\nDaputari halaman istimewa mowali bilehela to [[Special:SpecialPages|{{int:specialpages}}]]",
-       "error": "Tilala aba",
+       "error": "Tilala",
        "databaseerror": "Tilala tuwango data",
        "databaseerror-text": "Ma tilala tuwawu basis kueri.\nUtiya kira-kira tuwotiyo woluwo bug to pilaakasi moluluhi'o.",
        "databaseerror-textcl": "Tilala tuwawu basis kueri.",
        "databaseerror-query": "Kueri $1",
        "databaseerror-function": "Huna: $1",
-       "databaseerror-error": "Tilala aba: $1",
+       "databaseerror-error": "Tilala: $1",
        "transaction-duration-limit-exceeded": "Untuk mencegah penundaan replikasi yang tinggi, pengiriman ini dibatalkan karena durasi tulis ($1) melebihi batas $2 {{PLURAL:$2|detik|detik}}.\nJika Anda ingin mengganti banyak butir sekaligus, cobalah melakukan dalam operasi yang lebih kecil.",
        "laggedslavemode": "<strong>Warning:</strong> Halaman kira ja o tuwango u lobohuwa.",
        "readonly": "Basis data unti-unti",
        "directoryreadonlyerror": "Direktori \"$1\" bo pobaca.",
        "directorynotreadableerror": "Direktori \"$1\" jamowali pobaca.",
        "filenotfound": "Jamotapu tuwango \"$1\"",
-       "unexpected": "Nilai ja o'aata: \"$1\"=\"$2\".",
+       "unexpected": "Nilai ja o'āta: \"$1\"=\"$2\".",
        "formerror": "Tilala: Ja mowali molawo formulir",
        "badarticleerror": "Huhutu boti ja mowali pohutuwola to halaman boti.",
        "cannotdelete": "Halaman meyalo berkas \"$1\" jamowali lulutolo.\nKira-kira ma yiluluto tawu weewo.",
        "cannotdelete-title": "Ja mowali moluluta halaman \"$1\"",
        "delete-hook-aborted": "Moluluto bilatali lo kokayito.\nDiyaalu kataraangani.",
        "no-null-revision": "Ja mowali mohutu revisi noolo bohu lo halaman \"$1\"",
-       "badtitle": "Judul moleeto",
+       "badtitle": "Judul molēto",
        "badtitletext": "Judul halaman pilohile ja sah, ja otuwa, meyalo judul wolota lo bahasa meyalo wolota lo wiki u tilala lo humbuto.\nUtiye kira otuwa tuwawu meyalo limbata watade u ja mowali pomake to judul.",
        "title-invalid-empty": "Judul halaman pilohile ja otuwa meyalo bo otuwa tuwawu huwali lo tanggulo.",
        "title-invalid-utf8": "Judul halaman pilohile otuwa ayita UTF-8 u ja sah.",
        "ns-specialprotected": "Halaman spesial ja mowali ubaalo.",
        "titleprotected": "Judul botiya daha-daya monto ta mohutu oleh [[User:$1|$1]].\nAlasani u yilohiliyo de'uwito <em>$2</em>.",
        "invalidtitle-knownnamespace": "Judul u ja sah wolo huwali tanggulo \"$2\" wawu teks \"$3\"",
-       "exception-nologin": "Diipo tilumuwoto log",
+       "exception-nologin": "Dīpo tilumuwoto log",
        "exception-nologin-text": "Toduwolo tumuwoto log alihu mowali mokalaja to halaman botiye meyalo huhutu botiye.",
        "exception-nologin-text-manual": "Toduwoolo $1 tumuwoto alihu mowali mohutu halaman meyalo uweewo.",
        "virus-badscanner": "Tilala konfigurasi: pemindai virus ja iloonuhe: ''$1''",
        "virus-unknownscanner": "antivirus ja'otaawa",
        "cannotlogoutnow-title": "Ja mowali lumuwalo masatiya",
        "cannotlogoutnow-text": "Lumuwalo log ja mowali to'u mopohuna $1.",
-       "welcomeuser": "Toduwoolo, $1!",
+       "welcomeuser": "Toduwōlo, $1!",
        "welcomecreation-msg": "Akun olemu ma pilohutu. Ja lipata mongaturu konfigurasi [[Special:Preferences|preferensi {{SITENAME}}]] olemu.",
-       "yourname": "Ta ohu'uwo tanggulo",
-       "userlogin-yourname": "Ta ohu'uwo tanggulo",
-       "userlogin-yourname-ph": "Tuwota ta ohu'uwo lo tanggulo",
-       "createacct-another-username-ph": "Tuwota ta ohu'uwo lo tanggulo",
+       "yourname": "Tanggulo ta ohu'uwo",
+       "userlogin-yourname": "Tanggulo ta ohu'uwo",
+       "userlogin-yourname-ph": "Tuwota tanggulo ta ohu'uwo",
+       "createacct-another-username-ph": "Tuwota tanggulo ta ohu'uwo",
        "yourpassword": "Tahe u'unti",
        "userlogin-yourpassword": "Tahe u'unti",
        "userlogin-yourpassword-ph": "Tuwota tahe u'unti",
        "createacct-yourpassword-ph": "Tuwota tahe u'unti",
        "yourpasswordagain": "Ulangiya tahe u'unti",
        "createacct-yourpasswordagain": "Konfirmasi tahe u'unti",
-       "createacct-yourpasswordagain-ph": "Tuwota pooli tahe u'unti",
+       "createacct-yourpasswordagain-ph": "Tuwota pōli tahe u'unti",
        "userlogin-remembermypassword": "Hulima'o wa'u tuwo-tuwoto",
-       "userlogin-signwithsecure": "Popohunawa server aamani",
+       "userlogin-signwithsecure": "Popohunawa server āmani",
        "cannotloginnow-title": "Ja mowali tumuwoto log sa'ati botiya",
        "cannotloginnow-text": "Tumuwoto log ja mowali to'umopohuna $1.",
        "yourdomainname": "Domain Ulemu:",
        "nav-login-createaccount": "Tumuwoto log / mohutu akun",
        "logout": "Lumuwalo log",
        "userlogout": "Lumuwalo log",
-       "notloggedin": "Diipo tilumuwoto log",
-       "userlogin-noaccount": "Diipo o akun",
+       "notloggedin": "Dīpo tilumuwoto log",
+       "userlogin-noaccount": "Dīpo o akun",
        "userlogin-joinproject": "Motiwayito {{SITENAME}}",
        "createaccount": "Mohutu akun",
        "userlogin-resetpassword-link": "Ilolipata tahe u'unti?",
        "userlogin-helplink2": "Wubodu tumuwoto log",
        "userlogin-loggedin": "Yi'o ma tilumuwoto odelo {{GENDER:$1|$1}}\nPopohunawa formulir formulir to tibawa botiye odelo pengguna uweewo.",
        "userlogin-reauth": "Yi'o musti tumuwota pooli u mopopatato yi'o odelo {{GENDER:$1|$1}}",
-       "userlogin-createanother": "Mohutu akun uweewo",
-       "createacct-emailrequired": "Alaamati surel",
+       "userlogin-createanother": "Mohutu akun uwēwo",
+       "createacct-emailrequired": "Alamat tuladu email",
        "createacct-emailoptional": "Alamat tuladu email (paralu tuwangalo)",
        "createacct-email-ph": "Tuwanga alamat tuladu email",
        "createacct-another-email-ph": "Tuwanga alamat tuladu email",
        "badretype": "Tahe u'unti pilopotuwoto tilala.",
        "usernameinprogress": "Mohutu akun wolo tanggula botiye donggo na'o-na'o. Wulatipo ngope'e.",
        "userexists": "Ta ohu'uwo lo tanggulo pilopotuwoto ma pilomake lo tawu. Toduwolo molulawota tanggula uweewo.",
-       "loginerror": "Lotaalawa tilumuwato log",
-       "createacct-error": "Lotaalawa lohutu akun",
+       "loginerror": "Tilala tumuwato log",
+       "createacct-error": "Tilala lohutu akun",
        "createaccounterror": "Diya mowali mohutu akun: $1",
        "nocookiesnew": "Akun pengguna ma pilohutu, dabo Yi'o diipo tilumuwoto. {{SITENAME}} popohunawa kuki log pengguna.\nToduwolo mopo'aktif wawu tumuwota pooli wolo tanggulu ta ohu'uwo wawu tahe u'unti.",
        "noname": "Tanggulo ta ohu'uwo u pilopotuwotumu ja sah.",
        "blocked-mailpassword": "Alamat IP olemu ma diblokir monto u momoli'o. Modaha u mopotalawa, Yi'o diipo mowali mopobohu lo tahe u'unti moli alamat IP botiye.",
        "eauthentsent": "Tuladu elektronik u pokonfirmasi ma yilawo ode alamat lo tuladu. To'udiipo tuladu elektronik uweewo lawololo ode akun botiye, Yi'o musti modudu'a potunu to delomo tuladu boyito, u mokonformasi tutu liyo tutu alamat boyito banari ulemu.",
        "throttled-mailpassword": "Tahe u'unti bohu ma yilawo to delomo {{PLURAL:$1|$1 jam}}botiye.\nModaha ta mopotalawa, bo tuwawu tahe u'unti u lawololo timi'idu {{PLURAL:$1|jam|$1 jam}}.",
-       "mailerror": "Tilala lo lawo tuladu elektronik:$1",
+       "mailerror": "Tilala lolawo tuladu elektronik:$1",
        "emailauthenticated": "Alamat tuladu elektronikmu ma dikonfirmasi to $3, $2.",
        "emailnotauthenticated": "Alamat tuldu elektronikmu diipo dikonformasi.\nWonu diipo dikonfirmasi, Yi'o dila ta mololimo tulade elektronik monto fitur botiya.",
        "noemailprefs": "Yi'o musti mopomasu alamat surel to preferensimu alihu mowali mopohuna lo fitur-fitur botiye.",
        "botpasswords-label-update": "Mopobohu",
        "botpasswords-label-cancel": "Bataliya",
        "botpasswords-label-delete": "Luluta",
-       "passwordreset": "Ubawa tahe u'unti",
+       "passwordreset": "Boli'a tahe u'unti",
        "bold_sample": "Teks botiye ma cetakiyolo mohulodu",
        "bold_tip": "Teks mohulodu",
        "italic_sample": "Teks botiye ma cetakiyolo yinti-yintili",
        "yourdiff": "Hihede",
        "templatesused": "{{PLURAL:$1|Template}} pilopohuna to halaman botiye:",
        "templatesusedpreview": "{{PLURAL:$1|Template|Templates}} pilomake to'u mopobilohu.",
-       "template-protected": "(he dahalo)",
+       "template-protected": "(hedahalo)",
        "template-semiprotected": "(dahalo-ngowa)",
        "hiddencategories": "Halaman botiye woluwo anggota {{PLURAL:$1|1 kategori wanto-wanto'o $1}}:",
        "permissionserrors": "Tilala haku momu'o",
        "permissionserrorstext-withaction": "Yi'o ja haku akses $2, sababu {{PLURAL:$1|alasani}} botiya:",
        "recreate-moveddeleted-warn": "<Strong>Mopo'ota: Yi'o lohutu ulangi hlaman u ma yiluluto.</strong>\n\nPopotimbangiyapo huhutumu botiye delo mowali poturusiyolo.\nBotiya log piloluluta wawu piloheyiya halaman botiye.",
        "moveddeleted-notice": "Halaman botiye ma yiluluto.\nLog piloluluta, pilodahawa wawu piloheyiya halaman botiye woluwo to tibawa pohutu referensi.",
-       "postedit-confirmation-saved": "Biloli'umu ma tilahu.",
-       "edit-already-exists": "Ja mowali mohutu halaman bohu. Ma woluwo.",
+       "postedit-confirmation-saved": "Biloli'umu mā tilahu.",
+       "edit-already-exists": "Ja mowali mohutu halaman bohu. Mā woluwo.",
        "content-model-wikitext": "tuladu wiki",
        "undo-failure": "U biloli'a botiya ja mowali pohuwalingo sababu lodulehe ta lomoli'o.",
        "viewpagelogs": "Bilohi log lo halaman botiye",
        "nextrevision": "Biloli'o lapatiyoma'o →",
        "currentrevisionlink": "Biloli'o pulitiyo",
        "cur": "mst",
-       "last": "diipo",
+       "last": "dīpo",
        "histlegend": "Tulawota diff: Tuwoti kasi lo radio loboli'a u mopobandingiyo wawu woduta enter meyalo tombol to tibawa.<br />\nLegenda: <strong>({{int:cur}})</strong> = hihede wolo biloli'a pulitiyo, <strong>({{int:last}})</strong> = hihede wolo u biloli'a muloolo, <strong>{{int:minoreditletter}}</strong> = bilili'o ngo'idi.",
        "history-fieldset-title": "Lolohe u biloli'o",
        "histfirst": "mohihewo da'a",
        "mergelog": "Log mopohimbunguwo",
        "history-title": "Riwayati lo'u loboli'a lonto \"$1\"",
        "difference-title": "$1 hihede revisi",
-       "lineno": "Baarisi $1:",
+       "lineno": "Bārisi $1:",
        "compareselectedversions": "Popotadenga u tilulawoto",
        "editundo": "pohuwalinga",
        "diff-empty": "(Diya'a hihedeliyo)",
        "diff-multi-sameuser": "({{PLURAL:$1|$1 revisi wolota}} pilohutu lo tawu ngota ja pilopobilohu)",
        "diff-multi-otherusers": "({{PLURAL:$1|Tuwawu lopo'opiyohu wolota|$1 lopo'opiyohu wolota}} pilohutu {{PLURAL:$2|ngota ta ohu'uwo uweewo|$2 ta ohu'uwo}} ja pilopobilohu)",
        "searchresults": "U yilotapu",
-       "searchresults-title": "U yilotapu lololohe \"$1\"",
-       "prevn": "{{PLURAL:$1|$1}} to'udiipo",
+       "searchresults-title": "U yilotapu yilolohu \"$1\"",
+       "prevn": "{{PLURAL:$1|$1}} to'udīpo",
        "nextn": "{{PLURAL:$1|$1}} lapatiyoma'o",
-       "prevn-title": "To'u diipo $1 {{PLURAL:$1|hasili}}",
+       "prevn-title": "To'u dīpo $1 {{PLURAL:$1|hasili}}",
        "nextn-title": "$1 {{PLURAL:$1|hasili}}lapatiyoma'o",
-       "shown-title": "Popobilohe $1 {{PLURAL:$1|haasili}} per halaman",
+       "shown-title": "Popobilohe $1 {{PLURAL:$1|hāsili}} per halaman",
        "viewprevnext": "Bilohi ($1 {{int:pipe-separator}} $2) ($3)",
        "searchmenu-exists": "<strong>Woluwo halaman otanggula \"[[:$1]]\" to wiki botiye.</strong> {{PLURAL:$2|0=|Bilohi olo u yilotapu uweewo.}}",
        "searchmenu-new": "<strong>Mohutu halaman \"[[:$1]]\" to wiki botiya!</strong> {{PLURAL:$2|0=Bilohi halaman u yilotapu yilolohumu.|Bilohi hasili u yilotapu to'u yilolohu}}",
        "imgfile": "berkas",
        "listfiles": "Daputari berkas",
        "file-anchor-link": "Berkas",
-       "filehist": "Riwaayati lo berkas",
+       "filehist": "Riwāyati lo berkas",
        "filehist-help": "Klik to tanggal/wakutu momilohe berkas to saa'ati botiye.",
        "filehist-revert": "bataliya",
        "filehist-current": "baharu",
index a5c4009..066ca61 100644 (file)
@@ -10,6 +10,7 @@
        },
        "underline-always": "Միշտ",
        "underline-never": "Երբեք",
+       "editfont-serif": "Սերիֆ տառատեսակ",
        "sunday": "Կիրակի",
        "monday": "Երկուշաբթի",
        "tuesday": "Երեքշաբթի",
        "october-date": "$1 Հոկտեմբեր",
        "november-date": "$1 Նոյեմբեր",
        "december-date": "$1 Դեկտեմբեր",
+       "period-am": "Նախ Կէսօր",
+       "period-pm": "Կէսօրէն Յետոյ",
        "pagecategories": "{{PLURAL:$1|Ստորոգութիւն|Ստորոգութիւններ}}",
        "category_header": "«$1» ստորոգութեան մէջ էջեր",
        "subcategories": "Ենթաստորոգութիւններ",
        "category-media-header": "\"$1\" ստորոգութեան հաղորդամիջոց",
        "category-empty": "<em>Այս ստորոգութիւնը ներկայիս դատարկ է։<em>",
        "hidden-categories": "{{PLURAL:$1|Թաքուն ստորոգութիւն|Թաքուն ստորոգութիւններ}}",
+       "hidden-category-category": "Թաքցուած ստորոգութիւններ",
        "category-subcat-count": "{{PLURAL:$2|Այս ստորոգութիւնը ունի միայն հետեւեալ ենթաստորոգութիւնը։|Այս ստորոգութիւնը ունի հետեւեալ {{PLURAL:$1|ենթաստորոգութիւն|ենթաստորոգութիւններ}}ը՝ ընդհանուր $2էն։}}",
+       "category-subcat-count-limited": "Այս ստորոգութիւնը ունի հետեւեալ {{PLURAL:$1|ենթաստորոգութիւն|$1 ենթաստորոգութիւններ}}։",
        "category-article-count": "{{PLURAL:$2|Այս ստորոգութիւնը կը պարունակէ միայն հետեւեալ էջը։|Ստորեւ այս ստորոգութեան ընդհանուր $2էն {{PLURAL:$1|էջը|$1 էջերը}}։}}",
+       "category-article-count-limited": "Այս ստորոգութիւնի մէջ կը գտնուին հետեւեալ {{PLURAL:$1|էջը|$1 էջերը}}։",
        "category-file-count": "{{PLURAL:$2|Այս ստորոգութիւնը կը պարունակէ միայն հետեւեալ էջը։|Ստորեւ այս ստորոգութեան ընդհանուր $2-էն {{PLURAL:$1|էջը|$1 էջերը}}։}}",
+       "category-file-count-limited": "Այս ստորոգութիւնի մէջ կը գտնուին հետեւեալ  {{PLURAL:$1|նիշքը|$1 նիշքերը}}։",
        "listingcontinuesabbrev": "շար.",
+       "index-category": "Չցուցակագրուած էջեր",
        "noindex-category": "Չցուցակագրուած էջեր",
        "broken-file-category": "Հասցէազուրկ նիշքի յղումներով էջեր",
        "about": "Նախագիծին մասին",
+       "article": "Բովանդակութեան էջեր",
        "newwindow": "(Նոր պատուհանի մէջ կը բացուի)",
        "cancel": "Չեղարկել",
+       "moredotdotdot": "Աւելի...",
+       "morenotlisted": "Այս ցանկը կարելի է անկատար ըլլալ։",
        "mypage": "Էջ",
        "mytalk": "Քննարկում",
        "anontalk": "Քննարկել",
        "navigation": "Նաւարկութիւն",
        "and": "&#32;եւ",
+       "faq": "ՅՀՀ",
+       "actions": "Գործողութիւններ",
        "namespaces": "Անուանատարածքներ",
        "variants": "Տարբերակներ",
        "navigation-heading": "Նաւարկութեան ցուցակ",
+       "errorpagetitle": "Սխալ",
        "returnto": "Վերադարնալ դէպի $1։",
        "tagline": "{{SITENAME}}էն",
        "help": "Օգնութիւն",
        "search": "Որոնել",
        "searchbutton": "Որոնել",
+       "go": "‎Յառաջանալ",
        "searcharticle": "‎Յառաջանալ",
        "history": "Էջի պատմութիւն",
        "history_short": "Պատմութիւն",
        "tool-link-emailuser": "Ղրկել ասիկա էլ-նամակով {{GENDER:$1|գործածողին}}",
        "imagepage": "Դիտել նիշքի էջը",
        "mediawikipage": "Դիտել հաղորդագրութեան էջը",
+       "templatepage": "Դիտել կաղապարի էջը",
        "viewhelppage": "Դիտել օգնութեան էջը",
+       "categorypage": "Տեսնել ստորոգութեան էջը",
        "viewtalkpage": "Դիտել քննարկումը",
        "otherlanguages": "Այլ լեզուներով",
        "redirectedfrom": "(Վերայղուած է $1-էն)",
        "redirectpagesub": "վերայղման էջ",
        "redirectto": "Վերայղել դէպի՝",
        "lastmodifiedat": "Այս էջը վերջին անգամ խմբագրուած է $1 թուականի ժամը $2ին:",
+       "viewcount": "Այս էջը բացուած է {{PLURAL:$1|մէկ անգամ|$1 անգամ}}։",
        "protectedpage": "Պաշտպանուած էջ",
        "jumpto": "Ցատկել դէպի",
        "jumptonavigation": "նաւարկութիւն",
        "portal-url": "Project:Համայնքային դարպաս",
        "privacy": "Սեփական տուեալներու պահպանման քաղաքականութիւն",
        "privacypage": "Project:Սեփական տուեալներու պահպանման քաղաքականութիւն",
+       "badaccess": "Արտօնութեան սխալ",
+       "badaccess-group0": "Արտունութիւն չունիք այս գործողութիւնը կատարել:",
+       "badaccess-groups": "Տուեալ գործողութիւնը միայն $1 {{PLURAL:$2|խումբի|խումբերի}} մասնակիցները կ՛րնան կատարել։",
        "ok": "Լաւ",
        "retrievedfrom": "Վերցուած է «$1» էջէն",
        "youhavenewmessages": "{{PLURAL:$3|Դուք ունիք}} $1 ($2)։",
        "createacct-benefit-body1": "{{PLURAL:$1|խմբագրում}}",
        "createacct-benefit-body2": "$1 {{PLURAL:$1|էջ}}",
        "createacct-benefit-body3": "վերջին {{PLURAL:$1|մասնակից}}",
+       "loginsuccesstitle": "Բարեյաջող մուտք",
+       "loginsuccess": "'''Դուք մուտք գործեցիք {{SITENAME}}, իբր \"$1\"։'''",
+       "nouserspecified": "Հարկաւոր է նշել մասնակցին անունը։",
+       "login-userblocked": "Այս մասնակիցը արգելափակուած է: Մուտքը արգելուած է:",
+       "wrongpassword": "Սխալ մասնակիցի անուն կամ գաղտնաբար։ Հաճեցէք նորէն փորձել։",
+       "wrongpasswordempty": "Գաղտնաբար մը չը նշեցիք։ Հաճեցէք նորէն փորձել։",
+       "passwordtooshort": "Ամէնայ նուազագոյն գաղտնաբարը {{PLURAL:$1|1 նշանագիր |$1 նշանագիր}} նշանագիր կրնա՛յ ըլլալ:",
+       "passwordtoolong": "Ամենամեծ գաղտնաբարը {{PLURAL:$1|1 նշանագիր |$1 նշանագիր}} նշանագիր կրնա՛յ ըլլալ:",
+       "passwordtoopopular": "Դիւրուն գաղտնաբարներ չէք կրնալ գործածել:  Հաճեցէք աւելի ուժեղ գաղտնաբար մը:",
+       "password-name-match": "Ձեր գաղտնաբարը ձեր մասնակցի անունէն տարբեր պէտք է ելլայ։",
+       "password-login-forbidden": "Այս մասնակիցի անունը եւ գաղտաբարի օգտագործումը արգիլուած է:",
+       "mailmypassword": "Վերականգնել գաղտնաբառը",
+       "passwordremindertitle": "Նոր ժամանակաւոր գաղտնաբառ {{grammar:genitive|{{SITENAME}}}} համար",
+       "accountcreated": "Հաշիւը ստեղծուեցաւ:",
        "loginlanguagelabel": "Լեզու՝ $1",
        "pt-login": "Մուտք գործել",
        "pt-login-button": "Մուտք գործել",
+       "pt-login-continue-button": "Շարունակել մուտք գործել։",
        "pt-createaccount": "Հաշիւ ստեղծել",
        "pt-userlogout": "Դուրս գալ",
+       "php-mail-error-unknown": "Անյայտ սխալ PHP-ի mail() կախարկութեան մէջ:",
+       "changepassword": "Գաղտնաբառը փոխել",
+       "newpassword": "Նոր գաղտնաբառը.",
+       "retypenew": "Նորէն մուտքագրէք գաղտնաբառը",
+       "changepassword-success": "Ձեր գաղտնաբառը փոխուեցաւ։",
+       "botpasswords-label-create": "Ստեղծել",
+       "botpasswords-label-cancel": "Չեղարկել",
+       "botpasswords-label-delete": "Ջնջել",
+       "botpasswords-label-resetpassword": "Վերականգնել գաղտնաբառը",
+       "resetpass-submit-cancel": "Չեղարկել",
+       "resetpass-temp-password": "Ժամանակաւոր գաղտնաբառ.",
        "passwordreset": "Վերականգնել անցաբառը",
+       "passwordreset-domain": "Համակարգիչի պետութիւն.",
+       "passwordreset-email": "Էլ-նամակաի հասցէն.",
+       "passwordreset-emailtitle": "{{SITENAME}} հաշիւի մանրամասները",
        "bold_sample": "Շեշտուած տառերով գրութիւն",
        "bold_tip": "Շեշտուած տառերով գրութիւն",
        "italic_sample": "Շեղատառ գրութիւն",
        "sig_tip": "Ձեր ստորագրութիւնը ժամակնիքով",
        "hr_tip": "Հորիզոնական գիծ (գործածել խնայողաբար)",
        "summary": "Ամփոփում՝",
+       "subject": "Նիւթ.",
        "minoredit": "Ասիկա մանր խմբագրում է",
        "watchthis": "Հսկել այս էջը",
        "savearticle": "Էջը պահել",
+       "savechanges": "Պահպանել փոփոխութիւնները",
+       "publishpage": "Ստեղծել էջը",
+       "publishchanges": "Հրատարակել փոփոխութիւնները",
+       "savearticle-start": "Էջը պահել...",
+       "savechanges-start": "Պահպանել փոփոխութիւնները...",
+       "publishpage-start": "Ստեղծել էջը...",
        "preview": "Կանխաստուգել",
        "showpreview": "Կանխաստուգել",
        "showdiff": "Ցուցնել փոփոխութիւնները",
        "anoneditwarning": "<strong>Զգուշացում։</strong> Մուտք գործած չէք համակարգ։ Որեւէ խմբագրումի պարագային ձեր IP հասցէն տեսանելի կը դառնայ բոլորին։ Եթե <strong>[$1 մուտք գործէք]</strong> կամ <strong>[$2 ստեղծէք մասնակցային հաշիւ]</strong>, ձեր կատարած խմբագրումները կը կապուին ձեր մասնակցային անունին հետ, ինչպէս նաեւ կ՚ունենաք այլ առաւելութիւններ։",
-       "blockedtext": "<strong>Ձեր մասնակցային անոիւնը կամ IP հասցէն արգելակուած է։</strong>\n\nԱրգելակումը կատարուած է $1ի կողմէ.\nՊարտճառը՝ <em>$2</em>.\n\n* Արգելակման սկիբժ՝ $8\n* Արգելակման աւարտ՝ $6\n* արգելակուած առարկայ՝ $7\n\nԿրնաք կապուիլ $1ի կամ այլ անդատներու հետ [[{{MediaWiki:Grouppage-sysop}}|վարիչ]] արգելակման մասին զրուցելու համար.\nՉէք կրնար օգտագործել \"{{int:emailuser}}\" հնարաւորութիւնը բացի եթէ նշած էք իմակի վաւերական հասցէ մը ձեր [[Special:Նախասիրութիւններ|մասնակիցի նախասիրութիւններուն մէջ]] եւ արգելակուած չէ վեր անոր օգտագործումը.\nՁեր ընթացիկ IP հասցէն է $3, եւ արգելակման ինքնութեան համարն է #$5.\nԿը շնդրենք որ այս մանրամասնութիւնները նշէք ձեր բոլոր թղթակցութիւններուն մէջ։",
+       "blockedtext": "<strong>Ձեր մասնակցային անոիւնը կամ IP հասցէն արգելակուած է։</strong>\n\nԱրգելակումը կատարուած է $1ի կողմէ.\nՊարտճառը՝ <em>$2</em>.\n\n* Արգելակման սկիբժ՝ $8\n* Արգելակման աւարտ՝ $6\n* արգելակուած առարկայ՝ $7\n\nԿրնաք կապուիլ $1ի կամ այլ անդատներու հետ [[{{MediaWiki:Grouppage-sysop}}|վարիչ]] արգելակման մասին զրուցելու համար.\nՉէք կրնար օգտագործել \"{{int:emailuser}}\" հնարաւորութիւնը բացի եթէ նշած էք իմակի վաւերական հասցէ մը ձեր [[Special:Preferences|account preferences]] եւ արգելակուած չէ վեր անոր օգտագործումը.\nՁեր ընթացիկ IP հասցէն է $3, եւ արգելակման ինքնութեան համարն է #$5.\nԿը շնդրենք որ այս մանրամասնութիւնները նշէք ձեր բոլոր թղթակցութիւններուն մէջ։",
        "loginreqlink": "մուտք գործել",
        "newarticletext": "Դուք յղուած էք տակաւին գոյութիւն չունեցող էջի մը։\nԷջը ստեղծելու համար, մեքենագրեցէք ներքեւի տուփիկին մէջ (յաւելեալ տեղեկութեանց համար տե՛ս [$1 օգնութեան ցուցմունքներու էջը])։\nԵթէ սխալմամբ հոս հասած էք, սեղմել դիտարկիչի <strong>ետ</strong> կոճակը։",
        "anontalkpagetext": "<em> Այս էջը առայժմ հաշիւ չստեղծած, կամ հաշիւ չօգտագործող, անանուն մասնակիցներու քննարկման էջն է։</em>\nՈւրեմն որպէս ինքնութիւն ստիպուած ենք օգտագործել անոնց IP հասցէն։\nԱյսպիսի IP հասցէ կրնան ունենալ մէկէ աւելի մասնակիցներ։\nԵթէ դուք անանուն մասնակից էք եւ կը խորհիք որ անկապ դիտողութիւններու թիրախ դարձած էք, կը խնդրուի [[Special:CreateAccount|Հաշիւ ստեղծել]] կամ [[Special:UserLogin|մուտք գործել]] խուսափելու համար ապագային այլ անդանուն մասնակիցներու հետ շփոթուելու հնարաւորութենէն։",
        "editing": "Կը խմբագրուի՝ $1 էջը",
        "creating": "«$1» էջի ստեղծում",
        "editingsection": "$1 բաժինի խմբագրում",
+       "yourdiff": "Տարբերութիւններ",
        "templatesused": "Այս էջին մէջ օգտագործուած {{PLURAL:$1|կաղապարը|կաղապարները}}.",
        "templatesusedpreview": "{{PLURAL:$1|Կաղապար}} օգտագործուած այս կանխաստուգումին մէջ՝",
        "template-protected": "(պահպանուած)",
        "permissionserrorstext-withaction": "Արտօնութիւն չունիք $2 հետեւեալ {{PLURAL:$1|պատճառով|պատճառներով}}.",
        "recreate-moveddeleted-warn": "<strong>Զգուշացում. Նախապէս ջնջուած էջ մը պիտի վերստեղծուի։<strong>\n\nԿը խնդրուի մտածել այս էջի խմբագրման նպատակայարմարութեան մասին։ \nՁեր դիւրութեան համար ներքեւ կը գտնէք այս էջի ջնջումին և տեղափոխումին տեղեկատետրերը։",
        "moveddeleted-notice": "Այս էջը ջնջուած է։\nԷջին ջնջումի, պահպանումի եւ փոխադրումի տեղեկատետրը տրամադրելի է ներքեւ որպէս տեղեկութիւն։",
+       "edit-conflict": "Խմբագրման ընհարում։",
        "content-model-wikitext": "ուիքիթէքսթ",
+       "content-model-text": "պարզ բնաբան",
+       "content-model-javascript": "ՃաւաՍքրիփթ",
+       "content-json-empty-object": "Պարապ առարկայ",
+       "content-json-empty-array": "Պարապ շարք",
        "undo-failure": "Խմբագրումը կարելի չեղաւ ետ ընել միջանկեալ խմբագրումներու հետ ընդհարումի պատճառով։",
        "viewpagelogs": "Տեսնել այս էջին տեղեկատետրերը",
        "currentrev-asof": "Ընթացիկ տարբերակը $1ի դրութեամբ",
        "nextrevision": "Յաջորդ տարբերակ→",
        "currentrevisionlink": "Վերջին տարբերակ",
        "cur": "ընթ.",
+       "next": "յաջորդ",
        "last": "նախ.",
+       "page_first": "առաջին",
+       "page_last": "վերջին",
        "histlegend": "Տարբերութիւններու համեմատում. դրէ՛ք նշման կէտեր այն տարբերակներու կողքին, որոնք կ՚ուզէք համեմատել եւ սեղմեցէ՛ք ներքեւ գտնուող կոճակը։<br />\nԾանօթ.՝ <strong>({{int:cur}})</strong> = ընթացիկ տարբերակի հետ համեմատած տարբերութիւններ,\n<strong>({{int:last}})</strong> = նախորդ տարբերակի հետ համեմատած տարբերութիւններ,<br />'''չ''' = չնչին խմբագրում",
        "history-fieldset-title": "Որոնել տարբերակներ",
        "histfirst": "հնագոյն",
        "history-feed-description": "Ուիքիի այս էջին վերանայումներու ցուցակը",
        "history-feed-item-nocomment": "$1՝ $2",
        "rev-delundel": "ցուցնել/թաքցնել",
+       "rev-showdeleted": "Ցուցադրել",
+       "revdelete-show-file-submit": "Այո",
+       "revdelete-log": "Պատճառ.",
+       "revdelete-reasonotherlist": "Ուրիշ պատճառ.",
+       "mergehistory-reason": "Պատճառ.",
        "mergelog": "Ձուլման տեղեկատետր",
        "history-title": "«$1»ի վերանայումներու ցուցակ",
        "difference-title": "«$1»ի խմբագրումներու միջեւ տարբերութիւն",
        "searchresults-title": "«$1»-ի որոնման արդիւնքները",
        "prevn": "նախորդ {{PLURAL:$1|$1}}",
        "nextn": "յաջորդ {{PLURAL:$1|$1}}",
+       "prev-page": "նախորդ էջ",
+       "next-page": "յաջորդ էջ",
        "prevn-title": "Նախորդ $1 {{PLURAL:$1|արդիւնքը|արդիւնքները}}",
        "nextn-title": "Յաջորդ $1 {{PLURAL:$1|արդիւնքը|արդիւնքները}}",
        "shown-title": "Իւրաքանչիւր էջի վրայ ցոյց տալ $1 {{PLURAL:$1|արդիւնք|արդիւնքներ}}",
        "search-result-category-size": "{{PLURAL:$1|1 անդամ|$1 անդամներ}} ({{PLURAL:$2|1 ենթաստորոգութիւն|$2 ենթաստորոգութիւններ}}, {{PLURAL:$3|1 նիշք|$3 նիշքեր}})",
        "search-redirect": "(Վերայղուած է $1-էն)",
        "search-section": "(բաժին $1)",
+       "search-category": "(ստորոգութիւն $1)",
        "search-file-match": "(համապատասխան է նիշքի բովանդակութեան)",
        "search-suggest": "$1 Նկատի ունի՞ք",
+       "search-interwiki-more": "(աւելի)",
+       "search-interwiki-more-results": "աւելի շատ արդիւնքներ",
+       "search-relatedarticle": "Հարակից",
+       "searchrelated": "հարակից",
        "searchall": "բոլոր",
        "search-showingresults": "{{PLURAL:$4|<strong>$1</strong> արդիւնք <strong>$3</strong>-էն|<strong>$1 - $2</strong> արդիւնքներ <strong>$3</strong>-էն}}",
        "search-nonefound": "Որոնումին համապատասխանող արդիւնքներ չգտնուեցան",
        "mypreferences": "Նախընտրութիւններ",
+       "skin-preview": "Նախադիտել",
+       "stub-threshold-sample-link": "օրինակ",
+       "timezoneregion-africa": "Ափրիկէ",
+       "timezoneregion-america": "Ամերիկա",
+       "timezoneregion-antarctica": "Անթարքթիքա",
+       "timezoneregion-arctic": "Արքթիքա",
+       "timezoneregion-asia": "Ասիա",
+       "timezoneregion-australia": "Աւստրալիա",
+       "timezoneregion-europe": "Եւրոպա",
+       "timezoneregion-indian": "Հնդկական Ովկիանոս",
+       "timezoneregion-pacific": "Խաղաղ Ովկիանոս",
+       "youremail": "Էլեկտրական Նամակ",
+       "email": "Էլեկտրական Նամակ",
+       "group": "Խումբ.",
        "group-bot": "Մեքենայիկներ",
        "group-sysop": "Վարիչներ",
        "grouppage-bot": "{{ns:project}}:Մեքենայիկներ",
        "recentchangeslinked-feed": "Առնչուած փոփոխութիւններ",
        "recentchangeslinked-toolbox": "Առնչուող փոփոխութիւններ",
        "recentchangeslinked-title": "«$1» էջին առնչուած փոփոխութիւնները",
-       "recentchangeslinked-summary": "Նշել Էջի թիւը տեսնելու համար այդ էջին կամ էջէն յղուող փոփոխութիւնները։ (Ստորոգութեան մը անդամները տեսնելու համար, նշել {{ns:category}}:Ստորագութեան անունը))։\n[[Special:Հսկողութեան ցանկ|ձեր հսկողութեան ցանկ]]ի էջին վրայ գտնուող փոփոխութիւնները <strong>շեշտուած տառերով են</strong>։",
+       "recentchangeslinked-summary": "Նշել Էջի թիւը տեսնելու համար այդ էջին կամ էջէն յղուող փոփոխութիւնները։ (Ստորոգութեան մը անդամները տեսնելու համար, նշել {{ns:category}}:Ստորագութեան անունը)։\n [[Special:Watchlist|your Watchlist]] էջին վրայ գտնուող փոփոխութիւնները <strong>շեշտուած տառերով են</strong>։",
        "recentchangeslinked-page": "Էջին անունը՝",
        "recentchangeslinked-to": "Փոխարէնը ցոյց տալ տուեալ էջին առնչուած էջերուն մէջ կատարուած փոփոխութիւնները։",
        "upload": "Վերբեռնել նիշք",
        "watchlisttools-raw": "Խմբագրել հում հսկողութեան ցանկը",
        "signature": "[[{{ns:user}}:$1|$2]] ([[{{ns:user_talk}}:$1|քննարկում]])",
        "redirect": "Վերայղում նիշքի, մասնակիցի, էջի, տարբերակի կամ տեղեկատետրի ինքնութեան համարէն",
-       "redirect-summary": "Այս յատուկ էջը կը վերայղուի նիշքի մը (տրուած ըլլալով նիշքին անունը), էջի մը (տրուած ըլլալով վերանայման կամ էջի ինքնութեան համարը), մասնակիցի էջի մը. տրուած ըլլալով մասնակիցի մը թուային ինքնութեան համարը), եւ կամ տեղեկատետրի մը մէջ տողի մը, (տրուած ըլլալով տեղեկատետրի ինքնութեան համարը)։ Գործածութիւն՝  [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], [[{{#Special:Redirect}}/user/101]], or [[{{#Special:Redirect}}/logid/186]].",
+       "redirect-summary": "Այս յատուկ էջը կը վերայղուի նիշքի մը (տրուած ըլլալով նիշքին անունը), էջի մը (տրուած ըլլալով վերանայման կամ էջի ինքնութեան համարը), մասնակիցի էջի մը. (տրուած ըլլալով մասնակիցի մը թուային ինքնութեան համարը), եւ կամ տեղեկատետրի մը մէջ տողի մը, (տրուած ըլլալով տեղեկատետրի ինքնութեան համարը)։ Գործածութիւն՝  [[{{#Special:Redirect}}/file/Example.jpg]], [[{{#Special:Redirect}}/page/64308]], [[{{#Special:Redirect}}/revision/328429]], [[{{#Special:Redirect}}/user/101]], or [[{{#Special:Redirect}}/logid/186]].",
        "redirect-submit": "Յառաջ",
        "redirect-lookup": "Որոնում՝",
        "redirect-value": "Արժէք՝",
index 16aaf59..fd175c6 100644 (file)
        "right-unblockself": "ڪنهن تان بندش ختم ڪريو",
        "right-editinterface": "واپرائيندڙ باهمرُو کي سنواريو",
        "right-viewmywatchlist": "پنهنجي نظر ۾ فھرست ڏسو",
+       "right-editmywatchlist": "پنهنجي نگھداشت واري فهرست کي سنواريو. ياد رکو ڪجهه ڪم هن اختيار کان سواءِ پڻ ممڪن آهن.",
+       "right-editmyprivateinfo": "پنهنجي ذاتي معلومات سنواريو (جيئن برق ٽپال، اصل نالو)",
        "right-editmyoptions": "پنهنجون ترجيحون سنواريو",
        "right-import": "ٻين وڪيز کان صفحا درآمديو",
        "right-importupload": "ڪو فائيل چاڙهي صفحا درآمديو",
        "fileduplicatesearch-submit": "ڳوليو",
        "specialpages": "خاص صفحا",
        "specialpages-note-top": "ڪُنجي",
+       "specialpages-group-maintenance": "سنڀال رپورٽ",
        "specialpages-group-login": "داخل ٿيو / کاتو کوليو",
        "specialpages-group-users": "واپرائيندڙَ ۽ حق",
+       "specialpages-group-pages": "صفحن جي فهرست",
        "blankpage": "خالي صفحو",
        "intentionallyblankpage": "هيءُ صفحو ڄاڻي خالي ڇڏيو ويو آهي.",
        "tag-filter": "[[Special:Tags|ٽيگ]] ڇاڻي:",
index 8e7d4db..1702852 100644 (file)
        "group-autoconfirmed": "自動確認用戶",
        "group-bot": "機械人",
        "group-sysop": "操作員",
+       "group-interface-admin": "介面管理員",
        "group-bureaucrat": "事務員",
        "group-suppress": "監督",
        "group-all": "(全部)",
        "group-autoconfirmed-member": "{{GENDER:$1|自動確認用戶}}",
        "group-bot-member": "{{GENDER:$1|機械人}}",
        "group-sysop-member": "{{GENDER:$1|管理員}}",
+       "group-interface-admin-member": "{{GENDER:$1|介面管理員}}",
        "group-bureaucrat-member": "{{GENDER:$1|事務員}}",
        "group-suppress-member": "{{GENDER:$1|監督}}",
        "grouppage-user": "{{ns:project}}:用戶",
        "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": "讀版",
        "authmanager-realname-label": "真名",
        "authmanager-realname-help": "用戶嘅真名",
        "authprovider-resetpass-skip-label": "跳過",
+       "interfaceadmin-info": "$1\n\n改全站通用 CSS/JS/JSON 檔嘅權限由 <code>editinterface</code> 權限拆咗出嚟。如果你唔明點解會出呢個錯誤訊息,請睇[[mw:MediaWiki_1.32/interface-admin]]。",
        "passwordpolicies": "密碼政策",
        "passwordpolicies-summary": "爾度係對爾個wiki定義咗嘅用戶組來講有效嘅密碼政策一覽。",
        "passwordpolicies-group": "組",
index 9a6c51f..5b32f91 100644 (file)
@@ -95,29 +95,50 @@ $datePreferenceMigrationMap = [
 $specialPageAliases = [
        'Activeusers'               => [ 'АктивниКорисници', 'Активни_корисници' ],
        'Allmessages'               => [ 'СвеПоруке', 'Све_поруке' ],
+       'AllMyUploads'              => [ 'СваМојаОтпремања', 'СвеМојеДатотеке' ],
        'Allpages'                  => [ 'Све_странице' ],
+       'ApiSandbox'                => [ 'API_песак', 'АПИ_песак' ],
        'Ancientpages'              => [ 'НајстаријеСтранице', 'НајстаријиЧланци' ],
+       'AutoblockList'             => [ 'СписакАутоблокова', 'Аутоблокови' ],
        'Badtitle'                  => [ 'Лош_наслов' ],
        'Blankpage'                 => [ 'ПразнаСтраница' ],
        'Block'                     => [ 'Блокирај', 'БлокирајИП', 'БлокирајКорисника' ],
+       'Booksources'               => [ 'КњижевниИзвори', 'ШтампаниИзвори' ],
+       'BotPasswords'              => [ 'ЛозинкеБотова' ],
        'BrokenRedirects'           => [ 'Покварена_преусмерења', 'Неисправна_преусмерења' ],
        'Categories'                => [ 'Категорије' ],
+       'ChangeContentModel'        => [ 'ПромениМоделСадржаја', 'ИзмениМоделСадржаја' ],
+       'ChangeCredentials'         => [ 'ПромениАкредитиве' ],
+       'ChangeEmail'               => [ 'ПромениИмејлАдресу' ],
        'ChangePassword'            => [ 'ПромениЛозинку' ],
        'ComparePages'              => [ 'Упореди_странице' ],
        'Confirmemail'              => [ 'ПотврдиЕ-пошту', 'Потврда_е-поште' ],
        'Contributions'             => [ 'Доприноси', 'Прилози' ],
        'CreateAccount'             => [ 'ОтвориНалог', 'Отвори_налог' ],
+       'Deadendpages'              => [ 'Ћорсокаци', 'СтраницеКојеНеВодеНикуда', 'СлепеСтранице' ],
        'DeletedContributions'      => [ 'ОбрисаниДоприноси' ],
+       'Diff'                      => [ 'Разлике' ],
        'DoubleRedirects'           => [ 'Двострука_преусмерења' ],
+       'EditTags'                  => [ 'УредиОзнаке' ],
+       'EditWatchlist'             => [ 'УредиСписакНадгледања' ],
+       'Emailuser'                 => [ 'ПошаљиИмејлКориснику' ],
+       'ExpandTemplates'           => [ 'ПрошириШаблоне' ],
        'Export'                    => [ 'Извези' ],
        'Fewestrevisions'           => [ 'НајмањеИзмена', 'ЧланциСаНајмањеРевизија' ],
+       'FileDuplicateSearch'       => [ 'ПретрагаДупликатаДатотека' ],
        'Filepath'                  => [ 'Путања_датотеке' ],
+       'GoToInterwiki'             => [ 'ПосетиМеђувики' ],
        'Import'                    => [ 'Увези' ],
+       'Invalidateemail'           => [ 'ПоништиИмејл' ],
+       'JavaScriptTest'            => [ 'ТестирањеЈаваскрипта' ],
        'BlockList'                 => [ 'СписакБлокираних', 'ПописБлокираних' ],
+       'LinkSearch'                => [ 'ПретрагаВеза' ],
+       'LinkAccounts'              => [ 'ПовежиНалоге' ],
        'Listadmins'                => [ 'СписакАдминистратора', 'ПописАдминистратора', 'Списак_администратора' ],
        'Listbots'                  => [ 'СписакБотова', 'ПописБотова', 'Списак_ботова' ],
        'Listfiles'                 => [ 'СписакДатотека', 'СписакСлика', 'Списак_датотека' ],
        'Listgrouprights'           => [ 'СписакКорисничкихПрава', 'Списак_корисничких_права' ],
+       'Listgrants'                => [ 'СписакДозвола' ],
        'Listredirects'             => [ 'СписакПреусмерења', 'Списак_преусмерења' ],
        'ListDuplicatedFiles'       => [ 'СписакДупликата' ],
        'Listusers'                 => [ 'СписакКорисника', 'КорисничкиСписак', 'Списак_корисника', 'Кориснички_списак' ],
@@ -125,6 +146,7 @@ $specialPageAliases = [
        'Log'                       => [ 'Извештај', 'Извештаји' ],
        'Lonelypages'               => [ 'Сирочићи' ],
        'Longpages'                 => [ 'ДугачкеСтране' ],
+       'MediaStatistics'           => [ 'СтатистикеМедија' ],
        'MergeHistory'              => [ 'СпојиИсторију', 'Споји_историју' ],
        'MIMEsearch'                => [ 'MIME_претрага' ],
        'Mostcategories'            => [ 'НајвишеКатегорија', 'ЧланциСаНајвишеКатегорија' ],
@@ -142,24 +164,40 @@ $specialPageAliases = [
        'Myuploads'                 => [ 'Моја_слања' ],
        'Newimages'                 => [ 'НовеДатотеке', 'НовиФајлови', 'НовеСлике' ],
        'Newpages'                  => [ 'НовеСтране' ],
-       'PermanentLink'             => [ 'Привремена_веза' ],
+       'PagesWithProp'             => [ 'СтраницеСаСвојством' ],
+       'PageData'                  => [ 'ПодациСтранице' ],
+       'PageLanguage'              => [ 'ЈезикСтранице' ],
+       'PasswordPolicies'          => [ 'ПравилаЗаЛозинке' ],
+       'PasswordReset'             => [ 'РесетовањеЛозинке' ],
+       'PermanentLink'             => [ 'ТрајнаВеза', 'Привремена_веза' ],
        'Preferences'               => [ 'Подешавања', 'Поставке' ],
+       'Prefixindex'               => [ 'СтраницеСаПрефиксом' ],
        'Protectedpages'            => [ 'ЗаштићенеСтранице', 'Заштићене_странице' ],
        'Protectedtitles'           => [ 'ЗаштићениНаслови', 'Заштићени_наслови' ],
        'Randompage'                => [ 'СлучајнаСтрана', 'Насумична_страница' ],
+       'RandomInCategory'          => [ 'Случајна_страна_у_категорији' ],
        'Randomredirect'            => [ 'СлучајноПреусмерење' ],
+       'Randomrootpage'            => [ 'СлучајнаОсновнаСтрана' ],
        'Recentchanges'             => [ 'СкорашњеИзмене', 'Скорашње_измене' ],
+       'Recentchangeslinked'       => [ 'СроднеИзмене' ],
+       'Redirect'                  => [ 'Преусмерење' ],
+       'RemoveCredentials'         => [ 'УклониАкредитиве' ],
+       'ResetTokens'               => [ 'РесетујЖетоне' ],
+       'Revisiondelete'            => [ 'УклањањеИзмене' ],
+       'RunJobs'                   => [ 'ИзвршиПослове' ],
        'Search'                    => [ 'Претражи' ],
        'Shortpages'                => [ 'КраткеСтранице', 'КраткиЧланци' ],
-       'Specialpages'              => [ 'СпеÑ\86иÑ\98алнеСÑ\82Ñ\80ане', 'Ð\9fоÑ\81ебне_странице' ],
+       'Specialpages'              => [ 'Ð\9fоÑ\81ебнеСÑ\82Ñ\80ане', 'СпеÑ\86иÑ\98алнеСÑ\82Ñ\80ане', 'Ð\9fоÑ\81ебне_Ñ\81Ñ\82Ñ\80аниÑ\86е', 'СпеÑ\86иÑ\98алне_странице' ],
        'Statistics'                => [ 'Статистике' ],
        'Tags'                      => [ 'Ознаке' ],
+       'TrackingCategories'        => [ 'КатегоријеЗаПраћење' ],
        'Unblock'                   => [ 'Деблокирај' ],
        'Uncategorizedcategories'   => [ 'НекатегорисанеКатегорије', 'КатегоријеБезКатегорија' ],
        'Uncategorizedimages'       => [ 'НекатегорисанеДатотеке', 'СликеБезКатегорија' ],
        'Uncategorizedpages'        => [ 'НекатегорисанеСтранице', 'ЧланциБезКатегорија', 'Чланци_без_категорија' ],
        'Uncategorizedtemplates'    => [ 'НекатегорисаниШаблони', 'ШаблониБезКатегорија' ],
        'Undelete'                  => [ 'Врати' ],
+       'UnlinkAccounts'            => [ 'УклониПовезивањеНалога' ],
        'Unlockdb'                  => [ 'ОткључајБазу', 'Откључај_базу' ],
        'Unusedcategories'          => [ 'НеискоришћенеКатегорије' ],
        'Unusedimages'              => [ 'НеискоришћенеДатотеке', 'НеискоришћенеСлике' ],
index 6a02eea..6bb130d 100644 (file)
@@ -74,15 +74,16 @@ oojs-ui:
 jquery:
   type: file
   src: https://code.jquery.com/jquery-3.2.1.js
-  # From https://code.jquery.com/jquery/
+  # Integrity from link modals https://code.jquery.com/jquery/
   integrity: sha256-DZAnKJ/6XZ9si04Hgrsxu/8s717jcIzLy3oi35EouyE=
   dest: jquery.js
 qunitjs:
   type: multi-file
+  # Integrity from link modals at https://code.jquery.com/qunit/
   files:
     qunit.js:
       src: https://code.jquery.com/qunit/qunit-2.6.0.js
-      integrity: sha384-5O3bKbJBbAbxsqV+w/I1fcXgWJgbqM+hmYAPOE9aELSYpcTEsv48X8H+Hnq66V/9
+      integrity: sha256-QdI40P4EEDzPRIS0mktlE0sSyoXCnOs8fB4OSmy+VxI=
     qunit.css:
       src: https://code.jquery.com/qunit/qunit-2.6.0.css
-      integrity: sha384-8vDvsmsuiD7tCQyC+pW2LOwDDgsluGsIPeCqr3rHsDSF2k4WpmfvKKxcgSV5zPai
+      integrity: sha256-F4O5nugrHEEjfO0tfu/LKnSRFKctZ9Rdmi1oj22UD1s=
index 251b108..9f6f845 100644 (file)
@@ -55,6 +55,7 @@
                                rnds = new Uint16Array( 5 );
                                crypto.getRandomValues( rnds );
                        } else {
+                               rnds = new Array( 5 );
                                // 0x10000 is 2^16 so the operation below will return a number
                                // between 2^16 and zero
                                for ( i = 0; i < 5; i++ ) {
index 3fb7ffc..f927aad 100644 (file)
                                        init: function () {
                                                var raw, data;
 
-                                               if ( mw.loader.store.enabled !== null ) {
+                                               if ( this.enabled !== null ) {
                                                        // Init already ran
                                                        return;
                                                }
                                                        !mw.config.get( 'wgResourceLoaderStorageEnabled' )
                                                ) {
                                                        // Clear any previous store to free up space. (T66721)
-                                                       mw.loader.store.clear();
-                                                       mw.loader.store.enabled = false;
+                                                       this.clear();
+                                                       this.enabled = false;
                                                        return;
                                                }
                                                if ( mw.config.get( 'debug' ) ) {
                                                        // Disable module store in debug mode
-                                                       mw.loader.store.enabled = false;
+                                                       this.enabled = false;
                                                        return;
                                                }
 
                                                try {
                                                        // This a string we stored, or `null` if the key does not (yet) exist.
-                                                       raw = localStorage.getItem( mw.loader.store.getStoreKey() );
+                                                       raw = localStorage.getItem( this.getStoreKey() );
                                                        // If we get here, localStorage is available; mark enabled
-                                                       mw.loader.store.enabled = true;
+                                                       this.enabled = true;
                                                        // If null, JSON.parse() will cast to string and re-parse, still null.
                                                        data = JSON.parse( raw );
-                                                       if ( data && typeof data.items === 'object' && data.vary === mw.loader.store.getVary() ) {
-                                                               mw.loader.store.items = data.items;
+                                                       if ( data && typeof data.items === 'object' && data.vary === this.getVary() ) {
+                                                               this.items = data.items;
                                                                return;
                                                        }
                                                } catch ( e ) {
                                                //    We will disable the store below.
                                                if ( raw === undefined ) {
                                                        // localStorage failed; disable store
-                                                       mw.loader.store.enabled = false;
+                                                       this.enabled = false;
                                                }
                                        },
 
                                        get: function ( module ) {
                                                var key;
 
-                                               if ( !mw.loader.store.enabled ) {
+                                               if ( !this.enabled ) {
                                                        return false;
                                                }
 
                                                key = getModuleKey( module );
-                                               if ( key in mw.loader.store.items ) {
-                                                       mw.loader.store.stats.hits++;
-                                                       return mw.loader.store.items[ key ];
+                                               if ( key in this.items ) {
+                                                       this.stats.hits++;
+                                                       return this.items[ key ];
                                                }
-                                               mw.loader.store.stats.misses++;
+                                               this.stats.misses++;
                                                return false;
                                        },
 
                                        set: function ( module, descriptor ) {
                                                var args, key, src;
 
-                                               if ( !mw.loader.store.enabled ) {
+                                               if ( !this.enabled ) {
                                                        return;
                                                }
 
 
                                                if (
                                                        // Already stored a copy of this exact version
-                                                       key in mw.loader.store.items ||
+                                                       key in this.items ||
                                                        // Module failed to load
                                                        descriptor.state !== 'ready' ||
                                                        // Unversioned, private, or site-/user-specific
                                                }
 
                                                src = 'mw.loader.implement(' + args.join( ',' ) + ');';
-                                               if ( src.length > mw.loader.store.MODULE_SIZE_MAX ) {
+                                               if ( src.length > this.MODULE_SIZE_MAX ) {
                                                        return;
                                                }
-                                               mw.loader.store.items[ key ] = src;
-                                               mw.loader.store.update();
+                                               this.items[ key ] = src;
+                                               this.update();
                                        },
 
                                        /**
                                        prune: function () {
                                                var key, module;
 
-                                               for ( key in mw.loader.store.items ) {
+                                               for ( key in this.items ) {
                                                        module = key.slice( 0, key.indexOf( '@' ) );
                                                        if ( getModuleKey( module ) !== key ) {
-                                                               mw.loader.store.stats.expired++;
-                                                               delete mw.loader.store.items[ key ];
-                                                       } else if ( mw.loader.store.items[ key ].length > mw.loader.store.MODULE_SIZE_MAX ) {
+                                                               this.stats.expired++;
+                                                               delete this.items[ key ];
+                                                       } else if ( this.items[ key ].length > this.MODULE_SIZE_MAX ) {
                                                                // This value predates the enforcement of a size limit on cached modules.
-                                                               delete mw.loader.store.items[ key ];
+                                                               delete this.items[ key ];
                                                        }
                                                }
                                        },
                                         * Clear the entire module store right now.
                                         */
                                        clear: function () {
-                                               mw.loader.store.items = {};
+                                               this.items = {};
                                                try {
-                                                       localStorage.removeItem( mw.loader.store.getStoreKey() );
+                                                       localStorage.removeItem( this.getStoreKey() );
                                                } catch ( e ) {}
                                        },
 
index ee72166..03141b9 100644 (file)
@@ -55,7 +55,7 @@ window.isCompatible = function ( str ) {
                // https://caniuse.com/#feat=json / https://phabricator.wikimedia.org/T141344#2784065
                ( function () {
                        'use strict';
-                       return !this && !!Function.prototype.bind && !!window.JSON;
+                       return !this && Function.prototype.bind && window.JSON;
                }() ) &&
 
                // https://caniuse.com/#feat=queryselector
index ee4819f..32c190e 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use MediaWiki\Logger\LegacyLogger;
+
 /**
  * @group Database
  * @group GlobalFunctions
@@ -325,8 +327,9 @@ class GlobalTest extends MediaWikiTestCase {
                $this->setMwGlobals( [
                        'wgDebugLogFile' => $debugLogFile,
                        #  @todo FIXME: $wgDebugTimestamps should be tested
-                       'wgDebugTimestamps' => false
+                       'wgDebugTimestamps' => false,
                ] );
+               $this->setLogger( 'wfDebug', new LegacyLogger( 'wfDebug' ) );
 
                wfDebug( "This is a normal string" );
                $this->assertEquals( "This is a normal string\n", file_get_contents( $debugLogFile ) );
diff --git a/tests/phpunit/includes/Revision/RenderedRevisionTest.php b/tests/phpunit/includes/Revision/RenderedRevisionTest.php
new file mode 100644 (file)
index 0000000..a2a9d09
--- /dev/null
@@ -0,0 +1,454 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use Content;
+use Language;
+use MediaWiki\Revision\RenderedRevision;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use ParserOptions;
+use ParserOutput;
+use PHPUnit\Framework\MockObject\MockObject;
+use Title;
+use User;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\RenderedRevision
+ */
+class RenderedRevisionTest extends MediaWikiTestCase {
+
+       /** @var callable */
+       private $combinerCallback;
+
+       public function setUp() {
+               parent::setUp();
+
+               $this->combinerCallback = function ( RenderedRevision $rr, array $hints = [] ) {
+                       return $this->combineOutput( $rr, $hints );
+               };
+       }
+
+       private function combineOutput( RenderedRevision $rrev, array $hints = [] ) {
+               // NOTE: the is a slightly simplified version of RevisionRenderer::combineSlotOutput
+
+               $withHtml = $hints['generate-html'] ?? true;
+
+               $revision = $rrev->getRevision();
+               $slots = $revision->getSlots()->getSlots();
+
+               $combinedOutput = new ParserOutput( null );
+               $slotOutput = [];
+               foreach ( $slots as $role => $slot ) {
+                       $out = $rrev->getSlotParserOutput( $role, $hints );
+                       $slotOutput[$role] = $out;
+
+                       $combinedOutput->mergeInternalMetaDataFrom( $out );
+                       $combinedOutput->mergeTrackingMetaDataFrom( $out );
+               }
+
+               if ( $withHtml ) {
+                       $html = '';
+                       /** @var ParserOutput $out */
+                       foreach ( $slotOutput as $role => $out ) {
+
+                               if ( $html !== '' ) {
+                                       // skip header for the first slot
+                                       $html .= "(($role))";
+                               }
+
+                               $html .= $out->getRawText();
+                               $combinedOutput->mergeHtmlMetaDataFrom( $out );
+                       }
+
+                       $combinedOutput->setText( $html );
+               }
+
+               return $combinedOutput;
+       }
+
+       /**
+        * @param $articleId
+        * @param $revisionId
+        * @return Title
+        */
+       private function getMockTitle( $articleId, $revisionId ) {
+               /** @var Title|MockObject $mock */
+               $mock = $this->getMockBuilder( Title::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $mock->expects( $this->any() )
+                       ->method( 'getNamespace' )
+                       ->will( $this->returnValue( NS_MAIN ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getText' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getPrefixedText' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getDBkey' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getArticleID' )
+                       ->will( $this->returnValue( $articleId ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getLatestRevId' )
+                       ->will( $this->returnValue( $revisionId ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getContentModel' )
+                       ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getPageLanguage' )
+                       ->will( $this->returnValue( Language::factory( 'en' ) ) );
+               $mock->expects( $this->any() )
+                       ->method( 'isContentPage' )
+                       ->will( $this->returnValue( true ) );
+               $mock->expects( $this->any() )
+                       ->method( 'equals' )
+                       ->willReturnCallback( function ( Title $other ) use ( $mock ) {
+                               return $mock->getArticleID() === $other->getArticleID();
+                       } );
+               $mock->expects( $this->any() )
+                       ->method( 'userCan' )
+                       ->willReturnCallback( function ( $perm, User $user ) use ( $mock ) {
+                               return $user->isAllowed( $perm );
+                       } );
+
+               return $mock;
+       }
+
+       public function testGetRevisionParserOutput_new() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+               $text .= "* [[Link It]]\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+       }
+
+       public function testGetRevisionParserOutput_current() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 21 ); // current!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:21', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRevisionParserOutput_old() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRevisionParserOutput_suppressed() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $this->setExpectedException( SuppressedDataException::class );
+               $rr->getRevisionParserOutput();
+       }
+
+       public function testGetRevisionParserOutput_privileged() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged!
+               $rr = new RenderedRevision(
+                       $title,
+                       $rev,
+                       $options,
+                       $this->combinerCallback,
+                       RevisionRecord::FOR_THIS_USER,
+                       $sysop
+               );
+
+               $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               // Suppressed content should be visible for sysops
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRevisionParserOutput_raw() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision(
+                       $title,
+                       $rev,
+                       $options,
+                       $this->combinerCallback,
+                       RevisionRecord::RAW
+               );
+
+               $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               // Suppressed content should be visible for sysops
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRevisionParserOutput_multi() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $rev->setContent( 'main', new WikitextContent( '[[Kittens]]' ) );
+               $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $combinedOutput = $rr->getRevisionParserOutput();
+               $mainOutput = $rr->getSlotParserOutput( 'main' );
+               $auxOutput = $rr->getSlotParserOutput( 'aux' );
+
+               $combinedHtml = $combinedOutput->getText();
+               $mainHtml = $mainOutput->getText();
+               $auxHtml = $auxOutput->getText();
+
+               $this->assertContains( 'Kittens', $mainHtml );
+               $this->assertContains( 'Goats', $auxHtml );
+               $this->assertNotContains( 'Goats', $mainHtml );
+               $this->assertNotContains( 'Kittens', $auxHtml );
+               $this->assertContains( 'Kittens', $combinedHtml );
+               $this->assertContains( 'Goats', $combinedHtml );
+               $this->assertContains( 'aux', $combinedHtml, 'slot section header' );
+
+               $combinedLinks = $combinedOutput->getLinks();
+               $mainLinks = $mainOutput->getLinks();
+               $auxLinks = $auxOutput->getLinks();
+               $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
+               $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
+               $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
+               $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );
+       }
+
+       public function testNoHtml() {
+               /** @var MockObject|Content $mockContent */
+               $mockContent = $this->getMockBuilder( WikitextContent::class )
+                       ->setMethods( [ 'getParserOutput' ] )
+                       ->setConstructorArgs( [ 'Whatever' ] )
+                       ->getMock();
+               $mockContent->method( 'getParserOutput' )
+                       ->willReturnCallback( function ( Title $title, $revId = null,
+                               ParserOptions $options = null, $generateHtml = true
+                       ) {
+                               if ( !$generateHtml ) {
+                                       return new ParserOutput( null );
+                               } else {
+                                       $this->fail( 'Should not be called with $generateHtml == true' );
+                                       return null; // never happens, make analyzer happy
+                               }
+                       } );
+
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setContent( 'main', $mockContent );
+               $rev->setContent( 'aux', $mockContent );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $output = $rr->getSlotParserOutput( 'main', [ 'generate-html' => false ] );
+               $this->assertFalse( $output->hasText(), 'hasText' );
+
+               $output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] );
+               $this->assertFalse( $output->hasText(), 'hasText' );
+       }
+
+       public function testUpdateRevision() {
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+               $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = new RenderedRevision( $title, $rev, $options, $this->combinerCallback );
+
+               $firstOutput = $rr->getRevisionParserOutput();
+               $mainOutput = $rr->getSlotParserOutput( 'main' );
+               $auxOutput = $rr->getSlotParserOutput( 'aux' );
+
+               // emulate a saved revision
+               $savedRev = new MutableRevisionRecord( $title );
+               $savedRev->setContent( 'main', new WikitextContent( $text ) );
+               $savedRev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+               $savedRev->setId( 23 ); // saved, new
+               $savedRev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $savedRev->setTimestamp( '20180101000003' );
+
+               $rr->updateRevision( $savedRev );
+
+               $this->assertNotSame( $mainOutput, $rr->getSlotParserOutput( 'main' ), 'Reset main' );
+               $this->assertSame( $auxOutput, $rr->getSlotParserOutput( 'aux' ), 'Keep aux' );
+
+               $updatedOutput = $rr->getRevisionParserOutput();
+               $html = $updatedOutput->getText();
+
+               $this->assertNotSame( $firstOutput, $updatedOutput, 'Reset merged' );
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:23', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+               $this->assertContains( 'Goats', $html );
+
+               $rr->updateRevision( $savedRev ); // should do nothing
+               $this->assertSame( $updatedOutput, $rr->getRevisionParserOutput(), 'no more reset needed' );
+       }
+
+}
diff --git a/tests/phpunit/includes/Revision/RevisionRendererTest.php b/tests/phpunit/includes/Revision/RevisionRendererTest.php
new file mode 100644 (file)
index 0000000..ea195f1
--- /dev/null
@@ -0,0 +1,460 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use Content;
+use Language;
+use LogicException;
+use MediaWiki\Revision\RevisionRenderer;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use ParserOptions;
+use ParserOutput;
+use PHPUnit\Framework\MockObject\MockObject;
+use Title;
+use User;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\ILoadBalancer;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Revision\RevisionRenderer
+ */
+class RevisionRendererTest extends MediaWikiTestCase {
+
+       /**
+        * @param $articleId
+        * @param $revisionId
+        * @return Title
+        */
+       private function getMockTitle( $articleId, $revisionId ) {
+               /** @var Title|MockObject $mock */
+               $mock = $this->getMockBuilder( Title::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $mock->expects( $this->any() )
+                       ->method( 'getNamespace' )
+                       ->will( $this->returnValue( NS_MAIN ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getText' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getPrefixedText' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getDBkey' )
+                       ->will( $this->returnValue( __CLASS__ ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getArticleID' )
+                       ->will( $this->returnValue( $articleId ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getLatestRevId' )
+                       ->will( $this->returnValue( $revisionId ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getContentModel' )
+                       ->will( $this->returnValue( CONTENT_MODEL_WIKITEXT ) );
+               $mock->expects( $this->any() )
+                       ->method( 'getPageLanguage' )
+                       ->will( $this->returnValue( Language::factory( 'en' ) ) );
+               $mock->expects( $this->any() )
+                       ->method( 'isContentPage' )
+                       ->will( $this->returnValue( true ) );
+               $mock->expects( $this->any() )
+                       ->method( 'equals' )
+                       ->willReturnCallback(
+                               function ( Title $other ) use ( $mock ) {
+                                       return $mock->getArticleID() === $other->getArticleID();
+                               }
+                       );
+               $mock->expects( $this->any() )
+                       ->method( 'userCan' )
+                       ->willReturnCallback(
+                               function ( $perm, User $user ) use ( $mock ) {
+                                       return $user->isAllowed( $perm );
+                               }
+                       );
+
+               return $mock;
+       }
+
+       /**
+        * @param int $maxRev
+        * @param int $linkCount
+        *
+        * @return IDatabase
+        */
+       private function getMockDatabaseConnection( $maxRev = 100, $linkCount = 0 ) {
+               /** @var IDatabase|MockObject $db */
+               $db = $this->getMock( IDatabase::class );
+               $db->method( 'selectField' )
+                       ->willReturnCallback(
+                               function ( $table, $fields, $cond ) use ( $maxRev, $linkCount ) {
+                                       return $this->selectFieldCallback(
+                                               $table,
+                                               $fields,
+                                               $cond,
+                                               $maxRev,
+                                               $linkCount
+                                       );
+                               }
+                       );
+
+               return $db;
+       }
+
+       /**
+        * @return RevisionRenderer
+        */
+       private function newRevisionRenderer( $maxRev = 100, $useMaster = false ) {
+               $dbIndex = $useMaster ? DB_MASTER : DB_REPLICA;
+
+               $db = $this->getMockDatabaseConnection( $maxRev );
+
+               /** @var ILoadBalancer|MockObject $lb */
+               $lb = $this->getMock( ILoadBalancer::class );
+               $lb->method( 'getConnection' )
+                       ->with( $dbIndex )
+                       ->willReturn( $db );
+               $lb->method( 'getConnectionRef' )
+                       ->with( $dbIndex )
+                       ->willReturn( $db );
+               $lb->method( 'getLazyConnectionRef' )
+                       ->with( $dbIndex )
+                       ->willReturn( $db );
+
+               return new RevisionRenderer( $lb );
+       }
+
+       private function selectFieldCallback( $table, $fields, $cond, $maxRev ) {
+               if ( [ $table, $fields, $cond ] === [ 'revision', 'MAX(rev_id)', [] ] ) {
+                       return $maxRev;
+               }
+
+               $this->fail( 'Unexpected call to selectField' );
+               throw new LogicException( 'Ooops' ); // Can't happen, make analyzer happy
+       }
+
+       public function testGetRenderedRevision_new() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+               $text .= "* [[Link It]]\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:101', $html ); // from speculativeRevIdCallback
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_current() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 21 ); // current!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:21', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_master() {
+               $renderer = $this->newRevisionRenderer( 100, true ); // use master
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 21 ); // current!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options, null, [ 'use-master' => true ] );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'rev:21', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_old() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options );
+
+               $this->assertFalse( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_suppressed() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision( $rev, $options );
+
+               $this->assertNull( $rr, 'getRenderedRevision' );
+       }
+
+       public function testGetRenderedRevision_privileged() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $sysop = $this->getTestUser( [ 'sysop' ] )->getUser(); // privileged!
+               $rr = $renderer->getRenderedRevision( $rev, $options, $sysop );
+
+               $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               // Suppressed content should be visible for sysops
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_raw() {
+               $renderer = $this->newRevisionRenderer( 100 );
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setId( 11 ); // old!
+               $rev->setVisibility( RevisionRecord::DELETED_TEXT ); // suppressed!
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $text = "";
+               $text .= "* page:{{PAGENAME}}\n";
+               $text .= "* rev:{{REVISIONID}}\n";
+               $text .= "* user:{{REVISIONUSER}}\n";
+               $text .= "* time:{{REVISIONTIMESTAMP}}\n";
+
+               $rev->setContent( 'main', new WikitextContent( $text ) );
+
+               $options = ParserOptions::newCanonical( 'canonical' );
+               $rr = $renderer->getRenderedRevision(
+                       $rev,
+                       $options,
+                       null,
+                       [ 'audience' => RevisionRecord::RAW ]
+               );
+
+               $this->assertTrue( $rr->isContentDeleted(), 'isContentDeleted' );
+
+               $this->assertSame( $rev, $rr->getRevision() );
+               $this->assertSame( $options, $rr->getOptions() );
+
+               $html = $rr->getRevisionParserOutput()->getText();
+
+               // Suppressed content should be visible in raw mode
+               $this->assertContains( 'page:' . __CLASS__, $html );
+               $this->assertContains( 'rev:11', $html );
+               $this->assertContains( 'user:Frank', $html );
+               $this->assertContains( 'time:20180101000003', $html );
+
+               $this->assertSame( $html, $rr->getSlotParserOutput( 'main' )->getText() );
+       }
+
+       public function testGetRenderedRevision_multi() {
+               $renderer = $this->newRevisionRenderer();
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setUser( new UserIdentityValue( 9, 'Frank', 0 ) );
+               $rev->setTimestamp( '20180101000003' );
+
+               $rev->setContent( 'main', new WikitextContent( '[[Kittens]]' ) );
+               $rev->setContent( 'aux', new WikitextContent( '[[Goats]]' ) );
+
+               $rr = $renderer->getRenderedRevision( $rev );
+
+               $combinedOutput = $rr->getRevisionParserOutput();
+               $mainOutput = $rr->getSlotParserOutput( 'main' );
+               $auxOutput = $rr->getSlotParserOutput( 'aux' );
+
+               $combinedHtml = $combinedOutput->getText();
+               $mainHtml = $mainOutput->getText();
+               $auxHtml = $auxOutput->getText();
+
+               $this->assertContains( 'Kittens', $mainHtml );
+               $this->assertContains( 'Goats', $auxHtml );
+               $this->assertNotContains( 'Goats', $mainHtml );
+               $this->assertNotContains( 'Kittens', $auxHtml );
+               $this->assertContains( 'Kittens', $combinedHtml );
+               $this->assertContains( 'Goats', $combinedHtml );
+               $this->assertContains( '>aux<', $combinedHtml, 'slot header' );
+               $this->assertNotContains( '<mw:slotheader', $combinedHtml, 'slot header placeholder' );
+
+               // make sure output wrapping works right
+               $this->assertContains( 'class="mw-parser-output"', $mainHtml );
+               $this->assertContains( 'class="mw-parser-output"', $auxHtml );
+               $this->assertContains( 'class="mw-parser-output"', $combinedHtml );
+
+               // there should be only one wrapper div
+               $this->assertSame( 1, preg_match_all( '#class="mw-parser-output"#', $combinedHtml ) );
+               $this->assertNotContains( 'class="mw-parser-output"', $combinedOutput->getRawText() );
+
+               $combinedLinks = $combinedOutput->getLinks();
+               $mainLinks = $mainOutput->getLinks();
+               $auxLinks = $auxOutput->getLinks();
+               $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Kittens'] ), 'links from main slot' );
+               $this->assertTrue( isset( $combinedLinks[NS_MAIN]['Goats'] ), 'links from aux slot' );
+               $this->assertFalse( isset( $mainLinks[NS_MAIN]['Goats'] ), 'no aux links in main' );
+               $this->assertFalse( isset( $auxLinks[NS_MAIN]['Kittens'] ), 'no main links in aux' );
+       }
+
+       public function testGetRenderedRevision_noHtml() {
+               /** @var MockObject|Content $mockContent */
+               $mockContent = $this->getMockBuilder( WikitextContent::class )
+                       ->setMethods( [ 'getParserOutput' ] )
+                       ->setConstructorArgs( [ 'Whatever' ] )
+                       ->getMock();
+               $mockContent->method( 'getParserOutput' )
+                       ->willReturnCallback( function ( Title $title, $revId = null,
+                               ParserOptions $options = null, $generateHtml = true
+                       ) {
+                               if ( !$generateHtml ) {
+                                       return new ParserOutput( null );
+                               } else {
+                                       $this->fail( 'Should not be called with $generateHtml == true' );
+                                       return null; // never happens, make analyzer happy
+                               }
+                       } );
+
+               $renderer = $this->newRevisionRenderer();
+               $title = $this->getMockTitle( 7, 21 );
+
+               $rev = new MutableRevisionRecord( $title );
+               $rev->setContent( 'main', $mockContent );
+               $rev->setContent( 'aux', $mockContent );
+
+               // NOTE: we are testing the private combineSlotOutput() callback here.
+               $rr = $renderer->getRenderedRevision( $rev );
+
+               $output = $rr->getSlotParserOutput( 'main', [ 'generate-html' => false ] );
+               $this->assertFalse( $output->hasText(), 'hasText' );
+
+               $output = $rr->getRevisionParserOutput( [ 'generate-html' => false ] );
+               $this->assertFalse( $output->hasText(), 'hasText' );
+       }
+
+}
index c7f83de..0e0d609 100644 (file)
@@ -73,14 +73,22 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $user = $this->getTestUser()->getUser();
                $comment = CommentStoreComment::newUnsavedComment( $summary );
 
-               if ( !$content instanceof Content ) {
+               if ( $content === null || is_string( $content ) ) {
                        $content = new WikitextContent( $content ?? $summary );
                }
 
+               if ( !is_array( $content ) ) {
+                       $content = [ 'main' => $content ];
+               }
+
                $this->getDerivedPageDataUpdater( $page ); // flush cached instance before.
 
                $updater = $page->newPageUpdater( $user );
-               $updater->setContent( 'main', $content );
+
+               foreach ( $content as $role => $c ) {
+                       $updater->setContent( $role, $c );
+               }
+
                $rev = $updater->saveRevision( $comment );
 
                $this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
@@ -110,7 +118,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $this->assertSame( MediaWikiServices::getInstance()->getContentLanguage(),
                        $options1->getUserLangObj() );
 
-               $speculativeId = call_user_func( $options1->getSpeculativeRevIdCallback(), $page->getTitle() );
+               $speculativeId = $options1->getSpeculativeRevId();
                $this->assertSame( $parentRev->getId() + 1, $speculativeId );
 
                $rev = $this->makeRevision(
@@ -123,7 +131,6 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $updater->prepareUpdate( $rev );
 
                $options2 = $updater->getCanonicalParserOptions();
-               $this->assertNotSame( $options1, $options2 );
 
                $currentRev = call_user_func( $options2->getCurrentRevisionCallback(), $page->getTitle() );
                $this->assertSame( $rev->getId(), $currentRev->getId() );
@@ -167,7 +174,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
         * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
         */
        public function testPrepareContent() {
-               $user = $this->getTestUser()->getUser();
+               $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
                $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
 
                $this->assertFalse( $updater->isContentPrepared() );
@@ -186,10 +193,10 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $update->modifySlot( SlotRecord::newInherited( $auxSlot ) );
                // TODO: MCR: test removing slots!
 
-               $updater->prepareContent( $user, $update, false );
+               $updater->prepareContent( $sysop, $update, false );
 
                // second be ok to call again with the same params
-               $updater->prepareContent( $user, $update, false );
+               $updater->prepareContent( $sysop, $update, false );
 
                $this->assertNull( $updater->grabCurrentRevision() );
                $this->assertTrue( $updater->isContentPrepared() );
@@ -197,7 +204,10 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $this->assertFalse( $updater->pageExisted() );
                $this->assertTrue( $updater->isCreation() );
                $this->assertTrue( $updater->isChange() );
-               $this->assertTrue( $updater->isContentPublic() );
+               $this->assertFalse( $updater->isContentDeleted() );
+
+               $this->assertNotNull( $updater->getRevision() );
+               $this->assertNotNull( $updater->getRenderedRevision() );
 
                $this->assertEquals( [ 'main', 'aux' ], $updater->getSlots()->getSlotRoles() );
                $this->assertEquals( [ 'main' ], array_keys( $updater->getSlots()->getOriginalSlots() ) );
@@ -208,7 +218,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $mainSlot = $updater->getRawSlot( 'main' );
                $this->assertInstanceOf( SlotRecord::class, $mainSlot );
                $this->assertNotContains( '~~~', $mainSlot->getContent()->serialize(), 'PST should apply.' );
-               $this->assertContains( $user->getName(), $mainSlot->getContent()->serialize() );
+               $this->assertContains( $sysop->getName(), $mainSlot->getContent()->serialize() );
 
                $auxSlot = $updater->getRawSlot( 'aux' );
                $this->assertInstanceOf( SlotRecord::class, $auxSlot );
@@ -222,6 +232,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $canonicalOutput = $updater->getCanonicalParserOutput();
                $this->assertContains( 'first', $canonicalOutput->getText() );
                $this->assertContains( '<a ', $canonicalOutput->getText() );
+               $this->assertContains( 'inherited ', $canonicalOutput->getText() );
                $this->assertNotEmpty( $canonicalOutput->getLinks() );
        }
 
@@ -232,18 +243,19 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
         * @covers \MediaWiki\Storage\DerivedPageDataUpdater::isChange()
         */
        public function testPrepareContentInherit() {
-               $user = $this->getTestUser()->getUser();
+               $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
                $page = $this->getPage( __METHOD__ );
 
-               $mainContent1 = new WikitextContent( 'first [[main]] ~~~' );
+               $mainContent1 = new WikitextContent( 'first [[main]] ({{REVISIONUSER}}) ~~~' );
                $mainContent2 = new WikitextContent( 'second' );
 
-               $this->createRevision( $page, 'first', $mainContent1 );
+               $rev = $this->createRevision( $page, 'first', $mainContent1 );
+               $mainContent1 = $rev->getContent( 'main' ); // get post-pst content
 
                $update = new RevisionSlotsUpdate();
                $update->modifyContent( 'main', $mainContent1 );
                $updater1 = $this->getDerivedPageDataUpdater( $page );
-               $updater1->prepareContent( $user, $update, false );
+               $updater1->prepareContent( $sysop, $update, false );
 
                $this->assertNotNull( $updater1->grabCurrentRevision() );
                $this->assertTrue( $updater1->isContentPrepared() );
@@ -251,11 +263,20 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $this->assertFalse( $updater1->isCreation() );
                $this->assertFalse( $updater1->isChange() );
 
+               $this->assertNotNull( $updater1->getRevision() );
+               $this->assertNotNull( $updater1->getRenderedRevision() );
+
+               // parser-output for null-edit uses the original author's name
+               $html = $updater1->getRenderedRevision()->getRevisionParserOutput()->getText();
+               $this->assertNotContains( $sysop->getName(), $html, '{{REVISIONUSER}}' );
+               $this->assertNotContains( '{{REVISIONUSER}}', $html, '{{REVISIONUSER}}' );
+               $this->assertContains( '(' . $rev->getUser()->getName() . ')', $html, '{{REVISIONUSER}}' );
+
                // TODO: MCR: test inheritance from parent
                $update = new RevisionSlotsUpdate();
                $update->modifyContent( 'main', $mainContent2 );
                $updater2 = $this->getDerivedPageDataUpdater( $page );
-               $updater2->prepareContent( $user, $update, false );
+               $updater2->prepareContent( $sysop, $update, false );
 
                $this->assertFalse( $updater2->isCreation() );
                $this->assertTrue( $updater2->isChange() );
@@ -292,7 +313,10 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $this->assertTrue( $updater1->isContentPrepared() );
                $this->assertTrue( $updater1->isCreation() );
                $this->assertTrue( $updater1->isChange() );
-               $this->assertTrue( $updater1->isContentPublic() );
+               $this->assertFalse( $updater1->isContentDeleted() );
+
+               $this->assertNotNull( $updater1->getRevision() );
+               $this->assertNotNull( $updater1->getRenderedRevision() );
 
                $this->assertEquals( [ 'main' ], $updater1->getSlots()->getSlotRoles() );
                $this->assertEquals( [ 'main' ], array_keys( $updater1->getSlots()->getOriginalSlots() ) );
@@ -698,10 +722,20 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
        public function testDoUpdates() {
                $page = $this->getPage( __METHOD__ );
 
-               $mainContent1 = new WikitextContent( 'first [[main]]' );
-               $rev = $this->createRevision( $page, 'first', $mainContent1 );
+               $content = [ 'main' => new WikitextContent( 'first [[main]]' ) ];
+
+               if ( $this->hasMultiSlotSupport() ) {
+                       $content['aux'] = new WikitextContent( 'Aux [[Nix]]' );
+               }
+
+               $rev = $this->createRevision( $page, 'first', $content );
                $pageId = $page->getId();
+
                $oldStats = $this->db->selectRow( 'site_stats', '*', '1=1' );
+               $this->db->delete( 'pagelinks', '*' );
+
+               $pcache = MediaWikiServices::getInstance()->getParserCache();
+               $pcache->deleteOptionsKey( $page );
 
                $updater = $this->getDerivedPageDataUpdater( $page, $rev );
                $updater->setArticleCountMethod( 'link' );
@@ -712,15 +746,25 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $updater->doUpdates();
 
                // links table update
-               $linkCount = $this->db->selectRowCount( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
-               $this->assertSame( 1, $linkCount );
+               $pageLinks = $this->db->select(
+                       'pagelinks',
+                       '*',
+                       [ 'pl_from' => $pageId ],
+                       __METHOD__,
+                       [ 'ORDER BY' => 'pl_namespace, pl_title' ]
+               );
 
-               $pageLinksRow = $this->db->selectRow( 'pagelinks', '*', [ 'pl_from' => $pageId ] );
+               $pageLinksRow = $pageLinks->fetchObject();
                $this->assertInternalType( 'object', $pageLinksRow );
                $this->assertSame( 'Main', $pageLinksRow->pl_title );
 
+               if ( $this->hasMultiSlotSupport() ) {
+                       $pageLinksRow = $pageLinks->fetchObject();
+                       $this->assertInternalType( 'object', $pageLinksRow );
+                       $this->assertSame( 'Nix', $pageLinksRow->pl_title );
+               }
+
                // parser cache update
-               $pcache = MediaWikiServices::getInstance()->getParserCache();
                $cached = $pcache->get( $page, $updater->getCanonicalParserOptions() );
                $this->assertInternalType( 'object', $cached );
                $this->assertSame( $updater->getCanonicalParserOutput(), $cached );
@@ -742,4 +786,11 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                // TODO: test category membership update (with setRcWatchCategoryMembership())
        }
 
+       private function hasMultiSlotSupport() {
+               global $wgMultiContentRevisionSchemaMigrationStage;
+
+               return ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_WRITE_NEW )
+                       && ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW );
+       }
+
 }
index 62093f0..43678f9 100644 (file)
@@ -5,6 +5,7 @@ namespace MediaWiki\Tests\Storage;
 use CommentStoreComment;
 use InvalidArgumentException;
 use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\MutableRevisionSlots;
 use MediaWiki\Storage\RevisionAccessException;
 use MediaWiki\Storage\RevisionRecord;
 use MediaWiki\Storage\RevisionSlotsUpdate;
@@ -195,6 +196,11 @@ class MutableRevisionRecordTest extends MediaWikiTestCase {
                $this->assertSame( 'someHash', $record->getSha1() );
        }
 
+       public function testGetSlots() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertInstanceOf( MutableRevisionSlots::class, $record->getSlots() );
+       }
+
        public function testSetGetSize() {
                $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
                $this->assertSame( 0, $record->getSize() );
index 758537d..2805ea8 100644 (file)
@@ -7,6 +7,7 @@ use Content;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Storage\RevisionRecord;
 use MediaWikiTestCase;
+use ParserOptions;
 use RecentChange;
 use Revision;
 use TextContent;
@@ -494,4 +495,89 @@ class PageUpdaterTest extends MediaWikiTestCase {
                );
        }
 
+       public function provideMagicWords() {
+               yield 'PAGEID' => [
+                       'Test {{PAGEID}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getPageId();
+                       }
+               ];
+
+               yield 'REVISIONID' => [
+                       'Test {{REVISIONID}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getId();
+                       }
+               ];
+
+               yield 'REVISIONUSER' => [
+                       'Test {{REVISIONUSER}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getUser()->getName();
+                       }
+               ];
+
+               yield 'REVISIONTIMESTAMP' => [
+                       'Test {{REVISIONTIMESTAMP}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getTimestamp();
+                       }
+               ];
+
+               yield 'subst:REVISIONUSER' => [
+                       'Test {{subst:REVISIONUSER}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return $rev->getUser()->getName();
+                       }
+               ];
+
+               yield 'subst:PAGENAME' => [
+                       'Test {{subst:PAGENAME}} Test',
+                       function ( RevisionRecord $rev ) {
+                               return 'PageUpdaterTest::testMagicWords';
+                       }
+               ];
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+        *
+        * Integration test for PageUpdater, DerivedPageDataUpdater, RevisionRenderer
+        * and RenderedRevision, that ensures that magic words depending on revision meta-data
+        * are handled correctly. Note that each magic word needs to be tested separately,
+        * to assert correct behavior for each "vary" flag in the ParserOutput.
+        *
+        * @dataProvider provideMagicWords
+        */
+       public function testMagicWords( $wikitext, $callback ) {
+               $user = $this->getTestUser()->getUser();
+
+               $title = $this->getDummyTitle( __METHOD__ . '-' . $this->getName() );
+               $page = WikiPage::factory( $title );
+               $updater = $page->newPageUpdater( $user );
+
+               $updater->setContent( 'main', new \WikitextContent( $wikitext ) );
+
+               $summary = CommentStoreComment::newUnsavedComment( 'Just a test' );
+               $rev = $updater->saveRevision( $summary, EDIT_NEW );
+
+               if ( !$rev ) {
+                       $this->fail( $updater->getStatus()->getWikiText() );
+               }
+
+               $expected = strval( $callback( $rev ) );
+
+               $cache = MediaWikiServices::getInstance()->getParserCache();
+               $output = $cache->get(
+                       $page,
+                       ParserOptions::newCanonical(
+                               'canonical'
+                       )
+               );
+
+               $this->assertNotNull( $output, 'ParserCache::get' );
+
+               $this->assertContains( $expected, $output->getText() );
+       }
+
 }
index d585240..f36fbfd 100644 (file)
@@ -967,4 +967,32 @@ class TitleTest extends MediaWikiTestCase {
                        [ 'zz:Foo#тест', '#.D1.82.D0.B5.D1.81.D1.82' ],
                ];
        }
+
+       /**
+        * @covers Title::isRawHtmlMessage
+        * @dataProvider provideIsRawHtmlMessage
+        */
+       public function testIsRawHtmlMessage( $textForm, $expected ) {
+               $this->setMwGlobals( 'wgRawHtmlMessages', [
+                       'foobar',
+                       'foo_bar',
+                       'foo-bar',
+               ] );
+
+               $title = Title::newFromText( $textForm );
+               $this->assertSame( $expected, $title->isRawHtmlMessage() );
+       }
+
+       public function provideIsRawHtmlMessage() {
+               return [
+                       [ 'MediaWiki:Foobar', true ],
+                       [ 'MediaWiki:Foo bar', true ],
+                       [ 'MediaWiki:Foo-bar', true ],
+                       [ 'MediaWiki:foo bar', true ],
+                       [ 'MediaWiki:foo-bar', true ],
+                       [ 'MediaWiki:foobar', true ],
+                       [ 'MediaWiki:some-other-message', false ],
+                       [ 'Main Page', false ],
+               ];
+       }
 }
index 8c17780..6413ddd 100644 (file)
@@ -13,6 +13,7 @@ class ParserOptionsTest extends MediaWikiTestCase {
                $wrap->defaults = null;
                $wrap->lazyOptions = [
                        'dateformat' => [ ParserOptions::class, 'initDateFormat' ],
+                       'speculativeRevId' => [ ParserOptions::class, 'initSpeculativeRevId' ],
                ];
                $wrap->inCacheKey = [
                        'dateformat' => true,
@@ -309,4 +310,19 @@ class ParserOptionsTest extends MediaWikiTestCase {
                ], ParserOptions::allCacheVaryingOptions() );
        }
 
+       public function testGetSpeculativeRevid() {
+               $options = new ParserOptions();
+
+               $this->assertFalse( $options->getSpeculativeRevId() );
+
+               $counter = 0;
+               $options->setSpeculativeRevIdCallback( function () use( &$counter ) {
+                       return ++$counter;
+               } );
+
+               // make sure the same value is re-used once it is determined
+               $this->assertSame( 1, $options->getSpeculativeRevId() );
+               $this->assertSame( 1, $options->getSpeculativeRevId() );
+       }
+
 }
index 439b24d..3c73430 100644 (file)
@@ -1,10 +1,11 @@
 <?php
+use Wikimedia\TestingAccessWrapper;
 
 /**
  * @group Database
  *        ^--- trigger DB shadowing because we are using Title magic
  */
-class ParserOutputTest extends MediaWikiTestCase {
+class ParserOutputTest extends MediaWikiLangTestCase {
 
        public static function provideIsLinkInternal() {
                return [
@@ -306,4 +307,601 @@ EOF
                // phpcs:enable
        }
 
+       /**
+        * @covers ParserOutput::hasText
+        */
+       public function testHasText() {
+               $po = new ParserOutput();
+               $this->assertTrue( $po->hasText() );
+
+               $po = new ParserOutput( null );
+               $this->assertFalse( $po->hasText() );
+
+               $po = new ParserOutput( '' );
+               $this->assertTrue( $po->hasText() );
+
+               $po = new ParserOutput( null );
+               $po->setText( '' );
+               $this->assertTrue( $po->hasText() );
+       }
+
+       /**
+        * @covers ParserOutput::getText
+        */
+       public function testGetText_failsIfNoText() {
+               $po = new ParserOutput( null );
+
+               $this->setExpectedException( LogicException::class );
+               $po->getText();
+       }
+
+       /**
+        * @covers ParserOutput::getRawText
+        */
+       public function testGetRawText_failsIfNoText() {
+               $po = new ParserOutput( null );
+
+               $this->setExpectedException( LogicException::class );
+               $po->getRawText();
+       }
+
+       public function provideMergeHtmlMetaDataFrom() {
+               // title text ------------
+               $a = new ParserOutput();
+               $a->setTitleText( 'X' );
+               $b = new ParserOutput();
+               yield 'only left title text' => [ $a, $b, [ 'getTitleText' => 'X' ] ];
+
+               $a = new ParserOutput();
+               $b = new ParserOutput();
+               $b->setTitleText( 'Y' );
+               yield 'only right title text' => [ $a, $b, [ 'getTitleText' => 'Y' ] ];
+
+               $a = new ParserOutput();
+               $a->setTitleText( 'X' );
+               $b = new ParserOutput();
+               $b->setTitleText( 'Y' );
+               yield 'left title text wins' => [ $a, $b, [ 'getTitleText' => 'X' ] ];
+
+               // index policy ------------
+               $a = new ParserOutput();
+               $a->setIndexPolicy( 'index' );
+               $b = new ParserOutput();
+               yield 'only left index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ];
+
+               $a = new ParserOutput();
+               $b = new ParserOutput();
+               $b->setIndexPolicy( 'index' );
+               yield 'only right index policy' => [ $a, $b, [ 'getIndexPolicy' => 'index' ] ];
+
+               $a = new ParserOutput();
+               $a->setIndexPolicy( 'noindex' );
+               $b = new ParserOutput();
+               $b->setIndexPolicy( 'index' );
+               yield 'left noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ];
+
+               $a = new ParserOutput();
+               $a->setIndexPolicy( 'index' );
+               $b = new ParserOutput();
+               $b->setIndexPolicy( 'noindex' );
+               yield 'right noindex wins' => [ $a, $b, [ 'getIndexPolicy' => 'noindex' ] ];
+
+               // head items and friends ------------
+               $a = new ParserOutput();
+               $a->addHeadItem( '<foo1>' );
+               $a->addHeadItem( '<bar1>', 'bar' );
+               $a->addModules( 'test-module-a' );
+               $a->addModuleScripts( 'test-module-script-a' );
+               $a->addModuleStyles( 'test-module-styles-a' );
+               $b->addJsConfigVars( 'test-config-var-a', 'a' );
+
+               $b = new ParserOutput();
+               $b->setIndexPolicy( 'noindex' );
+               $b->addHeadItem( '<foo2>' );
+               $b->addHeadItem( '<bar2>', 'bar' );
+               $b->addModules( 'test-module-b' );
+               $b->addModuleScripts( 'test-module-script-b' );
+               $b->addModuleStyles( 'test-module-styles-b' );
+               $b->addJsConfigVars( 'test-config-var-b', 'b' );
+               $b->addJsConfigVars( 'test-config-var-a', 'X' );
+
+               yield 'head items and friends' => [ $a, $b, [
+                       'getHeadItems' => [
+                               '<foo1>',
+                               '<foo2>',
+                               'bar' => '<bar2>', // overwritten
+                       ],
+                       'getModules' => [
+                               'test-module-a',
+                               'test-module-b',
+                       ],
+                       'getModuleScripts' => [
+                               'test-module-script-a',
+                               'test-module-script-b',
+                       ],
+                       'getModuleStyles' => [
+                               'test-module-styles-a',
+                               'test-module-styles-b',
+                       ],
+                       'getJsConfigVars' => [
+                               'test-config-var-a' => 'X', // overwritten
+                               'test-config-var-b' => 'b',
+                       ],
+               ] ];
+
+               // TOC ------------
+               $a = new ParserOutput();
+               $a->setTOCHTML( '<p>TOC A</p>' );
+               $a->setSections( [ [ 'fromtitle' => 'A1' ], [ 'fromtitle' => 'A2' ] ] );
+
+               $b = new ParserOutput();
+               $b->setTOCHTML( '<p>TOC B</p>' );
+               $b->setSections( [ [ 'fromtitle' => 'B1' ], [ 'fromtitle' => 'B2' ] ] );
+
+               yield 'concat TOC' => [ $a, $b, [
+                       'getTOCHTML' => '<p>TOC A</p><p>TOC B</p>',
+                       'getSections' => [
+                               [ 'fromtitle' => 'A1' ],
+                               [ 'fromtitle' => 'A2' ],
+                               [ 'fromtitle' => 'B1' ],
+                               [ 'fromtitle' => 'B2' ]
+                       ],
+               ] ];
+
+               // Skin Control  ------------
+               $a = new ParserOutput();
+               $a->setNewSection( true );
+               $a->hideNewSection( true );
+               $a->setNoGallery( true );
+               $a->addWrapperDivClass( 'foo' );
+
+               $a->setIndicator( 'foo', 'Foo!' );
+               $a->setIndicator( 'bar', 'Bar!' );
+
+               $a->setExtensionData( 'foo', 'Foo!' );
+               $a->setExtensionData( 'bar', 'Bar!' );
+
+               $b = new ParserOutput();
+               $b->setNoGallery( true );
+               $b->setEnableOOUI( true );
+               $b->preventClickjacking( true );
+               $a->addWrapperDivClass( 'bar' );
+
+               $b->setIndicator( 'zoo', 'Zoo!' );
+               $b->setIndicator( 'bar', 'Barrr!' );
+
+               $b->setExtensionData( 'zoo', 'Zoo!' );
+               $b->setExtensionData( 'bar', 'Barrr!' );
+
+               yield 'skin control flags' => [ $a, $b, [
+                       'getNewSection' => true,
+                       'getHideNewSection' => true,
+                       'getNoGallery' => true,
+                       'getEnableOOUI' => true,
+                       'preventClickjacking' => true,
+                       'getIndicators' => [
+                               'foo' => 'Foo!',
+                               'bar' => 'Barrr!',
+                               'zoo' => 'Zoo!',
+                       ],
+                       'getWrapperDivClass' => 'foo bar',
+                       '$mExtensionData' => [
+                               'foo' => 'Foo!',
+                               'bar' => 'Barrr!',
+                               'zoo' => 'Zoo!',
+                       ],
+               ] ];
+       }
+
+       /**
+        * @dataProvider provideMergeHtmlMetaDataFrom
+        * @covers ParserOutput::mergeHtmlMetaDataFrom
+        *
+        * @param ParserOutput $a
+        * @param ParserOutput $b
+        * @param array $expected
+        */
+       public function testMergeHtmlMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
+               $a->mergeHtmlMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+
+               // test twice, to make sure the operation is idempotent (except for the TOC, see below)
+               $a->mergeHtmlMetaDataFrom( $b );
+
+               // XXX: TOC joining should get smarter. Can we make it idempotent as well?
+               unset( $expected['getTOCHTML'] );
+               unset( $expected['getSections'] );
+
+               $this->assertFieldValues( $a, $expected );
+       }
+
+       private function assertFieldValues( ParserOutput $po, $expected ) {
+               $po = TestingAccessWrapper::newFromObject( $po );
+
+               foreach ( $expected as $method => $value ) {
+                       if ( $method[0] === '$' ) {
+                               $field = substr( $method, 1 );
+                               $actual = $po->__get( $field );
+                       } else {
+                               $actual = $po->__call( $method, [] );
+                       }
+
+                       $this->assertEquals( $value, $actual, $method );
+               }
+       }
+
+       public function provideMergeTrackingMetaDataFrom() {
+               // links ------------
+               $a = new ParserOutput();
+               $a->addLink( Title::makeTitle( NS_MAIN, 'Kittens' ), 6 );
+               $a->addLink( Title::makeTitle( NS_TALK, 'Kittens' ), 16 );
+               $a->addLink( Title::makeTitle( NS_MAIN, 'Goats' ), 7 );
+
+               $a->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Goats' ), 107, 1107 );
+
+               $a->addLanguageLink( 'de' );
+               $a->addLanguageLink( 'ru' );
+               $a->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens DE', '', 'de' ) );
+               $a->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens RU', '', 'ru' ) );
+               $a->addExternalLink( 'https://kittens.wikimedia.test' );
+               $a->addExternalLink( 'https://goats.wikimedia.test' );
+
+               $a->addCategory( 'Foo', 'X' );
+               $a->addImage( 'Billy.jpg', '20180101000013', 'DEAD' );
+
+               $b = new ParserOutput();
+               $b->addLink( Title::makeTitle( NS_MAIN, 'Goats' ), 7 );
+               $b->addLink( Title::makeTitle( NS_TALK, 'Goats' ), 17 );
+               $b->addLink( Title::makeTitle( NS_MAIN, 'Dragons' ), 8 );
+               $b->addLink( Title::makeTitle( NS_FILE, 'Dragons.jpg' ), 28 );
+
+               $b->addTemplate( Title::makeTitle( NS_TEMPLATE, 'Dragons' ), 108, 1108 );
+               $a->addTemplate( Title::makeTitle( NS_MAIN, 'Dragons' ), 118, 1118 );
+
+               $b->addLanguageLink( 'fr' );
+               $b->addLanguageLink( 'ru' );
+               $b->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Kittens FR', '', 'fr' ) );
+               $b->addInterwikiLink( Title::makeTitle( NS_MAIN, 'Dragons RU', '', 'ru' ) );
+               $b->addExternalLink( 'https://dragons.wikimedia.test' );
+               $b->addExternalLink( 'https://goats.wikimedia.test' );
+
+               $b->addCategory( 'Bar', 'Y' );
+               $b->addImage( 'Puff.jpg', '20180101000017', 'BEEF' );
+
+               yield 'all kinds of links' => [ $a, $b, [
+                       'getLinks' => [
+                               NS_MAIN => [
+                                       'Kittens' => 6,
+                                       'Goats' => 7,
+                                       'Dragons' => 8,
+                               ],
+                               NS_TALK => [
+                                       'Kittens' => 16,
+                                       'Goats' => 17,
+                               ],
+                               NS_FILE => [
+                                       'Dragons.jpg' => 28,
+                               ],
+                       ],
+                       'getTemplates' => [
+                               NS_MAIN => [
+                                       'Dragons' => 118,
+                               ],
+                               NS_TEMPLATE => [
+                                       'Dragons' => 108,
+                                       'Goats' => 107,
+                               ],
+                       ],
+                       'getTemplateIds' => [
+                               NS_MAIN => [
+                                       'Dragons' => 1118,
+                               ],
+                               NS_TEMPLATE => [
+                                       'Dragons' => 1108,
+                                       'Goats' => 1107,
+                               ],
+                       ],
+                       'getLanguageLinks' => [ 'de', 'ru', 'fr' ],
+                       'getInterwikiLinks' => [
+                               'de' => [ 'Kittens_DE' => 1 ],
+                               'ru' => [ 'Kittens_RU' => 1, 'Dragons_RU' => 1, ],
+                               'fr' => [ 'Kittens_FR' => 1 ],
+                       ],
+                       'getCategories' => [ 'Foo' => 'X', 'Bar' => 'Y' ],
+                       'getImages' => [ 'Billy.jpg' => 1, 'Puff.jpg' => 1 ],
+                       'getFileSearchOptions' => [
+                               'Billy.jpg' => [ 'time' => '20180101000013', 'sha1' => 'DEAD' ],
+                               'Puff.jpg' => [ 'time' => '20180101000017', 'sha1' => 'BEEF' ],
+                       ],
+                       'getExternalLinks' => [
+                               'https://dragons.wikimedia.test' => 1,
+                               'https://kittens.wikimedia.test' => 1,
+                               'https://goats.wikimedia.test' => 1,
+                       ]
+               ] ];
+
+               // properties ------------
+               $a = new ParserOutput();
+
+               $a->setProperty( 'foo', 'Foo!' );
+               $a->setProperty( 'bar', 'Bar!' );
+
+               $a->setExtensionData( 'foo', 'Foo!' );
+               $a->setExtensionData( 'bar', 'Bar!' );
+
+               $b = new ParserOutput();
+
+               $b->setProperty( 'zoo', 'Zoo!' );
+               $b->setProperty( 'bar', 'Barrr!' );
+
+               $b->setExtensionData( 'zoo', 'Zoo!' );
+               $b->setExtensionData( 'bar', 'Barrr!' );
+
+               yield 'properties' => [ $a, $b, [
+                       'getProperties' => [
+                               'foo' => 'Foo!',
+                               'bar' => 'Barrr!',
+                               'zoo' => 'Zoo!',
+                       ],
+                       '$mExtensionData' => [
+                               'foo' => 'Foo!',
+                               'bar' => 'Barrr!',
+                               'zoo' => 'Zoo!',
+                       ],
+               ] ];
+       }
+
+       /**
+        * @dataProvider provideMergeTrackingMetaDataFrom
+        * @covers ParserOutput::mergeTrackingMetaDataFrom
+        *
+        * @param ParserOutput $a
+        * @param ParserOutput $b
+        * @param array $expected
+        */
+       public function testMergeTrackingMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
+               $a->mergeTrackingMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+
+               // test twice, to make sure the operation is idempotent
+               $a->mergeTrackingMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+       }
+
+       public function provideMergeInternalMetaDataFrom() {
+               // hooks
+               $a = new ParserOutput();
+
+               $a->addOutputHook( 'foo', 'X' );
+               $a->addOutputHook( 'bar' );
+
+               $b = new ParserOutput();
+
+               $b->addOutputHook( 'foo', 'Y' );
+               $b->addOutputHook( 'bar' );
+               $b->addOutputHook( 'zoo' );
+
+               yield 'hooks' => [ $a, $b, [
+                       'getOutputHooks' => [
+                               [ 'foo', 'X' ],
+                               [ 'bar', false ],
+                               [ 'foo', 'Y' ],
+                               [ 'zoo', false ],
+                       ],
+               ] ];
+
+               // flags & co
+               $a = new ParserOutput();
+
+               $a->addWarning( 'Oops' );
+               $a->addWarning( 'Whoops' );
+
+               $a->setFlag( 'foo' );
+               $a->setFlag( 'bar' );
+
+               $a->recordOption( 'Foo' );
+               $a->recordOption( 'Bar' );
+
+               $b = new ParserOutput();
+
+               $b->addWarning( 'Yikes' );
+               $b->addWarning( 'Whoops' );
+
+               $b->setFlag( 'zoo' );
+               $b->setFlag( 'bar' );
+
+               $b->recordOption( 'Zoo' );
+               $b->recordOption( 'Bar' );
+
+               yield 'flags' => [ $a, $b, [
+                       'getWarnings' => [ 'Oops', 'Whoops', 'Yikes' ],
+                       '$mFlags' => [ 'foo' => true, 'bar' => true, 'zoo' => true ],
+                       'getUsedOptions' => [ 'Foo', 'Bar', 'Zoo' ],
+               ] ];
+
+               // timestamp ------------
+               $a = new ParserOutput();
+               $a->setTimestamp( '20180101000011' );
+               $b = new ParserOutput();
+               yield 'only left timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+               $a = new ParserOutput();
+               $b = new ParserOutput();
+               $b->setTimestamp( '20180101000011' );
+               yield 'only right timestamp' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+               $a = new ParserOutput();
+               $a->setTimestamp( '20180101000011' );
+               $b = new ParserOutput();
+               $b->setTimestamp( '20180101000001' );
+               yield 'left timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+               $a = new ParserOutput();
+               $a->setTimestamp( '20180101000001' );
+               $b = new ParserOutput();
+               $b->setTimestamp( '20180101000011' );
+               yield 'right timestamp wins' => [ $a, $b, [ 'getTimestamp' => '20180101000011' ] ];
+
+               // speculative rev id ------------
+               $a = new ParserOutput();
+               $a->setSpeculativeRevIdUsed( 9 );
+               $b = new ParserOutput();
+               yield 'only left speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];
+
+               $a = new ParserOutput();
+               $b = new ParserOutput();
+               $b->setSpeculativeRevIdUsed( 9 );
+               yield 'only right speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];
+
+               $a = new ParserOutput();
+               $a->setSpeculativeRevIdUsed( 9 );
+               $b = new ParserOutput();
+               $b->setSpeculativeRevIdUsed( 9 );
+               yield 'same speculative rev id' => [ $a, $b, [ 'getSpeculativeRevIdUsed' => 9 ] ];
+
+               // limit report (recursive max) ------------
+               $a = new ParserOutput();
+
+               $a->setLimitReportData( 'naive1', 7 );
+               $a->setLimitReportData( 'naive2', 27 );
+
+               $a->setLimitReportData( 'limitreport-simple1', 7 );
+               $a->setLimitReportData( 'limitreport-simple2', 27 );
+
+               $a->setLimitReportData( 'limitreport-pair1', [ 7, 9 ] );
+               $a->setLimitReportData( 'limitreport-pair2', [ 27, 29 ] );
+
+               $a->setLimitReportData( 'limitreport-more1', [ 7, 9, 1 ] );
+               $a->setLimitReportData( 'limitreport-more2', [ 27, 29, 21 ] );
+
+               $a->setLimitReportData( 'limitreport-only-a', 13 );
+
+               $b = new ParserOutput();
+
+               $b->setLimitReportData( 'naive1', 17 );
+               $b->setLimitReportData( 'naive2', 17 );
+
+               $b->setLimitReportData( 'limitreport-simple1', 17 );
+               $b->setLimitReportData( 'limitreport-simple2', 17 );
+
+               $b->setLimitReportData( 'limitreport-pair1', [ 17, 19 ] );
+               $b->setLimitReportData( 'limitreport-pair2', [ 17, 19 ] );
+
+               $b->setLimitReportData( 'limitreport-more1', [ 17, 19, 11 ] );
+               $b->setLimitReportData( 'limitreport-more2', [ 17, 19, 11 ] );
+
+               $b->setLimitReportData( 'limitreport-only-b', 23 );
+
+               // first write wins
+               yield 'limit report' => [ $a, $b, [
+                       'getLimitReportData' => [
+                               'naive1' => 7,
+                               'naive2' => 27,
+                               'limitreport-simple1' => 7,
+                               'limitreport-simple2' => 27,
+                               'limitreport-pair1' => [ 7, 9 ],
+                               'limitreport-pair2' => [ 27, 29 ],
+                               'limitreport-more1' => [ 7, 9, 1 ],
+                               'limitreport-more2' => [ 27, 29, 21 ],
+                               'limitreport-only-a' => 13,
+                       ],
+                       'getLimitReportJSData' => [
+                               'naive1' => 7,
+                               'naive2' => 27,
+                               'limitreport' => [
+                                       'simple1' => 7,
+                                       'simple2' => 27,
+                                       'pair1' => [ 'value' => 7, 'limit' => 9 ],
+                                       'pair2' => [ 'value' => 27, 'limit' => 29 ],
+                                       'more1' => [ 7, 9, 1 ],
+                                       'more2' => [ 27, 29, 21 ],
+                                       'only-a' => 13,
+                               ],
+                       ],
+               ] ];
+       }
+
+       /**
+        * @dataProvider provideMergeInternalMetaDataFrom
+        * @covers ParserOutput::mergeInternalMetaDataFrom
+        *
+        * @param ParserOutput $a
+        * @param ParserOutput $b
+        * @param array $expected
+        */
+       public function testMergeInternalMetaDataFrom( ParserOutput $a, ParserOutput $b, $expected ) {
+               $a->mergeInternalMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+
+               // test twice, to make sure the operation is idempotent
+               $a->mergeInternalMetaDataFrom( $b );
+
+               $this->assertFieldValues( $a, $expected );
+       }
+
+       public function testMergeInternalMetaDataFrom_parseStartTime() {
+               /** @var object $a */
+               $a = new ParserOutput();
+               $a = TestingAccessWrapper::newFromObject( $a );
+
+               $a->resetParseStartTime();
+               $aClocks = $a->mParseStartTime;
+
+               $b = new ParserOutput();
+
+               $a->mergeInternalMetaDataFrom( $b );
+               $mergedClocks = $a->mParseStartTime;
+
+               foreach ( $mergedClocks as $clock => $timestamp ) {
+                       $this->assertSame( $aClocks[$clock], $timestamp, $clock );
+               }
+
+               // try again, with times in $b also set, and later than $a's
+               usleep( 1234 );
+
+               /** @var object $b */
+               $b = new ParserOutput();
+               $b = TestingAccessWrapper::newFromObject( $b );
+
+               $b->resetParseStartTime();
+
+               $bClocks = $b->mParseStartTime;
+
+               $a->mergeInternalMetaDataFrom( $b->object, 'b' );
+               $mergedClocks = $a->mParseStartTime;
+
+               foreach ( $mergedClocks as $clock => $timestamp ) {
+                       $this->assertSame( $aClocks[$clock], $timestamp, $clock );
+                       $this->assertLessThanOrEqual( $bClocks[$clock], $timestamp, $clock );
+               }
+
+               // try again, with $a's times being later
+               usleep( 1234 );
+               $a->resetParseStartTime();
+               $aClocks = $a->mParseStartTime;
+
+               $a->mergeInternalMetaDataFrom( $b->object, 'b' );
+               $mergedClocks = $a->mParseStartTime;
+
+               foreach ( $mergedClocks as $clock => $timestamp ) {
+                       $this->assertSame( $bClocks[$clock], $timestamp, $clock );
+                       $this->assertLessThanOrEqual( $aClocks[$clock], $timestamp, $clock );
+               }
+
+               // try again, with no times in $a set
+               $a = new ParserOutput();
+               $a = TestingAccessWrapper::newFromObject( $a );
+
+               $a->mergeInternalMetaDataFrom( $b->object, 'b' );
+               $mergedClocks = $a->mParseStartTime;
+
+               foreach ( $mergedClocks as $clock => $timestamp ) {
+                       $this->assertSame( $bClocks[$clock], $timestamp, $clock );
+               }
+       }
+
 }
index 9b90bfe..4b7a7eb 100644 (file)
@@ -24,7 +24,7 @@ abstract class DumpTestCase extends MediaWikiLangTestCase {
         * exception and store it until we are in setUp and may finally rethrow
         * the exception without crashing the test suite.
         *
-        * @var Exception|null
+        * @var \Exception|null
         */
        protected $exceptionFromAddDBData = null;
 
index ad9bf3e..b8a60be 100644 (file)
@@ -2,7 +2,9 @@
 
 namespace MediaWiki\Tests\Maintenance;
 
+use Exception;
 use MediaWikiLangTestCase;
+use MWException;
 use TextContentHandler;
 use TextPassDumper;
 use Title;
index f223ef7..751155d 100644 (file)
@@ -2,15 +2,19 @@
        QUnit.module( 'mediawiki.user', QUnit.newMwEnvironment( {
                setup: function () {
                        this.server = this.sandbox.useFakeServer();
-                       this.crypto = window.crypto;
-                       this.msCrypto = window.msCrypto;
+                       // Cannot stub by simple assignment because read-only.
+                       // Instead, stub in tests by using 'delete', and re-create
+                       // in teardown using the original descriptor (including its
+                       // accessors and readonly settings etc.)
+                       this.crypto = Object.getOwnPropertyDescriptor( window, 'crypto' );
+                       this.msCrypto = Object.getOwnPropertyDescriptor( window, 'msCrypto' );
                },
                teardown: function () {
                        if ( this.crypto ) {
-                               window.crypto = this.crypto;
+                               Object.defineProperty( window, 'crypto', this.crypto );
                        }
                        if ( this.msCrypto ) {
-                               window.msCrypto = this.msCrypto;
+                               Object.defineProperty( window, 'msCrypto', this.msCrypto );
                        }
                }
        } ) );
                var result, result2;
 
                // Pretend crypto API is not there to test the Math.random fallback
-               if ( window.crypto ) {
-                       window.crypto = undefined;
-               }
-               if ( window.msCrypto ) {
-                       window.msCrypto = undefined;
-               }
+               delete window.crypto;
+               delete window.msCrypto;
+               // Assert that the above actually worked. If we use the wrong method
+               // of stubbing, JavaScript silently continues and we need to know that
+               // it was the wrong method. As of writing, assigning undefined is
+               // ineffective as the window property for Crypto is read-only.
+               // However, deleting does work. (T203275)
+               assert.strictEqual( window.crypto || window.msCrypto, undefined, 'fallback is active' );
 
                result = mw.user.generateRandomSessionId();
                assert.strictEqual( typeof result, 'string', 'type' );