Merge "mw.rcfilters.ui.MenuSelectWidget: Always open this menu downwards"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Fri, 15 Dec 2017 00:26:44 +0000 (00:26 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Fri, 15 Dec 2017 00:26:45 +0000 (00:26 +0000)
88 files changed:
autoload.php
includes/Feed.php
includes/MediaWikiServices.php
includes/ServiceWiring.php
includes/Storage/BlobAccessException.php [new file with mode: 0644]
includes/Storage/BlobStore.php [new file with mode: 0644]
includes/Storage/IncompleteRevisionException.php [new file with mode: 0644]
includes/Storage/MutableRevisionRecord.php [new file with mode: 0644]
includes/Storage/MutableRevisionSlots.php [new file with mode: 0644]
includes/Storage/RevisionAccessException.php [new file with mode: 0644]
includes/Storage/RevisionArchiveRecord.php [new file with mode: 0644]
includes/Storage/RevisionFactory.php [new file with mode: 0644]
includes/Storage/RevisionLookup.php [new file with mode: 0644]
includes/Storage/RevisionRecord.php [new file with mode: 0644]
includes/Storage/RevisionSlots.php [new file with mode: 0644]
includes/Storage/RevisionStore.php [new file with mode: 0644]
includes/Storage/RevisionStoreRecord.php [new file with mode: 0644]
includes/Storage/SlotRecord.php [new file with mode: 0644]
includes/Storage/SqlBlobStore.php [new file with mode: 0644]
includes/Storage/SuppressedDataException.php [new file with mode: 0644]
includes/api/ApiQuerySearch.php
includes/api/i18n/de.json
includes/api/i18n/en.json
includes/api/i18n/es.json
includes/api/i18n/fr.json
includes/api/i18n/nb.json
includes/api/i18n/pt.json
includes/api/i18n/qqq.json
includes/api/i18n/zh-hans.json
includes/editpage/TextConflictHelper.php
includes/export/ExportProgressFilter.php [new file with mode: 0644]
includes/installer/PostgresUpdater.php
includes/jobqueue/jobs/EnqueueJob.php
includes/registration/ExtensionProcessor.php
includes/registration/ExtensionRegistry.php
includes/skins/SkinTemplate.php
includes/specials/SpecialWatchlist.php
includes/templates/AtomHeader.mustache [new file with mode: 0644]
includes/templates/AtomItem.mustache [new file with mode: 0644]
includes/templates/RSSHeader.mustache [new file with mode: 0644]
includes/templates/RSSItem.mustache [new file with mode: 0644]
includes/user/UserIdentityValue.php [new file with mode: 0644]
languages/i18n/af.json
languages/i18n/ar.json
languages/i18n/be-tarask.json
languages/i18n/bg.json
languages/i18n/ce.json
languages/i18n/es.json
languages/i18n/fr.json
languages/i18n/hr.json
languages/i18n/ka.json
languages/i18n/ko.json
languages/i18n/lb.json
languages/i18n/lv.json
languages/i18n/mk.json
languages/i18n/mr.json
languages/i18n/mt.json
languages/i18n/nb.json
languages/i18n/nl.json
languages/i18n/ps.json
languages/i18n/pt.json
languages/i18n/qqq.json
languages/i18n/ru.json
languages/i18n/sl.json
languages/i18n/th.json
languages/i18n/zh-hans.json
maintenance/backup.inc
maintenance/storage/checkStorage.php
resources/lib/oojs-ui/oojs-ui-core.js
resources/src/mediawiki.legacy/wikibits.js
resources/src/mediawiki.rcfilters/dm/mw.rcfilters.dm.ChangesListViewModel.js
resources/src/mediawiki.rcfilters/mw.rcfilters.UriProcessor.js
resources/src/mediawiki.rcfilters/mw.rcfilters.init.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.MenuSelectWidget.js
resources/src/mediawiki.rcfilters/ui/mw.rcfilters.ui.RclTargetPageWidget.js
resources/src/mediawiki/mediawiki.js
resources/src/startup.js
tests/parser/parserTests.txt
tests/phpunit/includes/Storage/MutableRevisionRecordTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/RevisionRecordTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/RevisionSlotsTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/RevisionStoreDbTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/RevisionStoreRecordTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/RevisionStoreTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/SlotRecordTest.php [new file with mode: 0644]
tests/phpunit/includes/Storage/SqlBlobStoreTest.php [new file with mode: 0644]
tests/qunit/suites/resources/mediawiki.rcfilters/UriProcessor.test.js

index 8aa6afb..6b8387b 100644 (file)
@@ -449,7 +449,7 @@ $wgAutoloadLocalClasses = [
        'Exif' => __DIR__ . '/includes/media/Exif.php',
        'ExifBitmapHandler' => __DIR__ . '/includes/media/ExifBitmap.php',
        'ExplodeIterator' => __DIR__ . '/includes/libs/ExplodeIterator.php',
-       'ExportProgressFilter' => __DIR__ . '/maintenance/backup.inc',
+       'ExportProgressFilter' => __DIR__ . '/includes/export/ExportProgressFilter.php',
        'ExportSites' => __DIR__ . '/maintenance/exportSites.php',
        'ExtensionJsonValidationError' => __DIR__ . '/includes/registration/ExtensionJsonValidationError.php',
        'ExtensionJsonValidator' => __DIR__ . '/includes/registration/ExtensionJsonValidator.php',
@@ -942,6 +942,22 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\Shell\\Result' => __DIR__ . '/includes/shell/Result.php',
        'MediaWiki\\Shell\\Shell' => __DIR__ . '/includes/shell/Shell.php',
        'MediaWiki\\Site\\MediaWikiPageNameNormalizer' => __DIR__ . '/includes/site/MediaWikiPageNameNormalizer.php',
+       'MediaWiki\\Storage\\BlobAccessException' => __DIR__ . '/includes/Storage/BlobAccessException.php',
+       'MediaWiki\\Storage\\BlobStore' => __DIR__ . '/includes/Storage/BlobStore.php',
+       'MediaWiki\\Storage\\IncompleteRevisionException' => __DIR__ . '/includes/Storage/IncompleteRevisionException.php',
+       'MediaWiki\\Storage\\MutableRevisionRecord' => __DIR__ . '/includes/Storage/MutableRevisionRecord.php',
+       'MediaWiki\\Storage\\MutableRevisionSlots' => __DIR__ . '/includes/Storage/MutableRevisionSlots.php',
+       'MediaWiki\\Storage\\RevisionAccessException' => __DIR__ . '/includes/Storage/RevisionAccessException.php',
+       'MediaWiki\\Storage\\RevisionArchiveRecord' => __DIR__ . '/includes/Storage/RevisionArchiveRecord.php',
+       'MediaWiki\\Storage\\RevisionFactory' => __DIR__ . '/includes/Storage/RevisionFactory.php',
+       'MediaWiki\\Storage\\RevisionLookup' => __DIR__ . '/includes/Storage/RevisionLookup.php',
+       'MediaWiki\\Storage\\RevisionRecord' => __DIR__ . '/includes/Storage/RevisionRecord.php',
+       'MediaWiki\\Storage\\RevisionSlots' => __DIR__ . '/includes/Storage/RevisionSlots.php',
+       'MediaWiki\\Storage\\RevisionStore' => __DIR__ . '/includes/Storage/RevisionStore.php',
+       'MediaWiki\\Storage\\RevisionStoreRecord' => __DIR__ . '/includes/Storage/RevisionStoreRecord.php',
+       'MediaWiki\\Storage\\SlotRecord' => __DIR__ . '/includes/Storage/SlotRecord.php',
+       'MediaWiki\\Storage\\SqlBlobStore' => __DIR__ . '/includes/Storage/SqlBlobStore.php',
+       'MediaWiki\\Storage\\SuppressedDataException' => __DIR__ . '/includes/Storage/SuppressedDataException.php',
        'MediaWiki\\Tidy\\BalanceActiveFormattingElements' => __DIR__ . '/includes/tidy/Balancer.php',
        'MediaWiki\\Tidy\\BalanceElement' => __DIR__ . '/includes/tidy/Balancer.php',
        'MediaWiki\\Tidy\\BalanceMarker' => __DIR__ . '/includes/tidy/Balancer.php',
@@ -961,6 +977,7 @@ $wgAutoloadLocalClasses = [
        'MediaWiki\\Tidy\\RemexMungerData' => __DIR__ . '/includes/tidy/RemexMungerData.php',
        'MediaWiki\\Tidy\\TidyDriverBase' => __DIR__ . '/includes/tidy/TidyDriverBase.php',
        'MediaWiki\\User\\UserIdentity' => __DIR__ . '/includes/user/UserIdentity.php',
+       'MediaWiki\\User\\UserIdentityValue' => __DIR__ . '/includes/user/UserIdentityValue.php',
        'MediaWiki\\Widget\\ComplexNamespaceInputWidget' => __DIR__ . '/includes/widget/ComplexNamespaceInputWidget.php',
        'MediaWiki\\Widget\\ComplexTitleInputWidget' => __DIR__ . '/includes/widget/ComplexTitleInputWidget.php',
        'MediaWiki\\Widget\\DateInputWidget' => __DIR__ . '/includes/widget/DateInputWidget.php',
index 35f2ce9..0e715df 100644 (file)
@@ -84,13 +84,23 @@ class FeedItem {
        }
 
        /**
-        * Get the unique id of this item
-        *
+        * Get the unique id of this item; already xml-encoded
+        * @return string
+        */
+       public function getUniqueID() {
+               $id = $this->getUniqueIDUnescaped();
+               if ( $id ) {
+                       return $this->xmlEncode( $id );
+               }
+       }
+
+       /**
+        * Get the unique id of this item, without any escaping
         * @return string
         */
-       public function getUniqueId() {
+       public function getUniqueIdUnescaped() {
                if ( $this->uniqueId ) {
-                       return $this->xmlEncode( wfExpandUrl( $this->uniqueId, PROTO_CURRENT ) );
+                       return wfExpandUrl( $this->uniqueId, PROTO_CURRENT );
                }
        }
 
@@ -123,6 +133,14 @@ class FeedItem {
                return $this->xmlEncode( $this->url );
        }
 
+       /** Get the URL of this item without any escaping
+        *
+        * @return string
+        */
+       public function getUrlUnescaped() {
+               return $this->url;
+       }
+
        /**
         * Get the description of this item; already xml-encoded
         *
@@ -132,6 +150,14 @@ class FeedItem {
                return $this->xmlEncode( $this->description );
        }
 
+       /**
+        * Get the description of this item without any escaping
+        *
+        */
+       public function getDescriptionUnescaped() {
+               return $this->description;
+       }
+
        /**
         * Get the language of this item
         *
@@ -160,6 +186,15 @@ class FeedItem {
                return $this->xmlEncode( $this->author );
        }
 
+       /**
+        * Get the author of this item without any escaping
+        *
+        * @return string
+        */
+       public function getAuthorUnescaped() {
+               return $this->author;
+       }
+
        /**
         * Get the comment of this item; already xml-encoded
         *
@@ -169,6 +204,15 @@ class FeedItem {
                return $this->xmlEncode( $this->comments );
        }
 
+       /**
+        * Get the comment of this item without any escaping
+        *
+        * @return string
+        */
+       public function getCommentsUnescaped() {
+               return $this->comments;
+       }
+
        /**
         * Quickie hack... strip out wikilinks to more legible form from the comment.
         *
@@ -187,6 +231,23 @@ class FeedItem {
  * @ingroup Feed
  */
 abstract class ChannelFeed extends FeedItem {
+
+       /** @var TemplateParser */
+       protected $templateParser;
+
+       /**
+        * @param string|Title $title Feed's title
+        * @param string $description
+        * @param string $url URL uniquely designating the feed.
+        * @param string $date Feed's date
+        * @param string $author Author's user name
+        * @param string $comments
+        */
+       function __construct( $title, $description, $url, $date = '', $author = '', $comments = '' ) {
+               parent::__construct( $title, $description, $url, $date, $author, $comments );
+               $this->templateParser = new TemplateParser();
+       }
+
        /**
         * Generate Header of the feed
         * @par Example:
@@ -279,13 +340,15 @@ abstract class ChannelFeed extends FeedItem {
 class RSSFeed extends ChannelFeed {
 
        /**
-        * Format a date given a timestamp
+        * Format a date given a timestamp. If a timestamp is not given, nothing is returned
         *
-        * @param int $ts Timestamp
-        * @return string Date string
+        * @param int|null $ts Timestamp
+        * @return string|null Date string
         */
        function formatTime( $ts ) {
-               return gmdate( 'D, d M Y H:i:s \G\M\T', wfTimestamp( TS_UNIX, $ts ) );
+               if ( $ts ) {
+                       return gmdate( 'D, d M Y H:i:s \G\M\T', wfTimestamp( TS_UNIX, $ts ) );
+               }
        }
 
        /**
@@ -295,15 +358,17 @@ class RSSFeed extends ChannelFeed {
                global $wgVersion;
 
                $this->outXmlHeader();
-               ?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
-       <channel>
-               <title><?php print $this->getTitle() ?></title>
-               <link><?php print wfExpandUrl( $this->getUrl(), PROTO_CURRENT ) ?></link>
-               <description><?php print $this->getDescription() ?></description>
-               <language><?php print $this->getLanguage() ?></language>
-               <generator>MediaWiki <?php print $wgVersion ?></generator>
-               <lastBuildDate><?php print $this->formatTime( wfTimestampNow() ) ?></lastBuildDate>
-<?php
+               // Manually escaping rather than letting Mustache do it because Mustache
+               // uses htmlentities, which does not work with XML
+               $templateParams = [
+                       'title' => $this->getTitle(),
+                       'url' => $this->xmlEncode( wfExpandUrl( $this->getUrlUnescaped(), PROTO_CURRENT ) ),
+                       'description' => $this->getDescription(),
+                       'language' => $this->xmlEncode( $this->getLanguage() ),
+                       'version' => $this->xmlEncode( $wgVersion ),
+                       'timestamp' => $this->xmlEncode( $this->formatTime( wfTimestampNow() ) )
+               ];
+               print $this->templateParser->processTemplate( 'RSSHeader', $templateParams );
        }
 
        /**
@@ -311,28 +376,30 @@ class RSSFeed extends ChannelFeed {
         * @param FeedItem $item Item to be output
         */
        function outItem( $item ) {
-               // @codingStandardsIgnoreStart Ignore long lines and formatting issues.
-       ?>
-               <item>
-                       <title><?php print $item->getTitle(); ?></title>
-                       <link><?php print wfExpandUrl( $item->getUrl(), PROTO_CURRENT ); ?></link>
-                       <guid<?php if ( !$item->rssIsPermalink ) { print ' isPermaLink="false"'; } ?>><?php print $item->getUniqueId(); ?></guid>
-                       <description><?php print $item->getDescription() ?></description>
-                       <?php if ( $item->getDate() ) { ?><pubDate><?php print $this->formatTime( $item->getDate() ); ?></pubDate><?php } ?>
-                       <?php if ( $item->getAuthor() ) { ?><dc:creator><?php print $item->getAuthor(); ?></dc:creator><?php }?>
-                       <?php if ( $item->getComments() ) { ?><comments><?php print wfExpandUrl( $item->getComments(), PROTO_CURRENT ); ?></comments><?php }?>
-               </item>
-<?php
-               // @codingStandardsIgnoreEnd
+               // Manually escaping rather than letting Mustache do it because Mustache
+               // uses htmlentities, which does not work with XML
+               $templateParams = [
+                       "title" => $item->getTitle(),
+                       "url" => $this->xmlEncode( wfExpandUrl( $item->getUrlUnescaped(), PROTO_CURRENT ) ),
+                       "permalink" => $item->rssIsPermalink,
+                       "uniqueID" => $item->getUniqueId(),
+                       "description" => $item->getDescription(),
+                       "date" => $this->xmlEncode( $this->formatTime( $item->getDate() ) ),
+                       "author" => $item->getAuthor()
+               ];
+               $comments = $item->getCommentsUnescaped();
+               if ( $comments ) {
+                       $commentsEscaped = $this->xmlEncode( wfExpandUrl( $comments, PROTO_CURRENT ) );
+                       $templateParams["comments"] = $commentsEscaped;
+               }
+               print $this->templateParser->processTemplate( 'RSSItem', $templateParams );
        }
 
        /**
         * Output an RSS 2.0 footer
         */
        function outFooter() {
-       ?>
-       </channel>
-</rss><?php
+               print "</channel></rss>";
        }
 }
 
@@ -343,14 +410,16 @@ class RSSFeed extends ChannelFeed {
  */
 class AtomFeed extends ChannelFeed {
        /**
-        * Format a date given timestamp.
+        * Format a date given timestamp, if one is given.
         *
-        * @param string|int $timestamp
-        * @return string
+        * @param string|int|null $timestamp
+        * @return string|null
         */
        function formatTime( $timestamp ) {
-               // need to use RFC 822 time format at least for rss2.0
-               return gmdate( 'Y-m-d\TH:i:s', wfTimestamp( TS_UNIX, $timestamp ) );
+               if ( $timestamp ) {
+                       // need to use RFC 822 time format at least for rss2.0
+                       return gmdate( 'Y-m-d\TH:i:s', wfTimestamp( TS_UNIX, $timestamp ) );
+               }
        }
 
        /**
@@ -358,20 +427,20 @@ class AtomFeed extends ChannelFeed {
         */
        function outHeader() {
                global $wgVersion;
-
                $this->outXmlHeader();
-               // @codingStandardsIgnoreStart Ignore long lines and formatting issues.
-               ?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="<?php print $this->getLanguage() ?>">
-               <id><?php print $this->getFeedId() ?></id>
-               <title><?php print $this->getTitle() ?></title>
-               <link rel="self" type="application/atom+xml" href="<?php print wfExpandUrl( $this->getSelfUrl(), PROTO_CURRENT ) ?>"/>
-               <link rel="alternate" type="text/html" href="<?php print wfExpandUrl( $this->getUrl(), PROTO_CURRENT ) ?>"/>
-               <updated><?php print $this->formatTime( wfTimestampNow() ) ?>Z</updated>
-               <subtitle><?php print $this->getDescription() ?></subtitle>
-               <generator>MediaWiki <?php print $wgVersion ?></generator>
-
-<?php
-               // @codingStandardsIgnoreEnd
+               // Manually escaping rather than letting Mustache do it because Mustache
+               // uses htmlentities, which does not work with XML
+               $templateParams = [
+                       'language' => $this->xmlEncode( $this->getLanguage() ),
+                       'feedID' => $this->getFeedID(),
+                       'title' => $this->getTitle(),
+                       'url' => $this->xmlEncode( wfExpandUrl( $this->getUrlUnescaped(), PROTO_CURRENT ) ),
+                       'selfUrl' => $this->getSelfUrl(),
+                       'timestamp' => $this->xmlEncode( $this->formatTime( wfTimestampNow() ) ),
+                       'description' => $this->getDescription(),
+                       'version' => $this->xmlEncode( $wgVersion ),
+               ];
+               print $this->templateParser->processTemplate( 'AtomHeader', $templateParams );
        }
 
        /**
@@ -401,30 +470,24 @@ class AtomFeed extends ChannelFeed {
         */
        function outItem( $item ) {
                global $wgMimeType;
-               // @codingStandardsIgnoreStart Ignore long lines and formatting issues.
-       ?>
-       <entry>
-               <id><?php print $item->getUniqueId(); ?></id>
-               <title><?php print $item->getTitle(); ?></title>
-               <link rel="alternate" type="<?php print $wgMimeType ?>" href="<?php print wfExpandUrl( $item->getUrl(), PROTO_CURRENT ); ?>"/>
-               <?php if ( $item->getDate() ) { ?>
-               <updated><?php print $this->formatTime( $item->getDate() ); ?>Z</updated>
-               <?php } ?>
-
-               <summary type="html"><?php print $item->getDescription() ?></summary>
-               <?php if ( $item->getAuthor() ) { ?><author><name><?php print $item->getAuthor(); ?></name></author><?php }?>
-       </entry>
-
-<?php /* @todo FIXME: Need to add comments
-       <?php if( $item->getComments() ) { ?><dc:comment><?php print $item->getComments() ?></dc:comment><?php }?>
-         */
+               // Manually escaping rather than letting Mustache do it because Mustache
+               // uses htmlentities, which does not work with XML
+               $templateParams = [
+                       "uniqueID" => $item->getUniqueId(),
+                       "title" => $item->getTitle(),
+                       "mimeType" => $this->xmlEncode( $wgMimeType ),
+                       "url" => $this->xmlEncode( wfExpandUrl( $item->getUrlUnescaped(), PROTO_CURRENT ) ),
+                       "date" => $this->xmlEncode( $this->formatTime( $item->getDate() ) ),
+                       "description" => $item->getDescription(),
+                       "author" => $item->getAuthor()
+               ];
+               print $this->templateParser->processTemplate( 'AtomItem', $templateParams );
        }
 
        /**
         * Outputs the footer for Atom 1.0 feed (basically '\</feed\>').
         */
-       function outFooter() {?>
-       </feed><?php
-               // @codingStandardsIgnoreEnd
+       function outFooter() {
+               print "</feed>";
        }
 }
index 19b71f1..33d0fd4 100644 (file)
@@ -11,6 +11,8 @@ use GlobalVarConfig;
 use Hooks;
 use IBufferingStatsdDataFactory;
 use MediaWiki\Shell\CommandFactory;
+use MediaWiki\Storage\BlobStore;
+use MediaWiki\Storage\RevisionStore;
 use Wikimedia\Rdbms\LBFactory;
 use LinkCache;
 use Wikimedia\Rdbms\LoadBalancer;
@@ -698,6 +700,22 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'ExternalStoreFactory' );
        }
 
+       /**
+        * @since 1.31
+        * @return BlobStore
+        */
+       public function getBlobStore() {
+               return $this->getService( 'BlobStore' );
+       }
+
+       /**
+        * @since 1.31
+        * @return RevisionStore
+        */
+       public function getRevisionStore() {
+               return $this->getService( 'RevisionStore' );
+       }
+
        ///////////////////////////////////////////////////////////////////////////
        // NOTE: When adding a service getter here, don't forget to add a test
        // case for it in MediaWikiServicesTest::provideGetters() and in
index dad0630..d21bcef 100644 (file)
@@ -42,6 +42,8 @@ use MediaWiki\Linker\LinkRendererFactory;
 use MediaWiki\Logger\LoggerFactory;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Shell\CommandFactory;
+use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Storage\SqlBlobStore;
 
 return [
        'DBLoadBalancerFactory' => function ( MediaWikiServices $services ) {
@@ -456,6 +458,46 @@ return [
                );
        },
 
+       'RevisionStore' => function ( MediaWikiServices $services ) {
+               /** @var SqlBlobStore $blobStore */
+               $blobStore = $services->getService( '_SqlBlobStore' );
+
+               $store = new RevisionStore(
+                       $services->getDBLoadBalancer(),
+                       $blobStore,
+                       $services->getMainWANObjectCache()
+               );
+
+               $config = $services->getMainConfig();
+               $store->setContentHandlerUseDB( $config->get( 'ContentHandlerUseDB' ) );
+
+               return $store;
+       },
+
+       'BlobStore' => function ( MediaWikiServices $services ) {
+               return $services->getService( '_SqlBlobStore' );
+       },
+
+       '_SqlBlobStore' => function ( MediaWikiServices $services ) {
+               global $wgContLang; // TODO: manage $wgContLang as a service
+
+               $store = new SqlBlobStore(
+                       $services->getDBLoadBalancer(),
+                       $services->getMainWANObjectCache()
+               );
+
+               $config = $services->getMainConfig();
+               $store->setCompressBlobs( $config->get( 'CompressRevisions' ) );
+               $store->setCacheExpiry( $config->get( 'RevisionCacheExpiry' ) );
+               $store->setUseExternalStore( $config->get( 'DefaultExternalStore' ) !== false );
+
+               if ( $config->get( 'LegacyEncoding' ) ) {
+                       $store->setLegacyEncoding( $config->get( 'LegacyEncoding' ), $wgContLang );
+               }
+
+               return $store;
+       },
+
        ///////////////////////////////////////////////////////////////////////////
        // NOTE: When adding a service here, don't forget to add a getter function
        // in the MediaWikiServices class. The convenience getter should just call
diff --git a/includes/Storage/BlobAccessException.php b/includes/Storage/BlobAccessException.php
new file mode 100644 (file)
index 0000000..ffc5eca
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Exception representing a failure to look up a revision.
+ *
+ * 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\Storage;
+
+use RuntimeException;
+
+/**
+ * Exception representing a failure to access a data blob.
+ *
+ * @since 1.31
+ */
+class BlobAccessException extends RuntimeException {
+
+}
diff --git a/includes/Storage/BlobStore.php b/includes/Storage/BlobStore.php
new file mode 100644 (file)
index 0000000..28caf3a
--- /dev/null
@@ -0,0 +1,113 @@
+<?php
+/**
+ * Service for loading and storing data blobs.
+ *
+ * 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\Storage;
+
+/**
+ * Service for loading and storing data blobs.
+ *
+ * @note This was written to act as a drop-in replacement for the corresponding
+ *       static methods in Revision.
+ *
+ * @since 1.31
+ */
+interface BlobStore {
+
+       /**
+        * Hint key for use with storeBlob, indicating the general role the block
+        * takes in the application. For instance, it should be "page-content" if
+        * the blob represents a Content object.
+        */
+       const DESIGNATION_HINT = 'designation';
+
+       /**
+        * Hint key for use with storeBlob, indicating the page the blob is associated with.
+        * This may be used for sharding.
+        */
+       const PAGE_HINT = 'page_id';
+
+       /**
+        * Hint key for use with storeBlob, indicating the slot the blob is associated with.
+        * May be relevant for reference counting.
+        */
+       const ROLE_HINT = 'role_name';
+
+       /**
+        * Hint key for use with storeBlob, indicating the revision the blob is associated with.
+        * This may be used for differential storage and reference counting.
+        */
+       const REVISION_HINT = 'rev_id';
+
+       /**
+        * Hint key for use with storeBlob, indicating the parent revision of the revision
+        * the blob is associated with. This may be used for differential storage.
+        */
+       const PARENT_HINT = 'rev_parent_id';
+
+       /**
+        * Hint key for use with storeBlob, providing the SHA1 hash of the blob as passed to the
+        * method. This can be used to avoid re-calculating the hash if it is needed by the BlobStore.
+        */
+       const SHA1_HINT = 'cont_sha1';
+
+       /**
+        * Hint key for use with storeBlob, indicating the model of the content encoded in the
+        * given blob. May be used to implement optimized storage for some well known models.
+        */
+       const MODEL_HINT = 'cont_model';
+
+       /**
+        * Hint key for use with storeBlob, indicating the serialization format used to create
+        * the blob, as a MIME type. May be used for optimized storage in the underlying database.
+        */
+       const FORMAT_HINT = 'cont_format';
+
+       /**
+        * Retrieve a blob, given an address.
+        *
+        * MCR migration note: this replaces Revision::loadText
+        *
+        * @param string $blobAddress The blob address as returned by storeBlob(),
+        *        such as "tt:12345" or "ex:DB://s16/456/9876".
+        * @param int $queryFlags See IDBAccessObject.
+        *
+        * @throws BlobAccessException
+        * @return string binary blob data
+        */
+       public function getBlob( $blobAddress, $queryFlags = 0 );
+
+       /**
+        * Stores an arbitrary blob of data and returns an address that can be used with
+        * getBlob() to retrieve the same blob of data,
+        *
+        * @param string $data raw binary data
+        * @param array $hints An array of hints. Implementations may use the hints to optimize storage.
+        * All hints are optional, supported hints depend on the implementation. Hint names by
+        * convention correspond to the names of fields in the database. Callers are encouraged to
+        * provide the well known hints as defined by the XXX_HINT constants.
+        *
+        * @throws BlobAccessException
+        * @return string an address that can be used with getBlob() to retrieve the data.
+        */
+       public function storeBlob( $data, $hints = [] );
+
+}
diff --git a/includes/Storage/IncompleteRevisionException.php b/includes/Storage/IncompleteRevisionException.php
new file mode 100644 (file)
index 0000000..bf45b01
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+/**
+ * Exception representing a failure to look up a revision.
+ *
+ * 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\Storage;
+
+/**
+ * Exception throw when trying to access undefined fields on an incomplete RevisionRecord.
+ *
+ * @since 1.31
+ */
+class IncompleteRevisionException extends RevisionAccessException {
+
+}
diff --git a/includes/Storage/MutableRevisionRecord.php b/includes/Storage/MutableRevisionRecord.php
new file mode 100644 (file)
index 0000000..a259ae0
--- /dev/null
@@ -0,0 +1,328 @@
+<?php
+/**
+ * Mutable RevisionRecord implementation, for building new revision entries programmatically.
+ *
+ * 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\Storage;
+
+use CommentStoreComment;
+use Content;
+use InvalidArgumentException;
+use MediaWiki\User\UserIdentity;
+use MWException;
+use Title;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Mutable RevisionRecord implementation, for building new revision entries programmatically.
+ * Provides setters for all fields.
+ *
+ * @since 1.31
+ */
+class MutableRevisionRecord extends RevisionRecord {
+
+       /**
+        * Returns an incomplete MutableRevisionRecord which uses $parent as its
+        * parent revision, and inherits all slots form it. If saved unchanged,
+        * the new revision will act as a null-revision.
+        *
+        * @param RevisionRecord $parent
+        * @param CommentStoreComment $comment
+        * @param UserIdentity $user
+        * @param string $timestamp
+        *
+        * @return MutableRevisionRecord
+        */
+       public static function newFromParentRevision(
+               RevisionRecord $parent,
+               CommentStoreComment $comment,
+               UserIdentity $user,
+               $timestamp
+       ) {
+               // TODO: ideally, we wouldn't need a Title here
+               $title = Title::newFromLinkTarget( $parent->getPageAsLinkTarget() );
+               $rev = new MutableRevisionRecord( $title, $parent->getWikiId() );
+
+               $rev->setComment( $comment );
+               $rev->setUser( $user );
+               $rev->setTimestamp( $timestamp );
+
+               foreach ( $parent->getSlotRoles() as $role ) {
+                       $slot = $parent->getSlot( $role, self::RAW );
+                       $rev->inheritSlot( $slot );
+               }
+
+               $rev->setPageId( $parent->getPageId() );
+               $rev->setParentId( $parent->getId() );
+
+               return $rev;
+       }
+
+       /**
+        * @note Avoid calling this constructor directly. Use the appropriate methods
+        * in RevisionStore instead.
+        *
+        * @param Title $title The title of the page this Revision is associated with.
+        * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
+        *        or false for the local site.
+        *
+        * @throws MWException
+        */
+       function __construct( Title $title, $wikiId = false ) {
+               $slots = new MutableRevisionSlots();
+
+               parent::__construct( $title, $slots, $wikiId );
+
+               $this->mSlots = $slots; // redundant, but nice for static analysis
+       }
+
+       /**
+        * @param int $parentId
+        */
+       public function setParentId( $parentId ) {
+               Assert::parameterType( 'integer', $parentId, '$parentId' );
+
+               $this->mParentId = $parentId;
+       }
+
+       /**
+        * Sets the given slot. If a slot with the same role is already present in the revision,
+        * it is replaced.
+        *
+        * @note This can only be used with a fresh "unattached" SlotRecord. Calling code that has a
+        * SlotRecord from another revision should use inheritSlot(). Calling code that has access to
+        * a Content object can use setContent().
+        *
+        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        *
+        * @note Calling this method will cause the revision size and hash to be re-calculated upon
+        *       the next call to getSize() and getSha1(), respectively.
+        *
+        * @param SlotRecord $slot
+        */
+       public function setSlot( SlotRecord $slot ) {
+               if ( $slot->hasRevision() && $slot->getRevision() !== $this->getId() ) {
+                       throw new InvalidArgumentException(
+                               'The given slot must be an unsaved, unattached one. '
+                               . 'This slot is already attached to revision ' . $slot->getRevision() . '. '
+                               . 'Use inheritSlot() instead to preserve a slot from a previous revision.'
+                       );
+               }
+
+               $this->mSlots->setSlot( $slot );
+               $this->resetAggregateValues();
+       }
+
+       /**
+        * "Inherits" the given slot's content.
+        *
+        * If a slot with the same role is already present in the revision, it is replaced.
+        *
+        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        *
+        * @param SlotRecord $parentSlot
+        */
+       public function inheritSlot( SlotRecord $parentSlot ) {
+               $slot = SlotRecord::newInherited( $parentSlot );
+               $this->setSlot( $slot );
+       }
+
+       /**
+        * Sets the content for the slot with the given role.
+        *
+        * If a slot with the same role is already present in the revision, it is replaced.
+        * Calling code that has access to a SlotRecord can use inheritSlot() instead.
+        *
+        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        *
+        * @note Calling this method will cause the revision size and hash to be re-calculated upon
+        *       the next call to getSize() and getSha1(), respectively.
+        *
+        * @param string $role
+        * @param Content $content
+        */
+       public function setContent( $role, Content $content ) {
+               $this->mSlots->setContent( $role, $content );
+               $this->resetAggregateValues();
+       }
+
+       /**
+        * Removes the slot with the given role from this revision.
+        * This effectively ends the "stream" with that role on the revision's page.
+        * Future revisions will no longer inherit this slot, unless it is added back explicitly.
+        *
+        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        *
+        * @note Calling this method will cause the revision size and hash to be re-calculated upon
+        *       the next call to getSize() and getSha1(), respectively.
+        *
+        * @param string $role
+        */
+       public function removeSlot( $role ) {
+               $this->mSlots->removeSlot( $role );
+               $this->resetAggregateValues();
+       }
+
+       /**
+        * @param CommentStoreComment $comment
+        */
+       public function setComment( CommentStoreComment $comment ) {
+               $this->mComment = $comment;
+       }
+
+       /**
+        * Set revision hash, for optimization. Prevents getSha1() from re-calculating the hash.
+        *
+        * @note This should only be used if the calling code is sure that the given hash is correct
+        * for the revision's content, and there is no chance of the content being manipulated
+        * later. When in doubt, this method should not be called.
+        *
+        * @param string $sha1 SHA1 hash as a base36 string.
+        */
+       public function setSha1( $sha1 ) {
+               Assert::parameterType( 'string', $sha1, '$sha1' );
+
+               $this->mSha1 = $sha1;
+       }
+
+       /**
+        * Set nominal revision size, for optimization. Prevents getSize() from re-calculating the size.
+        *
+        * @note This should only be used if the calling code is sure that the given size is correct
+        * for the revision's content, and there is no chance of the content being manipulated
+        * later. When in doubt, this method should not be called.
+        *
+        * @param int $size nominal size in bogo-bytes
+        */
+       public function setSize( $size ) {
+               Assert::parameterType( 'integer', $size, '$size' );
+
+               $this->mSize = $size;
+       }
+
+       /**
+        * @param int $visibility
+        */
+       public function setVisibility( $visibility ) {
+               Assert::parameterType( 'integer', $visibility, '$visibility' );
+
+               $this->mDeleted = $visibility;
+       }
+
+       /**
+        * @param string $timestamp A timestamp understood by wfTimestamp
+        */
+       public function setTimestamp( $timestamp ) {
+               Assert::parameterType( 'string', $timestamp, '$timestamp' );
+
+               $this->mTimestamp = wfTimestamp( TS_MW, $timestamp );
+       }
+
+       /**
+        * @param bool $minorEdit
+        */
+       public function setMinorEdit( $minorEdit ) {
+               Assert::parameterType( 'boolean', $minorEdit, '$minorEdit' );
+
+               $this->mMinorEdit = $minorEdit;
+       }
+
+       /**
+        * Set the revision ID.
+        *
+        * MCR migration note: this replaces Revision::setId()
+        *
+        * @warning Use this with care, especially when preparing a revision for insertion
+        *          into the database! The revision ID should only be fixed in special cases
+        *          like preserving the original ID when restoring a revision.
+        *
+        * @param int $id
+        */
+       public function setId( $id ) {
+               Assert::parameterType( 'integer', $id, '$id' );
+
+               $this->mId = $id;
+       }
+
+       /**
+        * Sets the user identity associated with the revision
+        *
+        * @param UserIdentity $user
+        */
+       public function setUser( UserIdentity $user ) {
+               $this->mUser = $user;
+       }
+
+       /**
+        * @param int $pageId
+        */
+       public function setPageId( $pageId ) {
+               Assert::parameterType( 'integer', $pageId, '$pageId' );
+
+               if ( $this->mTitle->exists() && $pageId !== $this->mTitle->getArticleID() ) {
+                       throw new InvalidArgumentException(
+                               'The given Title does not belong to page ID ' . $this->mPageId
+                       );
+               }
+
+               $this->mPageId = $pageId;
+       }
+
+       /**
+        * Returns the nominal size of this revision.
+        *
+        * MCR migration note: this replaces Revision::getSize
+        *
+        * @return int The nominal size, may be computed on the fly if not yet known.
+        */
+       public function getSize() {
+               // If not known, re-calculate and remember. Will be reset when slots change.
+               if ( $this->mSize === null ) {
+                       $this->mSize = $this->mSlots->computeSize();
+               }
+
+               return $this->mSize;
+       }
+
+       /**
+        * Returns the base36 sha1 of this revision.
+        *
+        * MCR migration note: this replaces Revision::getSha1
+        *
+        * @return string The revision hash, may be computed on the fly if not yet known.
+        */
+       public function getSha1() {
+               // If not known, re-calculate and remember. Will be reset when slots change.
+               if ( $this->mSha1 === null ) {
+                       $this->mSha1 = $this->mSlots->computeSha1();
+               }
+
+               return $this->mSha1;
+       }
+
+       /**
+        * Invalidate cached aggregate values such as hash and size.
+        */
+       private function resetAggregateValues() {
+               $this->mSize = null;
+               $this->mSha1 = null;
+       }
+
+}
diff --git a/includes/Storage/MutableRevisionSlots.php b/includes/Storage/MutableRevisionSlots.php
new file mode 100644 (file)
index 0000000..2e675c8
--- /dev/null
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Mutable version of RevisionSlots, for constructing a new revision.
+ *
+ * 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\Storage;
+
+use Content;
+
+/**
+ * Mutable version of RevisionSlots, for constructing a new revision.
+ *
+ * @since 1.31
+ */
+class MutableRevisionSlots extends RevisionSlots {
+
+       /**
+        * Constructs a MutableRevisionSlots that inherits from the given
+        * list of slots.
+        *
+        * @param SlotRecord[] $slots
+        *
+        * @return MutableRevisionSlots
+        */
+       public static function newFromParentRevisionSlots( array $slots ) {
+               $inherited = [];
+               foreach ( $slots as $slot ) {
+                       $role = $slot->getRole();
+                       $inherited[$role] = SlotRecord::newInherited( $slot );
+               }
+
+               return new MutableRevisionSlots( $inherited );
+       }
+
+       /**
+        * @param SlotRecord[] $slots An array of SlotRecords.
+        */
+       public function __construct( array $slots = [] ) {
+               parent::__construct( $slots );
+       }
+
+       /**
+        * Sets the given slot.
+        * If a slot with the same role is already present, it is replaced.
+        *
+        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        *
+        * @param SlotRecord $slot
+        */
+       public function setSlot( SlotRecord $slot ) {
+               if ( !is_array( $this->slots ) ) {
+                       $this->getSlots(); // initialize $this->slots
+               }
+
+               $role = $slot->getRole();
+               $this->slots[$role] = $slot;
+       }
+
+       /**
+        * Sets the content for the slot with the given role.
+        * If a slot with the same role is already present, it is replaced.
+        *
+        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        *
+        * @param string $role
+        * @param Content $content
+        */
+       public function setContent( $role, Content $content ) {
+               $slot = SlotRecord::newUnsaved( $role, $content );
+               $this->setSlot( $slot );
+       }
+
+       /**
+        * Remove the slot for the given role, discontinue the corresponding stream.
+        *
+        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        *
+        * @param string $role
+        */
+       public function removeSlot( $role ) {
+               if ( !is_array( $this->slots ) ) {
+                       $this->getSlots();  // initialize $this->slots
+               }
+
+               unset( $this->slots[$role] );
+       }
+
+       /**
+        * Return all slots that are not inherited.
+        *
+        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        *
+        * @return SlotRecord[]
+        */
+       public function getTouchedSlots() {
+               return array_filter(
+                       $this->getSlots(),
+                       function ( SlotRecord $slot ) {
+                               return !$slot->isInherited();
+                       }
+               );
+       }
+
+       /**
+        * Return all slots that are inherited.
+        *
+        * @note This may cause the slot meta-data for the revision to be lazy-loaded.
+        *
+        * @return SlotRecord[]
+        */
+       public function getInheritedSlots() {
+               return array_filter(
+                       $this->getSlots(),
+                       function ( SlotRecord $slot ) {
+                               return $slot->isInherited();
+                       }
+               );
+       }
+
+}
diff --git a/includes/Storage/RevisionAccessException.php b/includes/Storage/RevisionAccessException.php
new file mode 100644 (file)
index 0000000..ee6efc0
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+/**
+ * Exception representing a failure to look up a revision.
+ *
+ * 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\Storage;
+
+use RuntimeException;
+
+/**
+ * Exception representing a failure to look up a revision.
+ *
+ * @since 1.31
+ */
+class RevisionAccessException extends RuntimeException {
+
+}
diff --git a/includes/Storage/RevisionArchiveRecord.php b/includes/Storage/RevisionArchiveRecord.php
new file mode 100644 (file)
index 0000000..419cb95
--- /dev/null
@@ -0,0 +1,165 @@
+<?php
+/**
+ * A RevisionRecord representing a revision of a deleted page persisted in the archive table.
+ *
+ * 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\Storage;
+
+use CommentStoreComment;
+use MediaWiki\User\UserIdentity;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * A RevisionRecord representing a revision of a deleted page persisted in the archive table.
+ * Most getters on RevisionArchiveRecord will never return null. However, getId() and
+ * getParentId() may indeed return null if this information was not stored when the archive entry
+ * was created.
+ *
+ * @since 1.31
+ */
+class RevisionArchiveRecord extends RevisionRecord {
+
+       /**
+        * @var int
+        */
+       protected $mArchiveId;
+
+       /**
+        * @note Avoid calling this constructor directly. Use the appropriate methods
+        * in RevisionStore instead.
+        *
+        * @param Title $title The title of the page this Revision is associated with.
+        * @param UserIdentity $user
+        * @param CommentStoreComment $comment
+        * @param object $row An archive table row. Use RevisionStore::getArchiveQueryInfo() to build
+        *        a query that yields the required fields.
+        * @param RevisionSlots $slots The slots of this revision.
+        * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
+        *        or false for the local site.
+        */
+       function __construct(
+               Title $title,
+               UserIdentity $user,
+               CommentStoreComment $comment,
+               $row,
+               RevisionSlots $slots,
+               $wikiId = false
+       ) {
+               parent::__construct( $title, $slots, $wikiId );
+               Assert::parameterType( 'object', $row, '$row' );
+
+               $this->mArchiveId = intval( $row->ar_id );
+
+               // NOTE: ar_page_id may be different from $this->mTitle->getArticleID() in some cases,
+               // notably when a partially restored page has been moved, and a new page has been created
+               // with the same title. Archive rows for that title will then have the wrong page id.
+               $this->mPageId = isset( $row->ar_page_id ) ? intval( $row->ar_page_id ) : $title->getArticleID();
+
+               // NOTE: ar_parent_id = 0 indicates that there is no parent revision, while null
+               // indicates that the parent revision is unknown. As per MW 1.31, the database schema
+               // allows ar_parent_id to be NULL.
+               $this->mParentId = isset( $row->ar_parent_id ) ? intval( $row->ar_parent_id ) : null;
+               $this->mId = isset( $row->ar_rev_id ) ? intval( $row->ar_rev_id ) : null;
+               $this->mComment = $comment;
+               $this->mUser = $user;
+               $this->mTimestamp = wfTimestamp( TS_MW, $row->ar_timestamp );
+               $this->mMinorEdit = boolval( $row->ar_minor_edit );
+               $this->mDeleted = intval( $row->ar_deleted );
+               $this->mSize = intval( $row->ar_len );
+               $this->mSha1 = isset( $row->ar_sha1 ) ? $row->ar_sha1 : null;
+       }
+
+       /**
+        * Get archive row ID
+        *
+        * @return int
+        */
+       public function getArchiveId() {
+               return $this->mId;
+       }
+
+       /**
+        * @return int|null The revision id, or null if the original revision ID
+        *         was not recorded in the archive table.
+        */
+       public function getId() {
+               // overwritten just to refine the contract specification.
+               return parent::getId();
+       }
+
+       /**
+        * @return int The nominal revision size, never null. May be computed on the fly.
+        */
+       public function getSize() {
+               // If length is null, calculate and remember it (potentially SLOW!).
+               // This is for compatibility with old database rows that don't have the field set.
+               if ( $this->mSize === null ) {
+                       $this->mSize = $this->mSlots->computeSize();
+               }
+
+               return $this->mSize;
+       }
+
+       /**
+        * @return string The revision hash, never null. May be computed on the fly.
+        */
+       public function getSha1() {
+               // If hash is null, calculate it and remember (potentially SLOW!)
+               // This is for compatibility with old database rows that don't have the field set.
+               if ( $this->mSha1 === null ) {
+                       $this->mSha1 = $this->mSlots->computeSha1();
+               }
+
+               return $this->mSha1;
+       }
+
+       /**
+        * @param int $audience
+        * @param User|null $user
+        *
+        * @return UserIdentity The identity of the revision author, null if access is forbidden.
+        */
+       public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
+               // overwritten just to add a guarantee to the contract
+               return parent::getUser( $audience, $user );
+       }
+
+       /**
+        * @param int $audience
+        * @param User|null $user
+        *
+        * @return CommentStoreComment The revision comment, null if access is forbidden.
+        */
+       public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
+               // overwritten just to add a guarantee to the contract
+               return parent::getComment( $audience, $user );
+       }
+
+       /**
+        * @return string never null
+        */
+       public function getTimestamp() {
+               // overwritten just to add a guarantee to the contract
+               return parent::getTimestamp();
+       }
+
+}
diff --git a/includes/Storage/RevisionFactory.php b/includes/Storage/RevisionFactory.php
new file mode 100644 (file)
index 0000000..86e8c06
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+/**
+ * Service for constructing revision objects.
+ *
+ * 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\Storage;
+
+use MWException;
+use Title;
+
+/**
+ * Service for constructing revision objects.
+ *
+ * @since 1.31
+ *
+ * @note This was written to act as a drop-in replacement for the corresponding
+ *       static methods in Revision.
+ */
+interface RevisionFactory {
+
+       /**
+        * Constructs a new RevisionRecord based on the given associative array following the MW1.29
+        * database convention for the Revision constructor.
+        *
+        * MCR migration note: this replaces Revision::newFromRow
+        *
+        * @deprecated since 1.31. Use a MutableRevisionRecord instead.
+        *
+        * @param array $fields
+        * @param int $queryFlags Flags for lazy loading behavior, see IDBAccessObject::READ_XXX.
+        * @param Title|null $title
+        *
+        * @return MutableRevisionRecord
+        * @throws MWException
+        */
+       public function newMutableRevisionFromArray( array $fields, $queryFlags = 0, Title $title = null );
+
+       /**
+        * Constructs a RevisionRecord given a database row and content slots.
+        *
+        * MCR migration note: this replaces Revision::newFromRow for rows based on the
+        * revision, slot, and content tables defined for MCR since MW1.31.
+        *
+        * @param object $row A query result row as a raw object.
+        *        Use RevisionStore::getQueryInfo() to build a query that yields the required fields.
+        * @param int $queryFlags Flags for lazy loading behavior, see IDBAccessObject::READ_XXX.
+        * @param Title|null $title
+        *
+        * @return RevisionRecord
+        */
+       public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null );
+
+       /**
+        * Make a fake revision object from an archive table row. This is queried
+        * for permissions or even inserted (as in Special:Undelete)
+        *
+        * MCR migration note: this replaces Revision::newFromArchiveRow
+        *
+        * @param object $row A query result row as a raw object.
+        *        Use RevisionStore::getArchiveQueryInfo() to build a query that yields the
+        *        required fields.
+        * @param int $queryFlags Flags for lazy loading behavior, see IDBAccessObject::READ_XXX.
+        * @param Title $title
+        * @param array $overrides An associative array that allows fields in $row to be overwritten.
+        *        Keys in this array correspond to field names in $row without the "ar_" prefix, so
+        *        $overrides['user'] will override $row->ar_user, etc.
+        *
+        * @return RevisionRecord
+        */
+       public function newRevisionFromArchiveRow(
+               $row,
+               $queryFlags = 0,
+               Title $title = null,
+               array $overrides = []
+       );
+
+}
diff --git a/includes/Storage/RevisionLookup.php b/includes/Storage/RevisionLookup.php
new file mode 100644 (file)
index 0000000..5cd157b
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+/**
+ *  Service for looking up page revisions.
+ *
+ * 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\Storage;
+
+use \IDBAccessObject;
+use MediaWiki\Linker\LinkTarget;
+use Title;
+
+/**
+ * Service for looking up page revisions.
+ *
+ * @note This was written to act as a drop-in replacement for the corresponding
+ *       static methods in Revision.
+ *
+ * @since 1.31
+ */
+interface RevisionLookup extends IDBAccessObject {
+
+       /**
+        * Load a page revision from a given revision ID number.
+        * Returns null if no such revision can be found.
+        *
+        * MCR migration note: this replaces Revision::newFromId
+        *
+        * $flags include:
+        *
+        * @param int $id
+        * @param int $flags bit field, see IDBAccessObject::READ_XXX
+        * @return RevisionRecord|null
+        */
+       public function getRevisionById( $id, $flags = 0 );
+
+       /**
+        * Load either the current, or a specified, revision
+        * that's attached to a given link target. If not attached
+        * to that link target, will return null.
+        *
+        * MCR migration note: this replaces Revision::newFromTitle
+        *
+        * @param LinkTarget $linkTarget
+        * @param int $revId (optional)
+        * @param int $flags bit field, see IDBAccessObject::READ_XXX
+        * @return RevisionRecord|null
+        */
+       public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 );
+
+       /**
+        * Load either the current, or a specified, revision
+        * that's attached to a given page ID.
+        * Returns null if no such revision can be found.
+        *
+        * MCR migration note: this replaces Revision::newFromPageId
+        *
+        * @param int $pageId
+        * @param int $revId (optional)
+        * @param int $flags bit field, see IDBAccessObject::READ_XXX
+        * @return RevisionRecord|null
+        */
+       public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 );
+
+       /**
+        * Get previous revision for this title
+        *
+        * MCR migration note: this replaces Revision::getPrevious
+        *
+        * @param RevisionRecord $rev
+        *
+        * @return RevisionRecord|null
+        */
+       public function getPreviousRevision( RevisionRecord $rev );
+
+       /**
+        * Get next revision for this title
+        *
+        * MCR migration note: this replaces Revision::getNext
+        *
+        * @param RevisionRecord $rev
+        *
+        * @return RevisionRecord|null
+        */
+       public function getNextRevision( RevisionRecord $rev );
+
+       /**
+        * Load a revision based on a known page ID and current revision ID from the DB
+        *
+        * This method allows for the use of caching, though accessing anything that normally
+        * requires permission checks (aside from the text) will trigger a small DB lookup.
+        *
+        * MCR migration note: this replaces Revision::newKnownCurrent
+        *
+        * @param Title $title the associated page title
+        * @param int $revId current revision of this page
+        *
+        * @return RevisionRecord|bool Returns false if missing
+        */
+       public function getKnownCurrentRevision( Title $title, $revId );
+
+}
diff --git a/includes/Storage/RevisionRecord.php b/includes/Storage/RevisionRecord.php
new file mode 100644 (file)
index 0000000..f490f9b
--- /dev/null
@@ -0,0 +1,479 @@
+<?php
+/**
+ * Page revision base class.
+ *
+ * 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\Storage;
+
+use CommentStoreComment;
+use Content;
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\User\UserIdentity;
+use MWException;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Page revision base class.
+ *
+ * RevisionRecords are considered value objects, but they may use callbacks for lazy loading.
+ * Note that while the base class has no setters, subclasses may offer a mutable interface.
+ *
+ * @since 1.31
+ */
+abstract class RevisionRecord {
+
+       // RevisionRecord deletion constants
+       const DELETED_TEXT = 1;
+       const DELETED_COMMENT = 2;
+       const DELETED_USER = 4;
+       const DELETED_RESTRICTED = 8;
+       const SUPPRESSED_USER = 12; // convenience
+       const SUPPRESSED_ALL = 15; // convenience
+
+       // Audience options for accessors
+       const FOR_PUBLIC = 1;
+       const FOR_THIS_USER = 2;
+       const RAW = 3;
+
+       /** @var string Wiki ID; false means the current wiki */
+       protected $mWiki = false;
+       /** @var int|null */
+       protected $mId;
+       /** @var int|null */
+       protected $mPageId;
+       /** @var UserIdentity|null */
+       protected $mUser;
+       /** @var bool */
+       protected $mMinorEdit = false;
+       /** @var string|null */
+       protected $mTimestamp;
+       /** @var int using the DELETED_XXX and SUPPRESSED_XXX flags */
+       protected $mDeleted = 0;
+       /** @var int|null */
+       protected $mSize;
+       /** @var string|null */
+       protected $mSha1;
+       /** @var int|null */
+       protected $mParentId;
+       /** @var CommentStoreComment|null */
+       protected $mComment;
+
+       /**  @var Title */
+       protected $mTitle; // TODO: we only need the title for permission checks!
+
+       /** @var RevisionSlots */
+       protected $mSlots;
+
+       /**
+        * @note Avoid calling this constructor directly. Use the appropriate methods
+        * in RevisionStore instead.
+        *
+        * @param Title $title The title of the page this Revision is associated with.
+        * @param RevisionSlots $slots The slots of this revision.
+        * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
+        *        or false for the local site.
+        *
+        * @throws MWException
+        */
+       function __construct( Title $title, RevisionSlots $slots, $wikiId = false ) {
+               Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
+
+               $this->mTitle = $title;
+               $this->mSlots = $slots;
+               $this->mWiki = $wikiId;
+
+               // XXX: this is a sensible default, but we may not have a Title object here in the future.
+               $this->mPageId = $title->getArticleID();
+       }
+
+       /**
+        * Implemented to defy serialization.
+        *
+        * @throws LogicException always
+        */
+       public function __sleep() {
+               throw new LogicException( __CLASS__ . ' is not serializable.' );
+       }
+
+       /**
+        * @param RevisionRecord $rec
+        *
+        * @return bool True if this RevisionRecord is known to have same content as $rec.
+        *         False if the content is different (or not known to be the same).
+        */
+       public function hasSameContent( RevisionRecord $rec ) {
+               if ( $rec === $this ) {
+                       return true;
+               }
+
+               if ( $this->getId() !== null && $this->getId() === $rec->getId() ) {
+                       return true;
+               }
+
+               // check size before hash, since size is quicker to compute
+               if ( $this->getSize() !== $rec->getSize() ) {
+                       return false;
+               }
+
+               // instead of checking the hash, we could also check the content addresses of all slots.
+
+               if ( $this->getSha1() === $rec->getSha1() ) {
+                       return true;
+               }
+
+               return false;
+       }
+
+       /**
+        * Returns the Content of the given slot of this revision.
+        * Call getSlotNames() to get a list of available slots.
+        *
+        * Note that for mutable Content objects, each call to this method will return a
+        * fresh clone.
+        *
+        * MCR migration note: this replaces Revision::getContent
+        *
+        * @param string $role The role name of the desired slot
+        * @param int $audience
+        * @param User|null $user
+        *
+        * @throws RevisionAccessException if the slot does not exist or slot data
+        *        could not be lazy-loaded.
+        * @return Content|null The content of the given slot, or null if access is forbidden.
+        */
+       public function getContent( $role, $audience = self::FOR_PUBLIC, User $user = null ) {
+               // XXX: throwing an exception would be nicer, but would a further
+               // departure from the signature of Revision::getContent(), and thus
+               // more complex and error prone refactoring.
+               if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) {
+                       return null;
+               }
+
+               $content = $this->getSlot( $role, $audience, $user )->getContent();
+               return $content->copy();
+       }
+
+       /**
+        * Returns meta-data for the given slot.
+        *
+        * @param string $role The role name of the desired slot
+        * @param int $audience
+        * @param User|null $user
+        *
+        * @throws RevisionAccessException if the slot does not exist or slot data
+        *        could not be lazy-loaded.
+        * @return SlotRecord The slot meta-data. If access to the slot content is forbidden,
+        *         calling getContent() on the SlotRecord will throw an exception.
+        */
+       public function getSlot( $role, $audience = self::FOR_PUBLIC, User $user = null ) {
+               $slot = $this->mSlots->getSlot( $role );
+
+               if ( !$this->audienceCan( self::DELETED_TEXT, $audience, $user ) ) {
+                       return SlotRecord::newWithSuppressedContent( $slot );
+               }
+
+               return $slot;
+       }
+
+       /**
+        * Returns the slot names (roles) of all slots present in this revision.
+        * getContent() will succeed only for the names returned by this method.
+        *
+        * @return string[]
+        */
+       public function getSlotRoles() {
+               return $this->mSlots->getSlotRoles();
+       }
+
+       /**
+        * Get revision ID. Depending on the concrete subclass, this may return null if
+        * the revision ID is not known (e.g. because the revision does not yet exist
+        * in the database).
+        *
+        * MCR migration note: this replaces Revision::getId
+        *
+        * @return int|null
+        */
+       public function getId() {
+               return $this->mId;
+       }
+
+       /**
+        * Get parent revision ID (the original previous page revision).
+        * If there is no parent revision, this returns 0.
+        * If the parent revision is undefined or unknown, this returns null.
+        *
+        * @note As of MW 1.31, the database schema allows the parent ID to be
+        * NULL to indicate that it is unknown.
+        *
+        * MCR migration note: this replaces Revision::getParentId
+        *
+        * @return int|null
+        */
+       public function getParentId() {
+               return $this->mParentId;
+       }
+
+       /**
+        * Returns the nominal size of this revision, in bogo-bytes.
+        * May be calculated on the fly if not known, which may in the worst
+        * case may involve loading all content.
+        *
+        * MCR migration note: this replaces Revision::getSize
+        *
+        * @return int
+        */
+       abstract public function getSize();
+
+       /**
+        * Returns the base36 sha1 of this revision. This hash is derived from the
+        * hashes of all slots associated with the revision.
+        * May be calculated on the fly if not known, which may in the worst
+        * case may involve loading all content.
+        *
+        * MCR migration note: this replaces Revision::getSha1
+        *
+        * @return string
+        */
+       abstract public function getSha1();
+
+       /**
+        * Get the page ID. If the page does not yet exist, the page ID is 0.
+        *
+        * MCR migration note: this replaces Revision::getPage
+        *
+        * @return int
+        */
+       public function getPageId() {
+               return $this->mPageId;
+       }
+
+       /**
+        * Get the ID of the wiki this revision belongs to.
+        *
+        * @return string|false The wiki's logical name, of false to indicate the local wiki.
+        */
+       public function getWikiId() {
+               return $this->mWiki;
+       }
+
+       /**
+        * Returns the title of the page this revision is associated with as a LinkTarget object.
+        *
+        * MCR migration note: this replaces Revision::getTitle
+        *
+        * @return LinkTarget
+        */
+       public function getPageAsLinkTarget() {
+               return $this->mTitle;
+       }
+
+       /**
+        * Fetch revision's author's user identity, if it's available to the specified audience.
+        * If the specified audience does not have access to it, null will be
+        * returned. Depending on the concrete subclass, null may also be returned if the user is
+        * not yet specified.
+        *
+        * MCR migration note: this replaces Revision::getUser
+        *
+        * @param int $audience One of:
+        *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
+        *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
+        *   RevisionRecord::RAW              get the ID regardless of permissions
+        * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
+        *   to the $audience parameter
+        * @return UserIdentity|null
+        */
+       public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
+               if ( !$this->audienceCan( self::DELETED_USER, $audience, $user ) ) {
+                       return null;
+               } else {
+                       return $this->mUser;
+               }
+       }
+
+       /**
+        * Fetch revision comment, if it's available to the specified audience.
+        * If the specified audience does not have access to the comment,
+        * this will return null. Depending on the concrete subclass, null may also be returned
+        * if the comment is not yet specified.
+        *
+        * MCR migration note: this replaces Revision::getComment
+        *
+        * @param int $audience One of:
+        *   RevisionRecord::FOR_PUBLIC       to be displayed to all users
+        *   RevisionRecord::FOR_THIS_USER    to be displayed to the given user
+        *   RevisionRecord::RAW              get the text regardless of permissions
+        * @param User|null $user User object to check for, only if FOR_THIS_USER is passed
+        *   to the $audience parameter
+        *
+        * @return CommentStoreComment|null
+        */
+       public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
+               if ( !$this->audienceCan( self::DELETED_COMMENT, $audience, $user ) ) {
+                       return null;
+               } else {
+                       return $this->mComment;
+               }
+       }
+
+       /**
+        * MCR migration note: this replaces Revision::isMinor
+        *
+        * @return bool
+        */
+       public function isMinor() {
+               return (bool)$this->mMinorEdit;
+       }
+
+       /**
+        * MCR migration note: this replaces Revision::isDeleted
+        *
+        * @param int $field One of DELETED_* bitfield constants
+        *
+        * @return bool
+        */
+       public function isDeleted( $field ) {
+               return ( $this->getVisibility() & $field ) == $field;
+       }
+
+       /**
+        * Get the deletion bitfield of the revision
+        *
+        * MCR migration note: this replaces Revision::getVisibility
+        *
+        * @return int
+        */
+       public function getVisibility() {
+               return (int)$this->mDeleted;
+       }
+
+       /**
+        * MCR migration note: this replaces Revision::getTimestamp.
+        *
+        * May return null if the timestamp was not specified.
+        *
+        * @return string|null
+        */
+       public function getTimestamp() {
+               return $this->mTimestamp;
+       }
+
+       /**
+        * Check that the given audience has access to the given field.
+        *
+        * MCR migration note: this corresponds to Revision::userCan
+        *
+        * @param int $field One of self::DELETED_TEXT,
+        *        self::DELETED_COMMENT,
+        *        self::DELETED_USER
+        * @param int $audience One of:
+        *        RevisionRecord::FOR_PUBLIC       to be displayed to all users
+        *        RevisionRecord::FOR_THIS_USER    to be displayed to the given user
+        *        RevisionRecord::RAW              get the text regardless of permissions
+        * @param User|null $user User object to check. Required if $audience is FOR_THIS_USER,
+        *        ignored otherwise.
+        *
+        * @return bool
+        */
+       protected function audienceCan( $field, $audience, User $user = null ) {
+               if ( $audience == self::FOR_PUBLIC && $this->isDeleted( $field ) ) {
+                       return false;
+               } elseif ( $audience == self::FOR_THIS_USER ) {
+                       if ( !$user ) {
+                               throw new InvalidArgumentException(
+                                       'A User object must be given when checking FOR_THIS_USER audience.'
+                               );
+                       }
+
+                       if ( !$this->userCan( $field, $user ) ) {
+                               return false;
+                       }
+               }
+
+               return true;
+       }
+
+       /**
+        * Determine if the current user is allowed to view a particular
+        * field of this revision, if it's marked as deleted.
+        *
+        * MCR migration note: this corresponds to Revision::userCan
+        *
+        * @param int $field One of self::DELETED_TEXT,
+        *                              self::DELETED_COMMENT,
+        *                              self::DELETED_USER
+        * @param User $user User object to check
+        * @return bool
+        */
+       protected function userCan( $field, User $user ) {
+               // TODO: use callback for permission checks, so we don't need to know a Title object!
+               return self::userCanBitfield( $this->getVisibility(), $field, $user, $this->mTitle );
+       }
+
+       /**
+        * Determine if the current user is allowed to view a particular
+        * field of this revision, if it's marked as deleted. This is used
+        * by various classes to avoid duplication.
+        *
+        * MCR migration note: this replaces Revision::userCanBitfield
+        *
+        * @param int $bitfield Current field
+        * @param int $field One of self::DELETED_TEXT = File::DELETED_FILE,
+        *                               self::DELETED_COMMENT = File::DELETED_COMMENT,
+        *                               self::DELETED_USER = File::DELETED_USER
+        * @param User $user User object to check
+        * @param Title|null $title A Title object to check for per-page restrictions on,
+        *                          instead of just plain userrights
+        * @return bool
+        */
+       public static function userCanBitfield( $bitfield, $field, User $user, Title $title = null ) {
+               if ( $bitfield & $field ) { // aspect is deleted
+                       if ( $bitfield & self::DELETED_RESTRICTED ) {
+                               $permissions = [ 'suppressrevision', 'viewsuppressed' ];
+                       } elseif ( $field & self::DELETED_TEXT ) {
+                               $permissions = [ 'deletedtext' ];
+                       } else {
+                               $permissions = [ 'deletedhistory' ];
+                       }
+                       $permissionlist = implode( ', ', $permissions );
+                       if ( $title === null ) {
+                               wfDebug( "Checking for $permissionlist due to $field match on $bitfield\n" );
+                               return call_user_func_array( [ $user, 'isAllowedAny' ], $permissions );
+                       } else {
+                               $text = $title->getPrefixedText();
+                               wfDebug( "Checking for $permissionlist on $text due to $field match on $bitfield\n" );
+                               foreach ( $permissions as $perm ) {
+                                       if ( $title->userCan( $perm, $user ) ) {
+                                               return true;
+                                       }
+                               }
+                               return false;
+                       }
+               } else {
+                       return true;
+               }
+       }
+
+}
diff --git a/includes/Storage/RevisionSlots.php b/includes/Storage/RevisionSlots.php
new file mode 100644 (file)
index 0000000..8d3d7e3
--- /dev/null
@@ -0,0 +1,189 @@
+<?php
+/**
+ * Value object representing the set of slots belonging to a revision.
+ *
+ * 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\Storage;
+
+use Content;
+use LogicException;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Value object representing the set of slots belonging to a revision.
+ *
+ * @since 1.31
+ */
+class RevisionSlots {
+
+       /** @var SlotRecord[]|callable */
+       protected $slots;
+
+       /**
+        * @param SlotRecord[]|callable $slots SlotRecords,
+        *        or a callback that returns such a structure.
+        */
+       public function __construct( $slots ) {
+               Assert::parameterType( 'array|callable', $slots, '$slots' );
+
+               if ( is_callable( $slots ) ) {
+                       $this->slots = $slots;
+               } else {
+                       $this->setSlotsInternal( $slots );
+               }
+       }
+
+       /**
+        * @param SlotRecord[] $slots
+        */
+       private function setSlotsInternal( array $slots ) {
+               $this->slots = [];
+
+               // re-key the slot array
+               foreach ( $slots as $slot ) {
+                       $role = $slot->getRole();
+                       $this->slots[$role] = $slot;
+               }
+       }
+
+       /**
+        * Implemented to defy serialization.
+        *
+        * @throws LogicException always
+        */
+       public function __sleep() {
+               throw new LogicException( __CLASS__ . ' is not serializable.' );
+       }
+
+       /**
+        * Returns the Content of the given slot.
+        * Call getSlotNames() to get a list of available slots.
+        *
+        * Note that for mutable Content objects, each call to this method will return a
+        * fresh clone.
+        *
+        * @param string $role The role name of the desired slot
+        *
+        * @throws RevisionAccessException if the slot does not exist or slot data
+        *        could not be lazy-loaded.
+        * @return Content
+        */
+       public function getContent( $role ) {
+               // Return a copy to be safe. Immutable content objects return $this from copy().
+               return $this->getSlot( $role )->getContent()->copy();
+       }
+
+       /**
+        * Returns the SlotRecord of the given slot.
+        * Call getSlotNames() to get a list of available slots.
+        *
+        * @param string $role The role name of the desired slot
+        *
+        * @throws RevisionAccessException if the slot does not exist or slot data
+        *        could not be lazy-loaded.
+        * @return SlotRecord
+        */
+       public function getSlot( $role ) {
+               $slots = $this->getSlots();
+
+               if ( isset( $slots[$role] ) ) {
+                       return $slots[$role];
+               } else {
+                       throw new RevisionAccessException( 'No such slot: ' . $role );
+               }
+       }
+
+       /**
+        * Returns the slot names (roles) of all slots present in this revision.
+        * getContent() will succeed only for the names returned by this method.
+        *
+        * @return string[]
+        */
+       public function getSlotRoles() {
+               $slots = $this->getSlots();
+               return array_keys( $slots );
+       }
+
+       /**
+        * Computes the total nominal size of the revision's slots, in bogo-bytes.
+        *
+        * @warn This is potentially expensive! It may cause all slot's content to be loaded
+        * and deserialized.
+        *
+        * @return int
+        */
+       public function computeSize() {
+               return array_reduce( $this->getSlots(), function ( $accu, SlotRecord $slot ) {
+                       return $accu + $slot->getSize();
+               }, 0 );
+       }
+
+       /**
+        * Returns an associative array that maps role names to SlotRecords. Each SlotRecord
+        * represents the content meta-data of a slot, together they define the content of
+        * a revision.
+        *
+        * @note This may cause the content meta-data for the revision to be lazy-loaded.
+        *
+        * @return SlotRecord[] revision slot/content rows, keyed by slot role name.
+        */
+       public function getSlots() {
+               if ( is_callable( $this->slots ) ) {
+                       $slots = call_user_func( $this->slots );
+
+                       Assert::postcondition(
+                               is_array( $slots ),
+                               'Slots info callback should return an array of objects'
+                       );
+
+                       $this->setSlotsInternal( $slots );
+               }
+
+               return $this->slots;
+       }
+
+       /**
+        * Computes the combined hash of the revisions's slots.
+        *
+        * @note For backwards compatibility, the combined hash of a single slot
+        * is that slot's hash. For consistency, the combined hash of an empty set of slots
+        * is the hash of the empty string.
+        *
+        * @warn This is potentially expensive! It may cause all slot's content to be loaded
+        * and deserialized, then re-serialized and hashed.
+        *
+        * @return string
+        */
+       public function computeSha1() {
+               $slots = $this->getSlots();
+               ksort( $slots );
+
+               if ( empty( $slots ) ) {
+                       return SlotRecord::base36Sha1( '' );
+               }
+
+               return array_reduce( $slots, function ( $accu, SlotRecord $slot ) {
+                       return $accu === null
+                               ? $slot->getSha1()
+                               : SlotRecord::base36Sha1( $accu . $slot->getSha1() );
+               }, null );
+       }
+
+}
diff --git a/includes/Storage/RevisionStore.php b/includes/Storage/RevisionStore.php
new file mode 100644 (file)
index 0000000..b8debb8
--- /dev/null
@@ -0,0 +1,1914 @@
+<?php
+/**
+ * Service for looking up page revisions.
+ *
+ * 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
+ *
+ * Attribution notice: when this file was created, much of its content was taken
+ * from the Revision.php file as present in release 1.30. Refer to the history
+ * of that file for original authorship.
+ *
+ * @file
+ */
+
+namespace MediaWiki\Storage;
+
+use CommentStore;
+use CommentStoreComment;
+use Content;
+use ContentHandler;
+use DBAccessObjectUtils;
+use Hooks;
+use \IDBAccessObject;
+use InvalidArgumentException;
+use IP;
+use LogicException;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserIdentityValue;
+use Message;
+use MWException;
+use MWUnknownContentModelException;
+use RecentChange;
+use stdClass;
+use Title;
+use User;
+use WANObjectCache;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\Database;
+use Wikimedia\Rdbms\DBConnRef;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Service for looking up page revisions.
+ *
+ * @since 1.31
+ *
+ * @note This was written to act as a drop-in replacement for the corresponding
+ *       static methods in Revision.
+ */
+class RevisionStore implements IDBAccessObject, RevisionFactory, RevisionLookup {
+
+       /**
+        * @var SqlBlobStore
+        */
+       private $blobStore;
+
+       /**
+        * @var bool|string
+        */
+       private $wikiId;
+
+       /**
+        * @var boolean
+        */
+       private $contentHandlerUseDB = true;
+
+       /**
+        * @var LoadBalancer
+        */
+       private $loadBalancer;
+
+       /**
+        * @var WANObjectCache
+        */
+       private $cache;
+
+       /**
+        * @todo $blobStore should be allowed to be any BlobStore!
+        *
+        * @param LoadBalancer $loadBalancer
+        * @param SqlBlobStore $blobStore
+        * @param WANObjectCache $cache
+        * @param bool|string $wikiId
+        */
+       public function __construct(
+               LoadBalancer $loadBalancer,
+               SqlBlobStore $blobStore,
+               WANObjectCache $cache,
+               $wikiId = false
+       ) {
+               Assert::parameterType( 'string|boolean', $wikiId, '$wikiId' );
+
+               $this->loadBalancer = $loadBalancer;
+               $this->blobStore = $blobStore;
+               $this->cache = $cache;
+               $this->wikiId = $wikiId;
+       }
+
+       /**
+        * @return bool
+        */
+       public function getContentHandlerUseDB() {
+               return $this->contentHandlerUseDB;
+       }
+
+       /**
+        * @param bool $contentHandlerUseDB
+        */
+       public function setContentHandlerUseDB( $contentHandlerUseDB ) {
+               $this->contentHandlerUseDB = $contentHandlerUseDB;
+       }
+
+       /**
+        * @return LoadBalancer
+        */
+       private function getDBLoadBalancer() {
+               return $this->loadBalancer;
+       }
+
+       /**
+        * @param int $mode DB_MASTER or DB_REPLICA
+        *
+        * @return IDatabase
+        */
+       private function getDBConnection( $mode ) {
+               $lb = $this->getDBLoadBalancer();
+               return $lb->getConnection( $mode, [], $this->wikiId );
+       }
+
+       /**
+        * @param IDatabase $connection
+        */
+       private function releaseDBConnection( IDatabase $connection ) {
+               $lb = $this->getDBLoadBalancer();
+               $lb->reuseConnection( $connection );
+       }
+
+       /**
+        * @param int $mode DB_MASTER or DB_REPLICA
+        *
+        * @return DBConnRef
+        */
+       private function getDBConnectionRef( $mode ) {
+               $lb = $this->getDBLoadBalancer();
+               return $lb->getConnectionRef( $mode, [], $this->wikiId );
+       }
+
+       /**
+        * Determines the page Title based on the available information.
+        *
+        * MCR migration note: this corresponds to Revision::getTitle
+        *
+        * @param int|null $pageId
+        * @param int|null $revId
+        * @param int $queryFlags
+        *
+        * @return Title
+        * @throws RevisionAccessException
+        */
+       private function getTitle( $pageId, $revId, $queryFlags = 0 ) {
+               if ( !$pageId && !$revId ) {
+                       throw new InvalidArgumentException( '$pageId and $revId cannot both be 0 or null' );
+               }
+
+               $title = null;
+
+               // Loading by ID is best, but Title::newFromID does not support that for foreign IDs.
+               if ( $pageId !== null && $pageId > 0 && $this->wikiId === false ) {
+                       // TODO: better foreign title handling (introduce TitleFactory)
+                       $title = Title::newFromID( $pageId, $queryFlags );
+               }
+
+               // rev_id is defined as NOT NULL, but this revision may not yet have been inserted.
+               if ( !$title && $revId !== null && $revId > 0 ) {
+                       list( $dbMode, $dbOptions, , ) = DBAccessObjectUtils::getDBOptions( $queryFlags );
+
+                       $dbr = $this->getDbConnectionRef( $dbMode );
+                       // @todo: Title::getSelectFields(), or Title::getQueryInfo(), or something like that
+                       $row = $dbr->selectRow(
+                               [ 'revision', 'page' ],
+                               [
+                                       'page_namespace',
+                                       'page_title',
+                                       'page_id',
+                                       'page_latest',
+                                       'page_is_redirect',
+                                       'page_len',
+                               ],
+                               [ 'rev_id' => $revId ],
+                               __METHOD__,
+                               $dbOptions,
+                               [ 'page' => [ 'JOIN', 'page_id=rev_page' ] ]
+                       );
+                       if ( $row ) {
+                               // TODO: better foreign title handling (introduce TitleFactory)
+                               $title = Title::newFromRow( $row );
+                       }
+               }
+
+               if ( !$title ) {
+                       throw new RevisionAccessException(
+                               "Could not determine title for page ID $pageId and revision ID $revId"
+                       );
+               }
+
+               return $title;
+       }
+
+       /**
+        * @param mixed $value
+        * @param string $name
+        *
+        * @throw IncompleteRevisionException if $value is null
+        * @return mixed $value, if $value is not null
+        */
+       private function failOnNull( $value, $name ) {
+               if ( $value === null ) {
+                       throw new IncompleteRevisionException(
+                               "$name must not be " . var_export( $value, true ) . "!"
+                       );
+               }
+
+               return $value;
+       }
+
+       /**
+        * @param mixed $value
+        * @param string $name
+        *
+        * @throw IncompleteRevisionException if $value is empty
+        * @return mixed $value, if $value is not null
+        */
+       private function failOnEmpty( $value, $name ) {
+               if ( $value === null || $value === 0 || $value === '' ) {
+                       throw new IncompleteRevisionException(
+                               "$name must not be " . var_export( $value, true ) . "!"
+                       );
+               }
+
+               return $value;
+       }
+
+       /**
+        * Insert a new revision into the database, returning the new revision ID
+        * number on success and dies horribly on failure.
+        *
+        * MCR migration note: this replaces Revision::insertOn
+        *
+        * @param RevisionRecord $rev
+        * @param IDatabase $dbw (master connection)
+        *
+        * @throws InvalidArgumentException
+        * @return RevisionRecord the new revision record.
+        */
+       public function insertRevisionOn( RevisionRecord $rev, IDatabase $dbw ) {
+               // TODO: pass in a DBTransactionContext instead of a database connection.
+               $this->checkDatabaseWikiId( $dbw );
+
+               if ( !$rev->getSlotRoles() ) {
+                       throw new InvalidArgumentException( 'At least one slot needs to be defined!' );
+               }
+
+               if ( $rev->getSlotRoles() !== [ 'main' ] ) {
+                       throw new InvalidArgumentException( 'Only the main slot is supported for now!' );
+               }
+
+               // TODO: we shouldn't need an actual Title here.
+               $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
+               $pageId = $this->failOnEmpty( $rev->getPageId(), 'rev_page field' ); // check this early
+
+               $parentId = $rev->getParentId() === null
+                       ? $this->getPreviousRevisionId( $dbw, $rev )
+                       : $rev->getParentId();
+
+               // Record the text (or external storage URL) to the blob store
+               $slot = $rev->getSlot( 'main', RevisionRecord::RAW );
+
+               $size = $this->failOnNull( $rev->getSize(), 'size field' );
+               $sha1 = $this->failOnEmpty( $rev->getSha1(), 'sha1 field' );
+
+               if ( !$slot->hasAddress() ) {
+                       $content = $slot->getContent();
+                       $format = $content->getDefaultFormat();
+                       $model = $content->getModel();
+
+                       $this->checkContentModel( $content, $title );
+
+                       $data = $content->serialize( $format );
+
+                       // Hints allow the blob store to optimize by "leaking" application level information to it.
+                       // TODO: with the new MCR storage schema, we rev_id have this before storing the blobs.
+                       // When we have it, add rev_id as a hint. Can be used with rev_parent_id for
+                       // differential storage or compression of subsequent revisions.
+                       $blobHints = [
+                               BlobStore::DESIGNATION_HINT => 'page-content', // BlobStore may be used for other things too.
+                               BlobStore::PAGE_HINT => $pageId,
+                               BlobStore::ROLE_HINT => $slot->getRole(),
+                               BlobStore::PARENT_HINT => $parentId,
+                               BlobStore::SHA1_HINT => $slot->getSha1(),
+                               BlobStore::MODEL_HINT => $model,
+                               BlobStore::FORMAT_HINT => $format,
+                       ];
+
+                       $blobAddress = $this->blobStore->storeBlob( $data, $blobHints );
+               } else {
+                       $blobAddress = $slot->getAddress();
+                       $model = $slot->getModel();
+                       $format = $slot->getFormat();
+               }
+
+               $textId = $this->blobStore->getTextIdFromAddress( $blobAddress );
+
+               if ( !$textId ) {
+                       throw new LogicException(
+                               'Blob address not supported in 1.29 database schema: ' . $blobAddress
+                       );
+               }
+
+               // getTextIdFromAddress() is free to insert something into the text table, so $textId
+               // may be a new value, not anything already contained in $blobAddress.
+               $blobAddress = 'tt:' . $textId;
+
+               $comment = $this->failOnNull( $rev->getComment( RevisionRecord::RAW ), 'comment' );
+               $user = $this->failOnNull( $rev->getUser( RevisionRecord::RAW ), 'user' );
+               $timestamp = $this->failOnEmpty( $rev->getTimestamp(), 'timestamp field' );
+
+               # Record the edit in revisions
+               $row = [
+                       'rev_page'       => $pageId,
+                       'rev_parent_id'  => $parentId,
+                       'rev_text_id'    => $textId,
+                       'rev_minor_edit' => $rev->isMinor() ? 1 : 0,
+                       'rev_user'       => $this->failOnNull( $user->getId(), 'user field' ),
+                       'rev_user_text'  => $this->failOnEmpty( $user->getName(), 'user_text field' ),
+                       'rev_timestamp'  => $dbw->timestamp( $timestamp ),
+                       'rev_deleted'    => $rev->getVisibility(),
+                       'rev_len'        => $size,
+                       'rev_sha1'       => $sha1,
+               ];
+
+               if ( $rev->getId() !== null ) {
+                       // Needed to restore revisions with their original ID
+                       $row['rev_id'] = $rev->getId();
+               }
+
+               list( $commentFields, $commentCallback ) =
+                       CommentStore::newKey( 'rev_comment' )->insertWithTempTable( $dbw, $comment );
+               $row += $commentFields;
+
+               if ( $this->contentHandlerUseDB ) {
+                       // MCR migration note: rev_content_model and rev_content_format will go away
+
+                       $defaultModel = ContentHandler::getDefaultModelFor( $title );
+                       $defaultFormat = ContentHandler::getForModelID( $defaultModel )->getDefaultFormat();
+
+                       $row['rev_content_model'] = ( $model === $defaultModel ) ? null : $model;
+                       $row['rev_content_format'] = ( $format === $defaultFormat ) ? null : $format;
+               }
+
+               $dbw->insert( 'revision', $row, __METHOD__ );
+
+               if ( !isset( $row['rev_id'] ) ) {
+                       // only if auto-increment was used
+                       $row['rev_id'] = intval( $dbw->insertId() );
+               }
+               $commentCallback( $row['rev_id'] );
+
+               // Insert IP revision into ip_changes for use when querying for a range.
+               if ( $row['rev_user'] === 0 && IP::isValid( $row['rev_user_text'] ) ) {
+                       $ipcRow = [
+                               'ipc_rev_id'        => $row['rev_id'],
+                               'ipc_rev_timestamp' => $row['rev_timestamp'],
+                               'ipc_hex'           => IP::toHex( $row['rev_user_text'] ),
+                       ];
+                       $dbw->insert( 'ip_changes', $ipcRow, __METHOD__ );
+               }
+
+               $newSlot = SlotRecord::newSaved( $row['rev_id'], $blobAddress, $slot );
+               $slots = new RevisionSlots( [ 'main' => $newSlot ] );
+
+               $user = new UserIdentityValue( intval( $row['rev_user'] ), $row['rev_user_text'] );
+
+               $rev = new RevisionStoreRecord(
+                       $title,
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots,
+                       $this->wikiId
+               );
+
+               $newSlot = $rev->getSlot( 'main', RevisionRecord::RAW );
+
+               // sanity checks
+               Assert::postcondition( $rev->getId() > 0, 'revision must have an ID' );
+               Assert::postcondition( $rev->getPageId() > 0, 'revision must have a page ID' );
+               Assert::postcondition(
+                       $rev->getComment( RevisionRecord::RAW ) !== null,
+                       'revision must have a comment'
+               );
+               Assert::postcondition(
+                       $rev->getUser( RevisionRecord::RAW ) !== null,
+                       'revision must have a user'
+               );
+
+               Assert::postcondition( $newSlot !== null, 'revision must have a main slot' );
+               Assert::postcondition(
+                       $newSlot->getAddress() !== null,
+                       'main slot must have an addess'
+               );
+
+               Hooks::run( 'RevisionRecordInserted', [ $rev ] );
+
+               return $rev;
+       }
+
+       /**
+        * MCR migration note: this corresponds to Revision::checkContentModel
+        *
+        * @param Content $content
+        * @param Title $title
+        *
+        * @throws MWException
+        * @throws MWUnknownContentModelException
+        */
+       private function checkContentModel( Content $content, Title $title ) {
+               // Note: may return null for revisions that have not yet been inserted
+
+               $model = $content->getModel();
+               $format = $content->getDefaultFormat();
+               $handler = $content->getContentHandler();
+
+               $name = "$title";
+
+               if ( !$handler->isSupportedFormat( $format ) ) {
+                       throw new MWException( "Can't use format $format with content model $model on $name" );
+               }
+
+               if ( !$this->contentHandlerUseDB ) {
+                       // if $wgContentHandlerUseDB is not set,
+                       // all revisions must use the default content model and format.
+
+                       $defaultModel = ContentHandler::getDefaultModelFor( $title );
+                       $defaultHandler = ContentHandler::getForModelID( $defaultModel );
+                       $defaultFormat = $defaultHandler->getDefaultFormat();
+
+                       if ( $model != $defaultModel ) {
+                               throw new MWException( "Can't save non-default content model with "
+                                       . "\$wgContentHandlerUseDB disabled: model is $model, "
+                                       . "default for $name is $defaultModel"
+                               );
+                       }
+
+                       if ( $format != $defaultFormat ) {
+                               throw new MWException( "Can't use non-default content format with "
+                                       . "\$wgContentHandlerUseDB disabled: format is $format, "
+                                       . "default for $name is $defaultFormat"
+                               );
+                       }
+               }
+
+               if ( !$content->isValid() ) {
+                       throw new MWException(
+                               "New content for $name is not valid! Content model is $model"
+                       );
+               }
+       }
+
+       /**
+        * Create a new null-revision for insertion into a page's
+        * history. This will not re-save the text, but simply refer
+        * to the text from the previous version.
+        *
+        * Such revisions can for instance identify page rename
+        * operations and other such meta-modifications.
+        *
+        * MCR migration note: this replaces Revision::newNullRevision
+        *
+        * @todo Introduce newFromParentRevision(). newNullRevision can then be based on that
+        * (or go away).
+        *
+        * @param IDatabase $dbw
+        * @param Title $title Title of the page to read from
+        * @param CommentStoreComment $comment RevisionRecord's summary
+        * @param bool $minor Whether the revision should be considered as minor
+        * @param User $user The user to attribute the revision to
+        * @return RevisionRecord|null RevisionRecord or null on error
+        */
+       public function newNullRevision(
+               IDatabase $dbw,
+               Title $title,
+               CommentStoreComment $comment,
+               $minor,
+               User $user
+       ) {
+               $this->checkDatabaseWikiId( $dbw );
+
+               $fields = [ 'page_latest', 'page_namespace', 'page_title',
+                       'rev_id', 'rev_text_id', 'rev_len', 'rev_sha1' ];
+
+               if ( $this->contentHandlerUseDB ) {
+                       $fields[] = 'rev_content_model';
+                       $fields[] = 'rev_content_format';
+               }
+
+               $current = $dbw->selectRow(
+                       [ 'page', 'revision' ],
+                       $fields,
+                       [
+                               'page_id' => $title->getArticleID(),
+                               'page_latest=rev_id',
+                       ],
+                       __METHOD__,
+                       [ 'FOR UPDATE' ] // T51581
+               );
+
+               if ( $current ) {
+                       $fields = [
+                               'page'       => $title->getArticleID(),
+                               'user_text'  => $user->getName(),
+                               'user'       => $user->getId(),
+                               'comment'    => $comment,
+                               'minor_edit' => $minor,
+                               'text_id'    => $current->rev_text_id,
+                               'parent_id'  => $current->page_latest,
+                               'len'        => $current->rev_len,
+                               'sha1'       => $current->rev_sha1
+                       ];
+
+                       if ( $this->contentHandlerUseDB ) {
+                               $fields['content_model'] = $current->rev_content_model;
+                               $fields['content_format'] = $current->rev_content_format;
+                       }
+
+                       $fields['title'] = Title::makeTitle( $current->page_namespace, $current->page_title );
+
+                       $mainSlot = $this->emulateMainSlot_1_29( $fields, 0, $title );
+                       $revision = new MutableRevisionRecord( $title, $this->wikiId );
+                       $this->initializeMutableRevisionFromArray( $revision, $fields );
+                       $revision->setSlot( $mainSlot );
+               } else {
+                       $revision = null;
+               }
+
+               return $revision;
+       }
+
+       /**
+        * MCR migration note: this replaces Revision::isUnpatrolled
+        *
+        * @return int Rcid of the unpatrolled row, zero if there isn't one
+        */
+       public function isUnpatrolled( RevisionRecord $rev ) {
+               $rc = $this->getRecentChange( $rev );
+               if ( $rc && $rc->getAttribute( 'rc_patrolled' ) == 0 ) {
+                       return $rc->getAttribute( 'rc_id' );
+               } else {
+                       return 0;
+               }
+       }
+
+       /**
+        * Get the RC object belonging to the current revision, if there's one
+        *
+        * MCR migration note: this replaces Revision::getRecentChange
+        *
+        * @todo move this somewhere else?
+        *
+        * @param RevisionRecord $rev
+        * @param int $flags (optional) $flags include:
+        *      IDBAccessObject::READ_LATEST: Select the data from the master
+        *
+        * @return null|RecentChange
+        */
+       public function getRecentChange( RevisionRecord $rev, $flags = 0 ) {
+               $dbr = $this->getDBConnection( DB_REPLICA );
+
+               list( $dbType, ) = DBAccessObjectUtils::getDBOptions( $flags );
+
+               $userIdentity = $rev->getUser( RevisionRecord::RAW );
+
+               if ( !$userIdentity ) {
+                       // If the revision has no user identity, chances are it never went
+                       // into the database, and doesn't have an RC entry.
+                       return null;
+               }
+
+               // TODO: Select by rc_this_oldid alone - but as of Nov 2017, there is no index on that!
+               $rc = RecentChange::newFromConds(
+                       [
+                               'rc_user_text' => $userIdentity->getName(),
+                               'rc_timestamp' => $dbr->timestamp( $rev->getTimestamp() ),
+                               'rc_this_oldid' => $rev->getId()
+                       ],
+                       __METHOD__,
+                       $dbType
+               );
+
+               $this->releaseDBConnection( $dbr );
+
+               // XXX: cache this locally? Glue it to the RevisionRecord?
+               return $rc;
+       }
+
+       /**
+        * Maps fields of the archive row to corresponding revision rows.
+        *
+        * @param object $archiveRow
+        *
+        * @return object a revision row object, corresponding to $archiveRow.
+        */
+       private static function mapArchiveFields( $archiveRow ) {
+               $fieldMap = [
+                       // keep with ar prefix:
+                       'ar_id'        => 'ar_id',
+
+                       // not the same suffix:
+                       'ar_page_id'        => 'rev_page',
+                       'ar_rev_id'         => 'rev_id',
+
+                       // same suffix:
+                       'ar_text_id'        => 'rev_text_id',
+                       'ar_timestamp'      => 'rev_timestamp',
+                       'ar_user_text'      => 'rev_user_text',
+                       'ar_user'           => 'rev_user',
+                       'ar_minor_edit'     => 'rev_minor_edit',
+                       'ar_deleted'        => 'rev_deleted',
+                       'ar_len'            => 'rev_len',
+                       'ar_parent_id'      => 'rev_parent_id',
+                       'ar_sha1'           => 'rev_sha1',
+                       'ar_comment'        => 'rev_comment',
+                       'ar_comment_cid'    => 'rev_comment_cid',
+                       'ar_comment_id'     => 'rev_comment_id',
+                       'ar_comment_text'   => 'rev_comment_text',
+                       'ar_comment_data'   => 'rev_comment_data',
+                       'ar_comment_old'    => 'rev_comment_old',
+                       'ar_content_format' => 'rev_content_format',
+                       'ar_content_model'  => 'rev_content_model',
+               ];
+
+               if ( empty( $archiveRow->ar_text_id ) ) {
+                       $fieldMap['ar_text'] = 'old_text';
+                       $fieldMap['ar_flags'] = 'old_flags';
+               }
+
+               $revRow = new stdClass();
+               foreach ( $fieldMap as $arKey => $revKey ) {
+                       if ( property_exists( $archiveRow, $arKey ) ) {
+                               $revRow->$revKey = $archiveRow->$arKey;
+                       }
+               }
+
+               return $revRow;
+       }
+
+       /**
+        * Constructs a RevisionRecord for the revisions main slot, based on the MW1.29 schema.
+        *
+        * @param object|array $row Either a database row or an array
+        * @param int $queryFlags for callbacks
+        * @param Title $title
+        *
+        * @return SlotRecord The main slot, extracted from the MW 1.29 style row.
+        * @throws MWException
+        */
+       private function emulateMainSlot_1_29( $row, $queryFlags, Title $title ) {
+               $mainSlotRow = new stdClass();
+               $mainSlotRow->role_name = 'main';
+
+               $content = null;
+               $blobData = null;
+               $blobFlags = '';
+
+               if ( is_object( $row ) ) {
+                       // archive row
+                       if ( !isset( $row->rev_id ) && isset( $row->ar_user ) ) {
+                               $row = $this->mapArchiveFields( $row );
+                       }
+
+                       if ( isset( $row->rev_text_id ) && $row->rev_text_id > 0 ) {
+                               $mainSlotRow->cont_address = 'tt:' . $row->rev_text_id;
+                       } elseif ( isset( $row->ar_id ) ) {
+                               $mainSlotRow->cont_address = 'ar:' . $row->ar_id;
+                       }
+
+                       if ( isset( $row->old_text ) ) {
+                               // this happens when the text-table gets joined directly, in the pre-1.30 schema
+                               $blobData = isset( $row->old_text ) ? strval( $row->old_text ) : null;
+                               $blobFlags = isset( $row->old_flags ) ? strval( $row->old_flags ) : '';
+                       }
+
+                       $mainSlotRow->slot_revision = intval( $row->rev_id );
+
+                       $mainSlotRow->cont_size = isset( $row->rev_len ) ? intval( $row->rev_len ) : null;
+                       $mainSlotRow->cont_sha1 = isset( $row->rev_sha1 ) ? strval( $row->rev_sha1 ) : null;
+                       $mainSlotRow->model_name = isset( $row->rev_content_model )
+                               ? strval( $row->rev_content_model )
+                               : null;
+                       // XXX: in the future, we'll probably always use the default format, and drop content_format
+                       $mainSlotRow->format_name = isset( $row->rev_content_format )
+                               ? strval( $row->rev_content_format )
+                               : null;
+               } elseif ( is_array( $row ) ) {
+                       $mainSlotRow->slot_revision = isset( $row['id'] ) ? intval( $row['id'] ) : null;
+
+                       $mainSlotRow->cont_address = isset( $row['text_id'] )
+                               ? 'tt:' . intval( $row['text_id'] )
+                               : null;
+                       $mainSlotRow->cont_size = isset( $row['len'] ) ? intval( $row['len'] ) : null;
+                       $mainSlotRow->cont_sha1 = isset( $row['sha1'] ) ? strval( $row['sha1'] ) : null;
+
+                       $mainSlotRow->model_name = isset( $row['content_model'] )
+                               ? strval( $row['content_model'] ) : null;  // XXX: must be a string!
+                       // XXX: in the future, we'll probably always use the default format, and drop content_format
+                       $mainSlotRow->format_name = isset( $row['content_format'] )
+                               ? strval( $row['content_format'] ) : null;
+                       $blobData = isset( $row['text'] ) ? rtrim( strval( $row['text'] ) ) : null;
+                       $blobFlags = isset( $row['flags'] ) ? trim( strval( $row['flags'] ) ) : '';
+
+                       // if we have a Content object, override mText and mContentModel
+                       if ( !empty( $row['content'] ) ) {
+                               if ( !( $row['content'] instanceof Content ) ) {
+                                       throw new MWException( 'content field must contain a Content object.' );
+                               }
+
+                               /** @var Content $content */
+                               $content = $row['content'];
+                               $handler = $content->getContentHandler();
+
+                               $mainSlotRow->model_name = $content->getModel();
+
+                               // XXX: in the future, we'll probably always use the default format.
+                               if ( $mainSlotRow->format_name === null ) {
+                                       $mainSlotRow->format_name = $handler->getDefaultFormat();
+                               }
+                       }
+               } else {
+                       throw new MWException( 'Revision constructor passed invalid row format.' );
+               }
+
+               // With the old schema, the content changes with every revision.
+               // ...except for null-revisions. Would be nice if we could detect them.
+               $mainSlotRow->slot_inherited = 0;
+
+               if ( $mainSlotRow->model_name === null ) {
+                       $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) {
+                               // TODO: MCR: consider slot role in getDefaultModelFor()! Use LinkTarget!
+                               // TODO: MCR: deprecate $title->getModel().
+                               return ContentHandler::getDefaultModelFor( $title );
+                       };
+               }
+
+               if ( !$content ) {
+                       $content = function ( SlotRecord $slot )
+                               use ( $blobData, $blobFlags, $queryFlags, $mainSlotRow )
+                       {
+                               return $this->loadSlotContent(
+                                       $slot,
+                                       $blobData,
+                                       $blobFlags,
+                                       $mainSlotRow->format_name,
+                                       $queryFlags
+                               );
+                       };
+               }
+
+               return new SlotRecord( $mainSlotRow, $content );
+       }
+
+       /**
+        * Loads a Content object based on a slot row.
+        *
+        * This method does not call $slot->getContent(), and may be used as a callback
+        * called by $slot->getContent().
+        *
+        * MCR migration note: this roughly corresponds to Revision::getContentInternal
+        *
+        * @param SlotRecord $slot The SlotRecord to load content for
+        * @param string|null $blobData The content blob, in the form indicated by $blobFlags
+        * @param string $blobFlags Flags indicating how $blobData needs to be processed
+        * @param string|null $blobFormat MIME type indicating how $dataBlob is encoded
+        * @param int $queryFlags
+        *
+        * @throw RevisionAccessException
+        * @return Content
+        */
+       private function loadSlotContent(
+               SlotRecord $slot,
+               $blobData = null,
+               $blobFlags = '',
+               $blobFormat = null,
+               $queryFlags = 0
+       ) {
+               if ( $blobData !== null ) {
+                       Assert::parameterType( 'string', $blobData, '$blobData' );
+                       Assert::parameterType( 'string', $blobFlags, '$blobFlags' );
+
+                       $cacheKey = $slot->hasAddress() ? $slot->getAddress() : null;
+
+                       $data = $this->blobStore->expandBlob( $blobData, $blobFlags, $cacheKey );
+
+                       if ( $data === false ) {
+                               throw new RevisionAccessException(
+                                       "Failed to expand blob data using flags $blobFlags (key: $cacheKey)"
+                               );
+                       }
+               } else {
+                       $address = $slot->getAddress();
+                       try {
+                               $data = $this->blobStore->getBlob( $address, $queryFlags );
+                       } catch ( BlobAccessException $e ) {
+                               throw new RevisionAccessException(
+                                       "Failed to load data blob from $address: " . $e->getMessage(), 0, $e
+                               );
+                       }
+               }
+
+               // Unserialize content
+               $handler = ContentHandler::getForModelID( $slot->getModel() );
+
+               $content = $handler->unserializeContent( $data, $blobFormat );
+               return $content;
+       }
+
+       /**
+        * Load a page revision from a given revision ID number.
+        * Returns null if no such revision can be found.
+        *
+        * MCR migration note: this replaces Revision::newFromId
+        *
+        * $flags include:
+        *      IDBAccessObject::READ_LATEST: Select the data from the master
+        *      IDBAccessObject::READ_LOCKING : Select & lock the data from the master
+        *
+        * @param int $id
+        * @param int $flags (optional)
+        * @return RevisionRecord|null
+        */
+       public function getRevisionById( $id, $flags = 0 ) {
+               return $this->newRevisionFromConds( [ 'rev_id' => intval( $id ) ], $flags );
+       }
+
+       /**
+        * Load either the current, or a specified, revision
+        * that's attached to a given link target. If not attached
+        * to that link target, will return null.
+        *
+        * MCR migration note: this replaces Revision::newFromTitle
+        *
+        * $flags include:
+        *      IDBAccessObject::READ_LATEST: Select the data from the master
+        *      IDBAccessObject::READ_LOCKING : Select & lock the data from the master
+        *
+        * @param LinkTarget $linkTarget
+        * @param int $revId (optional)
+        * @param int $flags Bitfield (optional)
+        * @return RevisionRecord|null
+        */
+       public function getRevisionByTitle( LinkTarget $linkTarget, $revId = 0, $flags = 0 ) {
+               $conds = [
+                       'page_namespace' => $linkTarget->getNamespace(),
+                       'page_title' => $linkTarget->getDBkey()
+               ];
+               if ( $revId ) {
+                       // Use the specified revision ID.
+                       // Note that we use newRevisionFromConds here because we want to retry
+                       // and fall back to master if the page is not found on a replica.
+                       // Since the caller supplied a revision ID, we are pretty sure the revision is
+                       // supposed to exist, so we should try hard to find it.
+                       $conds['rev_id'] = $revId;
+                       return $this->newRevisionFromConds( $conds, $flags );
+               } else {
+                       // Use a join to get the latest revision.
+                       // Note that we don't use newRevisionFromConds here because we don't want to retry
+                       // and fall back to master. The assumption is that we only want to force the fallback
+                       // if we are quite sure the revision exists because the caller supplied a revision ID.
+                       // If the page isn't found at all on a replica, it probably simply does not exist.
+                       $db = $this->getDBConnection( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA );
+
+                       $conds[] = 'rev_id=page_latest';
+                       $rev = $this->loadRevisionFromConds( $db, $conds, $flags );
+
+                       $this->releaseDBConnection( $db );
+                       return $rev;
+               }
+       }
+
+       /**
+        * Load either the current, or a specified, revision
+        * that's attached to a given page ID.
+        * Returns null if no such revision can be found.
+        *
+        * MCR migration note: this replaces Revision::newFromPageId
+        *
+        * $flags include:
+        *      IDBAccessObject::READ_LATEST: Select the data from the master (since 1.20)
+        *      IDBAccessObject::READ_LOCKING : Select & lock the data from the master
+        *
+        * @param int $pageId
+        * @param int $revId (optional)
+        * @param int $flags Bitfield (optional)
+        * @return RevisionRecord|null
+        */
+       public function getRevisionByPageId( $pageId, $revId = 0, $flags = 0 ) {
+               $conds = [ 'page_id' => $pageId ];
+               if ( $revId ) {
+                       // Use the specified revision ID.
+                       // Note that we use newRevisionFromConds here because we want to retry
+                       // and fall back to master if the page is not found on a replica.
+                       // Since the caller supplied a revision ID, we are pretty sure the revision is
+                       // supposed to exist, so we should try hard to find it.
+                       $conds['rev_id'] = $revId;
+                       return $this->newRevisionFromConds( $conds, $flags );
+               } else {
+                       // Use a join to get the latest revision.
+                       // Note that we don't use newRevisionFromConds here because we don't want to retry
+                       // and fall back to master. The assumption is that we only want to force the fallback
+                       // if we are quite sure the revision exists because the caller supplied a revision ID.
+                       // If the page isn't found at all on a replica, it probably simply does not exist.
+                       $db = $this->getDBConnection( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA );
+
+                       $conds[] = 'rev_id=page_latest';
+                       $rev = $this->loadRevisionFromConds( $db, $conds, $flags );
+
+                       $this->releaseDBConnection( $db );
+                       return $rev;
+               }
+       }
+
+       /**
+        * Load the revision for the given title with the given timestamp.
+        * WARNING: Timestamps may in some circumstances not be unique,
+        * so this isn't the best key to use.
+        *
+        * MCR migration note: this replaces Revision::loadFromTimestamp
+        *
+        * @param Title $title
+        * @param string $timestamp
+        * @return RevisionRecord|null
+        */
+       public function getRevisionFromTimestamp( $title, $timestamp ) {
+               return $this->newRevisionFromConds(
+                       [
+                               'rev_timestamp' => $timestamp,
+                               'page_namespace' => $title->getNamespace(),
+                               'page_title' => $title->getDBkey()
+                       ],
+                       0,
+                       $title
+               );
+       }
+
+       /**
+        * Make a fake revision object from an archive table row. This is queried
+        * for permissions or even inserted (as in Special:Undelete)
+        *
+        * MCR migration note: this replaces Revision::newFromArchiveRow
+        *
+        * @param object $row
+        * @param int $queryFlags
+        * @param Title|null $title
+        * @param array $overrides associative array with fields of $row to override. This may be
+        *   used e.g. to force the parent revision ID or page ID. Keys in the array are fields
+        *   names from the archive table without the 'ar_' prefix, i.e. use 'parent_id' to
+        *   override ar_parent_id.
+        *
+        * @return RevisionRecord
+        * @throws MWException
+        */
+       public function newRevisionFromArchiveRow(
+               $row,
+               $queryFlags = 0,
+               Title $title = null,
+               array $overrides = []
+       ) {
+               Assert::parameterType( 'object', $row, '$row' );
+
+               // check second argument, since Revision::newFromArchiveRow had $overrides in that spot.
+               Assert::parameterType( 'integer', $queryFlags, '$queryFlags' );
+
+               if ( !$title && isset( $overrides['title'] ) ) {
+                       if ( !( $overrides['title'] instanceof Title ) ) {
+                               throw new MWException( 'title field override must contain a Title object.' );
+                       }
+
+                       $title = $overrides['title'];
+               }
+
+               if ( !isset( $title ) ) {
+                       if ( isset( $row->ar_namespace ) && isset( $row->ar_title ) ) {
+                               $title = Title::makeTitle( $row->ar_namespace, $row->ar_title );
+                       } else {
+                               throw new InvalidArgumentException(
+                                       'A Title or ar_namespace and ar_title must be given'
+                               );
+                       }
+               }
+
+               foreach ( $overrides as $key => $value ) {
+                       $field = "ar_$key";
+                       $row->$field = $value;
+               }
+
+               $user = $this->getUserIdentityFromRowObject( $row, 'ar_' );
+
+               $comment = CommentStore::newKey( 'ar_comment' )
+                       // Legacy because $row may have come from self::selectFields()
+                       ->getCommentLegacy( $this->getDBConnection( DB_REPLICA ), $row, true );
+
+               $mainSlot = $this->emulateMainSlot_1_29( $row, $queryFlags, $title );
+               $slots = new RevisionSlots( [ 'main' => $mainSlot ] );
+
+               return new RevisionArchiveRecord( $title, $user, $comment, $row, $slots, $this->wikiId );
+       }
+
+       /**
+        * @param object $row
+        * @param string $prefix Field prefix, such as 'rev_' or 'ar_'.
+        *
+        * @return UserIdentityValue
+        */
+       private function getUserIdentityFromRowObject( $row, $prefix = 'rev_' ) {
+               $idField = "{$prefix}user";
+               $nameField = "{$prefix}user_text";
+
+               $userId = intval( $row->$idField );
+
+               if ( isset( $row->user_name ) ) {
+                       $userName = $row->user_name;
+               } elseif ( isset( $row->$nameField ) ) {
+                       $userName = $row->$nameField;
+               } else {
+                       $userName = User::whoIs( $userId );
+               }
+
+               if ( $userName === false ) {
+                       wfWarn( __METHOD__ . ': Cannot determine user name for user ID ' . $userId );
+                       $userName = '';
+               }
+
+               return new UserIdentityValue( $userId, $userName );
+       }
+
+       /**
+        * @see RevisionFactory::newRevisionFromRow_1_29
+        *
+        * MCR migration note: this replaces Revision::newFromRow
+        *
+        * @param object $row
+        * @param int $queryFlags
+        * @param Title|null $title
+        *
+        * @return RevisionRecord
+        * @throws MWException
+        * @throws RevisionAccessException
+        */
+       private function newRevisionFromRow_1_29( $row, $queryFlags = 0, Title $title = null ) {
+               Assert::parameterType( 'object', $row, '$row' );
+
+               if ( !$title ) {
+                       $pageId = isset( $row->rev_page ) ? $row->rev_page : 0; // XXX: also check page_id?
+                       $revId = isset( $row->rev_id ) ? $row->rev_id : 0;
+
+                       $title = $this->getTitle( $pageId, $revId );
+               }
+
+               if ( !isset( $row->page_latest ) ) {
+                       $row->page_latest = $title->getLatestRevID();
+                       if ( $row->page_latest === 0 && $title->exists() ) {
+                               wfWarn( 'Encountered title object in limbo: ID ' . $title->getArticleID() );
+                       }
+               }
+
+               $user = $this->getUserIdentityFromRowObject( $row );
+
+               $comment = CommentStore::newKey( 'rev_comment' )
+                       // Legacy because $row may have come from self::selectFields()
+                       ->getCommentLegacy( $this->getDBConnection( DB_REPLICA ), $row, true );
+
+               $mainSlot = $this->emulateMainSlot_1_29( $row, $queryFlags, $title );
+               $slots = new RevisionSlots( [ 'main' => $mainSlot ] );
+
+               return new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $this->wikiId );
+       }
+
+       /**
+        * @see RevisionFactory::newRevisionFromRow
+        *
+        * MCR migration note: this replaces Revision::newFromRow
+        *
+        * @param object $row
+        * @param int $queryFlags
+        * @param Title|null $title
+        *
+        * @return RevisionRecord
+        */
+       public function newRevisionFromRow( $row, $queryFlags = 0, Title $title = null ) {
+               return $this->newRevisionFromRow_1_29( $row, $queryFlags, $title );
+       }
+
+       /**
+        * Constructs a new MutableRevisionRecord based on the given associative array following
+        * the MW1.29 convention for the Revision constructor.
+        *
+        * MCR migration note: this replaces Revision::newFromRow
+        *
+        * @param array $fields
+        * @param int $queryFlags
+        * @param Title|null $title
+        *
+        * @return MutableRevisionRecord
+        * @throws MWException
+        * @throws RevisionAccessException
+        */
+       public function newMutableRevisionFromArray(
+               array $fields,
+               $queryFlags = 0,
+               Title $title = null
+       ) {
+               if ( !$title && isset( $fields['title'] ) ) {
+                       if ( !( $fields['title'] instanceof Title ) ) {
+                               throw new MWException( 'title field must contain a Title object.' );
+                       }
+
+                       $title = $fields['title'];
+               }
+
+               if ( !$title ) {
+                       $pageId = isset( $fields['page'] ) ? $fields['page'] : 0;
+                       $revId = isset( $fields['id'] ) ? $fields['id'] : 0;
+
+                       $title = $this->getTitle( $pageId, $revId );
+               }
+
+               if ( !isset( $fields['page'] ) ) {
+                       $fields['page'] = $title->getArticleID( $queryFlags );
+               }
+
+               // if we have a content object, use it to set the model and type
+               if ( !empty( $fields['content'] ) ) {
+                       if ( !( $fields['content'] instanceof Content ) ) {
+                               throw new MWException( 'content field must contain a Content object.' );
+                       }
+
+                       if ( !empty( $fields['text_id'] ) ) {
+                               throw new MWException(
+                                       "Text already stored in external store (id {$fields['text_id']}), " .
+                                       "can't serialize content object"
+                               );
+                       }
+               }
+
+               // Replaces old lazy loading logic in Revision::getUserText.
+               if ( !isset( $fields['user_text'] ) && isset( $fields['user'] ) ) {
+                       if ( $fields['user'] instanceof UserIdentity ) {
+                               /** @var User $user */
+                               $user = $fields['user'];
+                               $fields['user_text'] = $user->getName();
+                               $fields['user'] = $user->getId();
+                       } else {
+                               // TODO: wrap this in a callback to make it lazy again.
+                               $name = $fields['user'] === 0 ? false : User::whoIs( $fields['user'] );
+
+                               if ( $name === false ) {
+                                       throw new MWException(
+                                               'user_text not given, and unknown user ID ' . $fields['user']
+                                       );
+                               }
+
+                               $fields['user_text'] = $name;
+                       }
+               }
+
+               if (
+                       isset( $fields['comment'] )
+                       && !( $fields['comment'] instanceof CommentStoreComment )
+               ) {
+                       $commentData = isset( $fields['comment_data'] ) ? $fields['comment_data'] : null;
+
+                       if ( $fields['comment'] instanceof Message ) {
+                               $fields['comment'] = CommentStoreComment::newUnsavedComment(
+                                       $fields['comment'],
+                                       $commentData
+                               );
+                       } else {
+                               $commentText = trim( strval( $fields['comment'] ) );
+                               $fields['comment'] = CommentStoreComment::newUnsavedComment(
+                                       $commentText,
+                                       $commentData
+                               );
+                       }
+               }
+
+               $mainSlot = $this->emulateMainSlot_1_29( $fields, $queryFlags, $title );
+
+               $revision = new MutableRevisionRecord( $title, $this->wikiId );
+               $this->initializeMutableRevisionFromArray( $revision, $fields );
+               $revision->setSlot( $mainSlot );
+
+               return $revision;
+       }
+
+       /**
+        * @param MutableRevisionRecord $record
+        * @param array $fields
+        */
+       private function initializeMutableRevisionFromArray(
+               MutableRevisionRecord $record,
+               array $fields
+       ) {
+               /** @var UserIdentity $user */
+               $user = null;
+
+               if ( isset( $fields['user'] ) && ( $fields['user'] instanceof UserIdentity ) ) {
+                       $user = $fields['user'];
+               } elseif ( isset( $fields['user'] ) && isset( $fields['user_text'] ) ) {
+                       $user = new UserIdentityValue( intval( $fields['user'] ), $fields['user_text'] );
+               } elseif ( isset( $fields['user'] ) ) {
+                       $user = User::newFromId( intval( $fields['user'] ) );
+               } elseif ( isset( $fields['user_text'] ) ) {
+                       $user = User::newFromName( $fields['user_text'] );
+
+                       // User::newFromName will return false for IP addresses (and invalid names)
+                       if ( $user == false ) {
+                               $user = new UserIdentityValue( 0, $fields['user_text'] );
+                       }
+               }
+
+               if ( $user ) {
+                       $record->setUser( $user );
+               }
+
+               $timestamp = isset( $fields['timestamp'] )
+                       ? strval( $fields['timestamp'] )
+                       : wfTimestampNow(); // TODO: use a callback, so we can override it for testing.
+
+               $record->setTimestamp( $timestamp );
+
+               if ( isset( $fields['page'] ) ) {
+                       $record->setPageId( intval( $fields['page'] ) );
+               }
+
+               if ( isset( $fields['id'] ) ) {
+                       $record->setId( intval( $fields['id'] ) );
+               }
+               if ( isset( $fields['parent_id'] ) ) {
+                       $record->setParentId( intval( $fields['parent_id'] ) );
+               }
+
+               if ( isset( $fields['sha1'] ) ) {
+                       $record->setSha1( $fields['sha1'] );
+               }
+               if ( isset( $fields['size'] ) ) {
+                       $record->setSize( intval( $fields['size'] ) );
+               }
+
+               if ( isset( $fields['minor_edit'] ) ) {
+                       $record->setMinorEdit( intval( $fields['minor_edit'] ) !== 0 );
+               }
+               if ( isset( $fields['deleted'] ) ) {
+                       $record->setVisibility( intval( $fields['deleted'] ) );
+               }
+
+               if ( isset( $fields['comment'] ) ) {
+                       Assert::parameterType(
+                               CommentStoreComment::class,
+                               $fields['comment'],
+                               '$row[\'comment\']'
+                       );
+                       $record->setComment( $fields['comment'] );
+               }
+       }
+
+       /**
+        * Load a page revision from a given revision ID number.
+        * Returns null if no such revision can be found.
+        *
+        * MCR migration note: this corresponds to Revision::loadFromId
+        *
+        * @note direct use is deprecated!
+        * @todo remove when unused! there seem to be no callers of Revision::loadFromId
+        *
+        * @param IDatabase $db
+        * @param int $id
+        *
+        * @return RevisionRecord|null
+        */
+       public function loadRevisionFromId( IDatabase $db, $id ) {
+               return $this->loadRevisionFromConds( $db, [ 'rev_id' => intval( $id ) ] );
+       }
+
+       /**
+        * Load either the current, or a specified, revision
+        * that's attached to a given page. If not attached
+        * to that page, will return null.
+        *
+        * MCR migration note: this replaces Revision::loadFromPageId
+        *
+        * @note direct use is deprecated!
+        * @todo remove when unused!
+        *
+        * @param IDatabase $db
+        * @param int $pageid
+        * @param int $id
+        * @return RevisionRecord|null
+        */
+       public function loadRevisionFromPageId( IDatabase $db, $pageid, $id = 0 ) {
+               $conds = [ 'rev_page' => intval( $pageid ), 'page_id' => intval( $pageid ) ];
+               if ( $id ) {
+                       $conds['rev_id'] = intval( $id );
+               } else {
+                       $conds[] = 'rev_id=page_latest';
+               }
+               return $this->loadRevisionFromConds( $db, $conds );
+       }
+
+       /**
+        * Load either the current, or a specified, revision
+        * that's attached to a given page. If not attached
+        * to that page, will return null.
+        *
+        * MCR migration note: this replaces Revision::loadFromTitle
+        *
+        * @note direct use is deprecated!
+        * @todo remove when unused!
+        *
+        * @param IDatabase $db
+        * @param Title $title
+        * @param int $id
+        *
+        * @return RevisionRecord|null
+        */
+       public function loadRevisionFromTitle( IDatabase $db, $title, $id = 0 ) {
+               if ( $id ) {
+                       $matchId = intval( $id );
+               } else {
+                       $matchId = 'page_latest';
+               }
+
+               return $this->loadRevisionFromConds(
+                       $db,
+                       [
+                               "rev_id=$matchId",
+                               'page_namespace' => $title->getNamespace(),
+                               'page_title' => $title->getDBkey()
+                       ],
+                       0,
+                       $title
+               );
+       }
+
+       /**
+        * Load the revision for the given title with the given timestamp.
+        * WARNING: Timestamps may in some circumstances not be unique,
+        * so this isn't the best key to use.
+        *
+        * MCR migration note: this replaces Revision::loadFromTimestamp
+        *
+        * @note direct use is deprecated! Use getRevisionFromTimestamp instead!
+        * @todo remove when unused!
+        *
+        * @param IDatabase $db
+        * @param Title $title
+        * @param string $timestamp
+        * @return RevisionRecord|null
+        */
+       public function loadRevisionFromTimestamp( IDatabase $db, $title, $timestamp ) {
+               return $this->loadRevisionFromConds( $db,
+                       [
+                               'rev_timestamp' => $db->timestamp( $timestamp ),
+                               'page_namespace' => $title->getNamespace(),
+                               'page_title' => $title->getDBkey()
+                       ],
+                       0,
+                       $title
+               );
+       }
+
+       /**
+        * Given a set of conditions, fetch a revision
+        *
+        * This method should be used if we are pretty sure the revision exists.
+        * Unless $flags has READ_LATEST set, this method will first try to find the revision
+        * on a replica before hitting the master database.
+        *
+        * MCR migration note: this corresponds to Revision::newFromConds
+        *
+        * @param array $conditions
+        * @param int $flags (optional)
+        * @param Title $title
+        *
+        * @return RevisionRecord|null
+        */
+       private function newRevisionFromConds( $conditions, $flags = 0, Title $title = null ) {
+               $db = $this->getDBConnection( ( $flags & self::READ_LATEST ) ? DB_MASTER : DB_REPLICA );
+               $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title );
+               $this->releaseDBConnection( $db );
+
+               $lb = $this->getDBLoadBalancer();
+
+               // Make sure new pending/committed revision are visibile later on
+               // within web requests to certain avoid bugs like T93866 and T94407.
+               if ( !$rev
+                       && !( $flags & self::READ_LATEST )
+                       && $lb->getServerCount() > 1
+                       && $lb->hasOrMadeRecentMasterChanges()
+               ) {
+                       $flags = self::READ_LATEST;
+                       $db = $this->getDBConnection( DB_MASTER );
+                       $rev = $this->loadRevisionFromConds( $db, $conditions, $flags, $title );
+                       $this->releaseDBConnection( $db );
+               }
+
+               return $rev;
+       }
+
+       /**
+        * Given a set of conditions, fetch a revision from
+        * the given database connection.
+        *
+        * MCR migration note: this corresponds to Revision::loadFromConds
+        *
+        * @param IDatabase $db
+        * @param array $conditions
+        * @param int $flags (optional)
+        * @param Title $title
+        *
+        * @return RevisionRecord|null
+        */
+       private function loadRevisionFromConds(
+               IDatabase $db,
+               $conditions,
+               $flags = 0,
+               Title $title = null
+       ) {
+               $row = $this->fetchRevisionRowFromConds( $db, $conditions, $flags );
+               if ( $row ) {
+                       $rev = $this->newRevisionFromRow( $row, $flags, $title );
+
+                       return $rev;
+               }
+
+               return null;
+       }
+
+       /**
+        * Throws an exception if the given database connection does not belong to the wiki this
+        * RevisionStore is bound to.
+        *
+        * @param IDatabase $db
+        * @throws MWException
+        */
+       private function checkDatabaseWikiId( IDatabase $db ) {
+               $storeWiki = $this->wikiId;
+               $dbWiki = $db->getDomainID();
+
+               if ( $dbWiki === $storeWiki ) {
+                       return;
+               }
+
+               // XXX: we really want the default database ID...
+               $storeWiki = $storeWiki ?: wfWikiID();
+               $dbWiki = $dbWiki ?: wfWikiID();
+
+               if ( $dbWiki !== $storeWiki ) {
+                       throw new MWException( "RevisionStore for $storeWiki "
+                               . "cannot be used with a DB connection for $dbWiki" );
+               }
+       }
+
+       /**
+        * Given a set of conditions, return a row with the
+        * fields necessary to build RevisionRecord objects.
+        *
+        * MCR migration note: this corresponds to Revision::fetchFromConds
+        *
+        * @param IDatabase $db
+        * @param array $conditions
+        * @param int $flags (optional)
+        *
+        * @return object|false data row as a raw object
+        */
+       private function fetchRevisionRowFromConds( IDatabase $db, $conditions, $flags = 0 ) {
+               $this->checkDatabaseWikiId( $db );
+
+               $revQuery = self::getQueryInfo( [ 'page', 'user' ] );
+               $options = [];
+               if ( ( $flags & self::READ_LOCKING ) == self::READ_LOCKING ) {
+                       $options[] = 'FOR UPDATE';
+               }
+               return $db->selectRow(
+                       $revQuery['tables'],
+                       $revQuery['fields'],
+                       $conditions,
+                       __METHOD__,
+                       $options,
+                       $revQuery['joins']
+               );
+       }
+
+       /**
+        * Return the tables, fields, and join conditions to be selected to create
+        * a new revision object.
+        *
+        * MCR migration note: this replaces Revision::getQueryInfo
+        *
+        * @since 1.31
+        *
+        * @param array $options Any combination of the following strings
+        *  - 'page': Join with the page table, and select fields to identify the page
+        *  - 'user': Join with the user table, and select the user name
+        *  - 'text': Join with the text table, and select fields to load page text
+        *
+        * @return array With three keys:
+        *  - tables: (string[]) to include in the `$table` to `IDatabase->select()`
+        *  - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
+        *  - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
+        */
+       public function getQueryInfo( $options = [] ) {
+               $ret = [
+                       'tables' => [],
+                       'fields' => [],
+                       'joins'  => [],
+               ];
+
+               $ret['tables'][] = 'revision';
+               $ret['fields'] = array_merge( $ret['fields'], [
+                       'rev_id',
+                       'rev_page',
+                       'rev_text_id',
+                       'rev_timestamp',
+                       'rev_user_text',
+                       'rev_user',
+                       'rev_minor_edit',
+                       'rev_deleted',
+                       'rev_len',
+                       'rev_parent_id',
+                       'rev_sha1',
+               ] );
+
+               $commentQuery = CommentStore::newKey( 'rev_comment' )->getJoin();
+               $ret['tables'] = array_merge( $ret['tables'], $commentQuery['tables'] );
+               $ret['fields'] = array_merge( $ret['fields'], $commentQuery['fields'] );
+               $ret['joins'] = array_merge( $ret['joins'], $commentQuery['joins'] );
+
+               if ( $this->contentHandlerUseDB ) {
+                       $ret['fields'][] = 'rev_content_format';
+                       $ret['fields'][] = 'rev_content_model';
+               }
+
+               if ( in_array( 'page', $options, true ) ) {
+                       $ret['tables'][] = 'page';
+                       $ret['fields'] = array_merge( $ret['fields'], [
+                               'page_namespace',
+                               'page_title',
+                               'page_id',
+                               'page_latest',
+                               'page_is_redirect',
+                               'page_len',
+                       ] );
+                       $ret['joins']['page'] = [ 'INNER JOIN', [ 'page_id = rev_page' ] ];
+               }
+
+               if ( in_array( 'user', $options, true ) ) {
+                       $ret['tables'][] = 'user';
+                       $ret['fields'] = array_merge( $ret['fields'], [
+                               'user_name',
+                       ] );
+                       $ret['joins']['user'] = [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ];
+               }
+
+               if ( in_array( 'text', $options, true ) ) {
+                       $ret['tables'][] = 'text';
+                       $ret['fields'] = array_merge( $ret['fields'], [
+                               'old_text',
+                               'old_flags'
+                       ] );
+                       $ret['joins']['text'] = [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ];
+               }
+
+               return $ret;
+       }
+
+       /**
+        * Return the tables, fields, and join conditions to be selected to create
+        * a new archived revision object.
+        *
+        * MCR migration note: this replaces Revision::getArchiveQueryInfo
+        *
+        * @since 1.31
+        *
+        * @return array With three keys:
+        *   - tables: (string[]) to include in the `$table` to `IDatabase->select()`
+        *   - fields: (string[]) to include in the `$vars` to `IDatabase->select()`
+        *   - joins: (array) to include in the `$join_conds` to `IDatabase->select()`
+        */
+       public function getArchiveQueryInfo() {
+               $commentQuery = CommentStore::newKey( 'ar_comment' )->getJoin();
+               $ret = [
+                       'tables' => [ 'archive' ] + $commentQuery['tables'],
+                       'fields' => [
+                                       'ar_id',
+                                       'ar_page_id',
+                                       'ar_namespace',
+                                       'ar_title',
+                                       'ar_rev_id',
+                                       'ar_text',
+                                       'ar_text_id',
+                                       'ar_timestamp',
+                                       'ar_user_text',
+                                       'ar_user',
+                                       'ar_minor_edit',
+                                       'ar_deleted',
+                                       'ar_len',
+                                       'ar_parent_id',
+                                       'ar_sha1',
+                               ] + $commentQuery['fields'],
+                       'joins' => $commentQuery['joins'],
+               ];
+
+               if ( $this->contentHandlerUseDB ) {
+                       $ret['fields'][] = 'ar_content_format';
+                       $ret['fields'][] = 'ar_content_model';
+               }
+
+               return $ret;
+       }
+
+       /**
+        * Do a batched query for the sizes of a set of revisions.
+        *
+        * MCR migration note: this replaces Revision::getParentLengths
+        *
+        * @param IDatabase $db
+        * @param int[] $revIds
+        * @return int[] associative array mapping revision IDs from $revIds to the nominal size
+        *         of the corresponding revision.
+        */
+       public function listRevisionSizes( IDatabase $db, array $revIds ) {
+               $this->checkDatabaseWikiId( $db );
+
+               $revLens = [];
+               if ( !$revIds ) {
+                       return $revLens; // empty
+               }
+
+               $res = $db->select(
+                       'revision',
+                       [ 'rev_id', 'rev_len' ],
+                       [ 'rev_id' => $revIds ],
+                       __METHOD__
+               );
+
+               foreach ( $res as $row ) {
+                       $revLens[$row->rev_id] = intval( $row->rev_len );
+               }
+
+               return $revLens;
+       }
+
+       /**
+        * Get previous revision for this title
+        *
+        * MCR migration note: this replaces Revision::getPrevious
+        *
+        * @param RevisionRecord $rev
+        *
+        * @return RevisionRecord|null
+        */
+       public function getPreviousRevision( RevisionRecord $rev ) {
+               $title = $this->getTitle( $rev->getPageId(), $rev->getId() );
+               $prev = $title->getPreviousRevisionID( $rev->getId() );
+               if ( $prev ) {
+                       return $this->getRevisionByTitle( $title, $prev );
+               }
+               return null;
+       }
+
+       /**
+        * Get next revision for this title
+        *
+        * MCR migration note: this replaces Revision::getNext
+        *
+        * @param RevisionRecord $rev
+        *
+        * @return RevisionRecord|null
+        */
+       public function getNextRevision( RevisionRecord $rev ) {
+               $title = $this->getTitle( $rev->getPageId(), $rev->getId() );
+               $next = $title->getNextRevisionID( $rev->getId() );
+               if ( $next ) {
+                       return $this->getRevisionByTitle( $title, $next );
+               }
+               return null;
+       }
+
+       /**
+        * Get previous revision Id for this page_id
+        * This is used to populate rev_parent_id on save
+        *
+        * MCR migration note: this corresponds to Revision::getPreviousRevisionId
+        *
+        * @param IDatabase $db
+        * @param RevisionRecord $rev
+        *
+        * @return int
+        */
+       private function getPreviousRevisionId( IDatabase $db, RevisionRecord $rev ) {
+               $this->checkDatabaseWikiId( $db );
+
+               if ( $rev->getPageId() === null ) {
+                       return 0;
+               }
+               # Use page_latest if ID is not given
+               if ( !$rev->getId() ) {
+                       $prevId = $db->selectField(
+                               'page', 'page_latest',
+                               [ 'page_id' => $rev->getPageId() ],
+                               __METHOD__
+                       );
+               } else {
+                       $prevId = $db->selectField(
+                               'revision', 'rev_id',
+                               [ 'rev_page' => $rev->getPageId(), 'rev_id < ' . $rev->getId() ],
+                               __METHOD__,
+                               [ 'ORDER BY' => 'rev_id DESC' ]
+                       );
+               }
+               return intval( $prevId );
+       }
+
+       /**
+        * Get rev_timestamp from rev_id, without loading the rest of the row
+        *
+        * MCR migration note: this replaces Revision::getTimestampFromId
+        *
+        * @param Title $title
+        * @param int $id
+        * @param int $flags
+        * @return string|bool False if not found
+        */
+       public function getTimestampFromId( $title, $id, $flags = 0 ) {
+               $db = $this->getDBConnection(
+                       ( $flags & IDBAccessObject::READ_LATEST ) ? DB_MASTER : DB_REPLICA
+               );
+
+               $conds = [ 'rev_id' => $id ];
+               $conds['rev_page'] = $title->getArticleID();
+               $timestamp = $db->selectField( 'revision', 'rev_timestamp', $conds, __METHOD__ );
+
+               $this->releaseDBConnection( $db );
+               return ( $timestamp !== false ) ? wfTimestamp( TS_MW, $timestamp ) : false;
+       }
+
+       /**
+        * Get count of revisions per page...not very efficient
+        *
+        * MCR migration note: this replaces Revision::countByPageId
+        *
+        * @param IDatabase $db
+        * @param int $id Page id
+        * @return int
+        */
+       public function countRevisionsByPageId( IDatabase $db, $id ) {
+               $this->checkDatabaseWikiId( $db );
+
+               $row = $db->selectRow( 'revision',
+                       [ 'revCount' => 'COUNT(*)' ],
+                       [ 'rev_page' => $id ],
+                       __METHOD__
+               );
+               if ( $row ) {
+                       return intval( $row->revCount );
+               }
+               return 0;
+       }
+
+       /**
+        * Get count of revisions per page...not very efficient
+        *
+        * MCR migration note: this replaces Revision::countByTitle
+        *
+        * @param IDatabase $db
+        * @param Title $title
+        * @return int
+        */
+       public function countRevisionsByTitle( IDatabase $db, $title ) {
+               $id = $title->getArticleID();
+               if ( $id ) {
+                       return $this->countRevisionsByPageId( $db, $id );
+               }
+               return 0;
+       }
+
+       /**
+        * Check if no edits were made by other users since
+        * the time a user started editing the page. Limit to
+        * 50 revisions for the sake of performance.
+        *
+        * MCR migration note: this replaces Revision::userWasLastToEdit
+        *
+        * @deprecated since 1.31; Can possibly be removed, since the self-conflict suppression
+        *       logic in EditPage that uses this seems conceptually dubious. Revision::userWasLastToEdit
+        *       has been deprecated since 1.24.
+        *
+        * @param IDatabase $db The Database to perform the check on.
+        * @param int $pageId The ID of the page in question
+        * @param int $userId The ID of the user in question
+        * @param string $since Look at edits since this time
+        *
+        * @return bool True if the given user was the only one to edit since the given timestamp
+        */
+       public function userWasLastToEdit( IDatabase $db, $pageId, $userId, $since ) {
+               $this->checkDatabaseWikiId( $db );
+
+               if ( !$userId ) {
+                       return false;
+               }
+
+               $res = $db->select(
+                       'revision',
+                       'rev_user',
+                       [
+                               'rev_page' => $pageId,
+                               'rev_timestamp > ' . $db->addQuotes( $db->timestamp( $since ) )
+                       ],
+                       __METHOD__,
+                       [ 'ORDER BY' => 'rev_timestamp ASC', 'LIMIT' => 50 ]
+               );
+               foreach ( $res as $row ) {
+                       if ( $row->rev_user != $userId ) {
+                               return false;
+                       }
+               }
+               return true;
+       }
+
+       /**
+        * Load a revision based on a known page ID and current revision ID from the DB
+        *
+        * This method allows for the use of caching, though accessing anything that normally
+        * requires permission checks (aside from the text) will trigger a small DB lookup.
+        *
+        * MCR migration note: this replaces Revision::newKnownCurrent
+        *
+        * @param Title $title the associated page title
+        * @param int $revId current revision of this page. Defaults to $title->getLatestRevID().
+        *
+        * @return RevisionRecord|bool Returns false if missing
+        */
+       public function getKnownCurrentRevision( Title $title, $revId ) {
+               $db = $this->getDBConnectionRef( DB_REPLICA );
+
+               $pageId = $title->getArticleID();
+
+               if ( !$pageId ) {
+                       return false;
+               }
+
+               if ( !$revId ) {
+                       $revId = $title->getLatestRevID();
+               }
+
+               if ( !$revId ) {
+                       wfWarn(
+                               'No latest revision known for page ' . $title->getPrefixedDBkey()
+                               . ' even though it exists with page ID ' . $pageId
+                       );
+                       return false;
+               }
+
+               $row = $this->cache->getWithSetCallback(
+                       // Page/rev IDs passed in from DB to reflect history merges
+                       $this->cache->makeGlobalKey( 'revision-row-1.29', $db->getDomainID(), $pageId, $revId ),
+                       WANObjectCache::TTL_WEEK,
+                       function ( $curValue, &$ttl, array &$setOpts ) use ( $db, $pageId, $revId ) {
+                               $setOpts += Database::getCacheSetOptions( $db );
+
+                               $conds = [
+                                       'rev_page' => intval( $pageId ),
+                                       'page_id' => intval( $pageId ),
+                                       'rev_id' => intval( $revId ),
+                               ];
+
+                               $row = $this->fetchRevisionRowFromConds( $db, $conds );
+                               return $row ?: false; // don't cache negatives
+                       }
+               );
+
+               // Reflect revision deletion and user renames
+               if ( $row ) {
+                       return $this->newRevisionFromRow( $row, 0, $title );
+               } else {
+                       return false;
+               }
+       }
+
+       // TODO: move relevant methods from Title here, e.g. getFirstRevision, isBigDeletion, etc.
+
+}
diff --git a/includes/Storage/RevisionStoreRecord.php b/includes/Storage/RevisionStoreRecord.php
new file mode 100644 (file)
index 0000000..50ae8d5
--- /dev/null
@@ -0,0 +1,207 @@
+<?php
+/**
+ * A RevisionRecord representing an existing revision persisted in the revision table.
+ *
+ * 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\Storage;
+
+use CommentStoreComment;
+use InvalidArgumentException;
+use MediaWiki\User\UserIdentity;
+use Title;
+use User;
+use Wikimedia\Assert\Assert;
+
+/**
+ * A RevisionRecord representing an existing revision persisted in the revision table.
+ * RevisionStoreRecord has no optional fields, getters will never return null.
+ *
+ * @since 1.31
+ */
+class RevisionStoreRecord extends RevisionRecord {
+
+       /** @var bool */
+       protected $mCurrent = false;
+
+       /**
+        * @note Avoid calling this constructor directly. Use the appropriate methods
+        * in RevisionStore instead.
+        *
+        * @param Title $title The title of the page this Revision is associated with.
+        * @param UserIdentity $user
+        * @param CommentStoreComment $comment
+        * @param object $row A row from the revision table. Use RevisionStore::getQueryInfo() to build
+        *        a query that yields the required fields.
+        * @param RevisionSlots $slots The slots of this revision.
+        * @param bool|string $wikiId the wiki ID of the site this Revision belongs to,
+        *        or false for the local site.
+        */
+       function __construct(
+               Title $title,
+               UserIdentity $user,
+               CommentStoreComment $comment,
+               $row,
+               RevisionSlots $slots,
+               $wikiId = false
+       ) {
+               parent::__construct( $title, $slots, $wikiId );
+               Assert::parameterType( 'object', $row, '$row' );
+
+               $this->mId = intval( $row->rev_id );
+               $this->mPageId = intval( $row->rev_page );
+               $this->mComment = $comment;
+
+               $timestamp = wfTimestamp( TS_MW, $row->rev_timestamp );
+               Assert::parameter( is_string( $timestamp ), '$row->rev_timestamp', 'must be a valid timestamp' );
+
+               $this->mUser = $user;
+               $this->mMinorEdit = boolval( $row->rev_minor_edit );
+               $this->mTimestamp = $timestamp;
+               $this->mDeleted = intval( $row->rev_deleted );
+
+               // NOTE: rev_parent_id = 0 indicates that there is no parent revision, while null
+               // indicates that the parent revision is unknown. As per MW 1.31, the database schema
+               // allows rev_parent_id to be NULL.
+               $this->mParentId = isset( $row->rev_parent_id ) ? intval( $row->rev_parent_id ) : null;
+               $this->mSize = isset( $row->rev_len ) ? intval( $row->rev_len ) : null;
+               $this->mSha1 = isset( $row->rev_sha1 ) ? $row->rev_sha1 : null;
+
+               // NOTE: we must not call $this->mTitle->getLatestRevID() here, since the state of
+               // page_latest may be in limbo during revision creation. In that case, calling
+               // $this->mTitle->getLatestRevID() would cause a bad value to be cached in the Title
+               // object. During page creation, that bad value would be 0.
+               if ( isset( $row->page_latest ) ) {
+                       $this->mCurrent = ( $row->rev_id == $row->page_latest );
+               }
+
+               // sanity check
+               if (
+                       $this->mPageId && $this->mTitle->exists()
+                       && $this->mPageId !== $this->mTitle->getArticleID()
+               ) {
+                       throw new InvalidArgumentException(
+                               'The given Title does not belong to page ID ' . $this->mPageId
+                       );
+               }
+       }
+
+       /**
+        * MCR migration note: this replaces Revision::isCurrent
+        *
+        * @return bool
+        */
+       public function isCurrent() {
+               return $this->mCurrent;
+       }
+
+       /**
+        * MCR migration note: this replaces Revision::isDeleted
+        *
+        * @param int $field One of DELETED_* bitfield constants
+        *
+        * @return bool
+        */
+       public function isDeleted( $field ) {
+               if ( $this->isCurrent() && $field === self::DELETED_TEXT ) {
+                       // Current revisions of pages cannot have the content hidden. Skipping this
+                       // check is very useful for Parser as it fetches templates using newKnownCurrent().
+                       // Calling getVisibility() in that case triggers a verification database query.
+                       return false; // no need to check
+               }
+
+               return parent::isDeleted( $field );
+       }
+
+       protected function userCan( $field, User $user ) {
+               if ( $this->isCurrent() && $field === self::DELETED_TEXT ) {
+                       // Current revisions of pages cannot have the content hidden. Skipping this
+                       // check is very useful for Parser as it fetches templates using newKnownCurrent().
+                       // Calling getVisibility() in that case triggers a verification database query.
+                       return true; // no need to check
+               }
+
+               return parent::userCan( $field, $user );
+       }
+
+       /**
+        * @return int The revision id, never null.
+        */
+       public function getId() {
+               // overwritten just to add a guarantee to the contract
+               return parent::getId();
+       }
+
+       /**
+        * @return string The nominal revision size, never null. May be computed on the fly.
+        */
+       public function getSize() {
+               // If length is null, calculate and remember it (potentially SLOW!).
+               // This is for compatibility with old database rows that don't have the field set.
+               if ( $this->mSize === null ) {
+                       $this->mSize = $this->mSlots->computeSize();
+               }
+
+               return $this->mSize;
+       }
+
+       /**
+        * @return string The revision hash, never null. May be computed on the fly.
+        */
+       public function getSha1() {
+               // If hash is null, calculate it and remember (potentially SLOW!)
+               // This is for compatibility with old database rows that don't have the field set.
+               if ( $this->mSha1 === null ) {
+                       $this->mSha1 = $this->mSlots->computeSha1();
+               }
+
+               return $this->mSha1;
+       }
+
+       /**
+        * @param int $audience
+        * @param User|null $user
+        *
+        * @return UserIdentity The identity of the revision author, null if access is forbidden.
+        */
+       public function getUser( $audience = self::FOR_PUBLIC, User $user = null ) {
+               // overwritten just to add a guarantee to the contract
+               return parent::getUser( $audience, $user );
+       }
+
+       /**
+        * @param int $audience
+        * @param User|null $user
+        *
+        * @return CommentStoreComment The revision comment, null if access is forbidden.
+        */
+       public function getComment( $audience = self::FOR_PUBLIC, User $user = null ) {
+               // overwritten just to add a guarantee to the contract
+               return parent::getComment( $audience, $user );
+       }
+
+       /**
+        * @return string timestamp, never null
+        */
+       public function getTimestamp() {
+               // overwritten just to add a guarantee to the contract
+               return parent::getTimestamp();
+       }
+
+}
diff --git a/includes/Storage/SlotRecord.php b/includes/Storage/SlotRecord.php
new file mode 100644 (file)
index 0000000..8769330
--- /dev/null
@@ -0,0 +1,430 @@
+<?php
+/**
+ * Value object representing a content slot associated with a page revision.
+ *
+ * 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\Storage;
+
+use Content;
+use LogicException;
+use OutOfBoundsException;
+use Wikimedia\Assert\Assert;
+
+/**
+ * Value object representing a content slot associated with a page revision.
+ * SlotRecord provides direct access to a Content object.
+ * That access may be implemented through a callback.
+ *
+ * @since 1.31
+ */
+class SlotRecord {
+
+       /**
+        * @var object database result row, as a raw object
+        */
+       private $row;
+
+       /**
+        * @var Content|callable
+        */
+       private $content;
+
+       /**
+        * Returns a new SlotRecord just like the given $slot, except that calling getContent()
+        * will fail with an exception.
+        *
+        * @param SlotRecord $slot
+        *
+        * @return SlotRecord
+        */
+       public static function newWithSuppressedContent( SlotRecord $slot ) {
+               $row = $slot->row;
+
+               return new SlotRecord( $row, function () {
+                       throw new SuppressedDataException( 'Content suppressed!' );
+               } );
+       }
+
+       /**
+        * Constructs a new SlotRecord from an existing SlotRecord, overriding some fields.
+        * The slot's content cannot be overwritten.
+        *
+        * @param SlotRecord $slot
+        * @param array $overrides
+        *
+        * @return SlotRecord
+        */
+       private static function newDerived( SlotRecord $slot, array $overrides = [] ) {
+               $row = $slot->row;
+
+               foreach ( $overrides as $key => $value ) {
+                       $row->$key = $value;
+               }
+
+               return new SlotRecord( $row, $slot->content );
+       }
+
+       /**
+        * Constructs a new SlotRecord for a new revision, inheriting the content of the given SlotRecord
+        * of a previous revision.
+        *
+        * @param SlotRecord $slot
+        *
+        * @return SlotRecord
+        */
+       public static function newInherited( SlotRecord $slot ) {
+               return self::newDerived( $slot, [
+                       'slot_inherited' => true,
+                       'slot_revision' => null,
+               ] );
+       }
+
+       /**
+        * Constructs a new Slot from a Content object for a new revision.
+        * This is the preferred way to construct a slot for storing Content that
+        * resulted from a user edit.
+        *
+        * @param string $role
+        * @param Content $content
+        * @param bool $inherited
+        *
+        * @return SlotRecord
+        */
+       public static function newUnsaved( $role, Content $content, $inherited = false ) {
+               Assert::parameterType( 'boolean', $inherited, '$inherited' );
+               Assert::parameterType( 'string', $role, '$role' );
+
+               $row = [
+                       'slot_id' => null, // not yet known
+                       'slot_address' => null, // not yet known. need setter?
+                       'slot_revision' => null, // not yet known
+                       'slot_inherited' => $inherited,
+                       'cont_size' => null, // compute later
+                       'cont_sha1' => null, // compute later
+                       'role_name' => $role,
+                       'model_name' => $content->getModel(),
+               ];
+
+               return new SlotRecord( (object)$row, $content );
+       }
+
+       /**
+        * Constructs a SlotRecord for a newly saved revision, based on the proto-slot that was
+        * supplied to the code that performed the save operation. This adds information that
+        * has only become available during saving, particularly the revision ID and blob address.
+        *
+        * @param int $revisionId
+        * @param string $blobAddress
+        * @param SlotRecord $protoSlot The proto-slot that was provided to the code that then
+        *
+        * @return SlotRecord
+        */
+       public static function newSaved( $revisionId, $blobAddress, SlotRecord $protoSlot ) {
+               Assert::parameterType( 'integer', $revisionId, '$revisionId' );
+               Assert::parameterType( 'string', $blobAddress, '$blobAddress' );
+
+               return self::newDerived( $protoSlot, [
+                       'slot_revision' => $revisionId,
+                       'cont_address' => $blobAddress,
+               ] );
+       }
+
+       /**
+        * SlotRecord constructor.
+        *
+        * The following fields are supported by the $row parameter:
+        *
+        *   $row->blob_data
+        *   $row->blob_address
+        *
+        * @param object $row A database row composed of fields of the slot and content tables,
+        *        as a raw object. Any field value can be a callback that produces the field value
+        *        given this SlotRecord as a parameter. However, plain strings cannot be used as
+        *        callbacks here, for security reasons.
+        * @param Content|callable $content The content object associated with the slot, or a
+        *        callback that will return that Content object, given this SlotRecord as a parameter.
+        */
+       public function __construct( $row, $content ) {
+               Assert::parameterType( 'object', $row, '$row' );
+               Assert::parameterType( 'Content|callable', $content, '$content' );
+
+               $this->row = $row;
+               $this->content = $content;
+       }
+
+       /**
+        * Implemented to defy serialization.
+        *
+        * @throws LogicException always
+        */
+       public function __sleep() {
+               throw new LogicException( __CLASS__ . ' is not serializable.' );
+       }
+
+       /**
+        * Returns the Content of the given slot.
+        *
+        * @note This is free to load Content from whatever subsystem is necessary,
+        * performing potentially expensive operations and triggering I/O-related
+        * failure modes.
+        *
+        * @note This method does not apply audience filtering.
+        *
+        * @throws SuppressedDataException if access to the content is not allowed according
+        * to the audience check performed by RevisionRecord::getSlot().
+        *
+        * @return Content The slot's content. This is a direct reference to the internal instance,
+        * copy before exposing to application logic!
+        */
+       public function getContent() {
+               if ( $this->content instanceof Content ) {
+                       return $this->content;
+               }
+
+               $obj = call_user_func( $this->content, $this );
+
+               Assert::postcondition(
+                       $obj instanceof Content,
+                       'Slot content callback should return a Content object'
+               );
+
+               $this->content = $obj;
+
+               return $this->content;
+       }
+
+       /**
+        * Returns the string value of a data field from the database row supplied to the constructor.
+        * If the field was set to a callback, that callback is invoked and the result returned.
+        *
+        * @param string $name
+        *
+        * @throws OutOfBoundsException
+        * @return mixed Returns the field's value, or null if the field is NULL in the DB row.
+        */
+       private function getField( $name ) {
+               if ( !isset( $this->row->$name ) ) {
+                       // distinguish between unknown and uninitialized fields
+                       if ( property_exists( $this->row, $name ) ) {
+                               throw new IncompleteRevisionException( 'Uninitialized field: ' . $name );
+                       } else {
+                               throw new OutOfBoundsException( 'No such field: ' . $name );
+                       }
+               }
+
+               $value = $this->row->$name;
+
+               // NOTE: allow callbacks, but don't trust plain string callables from the database!
+               if ( !is_string( $value ) && is_callable( $value ) ) {
+                       $value = call_user_func( $value, $this );
+                       $this->setField( $name, $value );
+               }
+
+               return $value;
+       }
+
+       /**
+        * Returns the string value of a data field from the database row supplied to the constructor.
+        *
+        * @param string $name
+        *
+        * @throws OutOfBoundsException
+        * @throws IncompleteRevisionException
+        * @return string Returns the string value
+        */
+       private function getStringField( $name ) {
+               return strval( $this->getField( $name ) );
+       }
+
+       /**
+        * Returns the int value of a data field from the database row supplied to the constructor.
+        *
+        * @param string $name
+        *
+        * @throws OutOfBoundsException
+        * @throws IncompleteRevisionException
+        * @return int Returns the int value
+        */
+       private function getIntField( $name ) {
+               return intval( $this->getField( $name ) );
+       }
+
+       /**
+        * @param string $name
+        * @return bool whether this record contains the given field
+        */
+       private function hasField( $name ) {
+               return isset( $this->row->$name );
+       }
+
+       /**
+        * Returns the ID of the revision this slot is associated with.
+        *
+        * @return int
+        */
+       public function getRevision() {
+               return $this->getIntField( 'slot_revision' );
+       }
+
+       /**
+        * Whether this slot was inherited from an older revision.
+        *
+        * @return bool
+        */
+       public function isInherited() {
+               return $this->getIntField( 'slot_inherited' ) !== 0;
+       }
+
+       /**
+        * Whether this slot has an address. Slots will have an address if their
+        * content has been stored. While building a new revision,
+        * SlotRecords will not have an address associated.
+        *
+        * @return bool
+        */
+       public function hasAddress() {
+               return $this->hasField( 'cont_address' );
+       }
+
+       /**
+        * Whether this slot has revision ID associated. Slots will have a revision ID associated
+        * only if they were loaded as part of an existing revision. While building a new revision,
+        * Slotrecords will not have a revision ID associated.
+        *
+        * @return bool
+        */
+       public function hasRevision() {
+               return $this->hasField( 'slot_revision' );
+       }
+
+       /**
+        * Returns the role of the slot.
+        *
+        * @return string
+        */
+       public function getRole() {
+               return $this->getStringField( 'role_name' );
+       }
+
+       /**
+        * Returns the address of this slot's content.
+        * This address can be used with BlobStore to load the Content object.
+        *
+        * @return string
+        */
+       public function getAddress() {
+               return $this->getStringField( 'cont_address' );
+       }
+
+       /**
+        * Returns the content size
+        *
+        * @return int size of the content, in bogo-bytes, as reported by Content::getSize.
+        */
+       public function getSize() {
+               try {
+                       $size = $this->getIntField( 'cont_size' );
+               } catch ( IncompleteRevisionException $ex ) {
+                       $size = $this->getContent()->getSize();
+                       $this->setField( 'cont_size', $size );
+               }
+
+               return $size;
+       }
+
+       /**
+        * Returns the content size
+        *
+        * @return string hash of the content.
+        */
+       public function getSha1() {
+               try {
+                       $sha1 = $this->getStringField( 'cont_sha1' );
+               } catch ( IncompleteRevisionException $ex ) {
+                       $format = $this->hasField( 'format_name' )
+                               ? $this->getStringField( 'format_name' )
+                               : null;
+
+                       $data = $this->getContent()->serialize( $format );
+                       $sha1 = self::base36Sha1( $data );
+                       $this->setField( 'cont_sha1', $sha1 );
+               }
+
+               return $sha1;
+       }
+
+       /**
+        * Returns the content model. This is the model name that decides
+        * which ContentHandler is appropriate for interpreting the
+        * data of the blob referenced by the address returned by getAddress().
+        *
+        * @return string the content model of the content
+        */
+       public function getModel() {
+               try {
+                       $model = $this->getStringField( 'model_name' );
+               } catch ( IncompleteRevisionException $ex ) {
+                       $model = $this->getContent()->getModel();
+                       $this->setField( 'model_name', $model );
+               }
+
+               return $model;
+       }
+
+       /**
+        * Returns the blob serialization format as a MIME type.
+        *
+        * @note When this method returns null, the caller is expected
+        * to auto-detect the serialization format, or to rely on
+        * the default format associated with the content model.
+        *
+        * @return string|null
+        */
+       public function getFormat() {
+               // XXX: we currently do not plan to store the format for each slot!
+
+               if ( $this->hasField( 'format_name' ) ) {
+                       return $this->getStringField( 'format_name' );
+               }
+
+               return null;
+       }
+
+       /**
+        * @param string $name
+        * @param string|int|null $value
+        */
+       private function setField( $name, $value ) {
+               $this->row->$name = $value;
+       }
+
+       /**
+        * Get the base 36 SHA-1 value for a string of text
+        *
+        * MCR migration note: this replaces Revision::base36Sha1
+        *
+        * @param string $blob
+        * @return string
+        */
+       public static function base36Sha1( $blob ) {
+               return \Wikimedia\base_convert( sha1( $blob ), 16, 36, 31 );
+       }
+
+}
diff --git a/includes/Storage/SqlBlobStore.php b/includes/Storage/SqlBlobStore.php
new file mode 100644 (file)
index 0000000..0714633
--- /dev/null
@@ -0,0 +1,580 @@
+<?php
+/**
+ * Service for storing and loading data blobs representing revision content.
+ *
+ * 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
+ *
+ * Attribution notice: when this file was created, much of its content was taken
+ * from the Revision.php file as present in release 1.30. Refer to the history
+ * of that file for original authorship.
+ *
+ * @file
+ */
+
+namespace MediaWiki\Storage;
+
+use DBAccessObjectUtils;
+use ExternalStore;
+use IDBAccessObject;
+use IExpiringStore;
+use InvalidArgumentException;
+use Language;
+use MWException;
+use WANObjectCache;
+use Wikimedia\Assert\Assert;
+use Wikimedia\Rdbms\IDatabase;
+use Wikimedia\Rdbms\LoadBalancer;
+
+/**
+ * Service for storing and loading Content objects.
+ *
+ * @since 1.31
+ *
+ * @note This was written to act as a drop-in replacement for the corresponding
+ *       static methods in Revision.
+ */
+class SqlBlobStore implements IDBAccessObject, BlobStore {
+
+       // Note: the name has been taken unchanged from the Revision class.
+       const TEXT_CACHE_GROUP = 'revisiontext:10';
+
+       /**
+        * @var LoadBalancer
+        */
+       private $dbLoadBalancer;
+
+       /**
+        * @var WANObjectCache
+        */
+       private $cache;
+
+       /**
+        * @var bool|string Wiki ID
+        */
+       private $wikiId;
+
+       /**
+        * @var int
+        */
+       private $cacheExpiry = 604800; // 7 days
+
+       /**
+        * @var bool
+        */
+       private $compressBlobs = false;
+
+       /**
+        * @var bool|string
+        */
+       private $legacyEncoding = false;
+
+       /**
+        * @var Language|null
+        */
+       private $legacyEncodingConversionLang = null;
+
+       /**
+        * @var boolean
+        */
+       private $useExternalStore = false;
+
+       /**
+        * @param LoadBalancer $dbLoadBalancer A load balancer for acquiring database connections
+        * @param WANObjectCache $cache A cache manager for caching blobs
+        * @param bool|string $wikiId The ID of the target wiki database. Use false for the local wiki.
+        */
+       public function __construct(
+               LoadBalancer $dbLoadBalancer,
+               WANObjectCache $cache,
+               $wikiId = false
+       ) {
+               $this->dbLoadBalancer = $dbLoadBalancer;
+               $this->cache = $cache;
+               $this->wikiId = $wikiId;
+       }
+
+       /**
+        * @return int time for which blobs can be cached, in seconds
+        */
+       public function getCacheExpiry() {
+               return $this->cacheExpiry;
+       }
+
+       /**
+        * @param int $cacheExpiry time for which blobs can be cached, in seconds
+        */
+       public function setCacheExpiry( $cacheExpiry ) {
+               Assert::parameterType( 'integer', $cacheExpiry, '$cacheExpiry' );
+
+               $this->cacheExpiry = $cacheExpiry;
+       }
+
+       /**
+        * @return bool whether blobs should be compressed for storage
+        */
+       public function getCompressBlobs() {
+               return $this->compressBlobs;
+       }
+
+       /**
+        * @param bool $compressBlobs whether blobs should be compressed for storage
+        */
+       public function setCompressBlobs( $compressBlobs ) {
+               $this->compressBlobs = $compressBlobs;
+       }
+
+       /**
+        * @return false|string The legacy encoding to assume for blobs that are not marked as utf8.
+        *         False means handling of legacy encoding is disabled, and utf8 assumed.
+        */
+       public function getLegacyEncoding() {
+               return $this->legacyEncoding;
+       }
+
+       /**
+        * @return Language|null The locale to use when decoding from a legacy encoding, or null
+        *         if handling of legacy encoding is disabled.
+        */
+       public function getLegacyEncodingConversionLang() {
+               return $this->legacyEncodingConversionLang;
+       }
+
+       /**
+        * @param string $legacyEncoding The legacy encoding to assume for blobs that are
+        *        not marked as utf8.
+        * @param Language $language The locale to use when decoding from a legacy encoding.
+        */
+       public function setLegacyEncoding( $legacyEncoding, Language $language ) {
+               Assert::parameterType( 'string', $legacyEncoding, '$legacyEncoding' );
+
+               $this->legacyEncoding = $legacyEncoding;
+               $this->legacyEncodingConversionLang = $language;
+       }
+
+       /**
+        * @return bool Whether to use the ExternalStore mechanism for storing blobs.
+        */
+       public function getUseExternalStore() {
+               return $this->useExternalStore;
+       }
+
+       /**
+        * @param bool $useExternalStore Whether to use the ExternalStore mechanism for storing blobs.
+        */
+       public function setUseExternalStore( $useExternalStore ) {
+               Assert::parameterType( 'boolean', $useExternalStore, '$useExternalStore' );
+
+               $this->useExternalStore = $useExternalStore;
+       }
+
+       /**
+        * @return LoadBalancer
+        */
+       private function getDBLoadBalancer() {
+               return $this->dbLoadBalancer;
+       }
+
+       /**
+        * @param int $index A database index, like DB_MASTER or DB_REPLICA
+        *
+        * @return IDatabase
+        */
+       private function getDBConnection( $index ) {
+               $lb = $this->getDBLoadBalancer();
+               return $lb->getConnection( $index, [], $this->wikiId );
+       }
+
+       /**
+        * Stores an arbitrary blob of data and returns an address that can be used with
+        * getBlob() to retrieve the same blob of data,
+        *
+        * @param string $data
+        * @param array $hints An array of hints.
+        *
+        * @throws BlobAccessException
+        * @return string an address that can be used with getBlob() to retrieve the data.
+        */
+       public function storeBlob( $data, $hints = [] ) {
+               try {
+                       $flags = $this->compressData( $data );
+
+                       # Write to external storage if required
+                       if ( $this->useExternalStore ) {
+                               // Store and get the URL
+                               $data = ExternalStore::insertToDefault( $data );
+                               if ( !$data ) {
+                                       throw new BlobAccessException( "Failed to store text to external storage" );
+                               }
+                               if ( $flags ) {
+                                       $flags .= ',';
+                               }
+                               $flags .= 'external';
+
+                               // TODO: we could also return an address for the external store directly here.
+                               // That would mean bypassing the text table entirely when the external store is
+                               // used. We'll need to assess expected fallout before doing that.
+                       }
+
+                       $dbw = $this->getDBConnection( DB_REPLICA );
+
+                       $old_id = $dbw->nextSequenceValue( 'text_old_id_seq' );
+                       $dbw->insert(
+                               'text',
+                               [
+                                       'old_id' => $old_id,
+                                       'old_text' => $data,
+                                       'old_flags' => $flags,
+                               ],
+                               __METHOD__
+                       );
+
+                       $textId = $dbw->insertId();
+
+                       return 'tt:' . $textId;
+               } catch ( MWException $e ) {
+                       throw new BlobAccessException( $e->getMessage(), 0, $e );
+               }
+       }
+
+       /**
+        * Retrieve a blob, given an address.
+        * Currently hardcoded to the 'text' table storage engine.
+        *
+        * MCR migration note: this replaces Revision::loadText
+        *
+        * @param string $blobAddress
+        * @param int $queryFlags
+        *
+        * @throws BlobAccessException
+        * @return string
+        */
+       public function getBlob( $blobAddress, $queryFlags = 0 ) {
+               Assert::parameterType( 'string', $blobAddress, '$blobAddress' );
+
+               // No negative caching; negative hits on text rows may be due to corrupted replica DBs
+               $blob = $this->cache->getWithSetCallback(
+                       // TODO: change key, since this is not necessarily revision text!
+                       $this->cache->makeKey( 'revisiontext', 'textid', $blobAddress ),
+                       $this->getCacheTTL(),
+                       function () use ( $blobAddress, $queryFlags ) {
+                               return $this->fetchBlob( $blobAddress, $queryFlags );
+                       },
+                       [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => IExpiringStore::TTL_PROC_LONG ]
+               );
+
+               if ( $blob === false ) {
+                       throw new BlobAccessException( 'Failed to load blob from address ' . $blobAddress );
+               }
+
+               return $blob;
+       }
+
+       /**
+        * MCR migration note: this corresponds to Revision::fetchText
+        *
+        * @param string $blobAddress
+        * @param int $queryFlags
+        *
+        * @throw BlobAccessException
+        * @return string|false
+        */
+       private function fetchBlob( $blobAddress, $queryFlags ) {
+               list( $schema, $id, ) = self::splitBlobAddress( $blobAddress );
+
+               //TODO: MCR: also support 'ex' schema with ExternalStore URLs, plus flags encoded in the URL!
+               //TODO: MCR: also support 'ar' schema for content blobs in old style archive rows!
+               if ( $schema === 'tt' ) {
+                       $textId = intval( $id );
+               } else {
+                       // XXX: change to better exceptions! That makes migration more difficult, though.
+                       throw new BlobAccessException( "Unknown blob address schema: $schema" );
+               }
+
+               if ( !$textId || $id !== (string)$textId ) {
+                       // XXX: change to better exceptions! That makes migration more difficult, though.
+                       throw new BlobAccessException( "Bad blob address: $blobAddress" );
+               }
+
+               // Callers doing updates will pass in READ_LATEST as usual. Since the text/blob tables
+               // do not normally get rows changed around, set READ_LATEST_IMMUTABLE in those cases.
+               $queryFlags |= DBAccessObjectUtils::hasFlags( $queryFlags, self::READ_LATEST )
+                       ? self::READ_LATEST_IMMUTABLE
+                       : 0;
+
+               list( $index, $options, $fallbackIndex, $fallbackOptions ) =
+                       DBAccessObjectUtils::getDBOptions( $queryFlags );
+
+               // Text data is immutable; check replica DBs first.
+               $row = $this->getDBConnection( $index )->selectRow(
+                       'text',
+                       [ 'old_text', 'old_flags' ],
+                       [ 'old_id' => $textId ],
+                       __METHOD__,
+                       $options
+               );
+
+               // Fallback to DB_MASTER in some cases if the row was not found, using the appropriate
+               // options, such as FOR UPDATE to avoid missing rows due to REPEATABLE-READ.
+               if ( !$row && $fallbackIndex !== null ) {
+                       $row = $this->getDBConnection( $fallbackIndex )->selectRow(
+                               'text',
+                               [ 'old_text', 'old_flags' ],
+                               [ 'old_id' => $textId ],
+                               __METHOD__,
+                               $fallbackOptions
+                       );
+               }
+
+               if ( !$row ) {
+                       wfWarn( __METHOD__ . ": No text row with ID $textId." );
+                       return false;
+               }
+
+               $blob = $this->expandBlob( $row->old_text, $row->old_flags, $blobAddress );
+
+               if ( $blob === false ) {
+                       wfWarn( __METHOD__ . ": Bad data in text row $textId." );
+                       return false;
+               }
+
+               return $blob;
+       }
+
+       /**
+        * Expand a raw data blob according to the flags given.
+        *
+        * MCR migration note: this replaces Revision::getRevisionText
+        *
+        * @note direct use is deprecated, use getBlob() or SlotRecord::getContent() instead.
+        * @todo make this private, there should be no need to use this method outside this class.
+        *
+        * @param string $raw The raw blob data, to be processed according to $flags.
+        *        May be the blob itself, or the blob compressed, or just the address
+        *        of the actual blob, depending on $flags.
+        * @param string|string[] $flags Blob flags, such as 'external' or 'gzip'.
+        * @param string|null $cacheKey May be used for caching if given
+        *
+        * @return false|string The expanded blob or false on failure
+        */
+       public function expandBlob( $raw, $flags, $cacheKey = null ) {
+               if ( is_string( $flags ) ) {
+                       $flags = explode( ',', $flags );
+               }
+
+               // Use external methods for external objects, text in table is URL-only then
+               if ( in_array( 'external', $flags ) ) {
+                       $url = $raw;
+                       $parts = explode( '://', $url, 2 );
+                       if ( count( $parts ) == 1 || $parts[1] == '' ) {
+                               return false;
+                       }
+
+                       if ( $cacheKey ) {
+                               // Make use of the wiki-local revision text cache.
+                               // The cached value should be decompressed, so handle that and return here.
+                               // NOTE: we rely on $this->cache being the right cache for $this->wikiId!
+                               return $this->cache->getWithSetCallback(
+                                       // TODO: change key, since this is not necessarily revision text!
+                                       $this->cache->makeKey( 'revisiontext', 'textid', $cacheKey ),
+                                       $this->getCacheTTL(),
+                                       function () use ( $url, $flags ) {
+                                               // No negative caching per BlobStore::getBlob()
+                                               $blob = ExternalStore::fetchFromURL( $url, [ 'wiki' => $this->wikiId ] );
+
+                                               return $this->decompressData( $blob, $flags );
+                                       },
+                                       [ 'pcGroup' => self::TEXT_CACHE_GROUP, 'pcTTL' => WANObjectCache::TTL_PROC_LONG ]
+                               );
+                       } else {
+                               $blob = ExternalStore::fetchFromURL( $url, [ 'wiki' => $this->wikiId ] );
+                               return $this->decompressData( $blob, $flags );
+                       }
+               } else {
+                       return $this->decompressData( $raw, $flags );
+               }
+       }
+
+       /**
+        * If $wgCompressRevisions is enabled, we will compress data.
+        * The input string is modified in place.
+        * Return value is the flags field: contains 'gzip' if the
+        * data is compressed, and 'utf-8' if we're saving in UTF-8
+        * mode.
+        *
+        * MCR migration note: this replaces Revision::compressRevisionText
+        *
+        * @note direct use is deprecated!
+        * @todo make this private, there should be no need to use this method outside this class.
+        *
+        * @param mixed &$blob Reference to a text
+        *
+        * @return string
+        */
+       public function compressData( &$blob ) {
+               $blobFlags = [];
+
+               // Revisions not marked as UTF-8 will have legacy decoding applied by decompressData().
+               // XXX: if $this->legacyEncoding is not set, we could skip this. May be risky, though.
+               $blobFlags[] = 'utf-8';
+
+               if ( $this->compressBlobs ) {
+                       if ( function_exists( 'gzdeflate' ) ) {
+                               $deflated = gzdeflate( $blob );
+
+                               if ( $deflated === false ) {
+                                       wfLogWarning( __METHOD__ . ': gzdeflate() failed' );
+                               } else {
+                                       $blob = $deflated;
+                                       $blobFlags[] = 'gzip';
+                               }
+                       } else {
+                               wfDebug( __METHOD__ . " -- no zlib support, not compressing\n" );
+                       }
+               }
+               return implode( ',', $blobFlags );
+       }
+
+       /**
+        * Re-converts revision text according to its flags.
+        *
+        * MCR migration note: this replaces Revision::decompressRevisionText
+        *
+        * @note direct use is deprecated, use getBlob() or SlotRecord::getContent() instead.
+        * @todo make this private, there should be no need to use this method outside this class.
+        *
+        * @param mixed $blob Reference to a text
+        * @param array $blobFlags Compression flags
+        *
+        * @return string|bool Decompressed text, or false on failure
+        */
+       public function decompressData( $blob, $blobFlags ) {
+               if ( $blob === false ) {
+                       // Text failed to be fetched; nothing to do
+                       return false;
+               }
+
+               if ( in_array( 'gzip', $blobFlags ) ) {
+                       # Deal with optional compression of archived pages.
+                       # This can be done periodically via maintenance/compressOld.php, and
+                       # as pages are saved if $wgCompressRevisions is set.
+                       $blob = gzinflate( $blob );
+
+                       if ( $blob === false ) {
+                               wfLogWarning( __METHOD__ . ': gzinflate() failed' );
+                               return false;
+                       }
+               }
+
+               if ( in_array( 'object', $blobFlags ) ) {
+                       # Generic compressed storage
+                       $obj = unserialize( $blob );
+                       if ( !is_object( $obj ) ) {
+                               // Invalid object
+                               return false;
+                       }
+                       $blob = $obj->getText();
+               }
+
+               // Needed to support old revisions left over from from the 1.4 / 1.5 migration.
+               if ( $blob !== false && $this->legacyEncoding && $this->legacyEncodingConversionLang
+                       && !in_array( 'utf-8', $blobFlags ) && !in_array( 'utf8', $blobFlags )
+               ) {
+                       # Old revisions kept around in a legacy encoding?
+                       # Upconvert on demand.
+                       # ("utf8" checked for compatibility with some broken
+                       #  conversion scripts 2008-12-30)
+                       $blob = $this->legacyEncodingConversionLang->iconv( $this->legacyEncoding, 'UTF-8', $blob );
+               }
+
+               return $blob;
+       }
+
+       /**
+        * Get the text cache TTL
+        *
+        * MCR migration note: this replaces Revision::getCacheTTL
+        *
+        * @return int
+        */
+       private function getCacheTTL() {
+               if ( $this->cache->getQoS( WANObjectCache::ATTR_EMULATION )
+                               <= WANObjectCache::QOS_EMULATION_SQL
+               ) {
+                       // Do not cache RDBMs blobs in...the RDBMs store
+                       $ttl = WANObjectCache::TTL_UNCACHEABLE;
+               } else {
+                       $ttl = $this->cacheExpiry ?: WANObjectCache::TTL_UNCACHEABLE;
+               }
+
+               return $ttl;
+       }
+
+       /**
+        * Returns an ID corresponding to the old_id field in the text table, corresponding
+        * to the given $address.
+        *
+        * Currently, $address must start with 'tt:' followed by a decimal integer representing
+        * the old_id; if $address does not start with 'tt:', null is returned. However,
+        * the implementation may change to insert rows into the text table on the fly.
+        *
+        * @note This method exists for use with the text table based storage schema.
+        * It should not be assumed that is will function with all future kinds of content addresses.
+        *
+        * @deprecated since 1.31, so not assume that all blob addresses refer to a row in the text
+        * table. This method should become private once the relevant refactoring in WikiPage is
+        * complete.
+        *
+        * @param string $address
+        *
+        * @return int|null
+        */
+       public function getTextIdFromAddress( $address ) {
+               list( $schema, $id, ) = self::splitBlobAddress( $address );
+
+               if ( $schema !== 'tt' ) {
+                       return null;
+               }
+
+               $textId = intval( $id );
+
+               if ( !$textId || $id !== (string)$textId ) {
+                       throw new InvalidArgumentException( "Malformed text_id: $id" );
+               }
+
+               return $textId;
+       }
+
+       /**
+        * Splits a blob address into three parts: the schema, the ID, and parameters/flags.
+        *
+        * @param string $address
+        *
+        * @throws InvalidArgumentException
+        * @return array [ $schema, $id, $parameters ], with $parameters being an assoc array.
+        */
+       private static function splitBlobAddress( $address ) {
+               if ( !preg_match( '/^(\w+):(\w+)(\?(.*))?$/', $address, $m ) ) {
+                       throw new InvalidArgumentException( "Bad blob address: $address" );
+               }
+
+               $schema = strtolower( $m[1] );
+               $id = $m[2];
+               $parameters = isset( $m[4] ) ? wfCgiToArray( $m[4] ) : [];
+
+               return [ $schema, $id, $parameters ];
+       }
+
+}
diff --git a/includes/Storage/SuppressedDataException.php b/includes/Storage/SuppressedDataException.php
new file mode 100644 (file)
index 0000000..24f16a6
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+/**
+ * Exception representing a failure to look up a revision.
+ *
+ * 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\Storage;
+
+/**
+ * Exception raised in response to an audience check when attempting to
+ * access suppressed information without permission.
+ *
+ * @since 1.31
+ */
+class SuppressedDataException extends RevisionAccessException {
+
+}
index f0c4180..2c681f5 100644 (file)
@@ -272,6 +272,16 @@ class ApiQuerySearch extends ApiQueryGeneratorBase {
                if ( isset( $prop['isfilematch'] ) ) {
                        $vals['isfilematch'] = $result->isFileMatch();
                }
+
+               if ( isset( $prop['extensiondata'] ) ) {
+                       $extra = $result->getExtensionData();
+                       // Add augmented data to the result. The data would be organized as a map:
+                       // augmentorName => data
+                       if ( $extra ) {
+                               $vals['extensiondata'] = ApiResult::addMetadataToResultVars( $extra );
+                       }
+               }
+
                return $vals;
        }
 
@@ -372,6 +382,7 @@ class ApiQuerySearch extends ApiQueryGeneratorBase {
                                        'categorysnippet',
                                        'score', // deprecated
                                        'hasrelated', // deprecated
+                                       'extensiondata',
                                ],
                                ApiBase::PARAM_ISMULTI => true,
                                ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
index ba8d2f9..85b65cf 100644 (file)
        "apihelp-query+search-param-prop": "Eigenschaften zur Rückgabe:",
        "apihelp-query+search-param-qiprofile": "Zu verwendendes anfrageunabhängiges Profil (wirkt sich auf den Ranking-Algorithmus aus).",
        "apihelp-query+search-paramvalue-prop-wordcount": "Ergänzt den Wortzähler der Seite.",
+       "apihelp-query+search-paramvalue-prop-extensiondata": "Ergänzt zusätzliche von Erweiterungen erzeugte Daten.",
        "apihelp-query+search-param-limit": "Wie viele Seiten insgesamt zurückgegeben werden sollen.",
        "apihelp-query+search-example-simple": "Nach <kbd>meaning</kbd> suchen.",
        "apihelp-query+search-example-text": "Texte nach <kbd>meaning</kbd> durchsuchen.",
index 91c3e18..e1360c8 100644 (file)
        "apihelp-query+search-paramvalue-prop-sectiontitle": "Adds the title of the matching section.",
        "apihelp-query+search-paramvalue-prop-categorysnippet": "Adds a parsed snippet of the matching category.",
        "apihelp-query+search-paramvalue-prop-isfilematch": "Adds a boolean indicating if the search matched file content.",
+       "apihelp-query+search-paramvalue-prop-extensiondata": "Adds extra data generated by extensions.",
        "apihelp-query+search-paramvalue-prop-score": "Ignored.",
        "apihelp-query+search-paramvalue-prop-hasrelated": "Ignored.",
        "apihelp-query+search-param-limit": "How many total pages to return.",
index b84057e..ef5f50d 100644 (file)
        "apihelp-query+search-paramvalue-prop-sectiontitle": "Añade el título de la sección correspondiente.",
        "apihelp-query+search-paramvalue-prop-categorysnippet": "Añade un fragmento analizado de la categoría correspondiente.",
        "apihelp-query+search-paramvalue-prop-isfilematch": "Añade un booleano que indica si la búsqueda corresponde al contenido del archivo.",
+       "apihelp-query+search-paramvalue-prop-extensiondata": "Añade datos adicionales generados por las extensiones.",
        "apihelp-query+search-paramvalue-prop-score": "Ignorado.",
        "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorado",
        "apihelp-query+search-param-limit": "Cuántas páginas en total se devolverán.",
index a56b42f..d9bf39c 100644 (file)
@@ -28,7 +28,8 @@
                        "Pols12",
                        "The RedBurn",
                        "Umherirrender",
-                       "Thibaut120094"
+                       "Thibaut120094",
+                       "KATRINE1992"
                ]
        },
        "apihelp-main-extended-description": "<div class=\"hlist plainlinks api-main-links\">\n* [[mw:Special:MyLanguage/API:Main_page|Documentation]]\n* [[mw:Special:MyLanguage/API:FAQ|FAQ]]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api Liste de diffusion]\n* [https://lists.wikimedia.org/mailman/listinfo/mediawiki-api-announce Annonces de l’API]\n* [https://phabricator.wikimedia.org/maniphest/query/GebfyV4uCaLd/#R Bogues et demandes]\n</div>\n<strong>État :</strong> Toutes les fonctionnalités affichées sur cette page devraient fonctionner, mais l’API est encore en cours de développement et peut changer à tout moment. Inscrivez-vous à [https://lists.wikimedia.org/pipermail/mediawiki-api-announce/ la liste de diffusion mediawiki-api-announce] pour être informé des mises à jour.\n\n<strong>Requêtes erronées :</strong> Si des requêtes erronées sont envoyées à l’API, un entête HTTP sera renvoyé avec la clé « MediaWiki-API-Error ». La valeur de cet entête et le code d’erreur renvoyé prendront la même valeur. Pour plus d’information, voyez [[mw:Special:MyLanguage/API:Errors_and_warnings|API: Errors and warnings]].\n\n<strong>Test :</strong> Pour faciliter le test des requêtes de l’API, voyez [[Special:ApiSandbox]].",
        "apihelp-query+search-paramvalue-prop-sectiontitle": "Ajoute le titre de la section correspondante.",
        "apihelp-query+search-paramvalue-prop-categorysnippet": "Ajoute un extrait analysé de la catégorie correspondante.",
        "apihelp-query+search-paramvalue-prop-isfilematch": "Ajoute un booléen indiquant si la recherche correspond au contenu du fichier.",
+       "apihelp-query+search-paramvalue-prop-extensiondata": "Va ajouter des données générées supplémentaires par extension.",
        "apihelp-query+search-paramvalue-prop-score": "Ignoré.",
        "apihelp-query+search-paramvalue-prop-hasrelated": "Ignoré.",
        "apihelp-query+search-param-limit": "Combien de pages renvoyer au total.",
index f2ba86a..2a3bb6b 100644 (file)
@@ -66,6 +66,7 @@
        "apihelp-compare-param-totitle": "Andre tittel å sammenligne.",
        "apihelp-compare-param-toid": "Andre side-ID å sammenligne.",
        "apihelp-compare-param-torev": "Andre revisjon å sammenligne.",
+       "apihelp-compare-param-torelative": "Bruk en revisjon som er relativ til revisjonen som hentes fra <var>fromtitle</var>, <var>fromid</var> eller <var>fromrev</var>. Alle de andre «to»-alternativene vil ignoreres.",
        "apihelp-compare-param-totext": "Bruk denne teksten i stedet for innholdet i revisjonen spesifisert av <var>totitle</var>, <var>toid</var> eller <var>torev</var>.",
        "apihelp-compare-param-topst": "Gjør en transformering av <var>totext</var> før lagring.",
        "apihelp-compare-param-tocontentmodel": "Innholdsmodellen til <var>totext</var>. Om denne ikke angis vil den bli gjettet ut fra andre parametere.",
@@ -78,6 +79,7 @@
        "apihelp-compare-paramvalue-prop-title": "Sidetitlene for «from»- og «to»-revisjonene.",
        "apihelp-compare-paramvalue-prop-user": "Brukernavnet og ID-en til «from»- og «to»-revisjonene.",
        "apihelp-compare-paramvalue-prop-comment": "Kommentaren til «from»- og «to»-revisjonene.",
+       "apihelp-compare-paramvalue-prop-parsedcomment": "Den parsede kommentaren til «from»- og «to»-revisjonene.",
        "apihelp-compare-paramvalue-prop-size": "Størrelsen til «from»- og «to»-revisjonene.",
        "apihelp-compare-example-1": "Lag en diff mellom revisjon 1 og 2.",
        "apihelp-createaccount-summary": "Opprett en ny brukerkonto.",
        "apihelp-edit-param-watch": "Legg til siden til aktuell brukers overvåkningsliste.",
        "apihelp-edit-param-unwatch": "Fjern siden fra aktuell brukers overvåkningsliste.",
        "apihelp-edit-param-prependtext": "Legg til denne teksten til starten av siden. Overstyrer $1text.",
+       "apihelp-edit-param-undo": "Fjern (gjør om) denne revisjonen. Overstyrer $1text, $1prependtext og $1appendtext.",
+       "apihelp-edit-param-undoafter": "Fjern alle revisjoner fra $1undo til denne. Om den ikke er satt, fjern kun én revisjon.",
        "apihelp-edit-param-redirect": "Bestem omdirigeringer automatisk.",
        "apihelp-edit-param-contentformat": "Innholdsserialiseringsformat brukt for inndatateksten.",
        "apihelp-edit-param-contentmodel": "Det nye innholdets innholdsmodell.",
+       "apihelp-edit-param-token": "Nøkkelen bør alltid sendes som siste parameter, eller i det minste etter parameteren $1text.",
        "apihelp-edit-example-edit": "Rediger en side.",
+       "apihelp-edit-example-prepend": "Legg til <kbd>_&#95;NOTOC_&#95;</kbd> i begynnelsen av en side.",
+       "apihelp-edit-example-undo": "Fjerner revisjon 13579–13585 med automatisk redigeringsforklaring.",
        "apihelp-emailuser-summary": "Send e-post til en bruker.",
        "apihelp-emailuser-param-target": "Bruker som det skal sendes e-post til.",
        "apihelp-emailuser-param-subject": "Emne.",
        "apihelp-emailuser-param-text": "E-post innhold.",
        "apihelp-emailuser-param-ccme": "Send en kopi av denne e-posten til meg.",
+       "apihelp-emailuser-example-email": "Send en epost til brukeren <kbd>WikiSysop</kbd> med teksten <kbd>Content</kbd>.",
        "apihelp-expandtemplates-summary": "Ekspanderer alle maler i wikitekst.",
        "apihelp-expandtemplates-param-title": "Sidetittel.",
        "apihelp-expandtemplates-param-text": "Wikitekst som skal konverteres.",
+       "apihelp-expandtemplates-param-revid": "Revisjons-ID, for <code><nowiki>{{REVISIONID}}</nowiki></code> og lignende variabler.",
+       "apihelp-expandtemplates-param-prop": "Hvilken informasjon som skal hentes.\n\nMerk at om ingen verdier velges vil resultatet inneholde wikiteksten, men utdataene vil komme i et utdatert format.",
        "apihelp-expandtemplates-paramvalue-prop-wikitext": "Den utvidede wikiteksten.",
        "apihelp-expandtemplates-paramvalue-prop-categories": "Kategorier som er tilstede i innputt som ikke representeres i utputt.",
+       "apihelp-expandtemplates-paramvalue-prop-properties": "Sideegenskaper definert av utvidede magiske ord i wikiteksten.",
+       "apihelp-expandtemplates-paramvalue-prop-volatile": "Hvorvidt utdataene er ustabile og ikke burde gjenbrukes andre steder på siden.",
+       "apihelp-expandtemplates-paramvalue-prop-ttl": "Maksimal tid som skal ha gått før mellomlagrede resultater skal ugyldiggjøres.",
+       "apihelp-expandtemplates-paramvalue-prop-jsconfigvars": "Gir JavaScript-konfigurasjonsvariabler som er spesifikke for siden.",
+       "apihelp-expandtemplates-paramvalue-prop-encodedjsconfigvars": "Gir JavaScript-konfigurasjonsvariabler som er spesifikke for siden som en JSON-streng.",
+       "apihelp-expandtemplates-param-includecomments": "Hvorvidt HTML-kommentarer skal inkluderes i utdataene.",
+       "apihelp-expandtemplates-example-simple": "Utvid wikiteksten <kbd><nowiki>{{Project:Sandbox}}</nowiki></kbd>.",
+       "apihelp-feedcontributions-summary": "Returnerer en mating med brukerbidrag.",
+       "apihelp-feedcontributions-param-feedformat": "Matingens format.",
+       "apihelp-feedcontributions-param-user": "Hvilke brukere det skal hentes bidrag av.",
+       "apihelp-feedcontributions-param-namespace": "Hvilke navnerom bidragene skal filtreres med.",
        "apihelp-feedcontributions-param-year": "Fra år (og tidligere).",
        "apihelp-feedcontributions-param-month": "Fra måned (og tidligere).",
        "apihelp-feedcontributions-param-tagfilter": "Filtrer bidrag som har disse merkene.",
        "apihelp-feedcontributions-param-hideminor": "Skjul mindre endringer.",
        "apihelp-feedcontributions-param-showsizediff": "Vis størrelsesforskjellen mellom revisjoner.",
        "apihelp-feedcontributions-example-simple": "Returner bidrag for brukeren <kbd>Example</kbd>.",
+       "apihelp-feedrecentchanges-summary": "Returnerer en mating med siste endringer.",
        "apihelp-feedrecentchanges-param-feedformat": "Matingens format.",
        "apihelp-feedrecentchanges-param-namespace": "Navnerom resultater skal begrenses til.",
        "apihelp-feedrecentchanges-param-invert": "Alle navnerom utenom det valgte.",
        "apihelp-feedrecentchanges-example-30days": "Vis siste endringer for 30 døgn.",
        "apihelp-feedwatchlist-summary": "Returnerer en overvåkningslistemating.",
        "apihelp-feedwatchlist-param-feedformat": "Matingens format.",
+       "apihelp-feedwatchlist-param-linktosections": "Lenk direkte til endrede seksjoner om mulig.",
+       "apihelp-feedwatchlist-example-default": "Vis matingen til overvåkningslisten.",
+       "apihelp-feedwatchlist-example-all6hrs": "Vis alle endringer på overvåkede sider de siste 6 timene.",
        "apihelp-filerevert-summary": "Tilbakestill en fil til en gammel versjon.",
        "apihelp-filerevert-param-filename": "Målfilnavn, uten prefikset File:.",
        "apihelp-filerevert-param-comment": "Opplastingskommentar.",
        "apihelp-import-extended-description": "Merk at HTTP POST må gjøres som filopplasting (altså med bruk av multipart/form-data) når man sender en fil for parameteren <var>xml</var>.",
        "apihelp-import-param-summary": "Sammendrag for importering av loggelement.",
        "apihelp-import-param-xml": "Opplastet XML-fil.",
+       "apihelp-import-param-assignknownusers": "Tildel redigeringer til lokale brukere der den navngitte brukeren finnes lokalt.",
        "apihelp-import-param-interwikisource": "For interwikiimport: wiki det skal importeres fra.",
        "apihelp-import-param-interwikipage": "For interwikiimport: side som skal importeres.",
        "apihelp-import-param-fullhistory": "For interwikiimport: importer hele historikken, ikke bare den nåværende versjonen.",
        "apihelp-import-param-rootpage": "Importer som underside av denne siden. Kan ikke brukes sammen med <var>$1namespace</var>.",
        "apihelp-import-param-tags": "Endringstagger som skal klistres på oppføringen i importloggen og nullrevisjonen til de importerte sidene.",
        "apihelp-import-example-import": "Importer [[meta:Help:ParserFunctions]] til navnerom 100 med full historikk.",
+       "apihelp-linkaccount-summary": "Lenk en konto fra en tredjepartsleverandør til den gjeldende brukeren.",
+       "apihelp-linkaccount-example-link": "Start prosessen med å lenke til en konto fra <kbd>Example</kbd>.",
+       "apihelp-login-summary": "Logg inn og få autentiseringsinformasjonskapsler.",
        "apihelp-login-param-name": "Brukernavn.",
        "apihelp-login-param-password": "Passord.",
        "apihelp-login-param-domain": "Domene (valgfritt).",
index b85ddc9..bbc83c0 100644 (file)
        "apihelp-query+search-paramvalue-prop-sectiontitle": "Adiciona o título da secção correspondente.",
        "apihelp-query+search-paramvalue-prop-categorysnippet": "Adiciona um fragmento de código com a categoria correspondente, após análise sintática.",
        "apihelp-query+search-paramvalue-prop-isfilematch": "Adiciona um valor booleano que indica se a pesquisa encontrou correspondência no conteúdo de ficheiros.",
+       "apihelp-query+search-paramvalue-prop-extensiondata": "Acrescenta dados adicionais gerados por extensões.",
        "apihelp-query+search-paramvalue-prop-score": "Ignorado.",
        "apihelp-query+search-paramvalue-prop-hasrelated": "Ignorado.",
        "apihelp-query+search-param-limit": "O número total de páginas a serem devolvidas.",
index 47afdc1..1724fa9 100644 (file)
        "apihelp-query+search-paramvalue-prop-sectiontitle": "{{doc-apihelp-paramvalue|query+search|prop|sectiontitle}}",
        "apihelp-query+search-paramvalue-prop-categorysnippet": "{{doc-apihelp-paramvalue|query+search|prop|categorysnippet}}",
        "apihelp-query+search-paramvalue-prop-isfilematch": "{{doc-apihelp-paramvalue|query+search|prop|isfilematch}}",
+       "apihelp-query+search-paramvalue-prop-extensiondata": "{{doc-apihelp-paramvalue|query+search|prop|extensiondata}}",
        "apihelp-query+search-paramvalue-prop-score": "{{doc-apihelp-paramvalue|query+search|prop|score}}\n{{Identical|Ignored}}",
        "apihelp-query+search-paramvalue-prop-hasrelated": "{{doc-apihelp-paramvalue|query+search|prop|hasrelated}}\n{{Identical|Ignored}}",
        "apihelp-query+search-param-limit": "{{doc-apihelp-param|query+search|limit}}",
index 2fb6178..3f15916 100644 (file)
        "apihelp-query+search-paramvalue-prop-sectiontitle": "添加匹配章节的标题。",
        "apihelp-query+search-paramvalue-prop-categorysnippet": "添加已解析的匹配分类片段。",
        "apihelp-query+search-paramvalue-prop-isfilematch": "添加布尔值,表明搜索是否匹配文件内容。",
+       "apihelp-query+search-paramvalue-prop-extensiondata": "添加由扩展生成的额外数据。",
        "apihelp-query+search-paramvalue-prop-score": "已忽略。",
        "apihelp-query+search-paramvalue-prop-hasrelated": "已忽略。",
        "apihelp-query+search-param-limit": "返回的总计页面数。",
index b1eaa4b..6e7e7ee 100644 (file)
@@ -140,6 +140,15 @@ class TextConflictHelper {
         */
        public function incrementResolvedStats() {
                $this->stats->increment( 'edit.failures.conflict.resolved' );
+               // Only include 'standard' namespaces to avoid creating unknown numbers of statsd metrics
+               if (
+                       $this->title->getNamespace() >= NS_MAIN &&
+                       $this->title->getNamespace() <= NS_CATEGORY_TALK
+               ) {
+                       $this->stats->increment(
+                               'edit.failures.conflict.resolved.byNamespaceId.' . $this->title->getNamespace()
+                       );
+               }
        }
 
        /**
diff --git a/includes/export/ExportProgressFilter.php b/includes/export/ExportProgressFilter.php
new file mode 100644 (file)
index 0000000..9b1571f
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+/**
+ * Copyright © 2005 Brion Vibber <brion@pobox.com>
+ * https://www.mediawiki.org/
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+/**
+ * @ingroup Dump
+ */
+class ExportProgressFilter extends DumpFilter {
+       /**
+        * @var BackupDumper
+        */
+       private $progress;
+
+       function __construct( &$sink, &$progress ) {
+               parent::__construct( $sink );
+               $this->progress = $progress;
+       }
+
+       function writeClosePage( $string ) {
+               parent::writeClosePage( $string );
+               $this->progress->reportPage();
+       }
+
+       function writeRevision( $rev, $string ) {
+               parent::writeRevision( $rev, $string );
+               $this->progress->revCount();
+       }
+}
index c38eb6a..393c2e1 100644 (file)
@@ -657,6 +657,13 @@ END;
                }
        }
 
+       protected function dropSequence( $table, $ns ) {
+               if ( $this->db->sequenceExists( $ns ) ) {
+                       $this->output( "Dropping sequence $ns\n" );
+                       $this->db->query( "DROP SEQUENCE $ns CASCADE" );
+               }
+       }
+
        protected function renameSequence( $old, $new ) {
                if ( $this->db->sequenceExists( $new ) ) {
                        $this->output( "...sequence $new already exists.\n" );
index 5ffb01b..ea7a8d7 100644 (file)
 /**
  * Router job that takes jobs and enqueues them to their proper queues
  *
- * This can be used for several things:
- *   - a) Making multi-job enqueues more robust by atomically enqueueing
- *        a single job that pushes the actual jobs (with retry logic)
- *   - b) Masking the latency of pushing jobs to different queues/wikis
- *   - c) Low-latency enqueues to push jobs from warm to hot datacenters
+ * This can be used for getting sets of multiple jobs or sets of jobs intended for multiple
+ * queues to be inserted more robustly. This is a single job that, upon running, enqueues the
+ * wrapped jobs. If some of those fail to enqueue then the EnqueueJob will be retried. Due to
+ * the possibility of duplicate enqueues, the wrapped jobs should be idempotent.
  *
  * @ingroup JobQueue
  * @since 1.25
index 5dc0b40..fe617c5 100644 (file)
@@ -378,9 +378,10 @@ class ExtensionProcessor implements Processor {
 
        protected function extractExtensionMessagesFiles( $dir, array $info ) {
                if ( isset( $info['ExtensionMessagesFiles'] ) ) {
-                       $this->globals["wgExtensionMessagesFiles"] += array_map( function ( $file ) use ( $dir ) {
-                               return "$dir/$file";
-                       }, $info['ExtensionMessagesFiles'] );
+                       foreach ( $info['ExtensionMessagesFiles'] as &$file ) {
+                               $file = "$dir/$file";
+                       }
+                       $this->globals["wgExtensionMessagesFiles"] += $info['ExtensionMessagesFiles'];
                }
        }
 
index bc2f8e4..994de97 100644 (file)
@@ -413,13 +413,14 @@ class ExtensionRegistry {
         * Fully expand autoloader paths
         *
         * @param string $dir
-        * @param array $info
+        * @param array $files
         * @return array
         */
-       protected function processAutoLoader( $dir, array $info ) {
+       protected function processAutoLoader( $dir, array $files ) {
                // Make paths absolute, relative to the JSON file
-               return array_map( function ( $file ) use ( $dir ) {
-                       return "$dir/$file";
-               }, $info );
+               foreach ( $files as &$file ) {
+                       $file = "$dir/$file";
+               }
+               return $files;
        }
 }
index 532ee51..badd7a2 100644 (file)
@@ -524,15 +524,48 @@ class SkinTemplate extends Skin {
         * @return string
         */
        public function getPersonalToolsList() {
+               return $this->makePersonalToolsList();
+       }
+
+       /**
+        * Get the HTML for the personal tools list
+        *
+        * @since 1.31
+        *
+        * @param array $personalTools
+        * @param array $options
+        * @return string
+        */
+       public function makePersonalToolsList( $personalTools = null, $options = [] ) {
                $tpl = $this->setupTemplateForOutput();
                $tpl->set( 'personal_urls', $this->buildPersonalUrls() );
                $html = '';
-               foreach ( $tpl->getPersonalTools() as $key => $item ) {
-                       $html .= $tpl->makeListItem( $key, $item );
+
+               if ( $personalTools === null ) {
+                       $personalTools = $tpl->getPersonalTools();
+               }
+
+               foreach ( $personalTools as $key => $item ) {
+                       $html .= $tpl->makeListItem( $key, $item, $options );
                }
+
                return $html;
        }
 
+       /**
+        * Get personal tools for the user
+        *
+        * @since 1.31
+        *
+        * @return array Array of personal tools
+        */
+       public function getStructuredPersonalTools() {
+               $tpl = $this->setupTemplateForOutput();
+               $tpl->set( 'personal_urls', $this->buildPersonalUrls() );
+
+               return $tpl->getPersonalTools();
+       }
+
        /**
         * Format language name for use in sidebar interlanguage links list.
         * By default it is capitalized.
index e8e828d..2ad70a6 100644 (file)
@@ -117,11 +117,6 @@ class SpecialWatchlist extends ChangesListSpecialPage {
                );
        }
 
-       public function isStructuredFilterUiEnabledByDefault() {
-               return $this->getConfig()->get( 'StructuredChangeFiltersOnWatchlist' ) &&
-                       $this->getUser()->getDefaultOption( 'rcenhancedfilters' );
-       }
-
        /**
         * Return an array of subpages that this special page will accept.
         *
diff --git a/includes/templates/AtomHeader.mustache b/includes/templates/AtomHeader.mustache
new file mode 100644 (file)
index 0000000..60ab75e
--- /dev/null
@@ -0,0 +1,8 @@
+<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="{{{language}}}">
+       <id>{{{feedID}}}</id>
+       <title>{{{title}}}</title>
+       <link rel="self" type="application/atom+xml" href="{{{selfUrl}}}"/>
+       <link rel="alternate" type="text/html" href="{{{url}}}"/>
+       <updated>{{{timestamp}}}Z</updated>
+       <subtitle>{{{description}}}</subtitle>
+       <generator>MediaWiki {{{version}}}</generator>
diff --git a/includes/templates/AtomItem.mustache b/includes/templates/AtomItem.mustache
new file mode 100644 (file)
index 0000000..32d2f01
--- /dev/null
@@ -0,0 +1,10 @@
+       <entry>
+               <id>{{{uniqueID}}}</id>
+               <title>{{{title}}}</title>
+               <link rel="alternate" type="{{{mimeType}}}" href="{{{url}}}"/>
+               {{#date}}<updated>{{{.}}}Z</updated>{{/date}}
+
+               <summary type="html">{{{description}}}</summary>
+               {{#author}}<author><name>{{{.}}}</name></author>{{/author}}
+               {{! FIXME: Need to add comments }}
+       </entry>
diff --git a/includes/templates/RSSHeader.mustache b/includes/templates/RSSHeader.mustache
new file mode 100644 (file)
index 0000000..385369d
--- /dev/null
@@ -0,0 +1,8 @@
+<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
+       <channel>
+               <title>{{{title}}}</title>
+               <link>{{{url}}}</link>
+               <description>{{{description}}}</description>
+               <language>{{{language}}}</language>
+               <generator>MediaWiki {{{version}}}</generator>
+               <lastBuildDate>{{{timestamp}}}</lastBuildDate>
diff --git a/includes/templates/RSSItem.mustache b/includes/templates/RSSItem.mustache
new file mode 100644 (file)
index 0000000..d00c100
--- /dev/null
@@ -0,0 +1,9 @@
+               <item>
+                       <title>{{{title}}}</title>
+                       <link>{{{url}}}</link>
+                       <guid{{^permalink}} isPermaLink="false"{{/permalink}}>{{{uniqueID}}}</guid>
+                       <description>{{{description}}}</description>
+                       {{#date}}<pubDate>{{{.}}}</pubDate>{{/date}}
+                       {{#author}}<dc:creator>{{{.}}}</dc:creator>{{/author}}
+                       {{#comments}}<comments>{{{.}}}</comments>{{/comments}}
+               </item>
diff --git a/includes/user/UserIdentityValue.php b/includes/user/UserIdentityValue.php
new file mode 100644 (file)
index 0000000..e728264
--- /dev/null
@@ -0,0 +1,70 @@
+<?php
+/**
+ * Value object representing a user's identity.
+ *
+ * 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\User;
+
+use Wikimedia\Assert\Assert;
+
+/**
+ * Value object representing a user's identity.
+ *
+ * @since 1.31
+ */
+class UserIdentityValue implements UserIdentity {
+
+       /**
+        * @var int
+        */
+       private $id;
+
+       /**
+        * @var string
+        */
+       private $name;
+
+       /**
+        * @param int $id
+        * @param string $name
+        */
+       public function __construct( $id, $name ) {
+               Assert::parameterType( 'integer', $id, '$id' );
+               Assert::parameterType( 'string', $name, '$name' );
+
+               $this->id = $id;
+               $this->name = $name;
+       }
+
+       /**
+        * @return int The user ID. May be 0 for anonymous users or for users with no local account.
+        */
+       public function getId() {
+               return $this->id;
+       }
+
+       /**
+        * @return string The user's logical name. May be an IPv4 or IPv6 address for anonymous users.
+        */
+       public function getName() {
+               return $this->name;
+       }
+
+}
index b28deeb..7751439 100644 (file)
        "newarticle": "(Nuut)",
        "newarticletext": "Hierdie bladsy bestaan nie.\nTik iets in die invoerboks hier onder om 'n nuwe bladsy te skep. Meer inligting is op die [$1 hulpbladsy] beskikbaar.\nAs u per ongeluk hier uitgekom het, gebruik u blaaier se '''terug'''-knoppie.",
        "anontalkpagetext": "----\n<em>Hierdie is die besprekingsblad vir 'n anonieme gebruiker wat nog nie 'n rekening geskep het nie, of wat dit nie gebruik nie.</em>\nDaarom moet ons sy/haar numeriese IP-adres vir identifikasie gebruik.\nSó 'n adres kan deur verskeie gebruikers gedeel word.\nIndien u 'n anonieme gebruiker is wat voel dat ontoepaslike kommentaar teen u gerig is, [[Special:CreateAccount|skep gerus 'n rekening]] of [[Special:UserLogin|meld aan]] om verwarring met ander anonieme gebruikers te voorkom.",
-       "noarticletext": "Hierdie bladsy bevat geen teks nie.\nU kan [[Special:Search/{{PAGENAME}}|vir die bladsytitel in ander bladsye soek]],\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} die verwante logboeke deursoek]\nof [{{fullurl:{{FULLPAGENAME}}|action=edit}} hierdie bladsy wysig]</span>.",
+       "noarticletext": "Hierdie bladsy bevat geen teks nie.\nU kan [[Special:Search/{{PAGENAME}}|vir die bladsytitel in ander bladsye soek]],\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} die verwante logboeke deursoek]\nof [{{fullurl:{{FULLPAGENAME}}|action=edit}} hierdie bladsy skep]</span>.",
        "noarticletext-nopermission": "Hierdie bladsy bevat geen teks nie.\nU kan vir die term [[Special:Search/{{PAGENAME}}|in ander bladsye soek]] of\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} die verwante logboeke deursoek]</span>, maar u kan nie die bladsy skep nie.",
        "missing-revision": "Die weergawe #$1 van die bladsy \"{{FULLPAGENAME}} bestaan nie.\n\nDit word meestal veroorsaak deur die volg van 'n verouderde verwysing na 'n bladsy wat verwyder is.\nMeer gegewens kan moontlik in die [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} skraplogboek] gevind word.",
        "userpage-userdoesnotexist": "U is besig om 'n gebruikersblad wat nie bestaan nie te wysig (gebruiker \"<nowiki>$1</nowiki>\"). Maak asseblief seker of u die bladsy wil skep/ wysig.",
        "rcfilters-activefilters": "Aktiewe filters",
        "rcfilters-advancedfilters": "Gevorderde filters",
        "rcfilters-limit-title": "Wysigings om te wys",
+       "rcfilters-limit-and-date-label": "{{PLURAL:$1|wysiging|$1 wysigings}}, $2",
+       "rcfilters-date-popup-title": "Tydperk om te deursoek",
        "rcfilters-days-title": "Afgelope dae",
        "rcfilters-hours-title": "Afgelope ure",
        "rcfilters-days-show-days": "$1 {{PLURAL:$1|dag|dae}}",
        "rcfilters-days-show-hours": "$1 {{PLURAL:$1|uur|ure}}",
        "rcfilters-highlighted-filters-list": "Bekleurklem: $1",
        "rcfilters-quickfilters": "Gestoorde filters",
-       "rcfilters-quickfilters-placeholder-title": "Geen gestoorde skakels",
+       "rcfilters-quickfilters-placeholder-title": "Geen gestoorde filters",
        "rcfilters-quickfilters-placeholder-description": "Om instellings te stoor en later weer te gebruik, kliek op die bladwyser-piktogram in die Aktiewe Filter-gebied onder.",
        "rcfilters-savedqueries-defaultlabel": "Gestoorde filters",
        "rcfilters-savedqueries-rename": "Hernoem",
        "rcfilters-restore-default-filters": "Stel filters terug",
        "rcfilters-clear-all-filters": "Verwyder alle filters",
        "rcfilters-show-new-changes": "Wys nuutste wysigings",
-       "rcfilters-search-placeholder": "Filter onlangse wysigings (blaai of begin tik)",
+       "rcfilters-search-placeholder": "Filter wysigings (blaai of begin tik)",
        "rcfilters-invalid-filter": "Ongeldig filter",
        "rcfilters-empty-filter": "Geen aktiewe filters. Alle wysigings word gewys.",
        "rcfilters-filterlist-title": "Filters",
        "rcfilters-filter-watchlist-notwatched-label": "Nie in dophoulys",
        "rcfilters-filter-watchlist-notwatched-description": "Alles behalwe wysigings aan bladsye op u dophoulys.",
        "rcfilters-filtergroup-watchlistactivity": "Dophoulys-bedrywighede",
+       "rcfilters-filter-watchlistactivity-unseen-label": "Nie-besigtigde wysigings",
+       "rcfilters-filter-watchlistactivity-unseen-description": "Wysigings aan blaaie wat u nog nie sedert die wysiging besoek het nie.",
+       "rcfilters-filter-watchlistactivity-seen-description": "Wysigings aan blaaie wat u reeds sedert die wysiging besoek het.",
        "rcfilters-filtergroup-changetype": "Soort wysiging",
        "rcfilters-filter-pageedits-label": "Bladsywysigings",
        "rcfilters-filter-pageedits-description": "Wysigings aan wiki-inhoud, besprekings en kategoriebeskrywings…",
        "rcfilters-view-tags": "Geëtiketteerde wysigings",
        "rcfilters-view-namespaces-tooltip": "Filtreer resultate volgens naamruimte",
        "rcfilters-view-tags-tooltip": "Filter resultate volgens wysigingsetikette",
+       "rcfilters-liveupdates-button": "Monitor bywerkings",
+       "rcfilters-liveupdates-button-title-off": "Wys nuwe wysigings soos hulle inrol.",
        "rcfilters-preference-label": "Versteek die verbeter weergawe van 'Onlangse wysigings'",
        "rcnotefrom": "{{PLURAL:$5|Wysiging|Wysigings}} sedert <strong>$3 om $4</strong> (maksimum van <strong>$1</strong> word gewys).",
        "rclistfrom": "Vertoon wysigings vanaf $3 $2",
index 55fb338..0dbc1e4 100644 (file)
        "timezoneregion-indian": "المحيط الهندي",
        "timezoneregion-pacific": "المحيط الهادي",
        "allowemail": "اسمح للمستخدمين الآخرين بإرسال بريد إلكتروني إلي",
+       "email-allow-new-users-label": "اسمح بالبريد الإلكتروني من المستخدمين الجدد تمامًا",
        "email-blacklist-label": "امنع هؤلاء المستخدمين من إرسال بريد إلكتروني لي:",
        "prefs-searchoptions": "البحث",
        "prefs-namespaces": "أسماء النطاقات",
        "rcfilters-preference-label": "أخف النسخة المحسنة من أحدث التغييرات",
        "rcfilters-preference-help": "يسترجع عملية إعادة تصميم الواجهة لعام 2017 وكل الأدوات التي أضيفت منذ ذلك الوقت.",
        "rcfilters-filter-showlinkedfrom-label": "عرض التغييرات في الصفحات الموصولة من",
-       "rcfilters-filter-showlinkedfrom-option-label": "اظهر التغييرات في الصفحات المرتبطة <strong>من</strong> صفحة",
+       "rcfilters-filter-showlinkedfrom-option-label": "Ø£ظهر التغييرات في الصفحات المرتبطة <strong>من</strong> صفحة",
        "rcfilters-filter-showlinkedto-label": "أظهر التغييرات في الصفحات الموصولة بصفحة",
        "rcfilters-filter-showlinkedto-option-label": "اظهر التغييرات في الصفحات المرتبطة <strong>إلى</strong> الصفحة",
        "rcfilters-target-page-placeholder": "أدخل اسم صفحة",
index 9caecd4..73efb80 100644 (file)
        "timezoneregion-indian": "Індыйскі акіян",
        "timezoneregion-pacific": "Ціхі акіян",
        "allowemail": "Дазволіць іншым удзельнікам і ўдзельніцам дасылаць мне лісты электроннай поштай",
+       "email-allow-new-users-label": "Дазволіць лісты электроннай пошты ад зусім новых удзельнікаў",
        "email-blacklist-label": "Забараніць гэтым удзельнікам дасылаць мне лісты электроннай поштай:",
        "prefs-searchoptions": "Пошук",
        "prefs-namespaces": "Прасторы назваў",
        "uploadstash-file-too-large": "Немагчыма апрацаваць файл памерам большым за $1 байтаў.",
        "uploadstash-not-logged-in": "Удзельнік не ўвайшоў у сыстэму, файлы мусяць належаць удзельнікам.",
        "uploadstash-wrong-owner": "Гэты файл ($1) не належыць цяперашняму ўдзельніку.",
+       "uploadstash-no-such-key": "Няма такога ключа ($1), немагчыма выдаліць.",
        "invalid-chunk-offset": "Няслушнае зрушэньне фрагмэнту",
        "img-auth-accessdenied": "Доступ забаронены",
        "img-auth-nopathinfo": "Адсутнічае PATH_INFO.\nВаш сэрвэр не ўстаноўлены на пропуск гэтай інфармацыі.\nМагчма, ён працуе праз CGI і не падтрымлівае img_auth.\nГлядзіце https://www.mediawiki.org/wiki/Special:MyLanguage/Manual:Image_Authorization.",
index e3d1c4b..a4a2623 100644 (file)
        "upload-file-error": "Вътрешна грешка",
        "upload-file-error-text": "Вътрешна грешка при опит за създаване на временен файл на сървъра.\nОбърнете се към [[Special:ListUsers/sysop|администратор]].",
        "upload-misc-error": "Неизвестна грешка при качване",
-       "upload-misc-error-text": "Неизвестна грешка при качване. Убедете се, че адресът е верен и опитайте отново. Ако отново имате проблем, обърнете се към [[Special:ListUsers/sysop|администратор]].",
+       "upload-misc-error-text": "Неизвестна грешка при качване.\nУбедете се, че адресът е верен и опитайте отново.\nАко отново имате проблем, обърнете се към [[Special:ListUsers/sysop|администратор]].",
        "upload-too-many-redirects": "Адресът съдържа твърде много пренасочвания",
        "upload-http-error": "Възникна HTTP грешка: $1",
        "upload-dialog-title": "Качване на файл",
index 2577a30..21c5df1 100644 (file)
        "rcfilters-advancedfilters": "Шуьйра литтарш",
        "rcfilters-limit-title": "Гойту хийцамаш",
        "rcfilters-limit-and-date-label": "$1 {{PLURAL:$1|хийцам}}, $2",
-       "rcfilters-date-popup-title": "Ð\9bаÑ\85аÑ\80на Ñ\85ен",
+       "rcfilters-date-popup-title": "Ð\9bаÑ\85аÑ\80на Ñ\85ан",
        "rcfilters-days-title": "ТӀеххьара денош",
        "rcfilters-hours-title": "ТӀеххьара сахьташ",
        "rcfilters-days-show-days": "$1 {{PLURAL:$1|де}}",
        "feedback-submit": "Дахьийта",
        "feedback-thanks-title": "Баркалла!",
        "feedback-useragent": "Браузер:",
-       "searchsuggest-search": "Лаха {{grammar:prepositional|{{SITENAME}}}}",
+       "searchsuggest-search": "Лахар",
        "searchsuggest-containing": "чуьраниг…",
        "api-error-publishfailed": "Чоьхьара гӀалат: серверна хана йолу файл Ӏалашъян цаелира.",
        "api-error-stashfailed": "Чоьхьара гӀалат: серверна хана йолу файл Ӏалашъян цаелира.",
index 969f08a..64d134b 100644 (file)
        "timezoneregion-indian": "Océano Índico",
        "timezoneregion-pacific": "Océano Pacífico",
        "allowemail": "Permitir que otros usuarios me envíen mensajes de correo",
+       "email-allow-new-users-label": "Permitir mensajes de correo de usuarios nuevos",
        "email-blacklist-label": "Prohibir a estos usuarios enviarme mensajes de correo:",
        "prefs-searchoptions": "Buscar",
        "prefs-namespaces": "Espacios de nombres",
        "tooltip-ca-undelete": "Restaurar las ediciones hechas a esta página antes de que fuese borrada",
        "tooltip-ca-move": "Trasladar esta página",
        "tooltip-ca-watch": "Añadir esta página a tu lista de seguimiento",
-       "tooltip-ca-unwatch": "Borrar esta página de su lista de seguimiento",
+       "tooltip-ca-unwatch": "Quitar esta página de tu lista de seguimiento",
        "tooltip-search": "Buscar en {{SITENAME}}",
        "tooltip-search-go": "Ir a la página con este nombre exacto si existe",
        "tooltip-search-fulltext": "Buscar este texto en las páginas",
index 7625d13..a3c8cd6 100644 (file)
        "timezoneregion-indian": "Océan indien",
        "timezoneregion-pacific": "Océan pacifique",
        "allowemail": "Autoriser les autres utilisateurs à m'envoyer des courriels",
+       "email-allow-new-users-label": "Autoriser les courriels émis par les nouveaux utilisateurs",
        "email-blacklist-label": "Empêcher ces utilisateurs de m'envoyer des courriels :",
        "prefs-searchoptions": "Recherche",
        "prefs-namespaces": "Espaces de noms",
index a5722ad..f98d3fe 100644 (file)
        "defemailsubject": "{{SITENAME}} e-mail od suradnika \"$1\"",
        "usermaildisabled": "Suradnička e-pošta je onemogućena",
        "usermaildisabledtext": "Ne možete slati e-poštu drugim suradnicima na ovom wikiju",
-       "noemailtitle": "Nema adrese primaoca",
+       "noemailtitle": "Nema adrese e-pošte",
        "noemailtext": "Ovaj suradnik nije odredio valjanu adresu e-pošte.",
        "nowikiemailtext": "Ovaj suradnik je odlučio ne primati e-mail od drugih suradnika.",
        "emailnotarget": "Nepostojeće ili nevažeće suradničko ime za primatelja.",
index 1915f47..0c314d7 100644 (file)
        "autosumm-blank": "გვერდის შიგთავსი დაცარიელდა",
        "autosumm-replace": "შინაარსი შეიცვალა „$1“-ით",
        "autoredircomment": "გადამისამართება [[$1]]-ზე",
+       "autosumm-removed-redirect": "წაშლილი გადამისამართება [[$1]]",
        "autosumm-new": "ახალი გვერდი: $1",
        "autosumm-newblank": "ცარიელი გვერდი შეიქმნა",
        "size-bytes": "$1 ბ",
index 6b76645..04e37e8 100644 (file)
        "timezoneregion-indian": "인도양",
        "timezoneregion-pacific": "태평양",
        "allowemail": "다른 사용자가 내게 이메일을 보낼 수 있게 허용",
+       "email-allow-new-users-label": "처음 온 사용자들로부터 오는 이메일 허용",
        "email-blacklist-label": "이 사용자들이 내게 이메일을 보내는 것을 금지합니다:",
        "prefs-searchoptions": "검색",
        "prefs-namespaces": "이름공간",
index 1865252..20ce7a7 100644 (file)
        "recentchangesdays-max": "(Maximal $1 {{PLURAL:$1|Dag|Deeg}})",
        "recentchangescount": "Zuel vun den Ännerungen déi als Standard gewise ginn:",
        "prefs-help-recentchangescount": "Inklusiv Rezent Ännerungen, Versiounshistoriquen a Logbicher.",
-       "prefs-help-watchlist-token2": "Dëst ass de geheime Schlëssel fir de Webfeed vun Ärer Iwwerwaachungslëscht. Jiddwereen deen e kennt kann Är Iwwerwaachungslëscht liesen, dofir sollt Dir en net weider ginn. [[Special:ResetTokens|Klickt hei wann Dir en zrécksetze musst]].",
+       "prefs-help-watchlist-token2": "Dëst ass de geheime Schlëssel fir de Webfeed vun Ärer Iwwerwaachungslëscht. Jiddwereen deen e kennt kann Är Iwwerwaachungslëscht liesen, dofir sollt Dir en net weider ginn. Wann Dir wëllt [[Special:ResetTokens|kënnt Dir en hei zrécksetze kënnt]].",
        "savedprefs": "Är Astellunge goufe gespäichert.",
        "savedrights": "D'Benotzergruppe vum {{GENDER:$1|$1}} goufe gespäichert.",
        "timezonelegend": "Zäitzon:",
        "recentchangeslinked-feed": "Ännerungen op verlinkt Säiten",
        "recentchangeslinked-toolbox": "Ännerungen op verlinkt Säiten",
        "recentchangeslinked-title": "Ännerungen a Verbindung mat \"$1\"",
-       "recentchangeslinked-summary": "Dëst ass eng Lëscht mat Ännerunge vu verlinkte Säiten op eng bestëmmte Säit (oder vu Membersäite vun der spezifizéierter Kategorie).\nSäite vun [[Special:Watchlist|Ärer Iwwerwaachungslëscht]] si '''fett''' geschriwwen.",
+       "recentchangeslinked-summary": "Gitt den Numm vun enger Säit a fir Ännerungen Säiten ze gesinn op déi oder vun deene gelinkt gëtt. Ännerungen op Säite vun [[Special:Watchlist|Ärer Iwwerwaachungslëscht]] si <strong>fett</strong> geschriwwen.",
        "recentchangeslinked-page": "Säitennumm:",
        "recentchangeslinked-to": "Weis Ännerungen zu de verlinkte Säiten aplaz vun der gefroter Säit",
        "recentchanges-page-added-to-category": "[[:$1]] an d'Kategorie derbäigesat",
index e106cb0..5a71f4e 100644 (file)
        "rcfilters-group-results-by-page": "Grupēt rezultātus pēc lapas",
        "rcfilters-activefilters": "Aktīvie filtri",
        "rcfilters-advancedfilters": "Paplašinātie filtri",
-       "rcfilters-limit-title": "Rādāmās izmaiņas",
+       "rcfilters-limit-title": "Rādāmie rezultāti",
        "rcfilters-limit-and-date-label": "{{PLURAL:$1|$1 izmaiņas|$1 izmaiņa|$1 izmaiņas}}, $2",
        "rcfilters-days-title": "Pēdējās dienas",
        "rcfilters-hours-title": "Pēdējās stundas",
        "enotif_reset": "Atzīmēt visas lapas kā apskatītas",
        "enotif_impersonal_salutation": "{{SITENAME}} lietotājs",
        "enotif_lastvisited": "$1 lai apskatītos visas izmaiņas kopš tava pēdējā apmeklējuma.",
-       "enotif_lastdiff": "$1 lai apskatītos šo izmaiņu.",
+       "enotif_lastdiff": "Lai apskatītu šo izmaiņu, skatīt $1",
        "enotif_anon_editor": "anonīms dalībnieks $1",
        "enotif_body": "$WATCHINGUSERNAME,\n\n\n{{grammar:ģenitīvs|{{SITENAME}}}} lapu $PAGETITLE $CHANGEDORCREATED $PAGEEDITOR, $PAGEEDITDATE, pašreizējā versja ir $PAGETITLE_URL.\n\n$NEWPAGE\n\nIzmaiņu kopsavilkums bija: $PAGESUMMARY $PAGEMINOREDIT\n\nSazināties ar attiecīgo lietotāju:\ne-pasts: $PAGEEDITOR_EMAIL\nwiki: $PAGEEDITOR_WIKI\n\nJa šo uzraugāmo lapu izmainīs vēl, turpmāku paziņojumu par to nebūs, kamēr tu to neatvērsi.\nTu arī vari atstatīt visu uzraugāmo lapu paziņojumu statusus uzraugāmo lapu sarakstā.\n\n             {{grammar:ģenitīvs|{{SITENAME}}}} paziņojumu sistēma\n\n--\nLai izmainītu uzraugāmo lapu saraksta uzstādījumus:\n{{canonicalurl:{{#special:EditWatchlist}}}}\n\nLai dzēstu lapu no uzraugāmo lapu saraksta:\n$UNWATCHURL\n\nPapildinformācija:\n$HELPPAGE",
        "enotif_minoredit": "Šis ir maznozīmīgs labojums",
        "feedback-useragent": "Lietotāja aģents:",
        "searchsuggest-search": "Meklēt {{SITENAME}}",
        "searchsuggest-containing": "Meklējamā frāze:",
-       "api-error-unknown-warning": "Nezināms brīdinājums: $1",
+       "api-error-unknown-warning": "Nezināms brīdinājums: \"$1\".",
        "api-error-unknownerror": "Nezināma kļūda: \"$1\"",
        "duration-seconds": "$1 {{PLURAL:$1|sekundes|sekunde|sekundes}}",
        "duration-minutes": "$1 {{PLURAL:$1|minūtes|minūte|minūtes}}",
index ae28e8d..d835015 100644 (file)
        "timezoneregion-indian": "Индиски Океан",
        "timezoneregion-pacific": "Тихи Океан",
        "allowemail": "Дозволи е-пошта од други корисници",
+       "email-allow-new-users-label": "Дозволи е-пошта од сосем нови корисници",
        "email-blacklist-label": "Забрани е-пошта од следниве корисници:",
        "prefs-searchoptions": "Пребарување",
        "prefs-namespaces": "Именски простори",
index e944f1b..32788be 100644 (file)
        "nosuchusershort": "\"$1\" या नावाचा सदस्य नाही. लिहीताना आपली चूक तर नाही ना झाली?",
        "nouserspecified": "तुम्हाला सदस्यनाव नमूद करावे लागेल.",
        "login-userblocked": "हा सदस्य ’प्रतिबंधित’ आहे. त्यास सनोंद-प्रवेशाची परवानगी नाही.",
-       "wrongpassword": "à¤\86पण à¤ªà¤°à¤µà¤²à¥\80à¤\9aा à¤¶à¤¬à¥\8dद à¤\9aà¥\81à¤\95à¥\80à¤\9aा à¤\9fाà¤\95ला à¤\86हà¥\87, पुन्हा एकदा प्रयत्न करा.",
+       "wrongpassword": "सदसà¥\8dयनाव à¤\85थवा à¤ªà¤°à¤µà¤²à¥\80à¤\9aा à¤¶à¤¬à¥\8dद à¤\9aà¥\81à¤\95à¥\80à¤\9aा à¤\9fाà¤\95णà¥\8dयात à¤\86ला à¤\86हà¥\87. पुन्हा एकदा प्रयत्न करा.",
        "wrongpasswordempty": "परवलीचा शब्द कोरा आहे; पुन्हा प्रयत्न करा.",
        "passwordtooshort": "तुमच्या परवलीच्या शब्दात किमान {{PLURAL:$1|१ अक्षर |$1 अक्षरे}} हवीत.",
        "passwordtoolong": "परवलीचा शब्द हा {{PLURAL:$1|१ वर्ण पेक्षा|$1 वर्णांपेक्षा}} लांबीचा नको.",
        "anonpreviewwarning": "\"'''सावधान:''' तुम्ही विकिपीडियाचे सदस्य म्हणून सनोंद-प्रवेश (लॉग-इन) केलेला नाही. या पानाच्या संपादन इतिहासात तुमचा अंकपत्ता (आय.पी. अॅड्रेस) नोंदला जाईल.\"",
        "missingsummary": "'''आठवण:''' आपण संपादन सारांश पुरवलेला नाही.आपण 'जतन करा' वर पुन्हा टिचकी मारली तर, ते त्याशिवायच जतन होईल.",
        "selfredirect": "<strong>ईशारा:</strong>आपण या पानास, त्याच पानावर पुनर्निर्देशित करीता आहात.\nआपण पुनर्निर्देशनासाठी चूकिचे लक्ष्य नमूद केले आहे किंवा आपण चूकिच्या पानाचे संपादन करीत आहात.\nजर आपण पुन्हा \"$1\" टिचकले तर, कसेहीकरुन ते पुनर्निर्देशन तयार होईल.",
-       "missingcommenttext": "à¤\95à¥\83पया à¤\96ालà¥\80 à¤ªà¥\8dरतिà¤\95à¥\8dरिया à¤­à¤°ा.",
+       "missingcommenttext": "à¤\95à¥\83पया à¤ªà¥\8dरतिà¤\95à¥\8dरिया à¤\9fाà¤\95ा.",
        "missingcommentheader": "<strong>आठवण:<strong> आपण या लेखनाकरिता विषय दिलेला नाही. आपण पुन्हा \"$1\" वर टिचकले तर, तुमचे संपादन त्याशिवायच जतन होईल.",
-       "summary-preview": "à¤\86ढावà¥\8dयाची झलक:",
-       "subject-preview": "विषय झलक:",
+       "summary-preview": "सà¤\82पादन à¤¸à¤¾à¤°à¤¾à¤\82शाची झलक:",
+       "subject-preview": "विषयाची झलक:",
        "previewerrortext": "आपल्या बदलांची झलक बघण्याचे प्रयत्नादरम्यान त्रुटी उद्भवली.",
        "blockedtitle": "हा सदस्य प्रतिबंधित आहे",
        "blockedtext": "'''तुमचे सदस्यनाव अथवा IP पत्ता ब्लॉक केलेला आहे.'''\n\nहा ब्लॉक $1 यांनी केलेला आहे.\nयासाठी ''$2'' हे कारण दिलेले आहे.\n\n* ब्लॉकची सुरूवात: $8\n* ब्लॉकचा शेवट: $6\n* कुणाला ब्लॉक करायचे आहे: $7\n\nतुम्ही ह्या ब्लॉक संदर्भातील चर्चेसाठी $1 अथवा [[{{MediaWiki:Grouppage-sysop}}|प्रबंधकांशी]] संपर्क करू शकता.\nतुम्ही जोवर वैध ई-मेल पत्ता आपल्या [[Special:Preferences|'माझ्या पसंती']] पानावर देत नाही तोवर तुम्ही ’सदस्याला ई-मेल पाठवा’ हा दुवा वापरू शकत नाही. तसेच असे करण्यापासून आपल्याला ब्लॉक केलेले नाही.\nतुमचा सध्याचा IP पत्ता $3 हा आहे, व तुमचा ब्लॉक क्रमांक #$5 हा आहे.\nकृपया या संदर्भातील चर्चेमध्ये वरील सर्व तपशिल उद्घृत करा.",
        "page_first": "प्रथम",
        "page_last": "अंतिम",
        "histlegend": "फरक निवडणे: जुन्या आवृत्तींमधील फरक पाहण्यासाठी रेडियो बॉक्स मध्ये खूण करा व एन्टर कळ दाबा अथवा खाली दिलेल्या कळीवर टिचकी द्या.<br />\nविवरण: '''({{int:cur}})''' = चालू आवृत्तीशी फरक,\n(मागील) = पूर्वीच्या आवृत्तीशी फरक, छो = किरकोळ संपादन",
-       "history-fieldset-title": "à¤\87तिहास à¤µà¤¿à¤\82à¤\9aरण à¤\95रा",
-       "history-show-deleted": "फà¤\95à¥\8dत à¤\95ाढà¥\82न à¤\9fाà¤\95लà¥\87लà¥\87",
+       "history-fieldset-title": "à¤\86वà¥\83तà¥\8dतà¥\8dयाà¤\82साठà¥\80 à¤¶à¥\8bधा",
+       "history-show-deleted": "फà¤\95à¥\8dत à¤µà¤\97ळलà¥\87लà¥\8dया à¤\86वà¥\83तà¥\8dतà¥\8dया",
        "histfirst": "सर्वात प्राचिन",
        "histlast": "नविनतम",
        "historysize": "({{PLURAL:$1|1 बाइट|$1 बाइट्स}})",
        "revdelete-unsuppress": "पुर्नस्थापीत आवृत्त्यांवरील बंधने ऊठवा",
        "revdelete-log": "कारण:",
        "revdelete-submit": "निवडलेल्या {{PLURAL:$1|आवृत्तीला|आवृत्त्यांना}} लागू करा",
-       "revdelete-success": "'''आवृत्त्यांची  दृश्यता यशस्वीपणे अद्ययावत केली.'''",
+       "revdelete-success": "आवृत्त्यांची दृश्यता अद्ययावत केली.",
        "revdelete-failure": "'''आवर्तन दृश्यता अद्ययावत करता येत नाही:'''\n$1",
-       "logdelete-success": "'''नोंदींची दृश्यता यशस्वी पणे स्थापिली.'''",
+       "logdelete-success": "नोंदींची दृश्यता स्थापिली.",
        "logdelete-failure": "'''नोंदींची दृश्यता स्थापिल्या गेली नाही.'''\n$1",
        "revdel-restore": "दृश्यता बदला",
        "pagehist": "पानाचा इतिहास",
        "mergehistory-empty": "कोणतेही आवर्तन एकत्रित करता येत नाही.",
        "mergehistory-done": "$1 {{PLURAL:$3|चे|ची}} $3 {{PLURAL:$3|आवर्तन|आवर्तने}} [[:$2]] मध्ये यशस्वीरीत्या एकत्रित केली.",
        "mergehistory-fail": "इतिहासाचे एकत्रीकरण कार्य करू शकत नाही आहे, कृपया पान आणि वेळ प्राचलांची पुनर्तपासणी करा.",
+       "mergehistory-fail-bad-timestamp": "वेळठसा अवैध आहे.",
+       "mergehistory-fail-invalid-source": "स्रोत पान अवैध आहे.",
+       "mergehistory-fail-invalid-dest": "लक्ष्य पान अवैध आहे.",
        "mergehistory-fail-toobig": "इतिहास एकत्रिकरण करणे शक्य झाले नाही कारण $1 मर्यादेपेक्षा अधिक {{PLURAL:$1|आवृत्ती|आवृत्त्या}} स्थानांतरीत केल्या जातील.",
        "mergehistory-no-source": "स्रोत पान $1 अस्तित्वात नाही.",
        "mergehistory-no-destination": "लक्ष्य पान $1  अस्तित्वात नाही.",
        "search-file-match": "(संचिका आशयाशी अनुरुपते)",
        "search-suggest": "तुम्हाला हेच म्हणायचे का: $1",
        "search-rewritten": "$1 साठीचे निकाल दाखवित आहे.त्याऐवजी $2 चा शोध घ्या.",
-       "search-interwiki-caption": "सह प्रकल्प",
+       "search-interwiki-caption": "सह-प्रकल्पांपासून प्राप्त निकाल",
        "search-interwiki-default": "$1चे निकाल:",
        "search-interwiki-more": "(आणखी)",
+       "search-interwiki-more-results": "अधिक निकाल",
        "search-relatedarticle": "जवळील",
        "searchrelated": "संबंधित",
        "searchall": "सर्व",
        "prefs-help-prefershttps": "हा पसंतीक्रम आपल्या पुढील सनोंद प्रवेशानंतर कार्यान्वित होईल.",
        "prefswarning-warning": "आपण आपल्या पसंतीक्रमात केलेला बदल अद्याप जतन झाला नाही.जर आपण \"$1\" न टिचकता, या पानावरुन दुसरीकडे गेलात तर आपला पसंतीक्रम अद्यतन होणार नाही.",
        "prefs-tabs-navigation-hint": "उपयुक्त सूचना:आपण कळींच्या यादीत, कळींदरम्यानच्या सुचालनास डावी व उजवी बाण-कळ वापरु शकता.",
-       "userrights": "सदस्य अधिकार व्यवस्थापन",
-       "userrights-lookup-user": "सदस्य गटांचे(ग्रूप्स) व्यवस्थापन करा.",
+       "userrights": "सदस्य अधिकार",
+       "userrights-lookup-user": "सदस्याची निवड करा",
        "userrights-user-editname": "सदस्य नाव टाका:",
-       "editusergroup": "सदस्याचे गट संपादित करा",
+       "editusergroup": "सदस्य गटांचे भारण करा",
        "editinguser": "या {{GENDER:$1|सदस्या}}चे सदस्य-अधिकारात बदल केला जात आहे<strong>[[User:$1|$1]]</strong> $2",
        "userrights-editusergroup": "{{GENDER:$1|सदस्य}} गट संपादित करा",
-       "saveusergroups": "सदस्य गट जतन करा",
+       "saveusergroups": "{{GENDER:$1|सदस्य}} गट जतन करा",
        "userrights-groupsmember": "याचा सभासद:",
        "userrights-groupsmember-auto": "याचा अव्यक्त सदस्य:",
        "userrights-groups-help": "तुम्ही एखाद्या सदस्याचे गट सदस्यत्व बदलू शकता:\n* निवडलेला चौकोन म्हणजे सदस्य त्या गटात आहे.\n* न निवडलेला चौकोन म्हणजे सदस्य त्या गटात नाही.\n* एक * चा अर्थ तुम्ही एकदा समावेश केल्यानंतर तो गट बदलू शकत नाही, किंवा काढल्यानंतर समावेश करू शकत नाही.",
        "rcfilters-group-results-by-page": "पानानुसार गट निकाल",
        "rcfilters-activefilters": "सक्रिय गाळण्या",
        "rcfilters-advancedfilters": "प्रगत गाळण्या",
-       "rcfilters-limit-title": "दाखविण्यासाठीचे बदल",
+       "rcfilters-limit-title": "दाखविण्यासाठीचे निकाल",
+       "rcfilters-limit-and-date-label": "{{PLURAL:$1|बदल}}, $2",
+       "rcfilters-date-popup-title": "शोधावयाचा कालावधी",
        "rcfilters-days-title": "अलीकडील दिवस",
        "rcfilters-hours-title": "अलीकडील तास",
        "rcfilters-days-show-days": "$1 {{PLURAL:$1|दिवस}}",
        "rcfilters-savedqueries-apply-label": "गाळणी तयार करा",
        "rcfilters-savedqueries-cancel-label": "रद्द करा",
        "rcfilters-savedqueries-add-new-title": "सध्या असलेल्या गाळण्यांच्या मांडण्या जतन करा",
+       "rcfilters-savedqueries-already-saved": "या गाळण्या पूर्वीच जतन केल्या आहेत.नवीन जतन केलेली गाळणी तयार करण्यासाठी आपल्या मांडण्या बदलवा.",
        "rcfilters-restore-default-filters": "अविचल गाळण्या पुनर्स्थापा",
        "rcfilters-clear-all-filters": "सर्व गाळण्या हटवा",
        "rcfilters-show-new-changes": "नवीनतम बदल बघा",
-       "rcfilters-search-placeholder": "à¤\85लà¥\80à¤\95डà¥\80ल à¤¬à¤¦à¤² à¤\97ाळा (नà¥\8dयाहाळा à¤\95िà¤\82वा à¤\9fà¤\82à¤\95न à¤¸à¥\81रà¥\82 à¤\95रा)",
+       "rcfilters-search-placeholder": "बदल à¤\97ाळा (à¤\97ाळणà¥\8dयाà¤\82à¤\9aà¥\8dया à¤¨à¤¾à¤µà¤¾à¤¸à¤¾à¤ à¥\80 à¤®à¥\87नà¥\8dयà¥\82 à¤\85थवा à¤¶à¥\8bध à¤µà¤¾à¤ªरा)",
        "rcfilters-invalid-filter": "अवैध गाळणी",
        "rcfilters-empty-filter": "कोणत्याच गाळण्या सक्रिय नाहीत. सर्व योगदाने दाखविण्यात येत आहेत.",
        "rcfilters-filterlist-title": "गाळण्या",
+       "rcfilters-filterlist-whatsthis": "हे कसे काम करते?",
        "rcfilters-filterlist-feedbacklink": "या (नवीन) गाळणी साधनांबद्दल आपले काय म्हणणे/विचार आहेत ते आम्हास सांगा",
        "rcfilters-highlightbutton-title": "निकालांवर झोत टाका",
        "rcfilters-highlightmenu-help": "या गुणधर्मासाठी झोताचा रंग निवडा",
        "rcfilters-filterlist-noresults": "कोणतीच गाळणी सापडली नाही",
        "rcfilters-filtergroup-authorship": "योगदानांचे लेखक",
-       "rcfilters-filter-editsbyself-label": "à¤\86पलà¥\80 à¤¸à¥\8dवत:à¤\9aà¥\80 à¤¸à¤\82पादनà¥\87",
+       "rcfilters-filter-editsbyself-label": "à¤\86पलà¥\87 à¤¸à¥\8dवतà¤\83à¤\9aà¥\87 à¤¬à¤¦à¤²",
        "rcfilters-filter-editsbyself-description": "आपली संपादने",
-       "rcfilters-filter-editsbyother-label": "à¤\87तराà¤\82à¤\9aà¥\80 à¤¸à¤\82पादनà¥\87",
+       "rcfilters-filter-editsbyother-label": "à¤\87तराà¤\82à¤\9aà¥\87 à¤¬à¤¦à¤²",
        "rcfilters-filter-editsbyother-description": "इतर सदस्यांनी तयार केलेली संपादने (आपण नाही).",
        "rcfilters-filtergroup-userExpLevel": "अनुभवाचा स्तर (फक्त नोंदणीकृत सदस्यांसाठीच)",
        "rcfilters-filter-user-experience-level-registered-label": "नोंदणीकृत",
-       "rcfilters-filter-user-experience-level-registered-description": "पà¥\8dरवà¥\87शलà¥\87लà¥\87 à¤¸à¤¦à¤¸à¥\8dय",
+       "rcfilters-filter-user-experience-level-registered-description": "पà¥\8dरवà¥\87शलà¥\87लà¥\87 à¤¸à¤\82पादà¤\95.",
        "rcfilters-filter-user-experience-level-unregistered-label": "अ-नोंदणीकृत",
        "rcfilters-filter-user-experience-level-unregistered-description": "संपादक जे प्रवेशित नाहीत.",
        "rcfilters-filter-user-experience-level-newcomer-label": "नवागत",
-       "rcfilters-filter-user-experience-level-newcomer-description": "१० संपादनांपेक्षा कमी व ४ दिवसांची सक्रियता असणारे नोंदणीकृत सदस्य.",
+       "rcfilters-filter-user-experience-level-newcomer-description": "१० à¤¸à¤\82पादनाà¤\82पà¥\87à¤\95à¥\8dषा à¤\95मà¥\80 à¤¸à¤\82पादनà¥\87 à¤\95à¥\87लà¥\87लà¥\87 à¤µ à¥ª à¤¦à¤¿à¤µà¤¸à¤¾à¤\82à¤\9aà¥\80 à¤¸à¤\95à¥\8dरियता à¤\85सणारà¥\87 à¤¨à¥\8bà¤\82दणà¥\80à¤\95à¥\83त à¤¸à¤¦à¤¸à¥\8dय.",
        "rcfilters-filter-user-experience-level-learner-label": "शिकाऊ",
        "rcfilters-filter-user-experience-level-learner-description": "\"शिकाऊ\" व \"नोंदणीकृत संपादक\" या दरम्यानचा अनुभव असणारे संपादक",
        "rcfilters-filter-user-experience-level-experienced-label": "अनुभवी सदस्य",
        "rcfilters-filter-major-description": "किरकोळ अशी खूण नसलेली संपादने",
        "rcfilters-filtergroup-watchlist": "निरीक्षणसूचीतील पाने",
        "rcfilters-filter-watchlist-watched-label": "निरीक्षणसूचीतील",
+       "rcfilters-filter-watchlist-watched-description": "आपल्या निरीक्षणसूचीत असलेल्या पानांमधील बदल.",
        "rcfilters-filter-watchlist-watchednew-label": "निरीक्षणसूचीतील नवीन बदल",
        "rcfilters-filter-watchlist-watchednew-description": "बदल झाल्यानंतर, आपण भेट न दिल्यापासून झालेले निरीक्षणसूचीच्या पानांतील बदल",
        "rcfilters-filter-watchlist-notwatched-label": "निरीक्षणसूचीत नसलेली",
        "rcfilters-filter-watchlist-notwatched-description": "आपल्या निरीक्षणसूचीतील बदलांशिवाय इतर सर्वकाही.",
+       "rcfilters-filter-watchlistactivity-unseen-label": "न-बघितलेले बदल",
+       "rcfilters-filter-watchlistactivity-seen-label": "बघितलेले बदल",
        "rcfilters-filtergroup-changetype": "बदलाचा प्रकार",
        "rcfilters-filter-pageedits-label": "पृष्ठ संपादने",
        "rcfilters-filter-newpages-label": "नवीन पान-निर्माण",
        "rcfilters-view-namespaces-tooltip": "नामविश्वांनुसार गाळण्यांचे निकाल",
        "rcfilters-view-tags-tooltip": "संपादन खूण वापरुन गाळण्यांचे निकाल",
        "rcfilters-view-tags-help-icon-tooltip": "खूण केलेल्या संपादनांबाबत अधिक जाणून घ्या",
+       "rcfilters-liveupdates-button": "सजीव अद्यतने",
+       "rcfilters-liveupdates-button-title-on": "सजीव अद्यतने बंद करा",
        "rcnotefrom": "खाली {{PLURAL:$5|हा बदल आहे|हे बदल आहेत}} <strong>$3, $4</strong>पासून ते(<strong>$1</strong>पर्यंतचे  बदल दाखविले आहेत).",
        "rclistfrom": "$2,$3 पासून सुरुवात करुन, नविन केल्या गेलेले बदल दाखवा.",
        "rcshowhideminor": "छोटे बदल $1",
        "changecontentmodel-title-label": "लेखपान शीर्ष",
        "changecontentmodel-reason-label": "कारण:",
        "changecontentmodel-submit": "बदला",
+       "changecontentmodel-success-title": "आशय नमूना बदलल्या गेला",
        "log-name-contentmodel": "आशय नमूना बदल नोंदी",
        "logentry-contentmodel-change-revertlink": "उलटवा",
        "logentry-contentmodel-change-revert": "उलटवा",
        "pageinfo-length": "पानाचा आकार (बाइट्समध्ये)",
        "pageinfo-article-id": "पृष्ठ-ओळखण",
        "pageinfo-language": "पान-आशय भाषा",
+       "pageinfo-language-change": "बदल",
        "pageinfo-content-model": "पान-आशय नमूना",
        "pageinfo-content-model-change": "बदला",
        "pageinfo-robot-policy": "यंत्रमानवांद्वारे अनुक्रमण",
        "confirmemail_body_set": "{{SITENAME}} वर कुणीतरी, बहुतेक आपणच, $1 या अंकपत्त्यावरून, \"$2\" या  खात्याकरिताचा विपत्रपत्ता (ई-मेल), या पत्त्यास स्थापिलेला आहे.\n\nहे खाते खरोखर आपलेच आहे याची खात्री करण्यासाठी आणि {{SITENAME}} वर विपत्रपत्ता प्रारुप सक्रिय(उपलब्ध) करण्यासाठी, हा दुवा आपल्या न्याहाळकात(ब्राउजर) उघडा:\n\n$3\n\nजर हे खाते आपले *नसेल* तर, ही विपत्रपत्याचे निश्चितीकरण वगळण्यासाठी,खालील दुव्यास अनुसरा:\n\n$5\n\nहा निश्चितीकरण संकेत(कन्फर्मेशन कोड) $4 ला कालबाह्य होईल.",
        "confirmemail_invalidated": "इ-मेल पत्ता तपासणी रद्द करण्यात आलेली आहे",
        "invalidateemail": "इ-मेल तपासणी रद्द करा",
+       "notificationemail_subject_changed": "{{SITENAME}} वर नोंदविलेला विपत्रपत्ता बदलवल्या गेला",
        "scarytranscludedisabled": "[आंतरविकि आंतरन्यास अनुपलब्ध केले आहे]",
        "scarytranscludefailed": "[क्षमस्व;$1करिताची साचा ओढी फसली]",
        "scarytranscludetoolong": "[आंतरजालपत्ता खूप लांब आहे]",
        "tags-apply-blocked": "आपण प्रतिबंधित असतांना आपल्या बदलांसह, बदल खूणपताकांना  लागू करु शकत नाही.",
        "tags-update-blocked": "आपण प्रतिबंधित असतांना बदल खूणपताकांना जोडू अथवा हटवू शकत नाही.",
        "tags-edit-reason": "कारण:",
+       "tags-edit-success": "बदल लागू केल्या गेलेत.",
        "tags-edit-none-selected": "जोडण्यास किंवा हटविण्यास किमान एक खूणपताका निवडा.",
        "comparepages": "पानांची तुलना करा",
        "compare-page1": "पान १",
        "sessionprovider-nocookies": "कुकिज अक्षम असू शकतात. याची खात्री करा कि कुकिज सक्षम केल्या आहेत व पुन्हा सुरुवात करा.",
        "randomrootpage": "अविशिष्ट मूळ पान",
        "log-action-filter-contentmodel": "आशय नमूना बदलाचा प्रकार",
+       "log-action-filter-rights-rights": "मानवी बदल",
        "log-action-filter-suppress-block": "रोधामार्फत सदस्य दाबणे",
        "changecredentials": "अधिकारपत्रे (क्रेडेंटियल्स)बदला",
        "removecredentials": "अधिकारपत्रे (क्रेडेंटियल्स) हटवा"
index 35e35ee..5202c2e 100644 (file)
        "minoredit": "Din hija modifika minuri",
        "watchthis": "Segwi din il-paġna",
        "savearticle": "Salva l-paġna",
+       "publishpage": "Ippubblika l-paġna",
        "publishchanges": "Ippubblika l-modifiki",
        "preview": "Dehra proviżorja",
        "showpreview": "Dehra proviżorja",
index 5aa5b97..aa9636f 100644 (file)
        "timezoneregion-indian": "Indiahavet",
        "timezoneregion-pacific": "Stillehavet",
        "allowemail": "Tillat andre å sende meg e-post",
+       "email-allow-new-users-label": "Tillat e-poster fra helt nyregistrerte brukere",
        "email-blacklist-label": "Forhindre disse brukerne fra å sende meg e-post:",
        "prefs-searchoptions": "Søk",
        "prefs-namespaces": "Navnerom",
index cf9db33..b8d8b37 100644 (file)
        "tog-hidepatrolled": "Gemarkeerde wijzigingen verbergen in recente wijzigingen",
        "tog-newpageshidepatrolled": "Gemarkeerde pagina's verbergen in de lijst met nieuwe pagina's",
        "tog-hidecategorization": "Categorisatie van pagina's verbergen",
-       "tog-extendwatchlist": "Uitgebreide volglijst gebruiken om alle wijzigingen te bekijken, en niet alleen de laatste",
+       "tog-extendwatchlist": "Volglijst uitbreiden om alle wijzigingen te tonen, en niet alleen de recentste",
        "tog-usenewrc": "Wijzigingen per pagina weergeven in recente wijzigingen en volglijst",
        "tog-numberheadings": "Koppen automatisch nummeren",
        "tog-showtoolbar": "Bewerkingswerkbalk weergeven",
        "tog-editondblclick": "Dubbelklikken voor bewerken",
        "tog-editsectiononrightclick": "Bewerken van deelpagina’s mogelijk maken met een rechtermuisklik op een tussenkop",
-       "tog-watchcreations": "Pagina's die ik aanmaak en bestanden die ik upload automatisch volgen",
-       "tog-watchdefault": "Pagina’s en bestanden die ik bewerk automatisch volgen",
+       "tog-watchcreations": "Pagina's die ik aanmaak en bestanden die ik upload aan mijn volglijst toevoegen",
+       "tog-watchdefault": "Pagina’s en bestanden die ik bewerk aan mijn volglijst toevoegen",
        "tog-watchmoves": "Pagina’s en bestanden die ik hernoem automatisch volgen",
        "tog-watchdeletion": "Pagina’s en bestanden die ik verwijder automatisch volgen",
-       "tog-watchuploads": "Nieuwe bestanden die ik upload toevoegen aan mijn volglijst",
+       "tog-watchuploads": "Nieuwe bestanden die ik upload aan mijn volglijst toevoegen",
        "tog-watchrollback": "Pagina's waarop ik heb teruggedraaid automatisch volgen",
        "tog-minordefault": "Mijn bewerkingen standaard als kleine bewerking markeren",
        "tog-previewontop": "Voorvertoning boven bewerkingsveld weergeven",
        "tog-watchlisthidebots": "Botbewerkingen op mijn volglijst verbergen",
        "tog-watchlisthideminor": "Kleine bewerkingen op mijn volglijst verbergen",
        "tog-watchlisthideliu": "Bewerkingen van aangemelde gebruikers op mijn volglijst verbergen",
-       "tog-watchlistreloadautomatically": "Herlaad de volglijst automatisch wanneer er een filter is veranderd (JavaScript vereist)",
-       "tog-watchlistunwatchlinks": "Voeg volgen/niet volgen-links toe aan regels in de volglijst (JavaScript vereist voor deze functionaliteit)",
+       "tog-watchlistreloadautomatically": "De volglijst automatisch herladen wanneer er een filter wordt veranderd (JavaScript vereist)",
+       "tog-watchlistunwatchlinks": "Volgen/niet volgen-links toevoegen aan regels in de volglijst (JavaScript vereist voor schakelfunctionaliteit)",
        "tog-watchlisthideanons": "Bewerkingen van anonieme gebruikers op mijn volglijst verbergen",
        "tog-watchlisthidepatrolled": "Gemarkeerde wijzigingen op mijn volglijst verbergen",
        "tog-watchlisthidecategorization": "Categorisatie van pagina's verbergen",
        "createacct-email-ph": "Geef uw e-mailadres op",
        "createacct-another-email-ph": "Geef een e-mailadres op",
        "createaccountmail": "Gebruik een tijdelijk willekeurig wachtwoord en stuur het naar het opgegeven e-mailadres",
-       "createaccountmail-help": "Kan worden gebruikt voor het aanmaken van een account voor een andere persoon zonder het wachtwoord te leren.",
+       "createaccountmail-help": "Kan worden gebruikt voor het aanmaken van een account voor een andere persoon zonder het wachtwoord te vernemen.",
        "createacct-realname": "Echte naam (optioneel)",
        "createacct-reason": "Reden",
        "createacct-reason-ph": "Waarom u een ander account aanmaakt",
        "createacct-reason-help": "Weergegeven bericht in het logbestand van aangemaakte gebruikers",
-       "createacct-submit": "Account aanmaken",
+       "createacct-submit": "Uw account aanmaken",
        "createacct-another-submit": "Account aanmaken",
-       "createacct-continue-submit": "Doorgaan met het maken van een account",
-       "createacct-another-continue-submit": "Doorgaan met het maken van een account",
+       "createacct-continue-submit": "Doorgaan met het aanmaken van een account",
+       "createacct-another-continue-submit": "Doorgaan met het aanmaken van een account",
        "createacct-benefit-heading": "{{SITENAME}} wordt gemaakt door mensen zoals u.",
        "createacct-benefit-body1": "bewerking{{PLURAL:$1||en}}",
        "createacct-benefit-body2": "pagina{{PLURAL:$1||'s}}",
        "createacct-benefit-body3": "recente bijdrager{{PLURAL:$1||s}}",
        "badretype": "De ingevoerde wachtwoorden verschillen van elkaar.",
-       "usernameinprogress": "Het aanmaken van een account met die naam is al bezig.\nEven geduld alstublieft.",
+       "usernameinprogress": "Het aanmaken van een account met die naam is al in behandeling.\nEven geduld alstublieft.",
        "userexists": "De gekozen gebruikersnaam is al in gebruik.\nKies een andere naam.",
        "loginerror": "Aanmeldfout",
        "createacct-error": "Fout tijdens aanmaken account",
        "eauthentsent": "Er is ter bevestiging een e-mail naar het opgegeven e-mailadres gezonden.\nVolg de aanwijzingen in de e-mail om te bevestigen dat het uw account is.\nTot die tijd wordt er geen andere e-mail naar het account gezonden.",
        "throttled-mailpassword": "In {{PLURAL:$1|het laatste uur|de laatste $1 uur}} is al een wachtwoordherinnering verzonden.\nOm misbruik te voorkomen wordt er slechts één wachtwoordherinnering per {{PLURAL:$1|uur|$1 uur}} verzonden.",
        "mailerror": "Fout bij het verzenden van e-mail: $1",
-       "acct_creation_throttle_hit": "Bezoekers van deze wiki hebben vanaf uw IP-adres de afgelopen $2 al {{PLURAL:$1|1 account|$1 accounts}} aangemaakt, wat het maximale toegestane aantal is voor deze periode.\nDaarom kunt u momenteel vanaf dit IP-adres geen nieuwe accounts aanmaken.",
+       "acct_creation_throttle_hit": "Bezoekers van deze wiki hebben vanaf uw IP-adres de afgelopen $2 al {{PLURAL:$1|een account|$1 accounts}} aangemaakt, wat het maximaal toegestane aantal is voor deze periode.\nDaarom kunt u momenteel vanaf dit IP-adres geen nieuwe accounts aanmaken.",
        "emailauthenticated": "Uw e-mailadres is bevestigd op $2 om $3.",
        "emailnotauthenticated": "Uw e-mailadres is niet bevestigd.\nDe volgende functies verzenden nog geen e-mail.",
        "noemailprefs": "Geef een e-mailadres op in uw voorkeuren om deze functies te gebruiken.",
        "createaccount-text": "Iemand heeft een account voor uw e-mailadres op {{SITENAME}} ($4) aangemaakt genaamd \"$2\", met wachtwoord \"$3\".\nMeld u aan en wijzig uw wachtwoord.\n\nU kunt dit bericht negeren als dit account zonder uw medeweten is aangemaakt.",
        "login-throttled": "U heeft recentelijk te veel aanmeldpogingen gedaan.\nWacht alstublieft $1 voordat u het opnieuw probeert.",
        "login-abort-generic": "Uw aanmelding is mislukt - Afgebroken",
-       "login-migrated-generic": "Uw gebruikersnaam is hernoemd, en uw gebruikersnaam bestaat niet langer op deze wiki.",
+       "login-migrated-generic": "Uw account is hernoemd, en uw gebruikersnaam bestaat niet langer op deze wiki.",
        "loginlanguagelabel": "Taal: $1",
        "suspicious-userlogout": "Uw verzoek om af te melden is genegeerd, omdat het lijkt alsof het verzoek is verzonden door een browser of cacheproxy die stuk is.",
        "createacct-another-realname-tip": "Een echte naam is optioneel.\nAls u een naam opgeeft, wordt deze gebruikt ter erkenning voor diens werk.",
        "changepassword-success": "Uw wachtwoord is gewijzigd!",
        "changepassword-throttled": "U heeft recentelijk te veel mislukte aanmeldpogingen gedaan.\nWacht alstublieft $1 voordat u het opnieuw probeert.",
        "botpasswords": "Botwachtwoorden",
-       "botpasswords-summary": "<em>Botwachtwoorden</em> zorgen voor toegang tot de API via een gebruikersaccount zonder gebruik te maken van de aanmeldgegevens van dat account. De gebruikersrechten die beschikbaar zijn kunnen afwijken indien er aangemeld is met een botwachtwoord.\n\nAls u niet weet wat de gevolgen hiervan zijn, is het handiger om dit ook dan niet te doen. Niemand hoort u te vragen om een botwachtwoord aan te maken en deze vervolgens aan hem of haar te geven.",
+       "botpasswords-summary": "<em>Botwachtwoorden</em> zorgen voor toegang tot een gebruikersaccount via de API, zonder gebruik te maken van de aanmeldgegevens van dat account. De beschikbare gebruikersrechten zijn mogelijk beperkt wanneer er met een botwachtwoord is aangemeld.\n\nAls u niet weet waarom u een botwachtwoord zou willen aanmaken, is het raadzaam het niet te doen. Niemand hoort u te vragen om een botwachtwoord aan te maken en dit aan hem of haar te geven.",
        "botpasswords-disabled": "Botwachtwoorden zijn uitgeschakeld.",
-       "botpasswords-no-central-id": "Om botwachtwoorden te gebruiken, moet u ingelogd zijn met een gecentraliseerd account",
+       "botpasswords-no-central-id": "Om botwachtwoorden te gebruiken, moet u ingelogd zijn met een gecentraliseerd account.",
        "botpasswords-existing": "Bestaande botwachtwoorden",
        "botpasswords-createnew": "Een nieuw botwachtwoord aanmaken",
        "botpasswords-editexisting": "Een bestaand botwachtwoord bewerken",
        "botpasswords-label-delete": "Verwijderen",
        "botpasswords-label-resetpassword": "Het wachtwoord opnieuw instellen",
        "botpasswords-label-grants": "Van toepassing zijnde rechten:",
-       "botpasswords-help-grants": "Toestemmingen geven toegang tot gebruikersrechten die u al heeft. Het geven van een toestemming op deze plek geeft u geen toegang tot gebruikersrechten die u anders niet zou hebben. Zie het [[Special:ListGrants|overzicht van toestemmingen]] voor meer informatie.",
+       "botpasswords-help-grants": "Toestemmingen geven toegang tot rechten die uw gebruikersaccount al heeft. Het geven van een toestemming op deze plek geeft geen toegang tot rechten die uw gebruikersaccount anders niet zou hebben. Zie het [[Special:ListGrants|overzicht van toestemmingen]] voor meer informatie.",
        "botpasswords-label-grants-column": "Toegewezen",
        "botpasswords-bad-appid": "De botnaam \"$1\" is niet geldig.",
        "botpasswords-insert-failed": "Toevoegen van botnaam \"$1\" mislukt. Is deze misschien al toegevoegd?",
        "passwordreset-domain": "Domein:",
        "passwordreset-email": "E-mailadres:",
        "passwordreset-emailtitle": "Accountgegevens op {{SITENAME}}",
-       "passwordreset-emailtext-ip": "Iemand (waarschijnlijk u, vanaf IP-adres $1) heeft een aanvraag gedaan om uw wachtwoord voor {{SITENAME}} ($4) opnieuw in te stellen. {{PLURAL:$3|Het volgende gebruikersaccount is|De volgende gebruikersaccounts zijn}} gekoppeld aan dit e-mailadres:\n\n$2\n\n{{PLURAL:$3|Dit tijdelijke wachtwoord vervalt|Deze tijdelijke wachtwoorden vervallen}} over {{PLURAL:$5|een dag|$5 dagen}}. Meld u aan en wijzig het wachtwoord nu. Als u dit verzoek niet zelf heeft gedaan, of als u het oorspronkelijke wachtwoord nog kent en het niet wilt wijzigen, negeer dit bericht dan en blijf uw oude wachtwoord gebruiken.",
+       "passwordreset-emailtext-ip": "Iemand (waarschijnlijk u, vanaf IP-adres $1) heeft een aanvraag gedaan om uw wachtwoord voor {{SITENAME}} ($4) opnieuw in te stellen. {{PLURAL:$3|Het volgende gebruikersaccount is|De volgende gebruikersaccounts zijn}} gekoppeld aan dit e-mailadres:\n\n$2\n\n{{PLURAL:$3|Dit tijdelijke wachtwoord vervalt|Deze tijdelijke wachtwoorden vervallen}} over {{PLURAL:$5|een dag|$5 dagen}}. Meld u aan en wijzig het wachtwoord nu. Als u dit verzoek niet zelf heeft gedaan, of als u het oorspronkelijke wachtwoord herinnert en het niet wilt wijzigen, negeer dit bericht dan en blijf uw oude wachtwoord gebruiken.",
        "passwordreset-emailtext-user": "Gebruiker $1 op {{SITENAME}} heeft een aanvraag gedaan om uw wachtwoord voor {{SITENAME}} ($4) opnieuw in te stellen. {{PLURAL:$3|Het volgende gebruikersaccount is|De volgende gebruikersaccounts zijn}} gekoppeld aan dit e-mailadres:\n\n$2\n\n{{PLURAL:$3|Dit tijdelijke wachtwoord vervalt|Deze tijdelijke wachtwoorden vervallen}} over {{PLURAL:$5|een dag|$5 dagen}}.\nMeld u aan en wijzig het wachtwoord nu. Als u dit verzoek niet zelf heeft gedaan, of als u het oorspronkelijke wachtwoord nog kent en het niet wilt wijzigen, negeer dit bericht dan en blijf uw oude wachtwoord gebruiken.",
        "passwordreset-emailelement": "Gebruikersnaam: \n$1\n\nTijdelijk wachtwoord: \n$2",
        "passwordreset-emailsentemail": "Als dit e-mailadres aan uw account gekoppeld is, dan wordt er een e-mail verzonden om uw wachtwoord opnieuw in te stellen.",
        "passwordreset-invalidemail": "Ongeldig e-mailadres",
        "passwordreset-nodata": "Er is geen gebruikersnaam of e-mailadres opgegeven",
        "changeemail": "E-mailadres wijzigen of verwijderen",
-       "changeemail-header": "Vul dit formulier in om uw e-mailadres te wijzigen. Als u het e-mailadres wilt ontkoppelen van uw account, laat het e-mailadres dan leeg als u het formulier opslaat.",
+       "changeemail-header": "Vul dit formulier in om uw e-mailadres te wijzigen. Als u geen enkel e-mailadres aan uw account gekoppeld wilt hebben, laat het veld voor het nieuwe e-mailadres dan leeg.",
        "changeemail-no-info": "U moet aangemeld zijn om rechtstreeks toegang te hebben tot deze pagina.",
        "changeemail-oldemail": "Huidig e-mailadres:",
        "changeemail-newemail": "Nieuw e-mailadres:",
-       "changeemail-newemail-help": "Laat dit veld leeg als u uw e-mailadres wilt verwijderen. Na het verwijderen kunt u niet langer een vergeten wachtwoord opnieuw instellen en u ontvangt geen e-mails van deze wiki meer.",
+       "changeemail-newemail-help": "Laat dit veld leeg als u uw e-mailadres wilt verwijderen. Zonder gekoppeld e-mailadres kunt u een vergeten wachtwoord niet opnieuw instellen en ontvangt u geen e-mails van deze wiki meer.",
        "changeemail-none": "(geen)",
        "changeemail-password": "Uw wachtwoord voor {{SITENAME}}:",
        "changeemail-submit": "E-mailadres wijzigen",
        "changeemail-throttled": "U heeft recentelijk te veel mislukte aanmeldpogingen gedaan.\nWacht alstublieft $1 voordat u het opnieuw probeert.",
        "changeemail-nochange": "Geef een ander e-mailadres op.",
        "resettokens": "Tokens opnieuw instellen",
-       "resettokens-text": "U kunt tokens opnieuw instellen die toegang geven tot bepaalde persoonlijke gegevens die aan uw account zijn verbonden.\n\nU zou dit moeten doen als u ze per ongeluk gedeeld heeft met anderen of als onbevoegden toegang tot uw account hebben gehad.",
+       "resettokens-text": "U kunt hier tokens die toegang geven tot bepaalde aan uw account gekoppelde privégegevens opnieuw instellen.\n\nU zou dit moeten doen als u ze per ongeluk met anderen hebt gedeeld of als onbevoegden toegang tot uw account hebben gehad.",
        "resettokens-no-tokens": "Er zijn geen tokens om opnieuw in te stellen.",
        "resettokens-tokens": "Tokens:",
        "resettokens-token-label": "$1 (huidige waarde: $2)",
        "showpreview": "Bewerking ter controle bekijken",
        "showdiff": "Wijzigingen bekijken",
        "blankarticle": "<strong>Waarschuwing:</strong> de pagina die u wilt aanmaken is leeg.\nAls u opnieuw op \"$1\" klikt, wordt de pagina aangemaakt zonder enige inhoud.",
-       "anoneditwarning": "<strong>Waarschuwing:</strong> U bent niet aangemeld.\nUw IP-adres zal voor iedereen zichtbaar zijn als u wijzigingen op deze pagina maakt. Wanneer u <strong>[$1 zich aanmeldt]</strong> of <strong>[$2 een account aanmaakt]</strong>, verschijnen uw bewerkingen onder uw gebruikersnaam, naast andere voordelen.",
+       "anoneditwarning": "<strong>Waarschuwing:</strong> U bent niet aangemeld.\nUw IP-adres zal voor iedereen zichtbaar zijn als u wijzigingen op deze pagina maakt. Wanneer u <strong>[$1 zich aanmeldt]</strong> of <strong>[$2 een account aanmaakt]</strong>, worden uw bewerkingen aan uw gebruikersnaam toegeschreven, naast andere voordelen.",
        "anonpreviewwarning": "<em>U bent niet aangemeld. Door uw bewerking op te slaan wordt uw IP-adres in de paginageschiedenis opgenomen.</em>",
        "missingsummary": "'''Let op:''' u hebt geen bewerkingssamenvatting opgegeven.\nAls u nogmaals op \"$1\" klikt wordt de bewerking zonder samenvatting opgeslagen.",
        "selfredirect": "<strong>Waarschuwing:</strong> U heeft een doorverwijzing gemaakt naar deze pagina. Mogelijk heeft u de verkeerde bestemming voor de doorverwijzing gebruikt, of bewerkt u de verkeerde pagina. Door nogmaals op \"$1\" te klikken word de doorverwijzing alsnog aangemaakt.",
        "noarticletext-nopermission": "Deze pagina bevat geen tekst.\nU kunt [[Special:Search/{{PAGENAME}}|naar deze term zoeken]] in andere pagina's of\n<span class=\"plainlinks\">[{{fullurl:{{#Special:Log}}|page={{FULLPAGENAMEE}}}} de logboeken doorzoeken]</span>, maar u mag de pagina niet aanmaken.",
        "missing-revision": "De versie #$1 van de pagina \"{{FULLPAGENAME}}\" bestaat niet.\n\nDit wordt meestal veroorzaakt door het volgen van een verouderde koppeling naar een pagina die is verwijderd.\nMeer gegevens zijn mogelijk te vinden in het [{{fullurl:{{#Special:Log}}/delete|page={{FULLPAGENAMEE}}}} verwijderingslogboek].",
        "userpage-userdoesnotexist": "Gebruikersaccount \"$1\" bestaat niet.\nControleer of u deze pagina wel wilt aanmaken/bewerken.",
-       "userpage-userdoesnotexist-view": "Gebruikersaccount \"$1\" is niet geregistreerd.",
+       "userpage-userdoesnotexist-view": "Gebruikersaccount \"$1\" bestaat niet.",
        "blocked-notice-logextract": "Deze gebruiker is momenteel geblokkeerd.\nDe laatste regel uit het blokkeerlogboek wordt hieronder ter referentie weergegeven:",
        "clearyourcache": "<strong>Opmerking:</strong> nadat u de wijzigingen hebt opgeslagen is het wellicht nodig uw browsercache te legen.\n* <strong>Firefox / Safari:</strong> houd <em>Shift</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em> of <em>Ctrl-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Google Chrome:</strong> druk op <em>Ctrl-Shift-R</em> (<em>⌘-Shift-R</em> op een Mac)\n* <strong>Internet Explorer:</strong> houd <em>Ctrl</em> ingedrukt terwijl u op <em>Vernieuwen</em> klikt of druk op <em>Ctrl-F5</em>\n* '''Opera:''' ga naar <em>Menu → Instellingen</em> (<em>Opera → Voorkeuren</em> op een Mac) en daarna naar <em>Privacy & beveiliging → Browsegegevens wissen... →  Tijdelijk opgeslgen afbeeldingen en bestanden</em>.",
        "usercssyoucanpreview": "'''Tip:''' gebruik de knop \"{{int:showpreview}}\" om uw nieuwe CSS te testen alvorens op te slaan.",
        "prefs-watchlist-days-max": "Maximaal $1 {{PLURAL:$1|dag|dagen}}",
        "prefs-watchlist-edits": "Maximaal aantal bewerkingen in de volglijst:",
        "prefs-watchlist-edits-max": "Maximale aantal: 1000",
-       "prefs-watchlist-token": "Volglijstsleutel:",
+       "prefs-watchlist-token": "Volglijsttoken:",
        "prefs-misc": "Diversen",
        "prefs-resetpass": "Wachtwoord wijzigen",
        "prefs-changeemail": "E-mailadres wijzigen of verwijderen",
        "recentchangesdays-max": "(maximaal $1 {{PLURAL:$1|dag|dagen}})",
        "recentchangescount": "Standaard aantal weer te geven bewerkingen:",
        "prefs-help-recentchangescount": "Dit geldt voor recente wijzigingen, paginageschiedenis en logboekpagina's.",
-       "prefs-help-watchlist-token2": "Dit is de geheime sleutel voor de webfeed van uw volglijst.\nIedereen die het token kent, kan uw volglijst bekijken, dus deel dit token niet.\nU kunt de [[Special:ResetTokens|tokens opnieuw instellen]] als u dat wilt.",
+       "prefs-help-watchlist-token2": "Dit is de geheime sleutel voor de webfeed van uw volglijst.\nIedereen die het token kent, kan uw volglijst bekijken, dus deel dit token niet.\nIndien nodig kunt u [[Special:ResetTokens|tokens opnieuw instellen]].",
        "savedprefs": "Uw voorkeuren zijn opgeslagen.",
        "savedrights": "De gebruikergroepen van {{GENDER:$1|$1}} zijn opgeslagen.",
        "timezonelegend": "Tijdzone:",
        "timezoneregion-indian": "Indische Oceaan",
        "timezoneregion-pacific": "Stille Oceaan",
        "allowemail": "Andere gebruikers toestaan mij e-mails te sturen",
+       "email-allow-new-users-label": "E-mails van gloednieuwe gebruikers toestaan",
        "email-blacklist-label": "Voorkom dat deze gebruikers e-mails naar mij kunnen sturen:",
        "prefs-searchoptions": "Zoeken",
        "prefs-namespaces": "Naamruimten",
        "recentchangeslinked-feed": "Verwante wijzigingen",
        "recentchangeslinked-toolbox": "Verwante wijzigingen",
        "recentchangeslinked-title": "Wijzigingen verwant aan \"$1\"",
-       "recentchangeslinked-summary": "Deze speciale pagina geeft de laatste bewerkingen weer op pagina's waarheen verwezen wordt vanaf een opgegeven pagina of op pagina's in een opgegeven categorie.\nPagina's die op [[Special:Watchlist|uw volglijst]] staan worden '''vet''' weergegeven.",
+       "recentchangeslinked-summary": "Voer een paginanaam in om bewerkingen te zien van pagina's waarheen vanaf die pagina verwezen wordt of die ernaar verwijzen. (Om leden van een categorie te zien, voert u <kbd>Categorie:''Naam van categorie''</kbd> in.) Bewerkingen van pagina's op [[Special:Watchlist|uw volglijst]] worden <strong>vet</strong> weergegeven.",
        "recentchangeslinked-page": "Paginanaam:",
        "recentchangeslinked-to": "Wijzigingen aan pagina's met koppelingen naar deze pagina bekijken",
        "recentchanges-page-added-to-category": "[[:$1]] aan categorie toegevoegd",
        "uploadstash-bad-path-invalid": "Pad is ongeldig.",
        "uploadstash-bad-path-unknown-type": "Onbekend type \"$1\".",
        "uploadstash-bad-path-unrecognized-thumb-name": "Miniatuurnaam onbekend.",
+       "uploadstash-bad-path-no-handler": "Geen handler gevonden voor mime $1 van bestand $2.",
        "uploadstash-bad-path-bad-format": "Sleutel \"$1\" is niet in het juiste formaat.",
        "uploadstash-file-not-found": "Sleutel \"$1\" niet gevonden in de opslag.",
        "uploadstash-file-not-found-no-thumb": "Kon geen miniatuur verkrijgen.",
+       "uploadstash-file-not-found-no-local-path": "Geen lokaal pad voor geschaalde afbeelding.",
        "uploadstash-file-not-found-no-object": "Kan geen lokaal bestandsobject voor het miniatuur aanmaken.",
        "uploadstash-file-not-found-no-remote-thumb": "Ophalen van het miniatuur mislukt: $1\nurl = $2",
        "uploadstash-file-not-found-missing-content-type": "content-type koptekst ontbreekt.",
        "listfiles-delete": "verwijderen",
        "listfiles-summary": "Op deze speciale pagina zijn alle toegevoegde bestanden te bekijken.",
        "listfiles_search_for": "Zoeken naar bestand:",
-       "listfiles-userdoesnotexist": "Het gebruikersaccount \"$1\" bestaat niet.",
+       "listfiles-userdoesnotexist": "Gebruikersaccount \"$1\" bestaat niet.",
        "imgfile": "bestand",
        "listfiles": "Bestandslijst",
        "listfiles_thumb": "Miniatuur",
        "listgrouprights-removegroup": "Gebruikers uit de volgende {{PLURAL:$2|groep|groepen}} verwijderen: $1",
        "listgrouprights-addgroup-all": "Gebruikers aan alle groepen toevoegen",
        "listgrouprights-removegroup-all": "Gebruikers uit alle groepen verwijderen",
-       "listgrouprights-addgroup-self": "De volgende {{PLURAL:$2|groep|groepen}} toevoegen aan eigen gebruiker: $1",
+       "listgrouprights-addgroup-self": "De volgende {{PLURAL:$2|groep|groepen}} toevoegen aan eigen account: $1",
        "listgrouprights-removegroup-self": "De volgende {{PLURAL:$2|groep|groepen}} verwijderen van eigen gebruiker: $1",
        "listgrouprights-addgroup-self-all": "Alle groepen toevoegen aan eigen account",
        "listgrouprights-removegroup-self-all": "Alle groepen verwijderen van eigen account",
        "listgrouprights-namespaceprotection-namespace": "Naamruimte",
        "listgrouprights-namespaceprotection-restrictedto": "Recht(en) waardoor gebruiker kan bewerken",
        "listgrants": "Toestemmingen",
-       "listgrants-summary": "Hieronder staat een lijst met toestemmingen en de bijbehorende gebruikersrechten. Gebruikers kunnen toepassingen machtigen voor toegang tot hun account, maar met beperkte rechten gebaseerd op de toestemmingen die de gebruiker aan de toepassing heeft gegeven. Een toepassing die namens een gebruiker handelt, kan nooit rechten gebruiken die een gebruiker niet heeft.\nEr zijn mogelijk [[{{MediaWiki:Listgrouprights-helppage}}|aanvullende  gegevens]] over individuele rechten.",
+       "listgrants-summary": "Hieronder staat een lijst met toestemmingen en de bijbehorende gebruikersrechten. Gebruikers kunnen toepassingen machtigen hun account te gebruiken, maar met beperkte rechten gebaseerd op de toestemmingen die de gebruiker aan de toepassing heeft gegeven. Een toepassing die namens een gebruiker handelt, kan echter geen rechten gebruiken die de gebruiker niet heeft.\nEr is mogelijk [[{{MediaWiki:Listgrouprights-helppage}}|aanvullende informatie]] over individuele rechten.",
        "listgrants-grant": "Toestemming",
        "listgrants-rights": "Rechten",
        "trackingcategories": "Volgcategorieën",
        "protect-text": "Hier kunt u het beveiligingsniveau voor de pagina '''$1''' bekijken en wijzigen.",
        "protect-locked-blocked": "U kunt het beveiligingsniveau niet wijzigen terwijl u geblokkeerd bent.\nDit zijn de huidige instellingen voor de pagina '''$1''':",
        "protect-locked-dblock": "Het beveiligingsniveau kan niet worden gewijzigd, omdat de database gesloten is.\nHier zijn de huidige instellingen voor de pagina '''$1''':",
-       "protect-locked-access": "U hebt geen rechten om het beveiligingsniveau te wijzigen.\nDit zijn de huidige instellingen voor de pagina '''$1''':",
+       "protect-locked-access": "Uw account heeft geen rechten om het beveiligingsniveau van pagina's te wijzigen.\nDit zijn de huidige instellingen voor de pagina <strong>$1</strong>:",
        "protect-cascadeon": "Deze pagina is beveiligd, omdat die in de volgende {{PLURAL:$1|pagina|pagina's}} is opgenomen, die beveiligd {{PLURAL:$1|is|zijn}} met de cascade-optie.\nWijzigingen aan beveiligingsniveau hebben geen invloed op de cascadebeveiliging.",
        "protect-default": "Toestaan voor alle gebruikers",
        "protect-fallback": "Alleen gebruikers met het recht \"$1\" toestaan",
        "mycontris": "Bijdragen",
        "anoncontribs": "Bijdragen",
        "contribsub2": "Voor {{GENDER:$3|$1}} ($2)",
-       "contributions-userdoesnotexist": "De account \"$1\" is niet geregistreerd.",
+       "contributions-userdoesnotexist": "Gebruikersaccount \"$1\" bestaat niet.",
        "nocontribs": "Geen wijzigingen gevonden die aan de gestelde criteria voldoen.",
        "uctop": "(laatste wijziging)",
        "month": "Van maand (en eerder):",
        "year": "Van jaar (en eerder):",
-       "sp-contributions-newbies": "Alleen de bijdragen van nieuwe gebruikers bekijken",
+       "sp-contributions-newbies": "Alleen bijdragen van nieuwe accounts bekijken",
        "sp-contributions-newbies-sub": "Voor nieuwelingen",
-       "sp-contributions-newbies-title": "Bijdragen van nieuwe gebruikers",
+       "sp-contributions-newbies-title": "Gebruikersbijdragen van nieuwe accounts",
        "sp-contributions-blocklog": "blokkeerlogboek",
        "sp-contributions-suppresslog": "onderdrukte {{GENDER:$1|gebruikersbijdragen}}",
        "sp-contributions-deleted": "verwijderde {{GENDER:$1|gebruiker}}sbijdragen",
        "ipaddressorusername": "IP-adres of gebruikersnaam:",
        "ipbexpiry": "Vervalt (maak een keuze):",
        "ipbreason": "Reden:",
-       "ipbreason-dropdown": "*Veelvoorkomende redenen voor blokkades\n** Foutieve informatie invoeren\n** Verwijderen van informatie uit pagina's\n** Spamkoppeling naar externe websites\n** Invoegen van nonsens in pagina's\n** Intimiderend gedrag\n** Misbruik door meerdere gebruikers\n** Onaanvaardbare gebruikersnaam",
+       "ipbreason-dropdown": "*Veelvoorkomende redenen voor blokkades\n** Foutieve informatie invoeren\n** Informatie uit pagina's verwijderen\n** Veelvuldig koppelingen naar externe websites plaatsen\n** Nonsens/gebrabbel in pagina's opnemen\n** Intimiderend gedragen/anderen lastigvallen\n** Van meerdere accounts misbruik maken\n** Een onaanvaardbare gebruikersnaam kiezen",
        "ipb-hardblock": "Aangemelde gebruikers de mogelijkheid ontnemen om vanaf dit IP-adres te bewerken",
        "ipbcreateaccount": "Registreren accounts blokkeren",
        "ipbemailban": "Gebruiker de mogelijkheid ontnemen om e-mail te versturen",
        "ipb_expiry_invalid": "Ongeldige duur.",
        "ipb_expiry_old": "Vervaldatum is in het verleden.",
        "ipb_expiry_temp": "Blokkades voor verborgen gebruikers moeten permanent zijn.",
-       "ipb_hide_invalid": "Het is niet mogelijk dit account te verbergen; het heeft meer dan {{PLURAL:$1|een bewerking|$1 bewerkingen}}.",
+       "ipb_hide_invalid": "Het is niet mogelijk dit account te verbergen; het heeft meer dan {{PLURAL:$1|één bewerking|$1 bewerkingen}}.",
        "ipb_already_blocked": "\"$1\" is al geblokkeerd",
        "ipb-needreblock": "$1 is al geblokkeerd.\nWilt u de instellingen wijzigen?",
        "ipb-otherblocks-header": "Andere {{PLURAL:$1|blokkade|blokkades}}",
        "newimages-legend": "Bestandsnaam",
        "newimages-label": "Bestandsnaam (of deel daarvan):",
        "newimages-user": "IP-adres of gebruikersnaam",
-       "newimages-newbies": "Alleen de bijdragen van nieuwe gebruikers bekijken",
+       "newimages-newbies": "Alleen bijdragen van nieuwe accounts bekijken",
        "newimages-showbots": "Uploads door bots weergeven",
        "newimages-hidepatrolled": "Gecontroleerde uploads verbergen",
        "newimages-mediatype": "Mediatype:",
        "logentry-move-move_redir-noredirect": "$1 {{GENDER:$2|heeft}} pagina $3 naar $4 hernoemd over een doorverwijzing zonder een doorverwijzing achter te laten",
        "logentry-patrol-patrol": "$1 {{GENDER:$2|heeft}} versie $4 van pagina $3 gemarkeerd als gecontroleerd",
        "logentry-patrol-patrol-auto": "$1 {{GENDER:$2|heeft}} versie $4 van pagina $3 automatisch gemarkeerd als gecontroleerd",
-       "logentry-newusers-newusers": "Gebruikersaccount $1 {{GENDER:$2|is}} aangemaakt",
+       "logentry-newusers-newusers": "Gebruikersaccount $1 is {{GENDER:$2|aangemaakt}}",
        "logentry-newusers-create": "Gebruikersaccount $1 {{GENDER:$2|is}} aangemaakt",
        "logentry-newusers-create2": "Gebruikersaccount $3 is {{GENDER:$2|aangemaakt}} door $1",
        "logentry-newusers-byemail": "Gebruikersaccount $3 is {{GENDER:$2|aangemaakt}} door $1 en het wachtwoord is per e-mail verzonden",
-       "logentry-newusers-autocreate": "Gebruikersaccount $1 {{GENDER:$2|is}} automatisch aangemaakt",
+       "logentry-newusers-autocreate": "Gebruikersaccount $1 is automatisch {{GENDER:$2|aangemaakt}}",
        "logentry-protect-move_prot": "$1 heeft de beveiligingsinstellingen {{GENDER:$2|verplaatst}} van $4 naar $3",
        "logentry-protect-unprotect": "$1 heeft de beveiliging {{GENDER:$2|opgeheven}} van $3",
        "logentry-protect-protect": "$1 heeft $3 {{GENDER:$2|beveiligd}} $4",
        "feedback-useragent": "Useragent:",
        "searchsuggest-search": "Doorzoek {{SITENAME}}",
        "searchsuggest-containing": "bevat...",
-       "api-error-badtoken": "Interne fout: het token klopt niet.",
+       "api-error-badtoken": "Interne fout: Foutief token.",
        "api-error-emptypage": "Het aanmaken van nieuwe, lege pagina's is niet toegestaan.",
        "api-error-publishfailed": "Interne fout: de server kon het tijdelijke bestand niet publiceren.",
        "api-error-stashfailed": "Interne fout: de server kon het tijdelijke bestand niet opslaan.",
        "authmanager-provider-password-domain": "Wachtwoord- en domeingebaseerde authentificatie",
        "authmanager-provider-temporarypassword": "Tijdelijk wachtwoord",
        "authprovider-confirmlink-message": "Op basis van uw recente aanmeldpogingen kunnen de volgende accounts aan uw wiki-account worden gekoppeld. Het koppelen stelt u in staat in te loggen via deze accounts. Selecteer welke accounts gekoppeld moeten worden.",
-       "authprovider-confirmlink-request-label": "Accounts die aan elkaar moeten worden gekoppeld.",
+       "authprovider-confirmlink-request-label": "Accounts die aan elkaar gekoppeld moeten worden.",
        "authprovider-confirmlink-success-line": "$1: Succesvol gekoppeld.",
-       "authprovider-confirmlink-failed": "Account koppelen is niet volledig gelukt: $1",
+       "authprovider-confirmlink-failed": "Het koppelen van accounts is niet volledig gelukt: $1",
        "authprovider-confirmlink-ok-help": "Doorgaan na het weergeven van de storingsmeldingen over het koppelen.",
        "authprovider-resetpass-skip-label": "Overslaan",
        "authprovider-resetpass-skip-help": "Sla het resetten van het wachtwoord over.",
        "specialpage-securitylevel-not-allowed": "Sorry, het is u niet toegestaan gebruik te maken van deze pagina omdat uw identiteit niet kon worden geverifieerd.",
        "authpage-cannot-login": "Niet in staat om aan te melden.",
        "authpage-cannot-login-continue": "Niet in staat om in te loggen. Uw sessie is waarschijnlijk verlopen.",
-       "authpage-cannot-create": "Kon het account aanmaken niet starten.",
-       "authpage-cannot-create-continue": "Niet in staat het account aan te maken. Uw sessie is waarschijnlijk verlopen.",
-       "authpage-cannot-link": "Niet in staat om het account te koppelen.",
-       "authpage-cannot-link-continue": "Niet in staat het account te koppelen. Uw sessie is waarschijnlijk verlopen.",
+       "authpage-cannot-create": "Niet in staat het aanmaken van het account te starten.",
+       "authpage-cannot-create-continue": "Niet in staat het aanmaken van het account voort te zetten. Uw sessie is waarschijnlijk verlopen.",
+       "authpage-cannot-link": "Niet in staat het koppelen van de accounts te starten.",
+       "authpage-cannot-link-continue": "Niet in staat het koppelen van de accounts voort te zetten. Uw sessie is waarschijnlijk verlopen.",
        "cannotauth-not-allowed-title": "Geen toegang",
        "cannotauth-not-allowed": "U hebt geen toestemming om deze pagina te gebruiken",
        "changecredentials": "Authenticatiegegevens wijzigen",
        "removecredentials-invalidsubpage": "$1 is geen geldig identificatietype.",
        "removecredentials-success": "Uw authenticatiegegevens zijn verwijderd.",
        "credentialsform-provider": "Soort authenticatiegegevens:",
-       "credentialsform-account": "Gebruikersnaam:",
+       "credentialsform-account": "Accountnaam:",
        "cannotlink-no-provider-title": "Er zijn geen accounts om te koppelen",
        "cannotlink-no-provider": "Er zijn geen accounts om te koppelen.",
        "linkaccounts": "Accounts koppelen",
index ecc4824..7427f03 100644 (file)
        "defaultmessagetext": "تلواليزه پيغام متن",
        "invalid-content-data": "د ناباوره منځپانګې ډاټا",
        "content-not-allowed-here": "\"$1\" په پاڼه کې منځپانګې ته اجازه نشته [[$2]]",
+       "editpage-invalidcontentmodel-text": "د منځپانګې موډيول \"$1\" ملاتړ ندی شوی.",
+       "editpage-notsupportedcontentformat-title": "د منځپانګې بڼه نده ملاتړ شوې",
        "content-model-wikitext": "ويکي متن",
        "content-model-text": "ساده متن",
        "content-model-javascript": "جاواسکرېپټ",
        "post-expand-template-inclusion-category": "هغه مخونه چې په کې د کارېدلو کينډيو شمېر له ټاکلې کچې ډېر دی",
        "post-expand-template-argument-warning": "'''گواښنه:''' دا مخ لږ تر لږه د يوې کينډۍ عاملين لري چې بې حده لوی دی.\nدا عاملين ړنگ شول.",
        "post-expand-template-argument-category": "هغه مخونه چې د کينډۍ ړنگ شوي عاملين لري.",
+       "parser-template-loop-warning": "د کينډۍ پته ترلاسه شوه: [[$1]]",
+       "template-loop-category": "مخونه له کينډۍ پته ترلاسه شوو سره",
        "undo-failure": "د منازعې منځنۍ برخې د بدلونونو له امله دا سمون ندی رد شوی.",
        "undo-norev": "دا سمون ناکړل کېدای نه شي دا ځکه چې دا سمون نشته او يا هم ړنگ شوی.",
        "viewpagelogs": "د دې مخ يادښتونه کتل",
        "mergehistory-list": "د اخږلو وړ سمون پېښليک",
        "mergehistory-go": "اخږلو وړ سمونونه ښکاره کول",
        "mergehistory-submit": "بڼې سره يوځای کول",
+       "mergehistory-empty": "هیڅ بدلون نه شي کیدای.",
        "mergehistory-done": "د $1 $3 {{PLURAL:$3|بڼه|بڼې}} په برياليتوب سره و [[:$2]] کې {{PLURAL:$3|واخږل شو|واخږل شول}}.",
        "mergehistory-fail-bad-timestamp": "وخت ټاپه ناسمه ده.",
        "mergehistory-fail-invalid-source": "د مخ سرچينې ناباوره دي.",
        "prefs-editor": "سمونگر",
        "prefs-preview": "مخليدنه",
        "prefs-advancedrc": "پرمختللې خوښنې",
+       "prefs-opt-out": "د پرمختګونو څخه لرې کول",
        "prefs-advancedrendering": "پرمختللې خوښنې",
        "prefs-advancedsearchoptions": "پرمختللې خوښنې",
        "prefs-advancedwatchlist": "پرمختللې خوښنې",
        "recentchanges-label-plusminus": "د بايټونو د شمېر له مخې د مخ د بدلون کچه",
        "recentchanges-legend-heading": "<strong>لنډونونه:</strong>",
        "recentchanges-legend-newpage": "{{int:recentchanges-label-newpage}} ([[Special:NewPages|د نويو مخونو لړليک]] هم وگورئ)",
-       "recentchanges-legend-plusminus": "(<em>±123</em>)",
+       "recentchanges-legend-plusminus": "(<em>±۱۲۳</em>)",
        "recentchanges-submit": "ښکاره کول",
        "rcfilters-tag-remove": "لرې کړئ'$1'",
+       "rcfilters-legend-heading": "<strong>د لنډیزونو لړليک:</strong>",
+       "rcfilters-other-review-tools": "د بیاکتنې نور وسايل",
        "rcfilters-activefilters": "فعال فيلټرونه",
        "rcfilters-advancedfilters": "پرمختللي فلټرونه",
        "rcfilters-limit-title": "د ښودلو لپاره بدلونونه",
+       "rcfilters-limit-and-date-label": "{{PLURAL:$1|بدلونونه|$1 بدلونونه}}، $2",
        "rcfilters-days-title": "وروستي ورځي",
        "rcfilters-hours-title": "وروستي ساعتونه",
        "rcfilters-days-show-days": "$1 {{PLURAL:$1|day|ورځې}}",
        "rcfilters-highlighted-filters-list": "لوړ شوی: $1",
        "rcfilters-quickfilters": "خوندي شوی فلټرونه",
        "rcfilters-quickfilters-placeholder-title": "هيڅ فيلټر نه دي صفت سوي",
+       "rcfilters-quickfilters-placeholder-description": "ددي لپاره چي د خپل فلټر امستنې سم کړي، او بيايې په دوهم پړاو کې وکاروي، د فعال فلټر ساحې لاندې د بکمارک په نښه کېکاږئ.",
        "rcfilters-savedqueries-defaultlabel": "خوندي شوی فيلټرونه",
        "rcfilters-savedqueries-rename": "نوم بدلول",
        "rcfilters-savedqueries-setdefault": "د فرض په ډول کښېږدي.",
        "rcfilters-savedqueries-apply-and-setdefault-label": "د فرض په ډول د فيلټر جوړول",
        "rcfilters-savedqueries-cancel-label": "ناگارل",
        "rcfilters-savedqueries-add-new-title": "د امستنې اوسنۍ فيلټر خوندي کړي",
+       "rcfilters-search-placeholder": "د فلټر بدلونونه (د مینو کارول یا د فلټر نوم لټونه)",
+       "rcfilters-invalid-filter": "غلط فلټر",
+       "rcfilters-empty-filter": "هيڅ فعال فلټر نشته. ټولي سمونې ښکاره شوي.",
        "rcfilters-filterlist-title": "چاڼگران",
+       "rcfilters-filterlist-whatsthis": "دوي سنګه کار کوي؟",
        "rcfilters-highlightmenu-title": "يو رنګ وټاکۍ",
        "rcfilters-filter-editsbyself-label": "بدلونونه ستاسو لخوا",
        "rcfilters-filter-editsbyself-description": "ستاسو خپل بدلونونه.",
        "rcfilters-filter-unpatrolled-description": "سمونې چي د ګزمې په توګه نه دي په نښه شوي.",
        "rcfilters-filtergroup-significance": "ارزښت",
        "rcfilters-filter-minor-label": "وړوکي سمونونه",
+       "rcfilters-filtergroup-watchlist": "د کتنلړ مخونه",
        "rcfilters-filter-watchlist-watched-label": "په کتنلړ کي",
        "rcfilters-filter-watchlist-notwatched-label": "په کتنلړ کې ندی",
        "rcfilters-filter-watchlist-notwatched-description": "هرڅه ستاسو په کتنلړ کې پرته ستاسو د بدلونونو مخونه.",
        "zip-wrong-format": "ځانگړې شوې دوتنه يوه ZIP دوتنه نه وه.",
        "uploadstash": "پورته کول سټش",
        "uploadstash-refresh": "د دوتنو لړليک بياتازه کول",
+       "uploadstash-bad-path-unknown-type": "ناڅرگنده ډول \"$1\".",
        "img-auth-accessdenied": "لاسرسی رد شو",
        "img-auth-nofile": "د $1 په نوم کومه دوتنه نشته.",
        "img-auth-streaming": "سټريمينګ \"$1\".",
        "apisandbox-results": "پايلې",
        "apisandbox-request-url-label": "د URL غوښتنه کول:",
        "apisandbox-request-time": "د غوښتنې وخت: {{PLURAL:$1|$1 م.ث}}",
+       "apisandbox-continue": "پرله پورې",
+       "apisandbox-continue-clear": "سپينول",
        "booksources": "د کتاب سرچينې",
        "booksources-search-legend": "د کتابي سرچينو پلټنه",
        "booksources-isbn": "ISBN:",
        "listgrouprights-namespaceprotection-header": "د نومتشيال محدوديتونه",
        "listgrouprights-namespaceprotection-namespace": "نوم-تشيال",
        "listgrouprights-namespaceprotection-restrictedto": "د کارن سمون ترسره کولو رښته(رښتې)",
+       "listgrants": "منلې",
        "listgrants-rights": "رښتې",
        "trackingcategories": "موندونکې وېشنيزې",
        "trackingcategories-summary": "په دې مخ کې هغه موندونکې وېشنيزې چې په اتوماتيک ډول د مېډياويکي ساوترې لخوا ډکېږي، د لړليک په توگه راغلي. د وېشنيزو نومونه د اړونده غونډال پيغامونو په بدلون سره چې د {{ns:8}} په نومتشيال کې دي، د بدلېدلو وړتيا لري.",
        "sp-contributions-uploads": "پورته کېدنې",
        "sp-contributions-logs": "يادښتونه",
        "sp-contributions-talk": "خبرې اترې",
-       "sp-contributions-userrights": "د کارن رښتو سمبالښت",
+       "sp-contributions-userrights": "د {{GENDER:$1|کارن}} رښتو سمبالښت",
        "sp-contributions-blocked-notice": "دم مهال په دې کارن بنديز لگېدلی.\nد بنديز يادښت تازه مالومات په لاندې توگه دي:",
        "sp-contributions-search": "د ونډو پلټنه",
        "sp-contributions-username": "IP پته يا کارن-نوم:",
        "block": "په کارن بنديز لگول",
        "unblock": "کارن له بنديزه وېستل",
        "blockip": "په {{GENDER:$1|کارن}} بنديز لگول",
-       "blockiptext": "د لاندينۍ فورمې په کارولو سره تاسې يو کارن او يا هم يوې ځانگړې IP پتې باندې د ليکلو بنديزونه لگولی شی.  \nدا بايد د پوهې سره دښمنۍ او ورانکارۍ د مخنيولو په تکل او د پښتو ويکيپېډيا د [[{{MediaWiki:Policy-url}}|تگلارې]] سره سم پلي شي.\nد بنديز لپاره مو يو ځانگړی دليل لاندې روښانه کړئ (د ساري په توگه، هغه مخونو ښکاره کول چې ورانکاري په کې ترسره شوې).",
+       "blockiptext": "د لاندينۍ فورمې په کارولو سره تاسې يو کارن او يا هم يوې ځانگړې IP پتې باندې د ليکلو بنديزونه لگولی شی.  \nدا بايد د پوهې سره دښمنۍ او ورانکارۍ د مخنيولو په تکل او د پښتو ويکيپېډيا د [[{{MediaWiki:Policy-url}}|تگلارې]] سره سم پلي شي.\nد بنديز لپاره مو يو ځانگړی دليل لاندې روښانه کړئ (د ساري په توگه، هغه مخونو ښکاره کول چې ورانکاري په کې ترسره شوې).\n[https://ps.wikipedia.org/wiki/Classless_Inter-Domain_Routing سي ډي اي ار] نخښه; the آر اجازه ورکول رینج دی /$1 لپاره د اي پي وي ۴ او /$2 لپاره د اي پي وي ۶.",
        "ipaddressorusername": "IP پته يا کارن نوم",
        "ipbexpiry": "د پای نېټه:",
        "ipbreason": "سبب:",
        "newimages-legend": "چاڼگر",
        "newimages-label": "د دوتنې نوم (يا د دې برخه):",
        "newimages-showbots": "د روباټونو لخوا پورته کېدنې ښکاره کول",
+       "newimages-mediatype": "د رسنۍ ډول:",
        "noimages": "د کتلو لپاره څه نشته.",
        "ilsubmit": "پلټل",
        "bydate": "د نېټې له مخې",
        "sp-newimages-showfrom": "هغه نوې دوتنې چې په $1 په $2 بجو پيلېږي ښکاره کول",
+       "minutes-abbrev": "$1 دقیقي",
        "hours-abbrev": "$1 گ",
        "seconds": "{{PLURAL:$1|$1 ثانيه|$1 ثانيې}}",
        "minutes": "{{PLURAL:$1|$1 دقيقه|$1 دقيقې}}",
        "authform-notoken": "نادرکه نښه",
        "authform-wrongtoken": "ناسمه نښه",
        "specialpage-securitylevel-not-allowed-title": "اجازه نسته",
+       "cannotauth-not-allowed-title": "د اجازې تفصيل",
        "changecredentials-submit": "بدلول",
        "removecredentials-submit": "غورځول",
+       "credentialsform-provider": "د اعتبار وړ ډول:",
        "credentialsform-account": "گڼون نوم:",
+       "cannotlink-no-provider-title": "دلته د منلو وړ حساب شتون نلري.",
+       "cannotlink-no-provider": "دلته د منلو وړ حساب شتون نلري.",
        "linkaccounts": "ورګډ سوي ګڼونونه",
        "linkaccounts-success-text": "ګڼون ورګډ سو.",
        "linkaccounts-submit": "لينک کڼوڼونه",
index 6808e60..4832e91 100644 (file)
        "timezoneregion-indian": "Oceano Índico",
        "timezoneregion-pacific": "Oceano Pacífico",
        "allowemail": "Permitir que outros utilizadores me enviem correio eletrónico",
+       "email-allow-new-users-label": "Permitir mensagens de correio de utilizadores novos",
        "email-blacklist-label": "Proibir estes utilizadores de me enviarem correio eletrónico:",
        "prefs-searchoptions": "Pesquisa",
        "prefs-namespaces": "Domínios",
        "rcfilters-filter-showlinkedfrom-option-label": "Mostrar mudanças de páginas <strong>PARA AS QUAIS</strong> uma página contém hiperligações",
        "rcfilters-filter-showlinkedto-label": "Mostrar mudanças de páginas que contêm hiperligações para a página",
        "rcfilters-filter-showlinkedto-option-label": "Mostrar mudanças de páginas <strong>QUE CONTÊM</strong> hiperligações para uma página",
-       "rcfilters-target-page-placeholder": "Selecionar uma página",
+       "rcfilters-target-page-placeholder": "Introduzir o nome de uma página",
        "rcnotefrom": "Abaixo {{PLURAL:$5|está a mudança|estão as mudanças}} desde <strong>$2</strong> (mostradas até <strong>$1</strong>).",
        "rclistfromreset": "Reiniciar a seleção da data",
        "rclistfrom": "Mostrar as novas mudanças a partir das $2 de $3",
        "recentchangeslinked-feed": "Alterações relacionadas",
        "recentchangeslinked-toolbox": "Alterações relacionadas",
        "recentchangeslinked-title": "Alterações relacionadas com \"$1\"",
-       "recentchangeslinked-summary": "Esta é uma lista de mudanças recentes a todas as páginas para as quais a página fornecida contém hiperligações (ou de todas as que pertencem à categoria fornecida).\nAs suas [[Special:Watchlist|páginas vigiadas]] aparecem a <strong>negrito</strong>.",
+       "recentchangeslinked-summary": "Introduza o nome de uma página para ver as mudanças a todas as páginas que contêm hiperligações para ela ou para as quais a página fornecida contém hiperligações (para ver as que pertencem a uma categoria, introduza Categoria:Nome da categoria). As mudanças às suas [[Special:Watchlist|páginas vigiadas]] aparecem a <strong>negrito</strong>.",
        "recentchangeslinked-page": "Nome da página:",
        "recentchangeslinked-to": "Inversamente, mostrar mudanças às páginas que contêm hiperligações para esta",
        "recentchanges-page-added-to-category": "[[:$1]] foi adicionada à categoria",
index f3f44c8..209838e 100644 (file)
        "mycontris": "In the personal urls page section - right upper corner.\n\nSee also:\n* {{msg-mw|Mycontris}}\n* {{msg-mw|Accesskey-pt-mycontris}}\n* {{msg-mw|Tooltip-pt-mycontris}}\n{{Identical|Contribution}}",
        "anoncontribs": "Same as {{msg-mw|mycontris}} but used for non-logged-in users.\n\nSee also:\n* {{msg-mw|Accesskey-pt-anoncontribs}}\n* {{msg-mw|Tooltip-pt-anoncontribs}}\n{{Identical|Contribution}}",
        "contribsub2": "Contributions for \"user\" (links). Parameters:\n* $1 is an IP address or a username, with a link which points to the user page (if registered user).\n* $2 is list of tool links. The list contains a link which has text {{msg-mw|Sp-contributions-talk}}.\n* $3 is a plain text username used for GENDER.\n{{Identical|For $1}}",
-       "contributions-userdoesnotexist": "This message is used in [[Special:Contributions]]. It is used to tell the user that the name he searched for doesn't exists.\n\nParameters:\n* $1 - a username\n{{Identical|Userdoesnotexist}}",
+       "contributions-userdoesnotexist": "This message is used in [[Special:Contributions]]. It is used to tell the user that the name he searched for doesn't exist.\n\nParameters:\n* $1 - a username\n{{Identical|Userdoesnotexist}}",
        "nocontribs": "Used in [[Special:Contributions]] and [[Special:DeletedContributions]].\n\nSee examples: [[Special:Contributions/x]] and [[Special:DeletedContributions/x]].\n\nParameters:\n* $1 - (Unused) the user name",
        "uctop": "This message is used in [[Special:Contributions]]. It is used to show that a particular edit was the last made to a page. Example: 09:57, 11 February 2008 (hist) (diff) Pagename‎ (edit summary) (current)\n{{Identical|Current}}",
        "month": "Used in [[Special:Contributions]] and history pages ([{{fullurl:Sandbox|action=history}} example]), as label for a dropdown box to select a specific month to view the edits made in that month, and the earlier months. See also {{msg-mw|year}}.",
index c898e13..1c3f0a1 100644 (file)
        "rcfilters-watchlist-showupdated": "Изменения страниц, которые вы не посещали с того момента, как они изменились, выделены <strong>жирным</strong> и отмечены полным маркером.",
        "rcfilters-preference-label": "Скрыть улучшенную версию Последних изменений",
        "rcfilters-preference-help": "Откатывает редизайн интерфейса 2017 года и все инструменты, добавленные с тех пор.",
+       "rcfilters-target-page-placeholder": "Введите имя страницы",
        "rcnotefrom": "Ниже {{PLURAL:$5|указано изменение|перечислены изменения}} с <strong>$3, $4</strong> (показано не более <strong>$1</strong>).",
        "rclistfromreset": "Сбросить выбор даты",
        "rclistfrom": "Показать изменения с $3 $2.",
        "autosumm-blank": "Полностью удалено содержимое страницы",
        "autosumm-replace": "Содержимое страницы заменено на «$1»",
        "autoredircomment": "Перенаправление на [[$1]]",
+       "autosumm-removed-redirect": "Удалённое перенаправление на [[$1]]",
+       "autosumm-changed-redirect-target": "Перенаправление изменено с [[$1]] на [[$2]]",
        "autosumm-new": "Новая страница: «$1»",
        "autosumm-newblank": "Создана пустая страница",
        "size-bytes": "$1 {{PLURAL:$1|байт|байта|байт}}",
        "tag-mw-blank-description": "Правки, которые очищают страницу",
        "tag-mw-replace": "Заменено",
        "tag-mw-replace-description": "Правки, которые удаляют более 90 % содержимого страницы",
-       "tag-mw-rollback": "Ð\9eткат",
+       "tag-mw-rollback": "откат",
        "tag-mw-rollback-description": "Правки, которые откатывают предыдущие правки по нажатию ссылки отката",
        "tags-title": "Метки",
        "tags-intro": "На этой странице приведён список меток, которыми программное обеспечение отмечает правки, а также значения этих меток.",
index 6ccd851..978fcdd 100644 (file)
        "timezoneregion-indian": "Indijski ocean",
        "timezoneregion-pacific": "Tihi ocean",
        "allowemail": "Drugim uporabnikom omogoči pošiljanje e-pošte",
+       "email-allow-new-users-label": "Dovoli e-pošto od čisto novih uporabnikov",
        "email-blacklist-label": "Prepreči naslednjim uporabnikom, da mi pošiljajo e-pošto:",
        "prefs-searchoptions": "Iskanje",
        "prefs-namespaces": "Imenski prostori",
index c7911a0..cfb1ef8 100644 (file)
        "rcfilters-filter-user-experience-level-unregistered-description": "ผู้ใช้ไม่ล็อกอิน",
        "rcfilters-filter-user-experience-level-newcomer-label": "ผู้ที่มาใหม่",
        "rcfilters-filter-user-experience-level-newcomer-description": "ผู้ใช้ลงทะเบียนที่แก้ไขน้อยกว่า 10 ครั้งหรืออายุน้อยกว่า 4 วัน",
-       "rcfilters-filter-user-experience-level-learner-label": "ผู้เรียน",
+       "rcfilters-filter-user-experience-level-learner-label": "ผู้เรียนรู้",
        "rcfilters-filter-user-experience-level-learner-description": "ผู้ใช้ลงทะเบียนที่มีประสบการณ์อยู่ระหว่าง \"ผู้มาใหม่\" กับ \"ผู้ใช้มีประสบการณ์\"",
        "rcfilters-filter-user-experience-level-experienced-label": "ผู้ใช้ที่มีความเชี่ยวชาญ",
        "rcfilters-filter-user-experience-level-experienced-description": "ผู้ใช้ลงทะเบียนที่มีการแก้ไขมากกว่า 500 ครั้งและอายุมากกว่า 30 วัน",
        "fileexists-forbidden": "มีไฟล์ชื่อนี้แล้ว และไม่สามารถเขียนทับได้\nหากคุณยังต้องการอัปโหลดไฟล์ของคุณ กรุณาย้อนกลับและใช้ชื่อใหม่ \n[[File:$1|thumb|center|$1]]",
        "fileexists-shared-forbidden": "ไฟล์ที่ใช้ชื่อนี้มีอยู่แล้วในระบบเก็บไฟล์ในส่วนกลาง\nถ้าคุณยังคงต้องการอัปโหลดไฟล์ของคุณ กรุณาย้อนกลับไปตั้งชื่อใหม่\n[[File:$1|thumb|center|$1]]",
        "fileexists-no-change": "ไฟล์ที่อัปโหลดเป็นคู่พอดีของ <strong>[[:$1]]</strong> รุ่นปัจจุบัน",
-       "fileexists-duplicate-version": "à¹\84à¸\9fลà¹\8cà¸\97ีà¹\88อัà¸\9bà¹\82หลà¸\94à¹\80à¸\9bà¹\87à¸\99à¸\84ูà¹\88à¸\9eอà¸\94ีà¸\82อà¸\87 <strong>[[:$1]]</strong> à¸£à¸¸à¹\88à¸\99à¸\81à¹\88อà¸\99",
+       "fileexists-duplicate-version": "à¹\84à¸\9fลà¹\8cà¸\97ีà¹\88อัà¸\9bà¹\82หลà¸\94à¸\8bà¹\89ำà¸\81ัà¸\9a <strong>[[:$1]]</strong> {{PLURAL:$2|}}รุà¹\88à¸\99à¸\81à¹\88อà¸\99à¸\9eอà¸\94ี",
        "file-exists-duplicate": "ไฟล์นี้ซ้ำกับ{{PLURAL:$1|ไฟล์|ไฟล์}}ต่อไปนี้:",
        "file-deleted-duplicate": "ไฟล์ที่เหมือนไฟล์นี้ ([[:$1]]) เคยถูกลบไปก่อนหน้านี้แล้ว\nคุณควรตรวจสอบว่าประวัติการลบของไฟล์ก่อนดำเนินการอัปโหลดใหม่",
        "file-deleted-duplicate-notitle": "ไฟล์ที่เหมือนกับไฟล์นี้เคยถูกลบมาก่อน และชื่อดังกล่าวถูกห้ามใช้ คุณควรสอบถามผู้ที่สามารถดูข้อมูลไฟล์ที่ถูกระงับเพื่อทบทวนสถานการณ์ก่อนดำเนินการอัปโหลดไฟล์อีกครั้ง",
        "unprotectedarticle": "ยกเลิกการล็อกจาก \"[[$1]]\"",
        "movedarticleprotection": "ย้ายการตั้งค่าการล็อกจาก \"[[$2]]\" ไป \"[[$1]]\"",
        "protectedarticle-comment": "ล็อก \"[[$1]]\"",
-       "modifiedarticleprotection-comment": "เปลี่ยนระดับการล็อกสำหรับ \"[[$1]]\"",
-       "unprotectedarticle-comment": "ปลดล็อก \"[[$1]]\"",
+       "modifiedarticleprotection-comment": "{{GENDER:$2|}}เปลี่ยนระดับการล็อกสำหรับ \"[[$1]]\"",
+       "unprotectedarticle-comment": "{{GENDER:$2|}}ปลดล็อก \"[[$1]]\"",
        "protect-title": "เปลี่ยนระดับการล็อกสำหรับ \"$1\"",
        "protect-title-notallowed": "ดูระดับการล็อกของ \"$1\"",
        "prot_1movedto2": "เปลี่ยนชื่อ [[$1]] เป็น [[$2]]",
        "htmlform-user-not-exists": "ไม่มี <strong>$1</strong>",
        "htmlform-user-not-valid": "<strong>$1</strong> มิใช่ชื่อผู้ใช้ที่สมเหตุสมผล",
        "logentry-delete-delete": "$1 ลบหน้า $3",
-       "logentry-delete-delete_redir": "$1 ลบหน้าเปลี่ยนทาง $3 โดยการเขียนทับ",
+       "logentry-delete-delete_redir": "$1 {{GENDER:$2|}}ลบหน้าเปลี่ยนทาง $3 โดยการเขียนทับ",
        "logentry-delete-restore": "$1 กู้คืนหน้า $3 ($4)",
-       "logentry-delete-restore-nocount": "$1 กู้คืนหน้า $3",
+       "logentry-delete-restore-nocount": "$1 {{GENDER:$2|}}กู้คืนหน้า $3",
        "restore-count-revisions": "$1 รุ่น",
        "restore-count-files": "$1 ไฟล์",
        "logentry-delete-event": "$1 เปลี่ยนทัศนวิสัยของ $5 รายการปูมใน $3: $4",
index 24f73ab..14ac649 100644 (file)
        "timezoneregion-indian": "印度洋",
        "timezoneregion-pacific": "太平洋",
        "allowemail": "允许其他用户向我发送电子邮件",
+       "email-allow-new-users-label": "允许来自新用户的电子邮件",
        "email-blacklist-label": "禁止这些用户给我发送电子邮件:",
        "prefs-searchoptions": "搜索",
        "prefs-namespaces": "名字空间",
index 341a299..00dbd00 100644 (file)
@@ -25,7 +25,6 @@
  */
 
 require_once __DIR__ . '/Maintenance.php';
-require_once __DIR__ . '/../includes/export/DumpFilter.php';
 
 use Wikimedia\Rdbms\LoadBalancer;
 use Wikimedia\Rdbms\IDatabase;
@@ -420,20 +419,3 @@ class BackupDumper extends Maintenance {
                }
        }
 }
-
-class ExportProgressFilter extends DumpFilter {
-       function __construct( &$sink, &$progress ) {
-               parent::__construct( $sink );
-               $this->progress = $progress;
-       }
-
-       function writeClosePage( $string ) {
-               parent::writeClosePage( $string );
-               $this->progress->reportPage();
-       }
-
-       function writeRevision( $rev, $string ) {
-               parent::writeRevision( $rev, $string );
-               $this->progress->revCount();
-       }
-}
index 4071a06..6348e96 100644 (file)
@@ -208,7 +208,9 @@ class CheckStorage {
                                        $blobsTable = $this->dbStore->getTable( $extDb );
                                        $res = $extDb->select( $blobsTable,
                                                [ 'blob_id' ],
-                                               [ 'blob_id IN( ' . implode( ',', $blobIds ) . ')' ], __METHOD__ );
+                                               [ 'blob_id' => $blobIds ],
+                                               __METHOD__
+                                       );
                                        foreach ( $res as $row ) {
                                                unset( $xBlobIds[$row->blob_id] );
                                        }
@@ -410,7 +412,9 @@ class CheckStorage {
                        $headerLength = strlen( self::CONCAT_HEADER );
                        $res = $extDb->select( $blobsTable,
                                [ 'blob_id', "LEFT(blob_text, $headerLength) AS header" ],
-                               [ 'blob_id IN( ' . implode( ',', $blobIds ) . ')' ], __METHOD__ );
+                               [ 'blob_id' => $blobIds ],
+                               __METHOD__
+                       );
                        foreach ( $res as $row ) {
                                if ( strcasecmp( $row->header, self::CONCAT_HEADER ) ) {
                                        $this->addError(
index e0d165f..b3a9012 100644 (file)
@@ -4832,7 +4832,7 @@ OO.ui.mixin.ClippableElement.prototype.setIdealSize = function ( width, height )
  * @return {string} 'left' or 'right'
  */
 OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
-       if ( this.computePosition && this.computePosition().right !== '' ) {
+       if ( this.computePosition && this.positioning && this.computePosition().right !== '' ) {
                return 'right';
        }
        return 'left';
@@ -4854,7 +4854,7 @@ OO.ui.mixin.ClippableElement.prototype.getHorizontalAnchorEdge = function () {
  * @return {string} 'top' or 'bottom'
  */
 OO.ui.mixin.ClippableElement.prototype.getVerticalAnchorEdge = function () {
-       if ( this.computePosition && this.computePosition().bottom !== '' ) {
+       if ( this.computePosition && this.positioning && this.computePosition().bottom !== '' ) {
                return 'bottom';
        }
        return 'top';
index f5bdfd8..27d049e 100644 (file)
@@ -49,7 +49,7 @@
                loadedScripts[ url ] = true;
                s = document.createElement( 'script' );
                s.setAttribute( 'src', url );
-               document.getElementsByTagName( 'head' )[ 0 ].appendChild( s );
+               document.head.appendChild( s );
                return s;
        }
 
@@ -72,7 +72,7 @@
                if ( media ) {
                        l.media = media;
                }
-               document.getElementsByTagName( 'head' )[ 0 ].appendChild( l );
+               document.head.appendChild( l );
                return l;
        }
 
index 15fe334..96b4410 100644 (file)
@@ -4,17 +4,19 @@
         *
         * @mixins OO.EventEmitter
         *
+        * @param {jQuery} $initialFieldset The initial server-generated legacy form content
         * @constructor
         */
-       mw.rcfilters.dm.ChangesListViewModel = function MwRcfiltersDmChangesListViewModel() {
+       mw.rcfilters.dm.ChangesListViewModel = function MwRcfiltersDmChangesListViewModel( $initialFieldset ) {
                // Mixin constructor
                OO.EventEmitter.call( this );
 
                this.valid = true;
                this.newChangesExist = false;
-               this.nextFrom = null;
                this.liveUpdate = false;
                this.unseenWatchedChanges = false;
+
+               this.extractNextFrom( $initialFieldset );
        };
 
        /* Initialization */
@@ -74,7 +76,6 @@
         * @param {jQuery|string} changesListContent
         * @param {jQuery} $fieldset
         * @param {string} noResultsDetails Type of no result error
-        *   timeout.
         * @param {boolean} [isInitialDOM] Using the initial (already attached) DOM elements
         * @param {boolean} [separateOldAndNew] Whether a logical separation between old and new changes is needed
         * @fires update
         */
        mw.rcfilters.dm.ChangesListViewModel.prototype.extractNextFrom = function ( $fieldset ) {
                var data = $fieldset.find( '.rclistfrom > a, .wlinfo' ).data( 'params' );
-               this.nextFrom = data ? data.from : null;
+               if ( data && data.from ) {
+                       this.nextFrom = data.from;
+               }
        };
 
        /**
index 3e1191f..7bb0a22 100644 (file)
         */
        mw.rcfilters.UriProcessor.prototype._normalizeTargetInUri = function ( uri ) {
                var parts,
-                       re = /^((?:\/.+\/)?.+:.+)\/(.+)$/; // matches [namespace:]Title/Subpage
+                       // matches [/wiki/]SpecialNS:RCL/[Namespace:]Title/Subpage/Subsubpage/etc
+                       re = /^((?:\/.+?\/)?.*?:.*?)\/(.*)$/;
 
                // target in title param
                if ( uri.query.title ) {
                }
 
                // target in path
-               parts = uri.path.match( re );
+               parts = mw.Uri.decode( uri.path ).match( re );
                if ( parts ) {
                        uri.path = parts[ 1 ];
                        uri.query.target = parts[ 2 ];
index 582d25f..100fa0b 100644 (file)
                        var $topSection,
                                mainWrapperWidget,
                                conditionalViews = {},
+                               $initialFieldset = $( 'fieldset.cloptions' ),
                                savedQueriesPreferenceName = mw.config.get( 'wgStructuredChangeFiltersSavedQueriesPreferenceName' ),
                                daysPreferenceName = mw.config.get( 'wgStructuredChangeFiltersDaysPreferenceName' ),
                                limitPreferenceName = mw.config.get( 'wgStructuredChangeFiltersLimitPreferenceName' ),
                                filtersModel = new mw.rcfilters.dm.FiltersViewModel(),
-                               changesListModel = new mw.rcfilters.dm.ChangesListViewModel(),
+                               changesListModel = new mw.rcfilters.dm.ChangesListViewModel( $initialFieldset ),
                                savedQueriesModel = new mw.rcfilters.dm.SavedQueriesModel( filtersModel ),
                                specialPage = mw.config.get( 'wgCanonicalSpecialPageName' ),
                                controller = new mw.rcfilters.Controller(
@@ -82,7 +83,7 @@
                                                '.mw-changeslist-timeout',
                                                '.mw-changeslist-notargetpage'
                                        ].join( ', ' ) ),
-                                       $formContainer: $( 'fieldset.cloptions' )
+                                       $formContainer: $initialFieldset
                                }
                        );
 
@@ -94,7 +95,7 @@
                        controller.initialize(
                                mw.config.get( 'wgStructuredChangeFilters' ),
                                // All namespaces without Media namespace
-                               this.getNamespaces( [ 'Media' ] ),
+                               rcfilters.getNamespaces( [ 'Media' ] ),
                                mw.config.get( 'wgRCFiltersChangeTags' ),
                                conditionalViews
                        );
index 1740c93..98acab0 100644 (file)
         */
        mw.rcfilters.ui.MenuSelectWidget.prototype.onKeyDown = function ( e ) {
                var nextItem,
-                       currentItem = this.getHighlightedItem() || this.getSelectedItem();
+                       currentItem = this.findHighlightedItem() || this.getSelectedItem();
 
                // Call parent
                mw.rcfilters.ui.MenuSelectWidget.parent.prototype.onKeyDown.call( this, e );
index 6673c08..d5c5e26 100644 (file)
@@ -71,6 +71,7 @@
         * Respond to the model being updated
         */
        mw.rcfilters.ui.RclTargetPageWidget.prototype.updateUiBasedOnModel = function () {
-               this.titleSearch.setValue( this.model.getValue() );
+               var title = mw.Title.newFromText( this.model.getValue() );
+               this.titleSearch.setValue( title ? title.toText() : this.model.getValue() );
        };
 }( mediaWiki ) );
index a661ae5..6a218e3 100644 (file)
                                        // Cache
                                        marker = document.querySelector( 'meta[name="ResourceLoaderDynamicStyles"]' );
                                        if ( !marker ) {
-                                               mw.log( 'Create <meta name="ResourceLoaderDynamicStyles"> dynamically' );
-                                               marker = $( '<meta>' ).attr( 'name', 'ResourceLoaderDynamicStyles' ).appendTo( 'head' )[ 0 ];
+                                               mw.log( 'Created ResourceLoaderDynamicStyles marker dynamically' );
+                                               marker = document.createElement( 'meta' );
+                                               marker.name = 'ResourceLoaderDynamicStyles';
+                                               document.head.appendChild( marker );
                                        }
                                }
                                return marker;
                                if ( nextNode && nextNode.parentNode ) {
                                        nextNode.parentNode.insertBefore( s, nextNode );
                                } else {
-                                       document.getElementsByTagName( 'head' )[ 0 ].appendChild( s );
+                                       document.head.appendChild( s );
                                }
 
                                return s;
                                                                l = document.createElement( 'link' );
                                                                l.rel = 'stylesheet';
                                                                l.href = modules;
-                                                               $( 'head' ).append( l );
+                                                               document.head.appendChild( l );
                                                                return;
                                                        }
                                                        if ( type === 'text/javascript' || type === undefined ) {
                        // If we have an exception object, log it to the warning channel to trigger
                        // proper stacktraces in browsers that support it.
                        if ( e && console.warn ) {
-                               console.warn( String( e ), e );
+                               console.warn( e );
                        }
                }
                /* eslint-enable no-console */
index b0c1578..8e8463d 100644 (file)
@@ -162,5 +162,5 @@ window.isCompatible = function ( str ) {
                // Callback
                startUp();
        };
-       document.getElementsByTagName( 'head' )[ 0 ].appendChild( script );
+       document.head.appendChild( script );
 }() );
index 7af3a36..72ee550 100644 (file)
@@ -546,15 +546,19 @@ Extra newlines between heading and content are swallowed
 Heading with line break in nowiki
 !! options
 parsoid=wt2html
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! wikitext
 == A <nowiki>B
 C</nowiki> ==
-!! html
-<h2><span class="mw-headline" id="A_B.0AC">A B
+!! html/php
+<h2><span id="A_B.0AC"></span><span class="mw-headline" id="A_B
+C">A B
 C</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: A B&#10;C">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
 
 !! html/parsoid
-<h2 id="A_B.0AC">A <span typeof="mw:Nowiki">B
+<h2 id="A_B
+C"><span id="A_B.0AC" typeof="mw:FallbackId"></span> A <span typeof="mw:Nowiki">B
 C</span> </h2>
 !! end
 
@@ -4851,8 +4855,8 @@ parsoid=wt2html,wt2wt
 </p>
 !! html/parsoid
 <p><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/Foo"></a></p>
-<p><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/Foo" title="wikipedia:Foo">Bar</a></p>
-<p><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/Foo" title="wikipedia:Foo"><span>Bar</span></a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" title="wikipedia:Foo">Bar</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" title="wikipedia:Foo"><span>Bar</span></a></p>
 !! end
 
 !! test
@@ -6715,9 +6719,9 @@ Don't break on | in extension attribute in template
 
 <references />
 !! html/parsoid
-<p><span about="#mwt2" class="mw-ref" id="cite_ref-hi.7Cho_1-0" rel="dc:references" typeof="mw:Transclusion  mw:Extension/ref" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;ref name=\"hi|ho\">ha&lt;/ref>"}},"i":0}}]}'><a href="./Main_Page#cite_note-hi.7Cho-1" style="counter-reset: mw-Ref 1;"><span class="mw-reflink-text">[1]</span></a></span></p>
+<p><span about="#mwt2" class="mw-ref" id="cite_ref-hi|ho_1-0" rel="dc:references" typeof="mw:Transclusion  mw:Extension/ref" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"&lt;ref name=\"hi|ho\">ha&lt;/ref>"}},"i":0}}]}'><a href="./Main_Page#cite_note-hi|ho-1" style="counter-reset: mw-Ref 1;"><span class="mw-reflink-text">[1]</span></a></span></p>
 
-<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt5" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-hi.7Cho-1" id="cite_note-hi.7Cho-1"><a href="./Main_Page#cite_ref-hi.7Cho_1-0" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-hi.7Cho-1" class="mw-reference-text">ha</span></li></ol>
+<ol class="mw-references references" typeof="mw:Extension/references" about="#mwt5" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-hi|ho-1" id="cite_note-hi|ho-1"><a href="./Main_Page#cite_ref-hi|ho_1-0" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-hi|ho-1" class="mw-reference-text">ha</span></li></ol>
 !! end
 
 ## We don't support roundtripping of these attributes in Parsoid.
@@ -7825,13 +7829,15 @@ Link with multiple pipes
 
 !! test
 Anchor containing a #. (T65430)
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! wikitext
 [[Main Page#And#Link]]
 !! html/php
-<p><a href="/wiki/Main_Page#And.23Link" title="Main Page">Main Page#And#Link</a>
+<p><a href="/wiki/Main_Page#And#Link" title="Main Page">Main Page#And#Link</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:WikiLink" href="./Main_Page#And.23Link" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#And.23Link"},"sa":{"href":"Main Page#And#Link"}}'>Main Page#And#Link</a></p>
+<p><a rel="mw:WikiLink" href="./Main_Page#And#Link" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#And#Link"},"sa":{"href":"Main Page#And#Link"}}'>Main Page#And#Link</a></p>
 !! end
 
 !! test
@@ -7949,13 +7955,27 @@ Link containing % as a double hex sequence interpreted to hex sequence
 ## Example for such a section: == < ==
 !! test
 Link containing "#<" and "#>" % as a hex sequences- these are valid section anchors
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! wikitext
 [[%23%3c]][[%23%3e]]
 !! html/php
-<p><a href="#.3C">#&lt;</a><a href="#.3E">#&gt;</a>
+<p><a href="#&lt;">#&lt;</a><a href="#&gt;">#&gt;</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:WikiLink" href="./Main_Page#.3C" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#.3C"},"sa":{"href":"%23%3c"}}'>#&lt;</a><a rel="mw:WikiLink" href="./Main_Page#.3E" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#.3E"},"sa":{"href":"%23%3e"}}'>#></a></p>
+<p><a rel="mw:WikiLink" href="./Main_Page#&lt;" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#&lt;"},"sa":{"href":"%23%3c"}}'>#&lt;</a><a rel="mw:WikiLink" href="./Main_Page#>" title="Main Page" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#>"},"sa":{"href":"%23%3e"}}'>#></a></p>
+!! end
+
+## Example for such a section: == < ==
+!! test
+Link containing "#<" and "#>" % as a hex sequences- these are valid section anchors (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+[[%23%3c]][[%23%3e]]
+!! html/php
+<p><a href="#.3C">#&lt;</a><a href="#.3E">#&gt;</a>
+</p>
 !! end
 
 !! test
@@ -8017,7 +8037,7 @@ Link containing double quotes and spaces
 <p><a href="/index.php?title=Cool_%22Gator%22&amp;action=edit&amp;redlink=1" class="new" title="Cool &quot;Gator&quot; (page does not exist)">Cool "Gator"</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:WikiLink" href="./Cool_%22Gator%22" title='Cool "Gator"'>Cool "Gator"</a></p>
+<p><a rel="mw:WikiLink" href='./Cool_"Gator"' title='Cool "Gator"'>Cool "Gator"</a></p>
 !! end
 
 !! test
@@ -8025,7 +8045,7 @@ File containing double quotes and spaces
 !! wikitext
 [[File:Cool "Gator".png]]
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Cool_%22Gator%22.png" data-parsoid='{"a":{"href":"./File:Cool_%22Gator%22.png"},"sa":{"href":"File:Cool \"Gator\".png"}}'><img resource='./File:Cool_"Gator".png' src="./Special:FilePath/Cool_%22Gator%22.png" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Cool_\"Gator\".png","height":"220","width":"220","src":"./Special:FilePath/Cool_%22Gator%22.png"},"sa":{"resource":"File:Cool \"Gator\".png","src":"./Special:FilePath/Cool_\"Gator\".png"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Cool_%22Gator%22.png" data-parsoid='{"a":{"href":"./File:Cool_%22Gator%22.png"},"sa":{"href":"File:Cool \"Gator\".png"}}'><img resource='./File:Cool_"Gator".png' src="./Special:FilePath/Cool_%22Gator%22.png" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Cool_\"Gator\".png","height":"220","width":"220","src":"./Special:FilePath/Cool_%22Gator%22.png"},"sa":{"resource":"File:Cool \"Gator\".png","src":"./Special:FilePath/Cool_\"Gator\".png"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -8073,7 +8093,7 @@ Link with double quotes in title part (literal) and alternate part (interpreted)
 </p><p><a href="/index.php?title=%27%27Pentecoste%27%27&amp;action=edit&amp;redlink=1" class="new" title="&#39;&#39;Pentecoste&#39;&#39; (page does not exist)"><i>Pentecoste</i></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Denys_Savchenko_''Pentecoste''.jpg"><img resource="./File:Denys_Savchenko_''Pentecoste''.jpg" src="./Special:FilePath/Denys_Savchenko_''Pentecoste''.jpg" height="220" width="220"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Denys_Savchenko_''Pentecoste''.jpg"><img resource="./File:Denys_Savchenko_''Pentecoste''.jpg" src="./Special:FilePath/Denys_Savchenko_''Pentecoste''.jpg" height="220" width="220"/></a></figure-inline></p>
 <p><a rel="mw:WikiLink" href="./''Pentecoste''" title="''Pentecoste''">''Pentecoste''</a></p>
 <p><a rel="mw:WikiLink" href="./''Pentecoste''" title="''Pentecoste''">Pentecoste</a></p>
 <p><a rel="mw:WikiLink" href="./''Pentecoste''" title="''Pentecoste''"><i>Pentecoste</i></a></p>
@@ -8093,10 +8113,10 @@ Broken image links with HTML captions (T41700)
 <a href="/index.php?title=Special:Upload&amp;wpDestFile=Nonexistent" class="new" title="File:Nonexistent">abc</a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;script>&lt;/script>"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&amp;lt;script>&amp;lt;/script>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></span>
-<span typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"100x100px"},{"ck":"caption","ak":"&lt;script>&lt;/script>"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&amp;lt;script>&amp;lt;/script>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="100" width="100" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"100","width":"100"},"sa":{"resource":"File:Nonexistent"}}'/></a></span>
-<span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp;lt;"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=&#39;{\"src\":\"&amp;amp;lt;\",\"srcContent\":\"&amp;lt;\",\"dsr\":[107,111,null,null]}&#39;>&amp;lt;&lt;/span>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></span>
-<span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"a&lt;i>b&lt;/i>c"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"a&lt;i data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[134,142,3,4]}&#39;>b&lt;/i>c"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;script>&lt;/script>"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&amp;lt;script>&amp;lt;/script>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></figure-inline>
+<figure-inline typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"100x100px"},{"ck":"caption","ak":"&lt;script>&lt;/script>"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&amp;lt;script>&amp;lt;/script>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="100" width="100" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"100","width":"100"},"sa":{"resource":"File:Nonexistent"}}'/></a></figure-inline>
+<figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp;lt;"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=&#39;{\"src\":\"&amp;amp;lt;\",\"srcContent\":\"&amp;lt;\",\"dsr\":[107,111,null,null]}&#39;>&amp;lt;&lt;/span>"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></figure-inline>
+<figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"a&lt;i>b&lt;/i>c"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"a&lt;i data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[134,142,3,4]}&#39;>b&lt;/i>c"}'><a href="./File:Nonexistent" data-parsoid='{"a":{"href":"./File:Nonexistent"},"sa":{}}'><img resource="./File:Nonexistent" src="./Special:FilePath/Nonexistent" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Nonexistent","height":"220","width":"220"},"sa":{"resource":"File:Nonexistent"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -8600,13 +8620,26 @@ Parsoid: Scoped parsing should handle mixed transclusions and plain text
 
 !! test
 Link with angle bracket after anchor
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! wikitext
 [[Foo#<bar>]]
 !! html/php
-<p><a href="/wiki/Foo#.3Cbar.3E" title="Foo">Foo#&lt;bar&gt;</a>
+<p><a href="/wiki/Foo#&lt;bar&gt;" title="Foo">Foo#&lt;bar&gt;</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:WikiLink" href="./Foo#.3Cbar.3E" title="Foo" data-parsoid='{"stx":"simple","a":{"href":"./Foo#.3Cbar.3E"},"sa":{"href":"Foo#&lt;bar>"}}'>Foo#&lt;bar></a></p>
+<p><a rel="mw:WikiLink" href="./Foo#&lt;bar>" title="Foo" data-parsoid='{"stx":"simple","a":{"href":"./Foo#&lt;bar>"},"sa":{"href":"Foo#&lt;bar>"}}'>Foo#&lt;bar></a></p>
+!! end
+
+!! test
+Link with angle bracket after anchor (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+[[Foo#<bar>]]
+!! html/php
+<p><a href="/wiki/Foo#.3Cbar.3E" title="Foo">Foo#&lt;bar&gt;</a>
+</p>
 !! end
 
 ###
@@ -8623,7 +8656,7 @@ parsoid=wt2html,wt2wt,html2html
 <p><a href="http://www.usemod.com/cgi-bin/mb.pl?SoftSecurity" class="extiw" title="meatball:SoftSecurity">MeatBall:SoftSecurity</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="http://www.usemod.com/cgi-bin/mb.pl?SoftSecurity" title="meatball:SoftSecurity">MeatBall:SoftSecurity</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?SoftSecurity" title="meatball:SoftSecurity">MeatBall:SoftSecurity</a></p>
 !! end
 
 !! test
@@ -8636,7 +8669,7 @@ parsoid=wt2html,wt2wt,html2html
 <p><a href="http://www.usemod.com/cgi-bin/mb.pl" class="extiw" title="meatball:">MeatBall:</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="http://www.usemod.com/cgi-bin/mb.pl?" title="meatball:">MeatBall:</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?" title="meatball:">MeatBall:</a></p>
 !! end
 
 ## html2wt and html2html will fail because we will prefer the :en: interwiki prefix over wikipedia:
@@ -8658,8 +8691,8 @@ parsoid=wt2html,wt2wt
 </ul>
 !! html/parsoid
 <ul>
-<li><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/ro:Olteniţa" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteniţa</a></li>
-<li><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/ro:Olteniţa" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteniţa</a></li>
+<li><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/ro:Olteniţa" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteniţa</a></li>
+<li><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/ro:Olteniţa" title="wikipedia:ro:Olteniţa">Wikipedia:ro:Olteniţa</a></li>
 </ul>
 !! end
 
@@ -8674,6 +8707,27 @@ Interwiki link with fragment (T4130)
 
 !! test
 Link scenarios with escaped fragments
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+[[#Is this great?]]
+[[Foo#Is this great?]]
+[[meatball:Foo#Is this great?]]
+!! html/php
+<p><a href="#Is_this_great?">#Is this great?</a>
+<a href="/wiki/Foo#Is_this_great?" title="Foo">Foo#Is this great?</a>
+<a href="http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great.3F" class="extiw" title="meatball:Foo">meatball:Foo#Is this great?</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Main_Page#Is_this_great?" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Is_this_great?"},"sa":{"href":"#Is this great?"}}'>#Is this great?</a>
+<a rel="mw:WikiLink" href="./Foo#Is_this_great?" title="Foo" data-parsoid='{"stx":"simple","a":{"href":"./Foo#Is_this_great?"},"sa":{"href":"Foo#Is this great?"}}'>Foo#Is this great?</a>
+<a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great?" title="meatball:Foo" data-parsoid='{"stx":"simple","a":{"href":"http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great?"},"sa":{"href":"meatball:Foo#Is this great?"},"isIW":true}'>meatball:Foo#Is this great?</a></p>
+!! end
+
+!! test
+Link scenarios with escaped fragments (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
 !! wikitext
 [[#Is this great?]]
 [[Foo#Is this great?]]
@@ -8683,10 +8737,6 @@ Link scenarios with escaped fragments
 <a href="/wiki/Foo#Is_this_great.3F" title="Foo">Foo#Is this great?</a>
 <a href="http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great.3F" class="extiw" title="meatball:Foo">meatball:Foo#Is this great?</a>
 </p>
-!! html/parsoid
-<p><a rel="mw:WikiLink" href="./Main_Page#Is_this_great.3F" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Is_this_great.3F"},"sa":{"href":"#Is this great?"}}'>#Is this great?</a>
-<a rel="mw:WikiLink" href="./Foo#Is_this_great.3F" title="Foo" data-parsoid='{"stx":"simple","a":{"href":"./Foo#Is_this_great.3F"},"sa":{"href":"Foo#Is this great?"}}'>Foo#Is this great?</a>
-<a rel="mw:ExtLink" href="http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great.3F" title="meatball:Foo" data-parsoid='{"stx":"simple","a":{"href":"http://www.usemod.com/cgi-bin/mb.pl?Foo#Is_this_great.3F"},"sa":{"href":"meatball:Foo#Is this great?"},"isIW":true}'>meatball:Foo#Is this great?</a></p>
 !! end
 
 # Ideally the wikipedia: prefix here should be proto-relative too
@@ -8711,19 +8761,19 @@ Different interwiki prefixes mapping to the same URL
 
 [[  wikiPEdia :Foo]]
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="//en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"simple","a":{"href":"//en.wikipedia.org/wiki/Foo"},"sa":{"href":":en:Foo"},"isIW":true}' title="en:Foo">en:Foo</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="//en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"simple","a":{"href":"//en.wikipedia.org/wiki/Foo"},"sa":{"href":":en:Foo"},"isIW":true}' title="en:Foo">en:Foo</a></p>
 
-<p><a rel="mw:ExtLink" href="//en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"piped","a":{"href":"//en.wikipedia.org/wiki/Foo"},"sa":{"href":":en:Foo"},"isIW":true}' title="en:Foo">Foo</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="//en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"piped","a":{"href":"//en.wikipedia.org/wiki/Foo"},"sa":{"href":":en:Foo"},"isIW":true}' title="en:Foo">Foo</a></p>
 
-<p><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":"wikipedia:Foo"},"isIW":true}' title="wikipedia:Foo">wikipedia:Foo</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":"wikipedia:Foo"},"isIW":true}' title="wikipedia:Foo">wikipedia:Foo</a></p>
 
-<p><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"piped","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":":wikipedia:Foo"},"isIW":true}' title="wikipedia:Foo">Foo</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"piped","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":":wikipedia:Foo"},"isIW":true}' title="wikipedia:Foo">Foo</a></p>
 
-<p><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/en:Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/en:Foo"},"sa":{"href":"wikipedia:en:Foo"},"isIW":true}' title="wikipedia:en:Foo">wikipedia:en:Foo</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/en:Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/en:Foo"},"sa":{"href":"wikipedia:en:Foo"},"isIW":true}' title="wikipedia:en:Foo">wikipedia:en:Foo</a></p>
 
-<p><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/en:Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/en:Foo"},"sa":{"href":":wikipedia:en:Foo"},"isIW":true}' title="wikipedia:en:Foo">wikipedia:en:Foo</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/en:Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/en:Foo"},"sa":{"href":":wikipedia:en:Foo"},"isIW":true}' title="wikipedia:en:Foo">wikipedia:en:Foo</a></p>
 
-<p><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":"  wikiPEdia :Foo"},"isIW":true}' title="wikipedia:Foo">  wikiPEdia :Foo</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"simple","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":"  wikiPEdia :Foo"},"isIW":true}' title="wikipedia:Foo">  wikiPEdia :Foo</a></p>
 !! end
 
 !! test
@@ -8743,9 +8793,9 @@ Interwiki links that cannot be represented in wiki syntax
 <a rel="nofollow" class="external text" href="http://de.wikipedia.org/wiki/#foo">is just fragment</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="http://www.usemod.com/cgi-bin/mb.pl?ok" title="meatball:ok">meatball:ok</a>
-<a rel="mw:ExtLink" href="http://www.usemod.com/cgi-bin/mb.pl?ok#foo" title="meatball:ok">ok with fragment</a>
-<a rel="mw:ExtLink" href="http://www.usemod.com/cgi-bin/mb.pl?ok_as_well?" title="meatball:ok as well?">ok ending with ? mark</a>
+<p><a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?ok" title="meatball:ok">meatball:ok</a>
+<a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?ok#foo" title="meatball:ok">ok with fragment</a>
+<a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?ok_as_well?" title="meatball:ok as well?">ok ending with ? mark</a>
 <a rel="mw:ExtLink" href="http://de.wikipedia.org/wiki/Foo?action=history">has query</a>
 <a rel="mw:ExtLink" href="http://de.wikipedia.org/wiki/#foo">is just fragment</a></p>
 !! end
@@ -8758,7 +8808,7 @@ Interwiki links: trail
 <p><a href="http://en.wikipedia.org/wiki/Foo" class="extiw" title="wikipedia:Foo">Bar</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"piped","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":"wikipedia:Foo"},"isIW":true,"tail":"r"}' title="wikipedia:Foo">Bar</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://en.wikipedia.org/wiki/Foo" data-parsoid='{"stx":"piped","a":{"href":"http://en.wikipedia.org/wiki/Foo"},"sa":{"href":"wikipedia:Foo"},"isIW":true,"tail":"r"}' title="wikipedia:Foo">Bar</a></p>
 !! end
 
 !! test
@@ -8812,7 +8862,7 @@ parsoid=wt2html,wt2wt,html2html
 <p><a href="http://www.usemod.com/cgi-bin/mb.pl?Hello" class="extiw" title="meatball:Hello">local:meatball:Hello</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="http://www.usemod.com/cgi-bin/mb.pl?Hello" title="meatball:Hello">local:meatball:Hello</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://www.usemod.com/cgi-bin/mb.pl?Hello" title="meatball:Hello">local:meatball:Hello</a></p>
 !! end
 
 !! test
@@ -8910,8 +8960,8 @@ Blah blah blah
 </p>
 !! html/parsoid
 <p>Blah blah blah
-<a rel="mw:ExtLink" href="http://es.wikipedia.org/wiki/Spanish" title="es:Spanish">es:Spanish</a>
-<a rel="mw:ExtLink" href="http://zh.wikipedia.org/wiki/Chinese" title="zh:Chinese"> zh : Chinese </a></p>
+<a rel="mw:WikiLink/Interwiki" href="http://es.wikipedia.org/wiki/Spanish" title="es:Spanish">es:Spanish</a>
+<a rel="mw:WikiLink/Interwiki" href="http://zh.wikipedia.org/wiki/Chinese" title="zh:Chinese"> zh : Chinese </a></p>
 !! end
 
 !! test
@@ -8928,7 +8978,7 @@ parsoid=wt2html
 [[:::es:Spanish]]
 </p>
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="http://es.wikipedia.org/wiki/Spanish" title="es:Spanish">es:Spanish</a>
+<p><a rel="mw:WikiLink/Interwiki" href="http://es.wikipedia.org/wiki/Spanish" title="es:Spanish">es:Spanish</a>
 [[::es:Spanish]]
 [[:::es:Spanish]]</p>
 !! end
@@ -9005,7 +9055,7 @@ parsoid=wt2html,wt2wt,html2html
 Blah blah blah
 [[zh:Chinese]]
 !! html/parsoid
-<p>Blah blah blah <a rel="mw:ExtLink" href="http://zh.wikipedia.org/wiki/Chinese" title="zh:Chinese">zh:Chinese</a></p>
+<p>Blah blah blah <a rel="mw:WikiLink/Interwiki" href="http://zh.wikipedia.org/wiki/Chinese" title="zh:Chinese">zh:Chinese</a></p>
 !! end
 
 ## PHP parser tests script needs an update
@@ -9019,7 +9069,7 @@ parsoid=wt2html,wt2wt,html2html
 Blah blah blah
 [[zh:Chinese]]
 !! html/parsoid
-<p>Blah blah blah <a rel="mw:ExtLink" href="http://zh.wikipedia.org/wiki/Chinese" title="zh:Chinese">zh:Chinese</a></p>
+<p>Blah blah blah <a rel="mw:WikiLink/Interwiki" href="http://zh.wikipedia.org/wiki/Chinese" title="zh:Chinese">zh:Chinese</a></p>
 !! end
 
 !! test
@@ -9106,7 +9156,7 @@ parsoid=wt2html,wt2wt,html2html
 </p><p><a href="/wiki/Ko:" title="Ko:">ko:</a>
 </p>
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="http://es.wikipedia.org/wiki/" title="es:">es:</a></p>
+<p><a rel="mw:WikiLink/Interwiki" href="http://es.wikipedia.org/wiki/" title="es:">es:</a></p>
 <p><a rel="mw:WikiLink" href="./Ko:" title="Ko:">ko:</a></p>
 !! end
 
@@ -9134,7 +9184,7 @@ Blah blah blah
 </p>
 !! html/parsoid
 <p>Blah blah blah
-<a rel="mw:ExtLink" href="http://es.wikipedia.org/wiki/Spanish" title="es:Spanish">local:es:Spanish</a></p>
+<a rel="mw:WikiLink/Interwiki" href="http://es.wikipedia.org/wiki/Spanish" title="es:Spanish">local:es:Spanish</a></p>
 !! end
 
 !! test
@@ -9177,10 +9227,12 @@ Blah blah blah
 # This tests the Parsoid bail-out code.
 !! test
 3. Other redirect variants
+!! options
+parsoid=wt2html
 !! wikitext
 #REDIRECT [[<nowiki>[[Bar]]</nowiki>]]
 !! html/parsoid
-<ol><li data-parsoid>REDIRECT [[[[Bar]]]]</li></ol>
+<ol><li>REDIRECT [[<span typeof="mw:Nowiki">[[Bar]]</span>]]</li></ol>
 !! end
 
 !! test
@@ -11989,14 +12041,14 @@ some
 <h3><span class="mw-headline" id="here">here</span></h3>
 
 !! html/parsoid
-<!-- comment --><meta typeof="mw:Includes/NoInclude" data-parsoid='{"src":"&lt;noinclude>"}'/><!-- comment --><meta typeof="mw:Includes/NoInclude/End" data-parsoid='{"src":"&lt;/noinclude>"}'/><!-- comment --><h2> hu </h2>
+<!-- comment --><meta typeof="mw:Includes/NoInclude" data-parsoid='{"src":"&lt;noinclude>"}'/><!-- comment --><meta typeof="mw:Includes/NoInclude/End" data-parsoid='{"src":"&lt;/noinclude>"}'/><!-- comment --><h2 id="hu"> hu </h2>
 
 <meta typeof="mw:Includes/NoInclude" data-parsoid='{"src":"&lt;noinclude>"}'/>
 <p>some</p>
 <meta typeof="mw:Includes/NoInclude/End" data-parsoid='{"src":"&lt;/noinclude>"}'/><ul><li> stuff</li>
 <li> here</li></ul>
 
-<meta typeof="mw:Includes/IncludeOnly" data-parsoid='{"src":"&lt;includeonly>can have stuff&lt;/includeonly>"}'/><meta typeof="mw:Includes/IncludeOnly/End" data-parsoid='{"src":""}'/><h3> here </h3>
+<meta typeof="mw:Includes/IncludeOnly" data-parsoid='{"src":"&lt;includeonly>can have stuff&lt;/includeonly>"}'/><meta typeof="mw:Includes/IncludeOnly/End" data-parsoid='{"src":""}'/><h3 id="here"> here </h3>
 
 !! end
 
@@ -12520,6 +12572,8 @@ Preprocessor precedence 14: broken language converter in comment
 
 !! test
 Preprocessor precedence 15: broken brace markup in headings
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! options
 parsoid=wt2html
 !! wikitext
@@ -12537,32 +12591,31 @@ __NOTOC__ __NOEDITSECTION__
 ===6 foo-{bar 6===
 6
 !! html/php+tidy
-<h3><span class="mw-headline" id="1_foo.5Bbar_1">1 foo[bar 1</span></h3>
+<h3><span id="1_foo.5Bbar_1"></span><span class="mw-headline" id="1_foo[bar_1">1 foo[bar 1</span></h3>
 <p>1</p>
-<h3><span class="mw-headline" id="2_foo.5B.5Bbar_2">2 foo[[bar 2</span></h3>
+<h3><span id="2_foo.5B.5Bbar_2"></span><span class="mw-headline" id="2_foo[[bar_2">2 foo[[bar 2</span></h3>
 <p>2</p>
-<h3><span class="mw-headline" id="3_foo.7Bbar_3">3 foo{bar 3</span></h3>
+<h3><span id="3_foo.7Bbar_3"></span><span class="mw-headline" id="3_foo{bar_3">3 foo{bar 3</span></h3>
 <p>3</p>
-<h3><span class="mw-headline" id="4_foo.7B.7Bbar_4">4 foo{{bar 4</span></h3>
+<h3><span id="4_foo.7B.7Bbar_4"></span><span class="mw-headline" id="4_foo{{bar_4">4 foo{{bar 4</span></h3>
 <p>4</p>
-<h3><span class="mw-headline" id="5_foo.7B.7B.7Bbar_5">5 foo{{{bar 5</span></h3>
+<h3><span id="5_foo.7B.7B.7Bbar_5"></span><span class="mw-headline" id="5_foo{{{bar_5">5 foo{{{bar 5</span></h3>
 <p>5</p>
-<h3><span class="mw-headline" id="6_foo-.7Bbar_6">6 foo-{bar 6</span></h3>
+<h3><span id="6_foo-.7Bbar_6"></span><span class="mw-headline" id="6_foo-{bar_6">6 foo-{bar 6</span></h3>
 <p>6</p>
 !! html/parsoid
-<meta property="mw:PageProp/notoc"/> <meta property="mw:PageProp/noeditsection"/
->
-<h3>1 foo[bar 1</h3>
+<meta property="mw:PageProp/notoc"/> <meta property="mw:PageProp/noeditsection"/>
+<h3 id="1_foo[bar_1"><span id="1_foo.5Bbar_1" typeof="mw:FallbackId"></span>1 foo[bar 1</h3>
 <p>1</p>
-<h3>2 foo[[bar 2</h3>
+<h3 id="2_foo[[bar_2"><span id="2_foo.5B.5Bbar_2" typeof="mw:FallbackId"></span>2 foo[[bar 2</h3>
 <p>2</p>
-<h3>3 foo{bar 3</h3>
+<h3 id="3_foo{bar_3"><span id="3_foo.7Bbar_3" typeof="mw:FallbackId"></span>3 foo{bar 3</h3>
 <p>3</p>
-<h3>4 foo{{bar 4</h3>
+<h3 id="4_foo{{bar_4"><span id="4_foo.7B.7Bbar_4" typeof="mw:FallbackId"></span>4 foo{{bar 4</h3>
 <p>4</p>
-<h3>5 foo{{{bar 5</h3>
+<h3 id="5_foo{{{bar_5"><span id="5_foo.7B.7B.7Bbar_5" typeof="mw:FallbackId"></span>5 foo{{{bar 5</h3>
 <p>5</p>
-<h3>6 foo-{bar 6</h3>
+<h3 id="6_foo-{bar_6"><span id="6_foo-.7Bbar_6" typeof="mw:FallbackId"></span>6 foo-{bar 6</h3>
 <p>6</p>
 !! end
 
@@ -14264,15 +14317,15 @@ parsoid=wt2html,wt2wt,html2html
 <p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !! end
 
 !! test
-Serialize simple image with figure-inline wrapper
+Serialize simple image with span wrapper
 !! options
 parsoid=html2wt
 !! html/parsoid
-<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
 !! wikitext
 [[File:Foobar.jpg]]
 !! end
@@ -14285,7 +14338,7 @@ Simple image (using File: namespace, now canonical)
 <p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14402,7 +14455,7 @@ Linktrails should not work for images: [[File:Foobar.jpg]]s
 <p>Linktrails should not work for images: <a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>s
 </p>
 !! html/parsoid
-<p>Linktrails should not work for images: <span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span>s</p>
+<p>Linktrails should not work for images: <figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline>s</p>
 !! end
 
 !! test
@@ -14448,7 +14501,7 @@ parsoid=wt2html,wt2wt,html2html
 <p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" width="50" height="6" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/75px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/100px-Foobar.jpg 2x" /></a>
 </p>
 !! html/parsoid
-<p><span typeof="mw:Image mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"width","ak":"{{echo|50px}}"}]}' data-mw='{"attribs":[["width",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[18,31,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"50px\"}},\"i\":0}}]}&#39;>50px&lt;/span>"}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline typeof="mw:Image mw:ExpandedAttrs" about="#mwt2" data-parsoid='{"optList":[{"ck":"width","ak":"{{echo|50px}}"}]}' data-mw='{"attribs":[["width",{"html":"&lt;span about=\"#mwt1\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[{\"k\":\"1\"}]],\"dsr\":[18,31,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"50px\"}},\"i\":0}}]}&#39;>50px&lt;/span>"}]]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 ## Parsoid does not provide editing support for images where templates produce multiple image attributes.
@@ -14492,7 +14545,7 @@ thumbsize=220
 </div>
 <p>456</p>
 !! html/parsoid
-<p>123<span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span>456</p>
+<p>123<figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline>456</p>
 <p>123</p><figure class="mw-default-size mw-halign-right" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure><p>456</p>
 <p>123</p><figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure><p>456</p>
 !! end
@@ -14516,7 +14569,7 @@ Image with multiple widths -- use last
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="caption"><img alt="caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg" width="300" height="34" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/450px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/600px-Foobar.jpg 2x" /></a>
 </p>
 !! html/parsoid
-<p><span typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="34" width="300"/></a></span></p>
+<p><figure-inline typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/300px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="34" width="300"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14533,7 +14586,7 @@ thumbsize=220
 </p>
 !! html/parsoid
 <figure class="mw-default-size mw-halign-left" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>caption</figcaption></figure>
-<p><span class="mw-default-size mw-valign-middle" typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size mw-valign-middle" typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14566,7 +14619,7 @@ parsoid=wt2html,wt2wt,html2html
 <a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/177px-Foobar.jpg" width="177" height="20" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/265px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/353px-Foobar.jpg 2x" /></a>
 </p>
 !! html/parsoid
-<p><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="2" width="20"/></a></span> <span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/177px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="20" width="177"/></a></span></p>
+<p><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="2" width="20"/></a></figure-inline> <figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/177px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="20" width="177"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14577,7 +14630,7 @@ Image with link parameter, wiki target
 <p><a href="/wiki/Main_Page" title="Main Page"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image"><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !! end
 
 # parsoid T51293 (part 1)
@@ -14589,7 +14642,7 @@ Image with link parameter, URL target
 <p><a href="http://example.com/" rel="nofollow"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image"><a href="http://example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="http://example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !! end
 
 # parsoid T51293 (part 2)
@@ -14601,7 +14654,7 @@ Image with link parameter, protocol-less URL target
 <p><a href="//example.com/" rel="nofollow"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image"><a href="//example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><a href="//example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14673,7 +14726,7 @@ Image with empty link parameter
 <p><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" />
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image"><span><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></span></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image"><span><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></span></figure-inline></p>
 !! end
 
 !! test
@@ -14684,7 +14737,7 @@ Image with link parameter (wiki target) and unnamed parameter
 <p><a href="/wiki/Main_Page" title="Title"><img alt="Title" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"Title"}'><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"Title"}'><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14695,7 +14748,7 @@ Image with link parameter (URL target) and unnamed parameter
 <p><a href="http://example.com/" title="Title" rel="nofollow"><img alt="Title" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"Title"}'><a href="http://example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"Title"}'><a href="http://example.com/"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14818,9 +14871,9 @@ Image with wiki markup in implicit alt
 </p><p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="testing bold in alt" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt"}]}' data-mw='{"caption":"testing &lt;b data-parsoid=&#39;{\"dsr\":[27,37,3,3]}&#39;>bold&lt;/b> in alt"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt"}]}' data-mw='{"caption":"testing &lt;b data-parsoid=&#39;{\"dsr\":[27,37,3,3]}&#39;>bold&lt;/b> in alt"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:Foobar.jpg"}}'/></a></figure-inline></p>
 
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"alt","ak":"alt=testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img alt="testing bold in alt" resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"alt":"testing bold in alt","resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"alt":"alt=testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt","resource":"Image:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"alt","ak":"alt=testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img alt="testing bold in alt" resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"alt":"testing bold in alt","resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"alt":"alt=testing &#39;&#39;&#39;bold&#39;&#39;&#39; in alt","resource":"Image:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14913,9 +14966,9 @@ parsoid=wt2html,wt2wt,html2html
 </p><p><a href="/wiki/File:Foobar.jpg" class="image" title="caption"><img alt="caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></span></p>
-<p><span class="mw-default-size" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></span></p>
-<p><span class="mw-default-size" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure-inline></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure-inline></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14980,8 +15033,8 @@ parsoid=wt2html,wt2wt,html2html
 </p><p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="2000" height="227" class="thumbborder" /></a>
 </p>
 !! html/parsoid
-<p><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="227" width="2000"/></a></span></p>
-<p><span class="mw-image-border" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="227" width="2000"/></a></span></p>
+<p><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="227" width="2000"/></a></figure-inline></p>
+<p><figure-inline class="mw-image-border" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="227" width="2000"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -14997,8 +15050,8 @@ parsoid=wt2html,wt2wt,html2html
 </p><p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg" width="1000" height="113" class="thumbborder" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/1500px-Foobar.jpg 1.5x, http://example.com/images/3/3a/Foobar.jpg 2x" /></a>
 </p>
 !! html/parsoid
-<p><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="113" width="1000"/></a></span></p>
-<p><span class="mw-image-border" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="113" width="1000"/></a></span></p>
+<p><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="113" width="1000"/></a></figure-inline></p>
+<p><figure-inline class="mw-image-border" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/1000px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="113" width="1000"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15041,7 +15094,7 @@ parsoid=wt2html,wt2wt,html2html
 <p><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" width="50" height="6" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/75px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/100px-Foobar.jpg 2x" /></a>
 </p>
 !! html/parsoid
-<p><span typeof="mw:Image/Frameless"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></span></p>
+<p><figure-inline typeof="mw:Image/Frameless"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15057,8 +15110,8 @@ parsoid=wt2html,wt2wt,html2html
 </p><p><a href="/wiki/File:Foobar.svg" class="image"><img alt="Foobar.svg" src="http://example.com/images/thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png" width="2000" height="1500" srcset="http://example.com/images/thumb/f/ff/Foobar.svg/3000px-Foobar.svg.png 1.5x, http://example.com/images/thumb/f/ff/Foobar.svg/4000px-Foobar.svg.png 2x" /></a>
 </p>
 !! html/parsoid
-<p><span typeof="mw:Image/Frameless"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
-<p><span typeof="mw:Image/Frameless"><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png" data-file-width="240" data-file-height="180" data-file-type="drawing" height="1500" width="2000"/></a></span></p>
+<p><figure-inline typeof="mw:Image/Frameless"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
+<p><figure-inline typeof="mw:Image/Frameless"><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/thumb/f/ff/Foobar.svg/2000px-Foobar.svg.png" data-file-width="240" data-file-height="180" data-file-type="drawing" height="1500" width="2000"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15116,7 +15169,7 @@ Frameless image caption with a free URL
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="http://example.com"><img alt="http://example.com" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"http://example.com"}]}' data-mw='{"caption":"&lt;a rel=\"mw:ExtLink\" href=\"http://example.com\" data-parsoid=&#39;{\"stx\":\"url\",\"dsr\":[18,36,0,0]}&#39;>http://example.com&lt;/a>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"http://example.com"}]}' data-mw='{"caption":"&lt;a rel=\"mw:ExtLink\" href=\"http://example.com\" data-parsoid=&#39;{\"stx\":\"url\",\"dsr\":[18,36,0,0]}&#39;>http://example.com&lt;/a>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15226,7 +15279,7 @@ T2648: Frameless image caption with a link
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a link in it"><img alt="text with a link in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[30,38,2,2]}&#39;>link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[30,38,2,2]}&#39;>link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15237,7 +15290,7 @@ T2648: Frameless image caption with a link (suffix)
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a linkfoo in it"><img alt="text with a linkfoo in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[link]]foo in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[30,41,2,5],\"tail\":\"foo\"}&#39;>linkfoo&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[link]]foo in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink\" href=\"./Link\" title=\"Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Link\"},\"sa\":{\"href\":\"link\"},\"dsr\":[30,41,2,5],\"tail\":\"foo\"}&#39;>linkfoo&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15248,7 +15301,7 @@ T2648: Frameless image caption with an interwiki link
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a MeatBall:Link in it"><img alt="text with a MeatBall:Link in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[MeatBall:Link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:ExtLink\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"http://www.usemod.com/cgi-bin/mb.pl?Link\"},\"sa\":{\"href\":\"MeatBall:Link\"},\"isIW\":true,\"dsr\":[30,47,2,2]}&#39;>MeatBall:Link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[MeatBall:Link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink/Interwiki\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"http://www.usemod.com/cgi-bin/mb.pl?Link\"},\"sa\":{\"href\":\"MeatBall:Link\"},\"isIW\":true,\"dsr\":[30,47,2,2]}&#39;>MeatBall:Link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15259,7 +15312,7 @@ T2648: Frameless image caption with a piped interwiki link
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="text with a link in it"><img alt="text with a link in it" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[MeatBall:Link|link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:ExtLink\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=&#39;{\"stx\":\"piped\",\"a\":{\"href\":\"http://www.usemod.com/cgi-bin/mb.pl?Link\"},\"sa\":{\"href\":\"MeatBall:Link\"},\"isIW\":true,\"dsr\":[30,52,16,2]}&#39;>link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"text with a [[MeatBall:Link|link]] in it"}]}' data-mw='{"caption":"text with a &lt;a rel=\"mw:WikiLink/Interwiki\" href=\"http://www.usemod.com/cgi-bin/mb.pl?Link\" title=\"meatball:Link\" data-parsoid=&#39;{\"stx\":\"piped\",\"a\":{\"href\":\"http://www.usemod.com/cgi-bin/mb.pl?Link\"},\"sa\":{\"href\":\"MeatBall:Link\"},\"isIW\":true,\"dsr\":[30,52,16,2]}&#39;>link&lt;/a> in it"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15267,7 +15320,7 @@ T107474: Frameless image caption with <nowiki>
 !! wikitext
 [[File:Foobar.jpg|<nowiki>text with a [[MeatBall:Link|link]] in it</nowiki>]]
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;nowiki>text with a [[MeatBall:Link|link]] in it&lt;/nowiki>"}]}' data-mw='{"caption":"&lt;span typeof=\"mw:Nowiki\" data-parsoid=&#39;{\"dsr\":[18,75,8,9]}&#39;>text with a [[MeatBall:Link|link]] in it&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;nowiki>text with a [[MeatBall:Link|link]] in it&lt;/nowiki>"}]}' data-mw='{"caption":"&lt;span typeof=\"mw:Nowiki\" data-parsoid=&#39;{\"dsr\":[18,75,8,9]}&#39;>text with a [[MeatBall:Link|link]] in it&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15278,7 +15331,7 @@ Escape HTML special chars in image alt text
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="&amp; &lt; &gt; &quot;"><img alt="&amp; &lt; &gt; &quot;" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp; &lt; > \""}]}' data-mw='{"caption":"&amp;amp; &amp;lt; > \""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp; &lt; > \""}]}' data-mw='{"caption":"&amp;amp; &amp;lt; > \""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15291,7 +15344,7 @@ language=zh
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="&amp; &lt; &gt; &quot;"><img alt="&amp; &lt; &gt; &quot;" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp; &lt; > \""}]}' data-mw='{"caption":"&amp;amp; &amp;lt; > \""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp; &lt; > \""}]}' data-mw='{"caption":"&amp;amp; &amp;lt; > \""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15302,7 +15355,7 @@ Entities in file name and attributes
 <p><a href="/index.php?title=Special:Upload&amp;wpDestFile=7%25_solution.gif" class="new" title="File:7% solution.gif">7% solution</a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"bogus","ak":"manualthumb=7%25 solution.gif"},{"ck":"link","ak":"link=7%25 solution"},{"ck":"caption","ak":"[[7%25 solution]]"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&lt;a rel=\"mw:WikiLink\" href=\"./7%25_solution\" title=\"7% solution\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./7%25_solution\"},\"sa\":{\"href\":\"7%25 solution\"},\"dsr\":[74,91,2,2]}&#39;>7% solution&lt;/a>"}'><a href="./7%25_solution" data-parsoid='{"a":{"href":"./7%25_solution"},"sa":{"href":"link=7%25 solution"}}'><img resource="./File:7%25_solution.gif" src="./Special:FilePath/7%25_solution.gif" height="220" width="220" data-parsoid='{"a":{"resource":"./File:7%25_solution.gif","height":"220","width":"220"},"sa":{"resource":"File:7%25 solution.gif"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"bogus","ak":"manualthumb=7%25 solution.gif"},{"ck":"link","ak":"link=7%25 solution"},{"ck":"caption","ak":"[[7%25 solution]]"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&lt;a rel=\"mw:WikiLink\" href=\"./7%25_solution\" title=\"7% solution\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./7%25_solution\"},\"sa\":{\"href\":\"7%25 solution\"},\"dsr\":[74,91,2,2]}&#39;>7% solution&lt;/a>"}'><a href="./7%25_solution" data-parsoid='{"a":{"href":"./7%25_solution"},"sa":{"href":"link=7%25 solution"}}'><img resource="./File:7%25_solution.gif" src="./Special:FilePath/7%25_solution.gif" height="220" width="220" data-parsoid='{"a":{"resource":"./File:7%25_solution.gif","height":"220","width":"220"},"sa":{"resource":"File:7%25 solution.gif"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15313,7 +15366,7 @@ T2499: Alt text should have &#1234;, not &amp;1234;
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="♀"><img alt="♀" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp;#9792;"}]}' data-mw='{"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=&#39;{\"src\":\"&amp;amp;#9792;\",\"srcContent\":\"♀\",\"dsr\":[18,25,null,null]}&#39;>♀&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&amp;#9792;"}]}' data-mw='{"caption":"&lt;span typeof=\"mw:Entity\" data-parsoid=&#39;{\"src\":\"&amp;amp;#9792;\",\"srcContent\":\"♀\",\"dsr\":[18,25,null,null]}&#39;>♀&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15337,7 +15390,7 @@ Image caption containing another image
 <div class="thumb tright"><div class="thumbinner" style="width:182px;"><a href="/wiki/File:Foobar.jpg" class="image"><img alt="" src="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg" width="180" height="20" class="thumbimage" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/270px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/360px-Foobar.jpg 2x" /></a>  <div class="thumbcaption"><div class="magnify"><a href="/wiki/File:Foobar.jpg" class="internal" title="Enlarge"></a></div>This is a caption with another <a href="/wiki/File:Thumb.png" class="image" title="image"><img alt="image" src="http://example.com/images/e/ea/Thumb.png" width="135" height="135" /></a> inside it!</div></div></div>
 
 !! html/parsoid
-<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>This is a caption with another <span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"image"}'><a href="./File:Thumb.png"><img resource="./File:Thumb.png" src="//example.com/images/e/ea/Thumb.png" data-file-width="135" data-file-height="135" data-file-type="bitmap" height="135" width="135"/></a></span> inside it!</figcaption></figure>
+<figure class="mw-default-size" typeof="mw:Image/Thumb"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a><figcaption>This is a caption with another <figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"image"}'><a href="./File:Thumb.png"><img resource="./File:Thumb.png" src="//example.com/images/e/ea/Thumb.png" data-file-width="135" data-file-height="135" data-file-type="bitmap" height="135" width="135"/></a></figure-inline> inside it!</figcaption></figure>
 !! end
 
 !! test
@@ -15349,7 +15402,7 @@ Image: caption containing a newline
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="This *is some text"><img alt="This *is some text" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"This\n*is some text"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"This\n*is some text"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !!end
 
 !!test
@@ -15410,7 +15463,7 @@ parsoid=wt2html,wt2wt,html2html
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="a"><img alt="a" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" class="b" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size b" typeof="mw:Image" data-mw='{"caption":"a"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><figure-inline class="mw-default-size b" typeof="mw:Image" data-mw='{"caption":"a"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15464,7 +15517,7 @@ parsoid=wt2html,wt2wt,html2html
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="caption"><img alt="caption" src="http://example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" width="220" height="25" class="extra thumbborder" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/330px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/440px-Foobar.jpg 2x" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size mw-image-border extra" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></span></p>
+<p><figure-inline class="mw-default-size mw-image-border extra" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/220px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="25" width="220"/></a></figure-inline></p>
 !! end
 
 # Note that 'right' is the default alignment, despite the misspelled 'righ' below
@@ -15517,7 +15570,7 @@ wgEnableUploads=0
 <p><a href="/wiki/File:Foobaz.jpg" title="File:Foobaz.jpg">File:Foobaz.jpg</a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Foobaz.jpg"><img resource="./File:Foobaz.jpg" src="./Special:FilePath/Foobaz.jpg" height="220" width="220"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Foobaz.jpg"><img resource="./File:Foobaz.jpg" src="./Special:FilePath/Foobaz.jpg" height="220" width="220"/></a></figure-inline></p>
 !! end
 
 # Parsoid-specific testing for images
@@ -15532,7 +15585,7 @@ Parsoid-specific image handling - simple image with size and middle alignment
 !! wikitext
 [[File:Foobar.jpg|middle|50px]]
 !! html/parsoid
-<p><span class="mw-valign-middle" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></span></p>
+<p><figure-inline class="mw-valign-middle" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15543,7 +15596,7 @@ parsoid=wt2wt,wt2html,html2html
 !! wikitext
 [[Image:Foobar.jpg|middle|50px]]
 !! html/parsoid
-<p><span class="mw-valign-middle" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></span></p>
+<p><figure-inline class="mw-valign-middle" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15552,7 +15605,7 @@ Parsoid-specific image handling - simple image with size and middle alignment
 !! wikitext
 [[File:Foobar.jpg|50px|middle]]
 !! html/parsoid
-<p><span class="mw-valign-middle" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"50px"},{"ck":"middle","ak":"middle"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-valign-middle" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"width","ak":"50px"},{"ck":"middle","ak":"middle"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15563,7 +15616,7 @@ parsoid=wt2html,wt2wt,html2html
 !! wikitext
 [[Image:Foobar.jpg|50px|middle]]
 !! html/parsoid
-<p><span class="mw-valign-middle" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></span></p>
+<p><figure-inline class="mw-valign-middle" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15571,7 +15624,7 @@ Parsoid-specific image handling - simple image with both sizes, a baseline align
 !! wikitext
 [[File:Foobar.jpg|500x10px|baseline|caption]]
 !! html/parsoid
-<p><span class="mw-valign-baseline" typeof="mw:Image" data-mw='{"caption":"caption"}' data-parsoid='{"optList":[{"ck":"width","ak":"500x10px"},{"ck":"baseline","ak":"baseline"},{"ck":"caption","ak":"caption"}],"size":"500x10"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/89px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="10" width="89" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"10","width":"89"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-valign-baseline" typeof="mw:Image" data-mw='{"caption":"caption"}' data-parsoid='{"optList":[{"ck":"width","ak":"500x10px"},{"ck":"baseline","ak":"baseline"},{"ck":"caption","ak":"caption"}],"size":"500x10"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/89px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="10" width="89" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"10","width":"89"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15579,7 +15632,7 @@ Parsoid-specific image handling - simple image with border and size spec
 !! wikitext
 [[File:Foobar.jpg|50px|border|caption]]
 !! html/parsoid
-<p><span class="mw-image-border" typeof="mw:Image" data-mw='{"caption":"caption"}' data-parsoid='{"optList":[{"ck":"width","ak":"50px"},{"ck":"border","ak":"border"},{"ck":"caption","ak":"caption"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-image-border" typeof="mw:Image" data-mw='{"caption":"caption"}' data-parsoid='{"optList":[{"ck":"width","ak":"50px"},{"ck":"border","ak":"border"},{"ck":"caption","ak":"caption"}]}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/50px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="6" width="50" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"6","width":"50"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15643,7 +15696,7 @@ Parsoid-specific image handling - frameless image with specific size, border, an
 !! wikitext
 [[File:Foobar.jpg|frameless|442x50px|border|caption]]
 !! html/parsoid
-<p><span class="mw-image-border" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}' data-parsoid='{"optList":[{"ck":"frameless","ak":"frameless"},{"ck":"width","ak":"442x50px"},{"ck":"border","ak":"border"},{"ck":"caption","ak":"caption"}],"size":"442x50"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/442px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="50" width="442" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"50","width":"442"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-image-border" typeof="mw:Image/Frameless" data-mw='{"caption":"caption"}' data-parsoid='{"optList":[{"ck":"frameless","ak":"frameless"},{"ck":"width","ak":"442x50px"},{"ck":"border","ak":"border"},{"ck":"caption","ak":"caption"}],"size":"442x50"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/442px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="50" width="442" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"50","width":"442"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15651,7 +15704,7 @@ Parsoid-specific image handling - simple image with a formatted caption
 !! wikitext
 [[File:Foobar.jpg|<table><tr><td>a</td><td>b</td></tr><tr><td>c</td></tr></table>]]
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;table>&lt;tr>&lt;td>a&lt;/td>&lt;td>b&lt;/td>&lt;/tr>&lt;tr>&lt;td>c&lt;/td>&lt;/tr>&lt;/table>"}]}' data-mw='{"caption":"&lt;table data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[18,81,7,8]}&#39;>&lt;tbody data-parsoid=&#39;{\"dsr\":[25,73,0,0]}&#39;>&lt;tr data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[25,54,4,5]}&#39;>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[29,39,4,5]}&#39;>a&lt;/td>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[39,49,4,5]}&#39;>b&lt;/td>&lt;/tr>&lt;tr data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[54,73,4,5]}&#39;>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[58,68,4,5]}&#39;>c&lt;/td>&lt;/tr>&lt;/tbody>&lt;/table>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"&lt;table>&lt;tr>&lt;td>a&lt;/td>&lt;td>b&lt;/td>&lt;/tr>&lt;tr>&lt;td>c&lt;/td>&lt;/tr>&lt;/table>"}]}' data-mw='{"caption":"&lt;table data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[18,81,7,8]}&#39;>&lt;tbody data-parsoid=&#39;{\"dsr\":[25,73,0,0]}&#39;>&lt;tr data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[25,54,4,5]}&#39;>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[29,39,4,5]}&#39;>a&lt;/td>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[39,49,4,5]}&#39;>b&lt;/td>&lt;/tr>&lt;tr data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[54,73,4,5]}&#39;>&lt;td data-parsoid=&#39;{\"stx\":\"html\",\"dsr\":[58,68,4,5]}&#39;>c&lt;/td>&lt;/tr>&lt;/tbody>&lt;/table>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -15721,7 +15774,7 @@ foo
 bar
 !! html/parsoid
 <p>foo
-<span class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/f/ff/Foobar.svg" lang="de" data-file-width="240" data-file-height="180" data-file-type="drawing" height="180" width="240"/></a></span>
+<figure-inline class="mw-default-size" typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.svg"><img resource="./File:Foobar.svg" src="//example.com/images/f/ff/Foobar.svg" lang="de" data-file-width="240" data-file-height="180" data-file-type="drawing" height="180" width="240"/></a></figure-inline>
 bar</p>
 !! end
 
@@ -15745,7 +15798,7 @@ T93580: 2. <ref> inside inline images
 
 <references />
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: &lt;ref>foo&lt;/ref>"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;span about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[64,78,5,6]}&#39; data-mw=&#39;{\"name\":\"ref\",\"body\":{\"id\":\"mw-reference-text-cite_note-1\"},\"attrs\":{}}&#39;>&lt;a href=\"./Main_Page#cite_note-1\" style=\"counter-reset: mw-Ref 1;\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[1]&lt;/span>&lt;/a>&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: &lt;ref>foo&lt;/ref>"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;span about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[64,78,5,6]}&#39; data-mw=&#39;{\"name\":\"ref\",\"body\":{\"id\":\"mw-reference-text-cite_note-1\"},\"attrs\":{}}&#39;>&lt;a href=\"./Main_Page#cite_note-1\" style=\"counter-reset: mw-Ref 1;\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[1]&lt;/span>&lt;/a>&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 
 <ol class="mw-references references" typeof="mw:Extension/references" about="#mwt4" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><a href="./Main_Page#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text">foo</span></li></ol>
 !! end
@@ -15757,7 +15810,7 @@ T93580: 3. Templated <ref> inside inline images
 
 <references />
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: {{echo|&lt;ref>{{echo|foo}}&lt;/ref>}}"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;span about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Transclusion  mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[64,96,null,null],\"pi\":[[{\"k\":\"1\"}]]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"&amp;lt;ref>{{echo|foo}}&amp;lt;/ref>\"}},\"i\":0}}]}&#39;>&lt;a href=\"./Main_Page#cite_note-1\" style=\"counter-reset: mw-Ref 1;\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[1]&lt;/span>&lt;/a>&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"caption","ak":"Undisplayed caption in inline image with ref: {{echo|&lt;ref>{{echo|foo}}&lt;/ref>}}"}]}' data-mw='{"caption":"Undisplayed caption in inline image with ref: &lt;span about=\"#mwt2\" class=\"mw-ref\" id=\"cite_ref-1\" rel=\"dc:references\" typeof=\"mw:Transclusion  mw:Extension/ref\" data-parsoid=&#39;{\"dsr\":[64,96,null,null],\"pi\":[[{\"k\":\"1\"}]]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"echo\",\"href\":\"./Template:Echo\"},\"params\":{\"1\":{\"wt\":\"&amp;lt;ref>{{echo|foo}}&amp;lt;/ref>\"}},\"i\":0}}]}&#39;>&lt;a href=\"./Main_Page#cite_note-1\" style=\"counter-reset: mw-Ref 1;\" data-parsoid=\"{}\">&lt;span class=\"mw-reflink-text\" data-parsoid=\"{}\">[1]&lt;/span>&lt;/a>&lt;/span>"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"File:Foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 
 <ol class="mw-references references" typeof="mw:Extension/references" about="#mwt6" data-mw='{"name":"references","attrs":{}}'><li about="#cite_note-1" id="cite_note-1"><a href="./Main_Page#cite_ref-1" rel="mw:referencedBy"><span class="mw-linkback-text">↑ </span></a> <span id="mw-reference-text-cite_note-1" class="mw-reference-text">foo</span></li></ol>
 !! end
@@ -16585,8 +16638,11 @@ __FORCETOC__
 !! end
 
 # perl -e 'print "="x$_," Level $_ heading","="x$_,"\n" for 1..10'
+# Parsoid html2wt direction adds <nowiki> for level 7 and up.
 !! test
 Handling of sections up to level 6 and beyond
+!! options
+parsoid=wt2html
 !! wikitext
 = Level 1 Heading=
 == Level 2 Heading==
@@ -16598,7 +16654,7 @@ Handling of sections up to level 6 and beyond
 ======== Level 8 Heading========
 ========= Level 9 Heading=========
 ========== Level 10 Heading==========
-!! html
+!! html/php
 <div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 <ul>
 <li class="toclevel-1 tocsection-1"><a href="#Level_1_Heading"><span class="tocnumber">1</span> <span class="toctext">Level 1 Heading</span></a>
@@ -16640,6 +16696,17 @@ Handling of sections up to level 6 and beyond
 <h6><span class="mw-headline" id=".3D.3D.3D_Level_9_Heading.3D.3D.3D">=== Level 9 Heading===</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=9" title="Edit section: === Level 9 Heading===">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
 <h6><span class="mw-headline" id=".3D.3D.3D.3D_Level_10_Heading.3D.3D.3D.3D">==== Level 10 Heading====</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=10" title="Edit section: ==== Level 10 Heading====">edit</a><span class="mw-editsection-bracket">]</span></span></h6>
 
+!! html/parsoid
+<h1 id="Level_1_Heading" data-parsoid='{}'> Level 1 Heading</h1>
+<h2 id="Level_2_Heading" data-parsoid='{}'> Level 2 Heading</h2>
+<h3 id="Level_3_Heading" data-parsoid='{}'> Level 3 Heading</h3>
+<h4 id="Level_4_Heading" data-parsoid='{}'> Level 4 Heading</h4>
+<h5 id="Level_5_Heading" data-parsoid='{}'> Level 5 Heading</h5>
+<h6 id="Level_6_Heading" data-parsoid='{}'> Level 6 Heading</h6>
+<h6 id="=_Level_7_Heading=" data-parsoid='{}'><span id=".3D_Level_7_Heading.3D" typeof="mw:FallbackId"></span>= Level 7 Heading=</h6>
+<h6 id="==_Level_8_Heading==" data-parsoid='{}'><span id=".3D.3D_Level_8_Heading.3D.3D" typeof="mw:FallbackId"></span>== Level 8 Heading==</h6>
+<h6 id="===_Level_9_Heading===" data-parsoid='{}'><span id=".3D.3D.3D_Level_9_Heading.3D.3D.3D" typeof="mw:FallbackId"></span>=== Level 9 Heading===</h6>
+<h6 id="====_Level_10_Heading====" data-parsoid='{}'><span id=".3D.3D.3D.3D_Level_10_Heading.3D.3D.3D.3D" typeof="mw:FallbackId"></span>==== Level 10 Heading====</h6>
 !! end
 
 !! test
@@ -16863,24 +16930,33 @@ http://example.com [[File:Foobar.jpg]]
 <p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a> <a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="http://example.com">http://example.com</a> <span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><a rel="mw:ExtLink" href="http://example.com">http://example.com</a> <figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !!end
 
+# Parsoid doesn't wt2wt this cleanly because it adds <nowiki>s.
 !! test
 Short headings with trailing space should match behavior of Parser::doHeadings (T21910)
+!! options
+parsoid=wt2html,html2html
 !! wikitext
 === 
 The line above must have a trailing space!
 === <!--
 --> <!-- -->
 But just in case it doesn't...
-!! html
+!! html/php
 <h1><span class="mw-headline" id=".3D">=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: =">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
 <p>The line above must have a trailing space!
 </p>
 <h1><span class="mw-headline" id=".3D_2">=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: =">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
 <p>But just in case it doesn't...
 </p>
+!! html/parsoid
+<h1 id="="><span id=".3D" typeof="mw:FallbackId"></span>=</h1> 
+<p>The line above must have a trailing space!</p>
+<h1 id="=_2"><span id=".3D_2" typeof="mw:FallbackId"></span>=</h1> <!--
+--> <!-- -->
+<p>But just in case it doesn't...</p>
 !! end
 
 !! test
@@ -16902,7 +16978,7 @@ section 4
 
 == text " text ==
 section 5
-!! html
+!! html/php
 <p>The tooltips shall not show entities to the user (ie. be double escaped)
 </p>
 <div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
@@ -16930,6 +17006,23 @@ section 5
 <h2><span class="mw-headline" id="text_.22_text">text " text</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=5" title="Edit section: text &quot; text">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
 <p>section 5
 </p>
+!! html/parsoid
+<p>The tooltips shall not show entities to the user (ie. be double escaped)</p>
+
+<h2 id="text_>_text"><span id="text_.3E_text" typeof="mw:FallbackId"></span> text > text </h2>
+<p>section 1</p>
+
+<h2 id="text_&lt;_text"><span id="text_.3C_text" typeof="mw:FallbackId"></span> text &lt; text </h2>
+<p>section 2</p>
+
+<h2 id="text_&amp;_text"><span id="text_.26_text" typeof="mw:FallbackId"></span> text &amp; text </h2>
+<p>section 3</p>
+
+<h2 id="text_'_text"><span id="text_.27_text" typeof="mw:FallbackId"></span> text ' text </h2>
+<p>section 4</p>
+
+<h2 id='text_"_text'><span id="text_.22_text" typeof="mw:FallbackId"></span> text " text </h2>
+<p>section 5</p>
 !! end
 
 !! test
@@ -16961,7 +17054,7 @@ section 6
 [[#Plus-Entity&#43;between&#43;Text]]
 [[#Underscore_between_Text]]
 [[#Underscore-Entity&#95;between&#95;Text]]
-!! html
+!! html/php
 <p>Id should not contain + for spaces
 </p>
 <div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
@@ -16999,17 +17092,47 @@ section 6
 <a href="#Underscore_between_Text">#Underscore_between_Text</a>
 <a href="#Underscore-Entity_between_Text">#Underscore-Entity&#95;between&#95;Text</a>
 </p>
+!! html/parsoid
+<p>Id should not contain + for spaces</p>
+
+<h2 id="Space_between_Text"> Space between Text </h2>
+<p>section 1</p>
+
+<h2 id="Space-Entity_between_Text"> Space-Entity<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#32;","srcContent":" "}'> </span>between<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#32;","srcContent":" "}'> </span>Text </h2>
+<p>section 2</p>
+
+<h2 id="Plus+between+Text"><span id="Plus.2Bbetween.2BText" typeof="mw:FallbackId"></span> Plus+between+Text </h2>
+<p>section 3</p>
+
+<h2 id="Plus-Entity+between+Text"><span id="Plus-Entity.2Bbetween.2BText" typeof="mw:FallbackId"></span> Plus-Entity<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#43;","srcContent":"+"}'>+</span>between<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#43;","srcContent":"+"}'>+</span>Text </h2>
+<p>section 4</p>
+
+<h2 id="Underscore_between_Text"> Underscore_between_Text </h2>
+<p>section 5</p>
+
+<h2 id="Underscore-Entity_between_Text"> Underscore-Entity<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#95;","srcContent":"_"}'>_</span>between<span typeof="mw:Entity" data-parsoid='{"src":"&amp;#95;","srcContent":"_"}'>_</span>Text </h2>
+<p>section 6</p>
+
+<p><a rel="mw:WikiLink" href="./Main_Page#Space_between_Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Space_between_Text"},"sa":{"href":"#Space between Text"}}'>#Space between Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Space-Entity_between_Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Space-Entity_between_Text"},"sa":{"href":"#Space-Entity&amp;#32;between&amp;#32;Text"}}'>#Space-Entity between Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Plus+between+Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Plus+between+Text"},"sa":{"href":"#Plus+between+Text"}}'>#Plus+between+Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Plus-Entity+between+Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Plus-Entity+between+Text"},"sa":{"href":"#Plus-Entity&amp;#43;between&amp;#43;Text"}}'>#Plus-Entity+between+Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Underscore_between_Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Underscore_between_Text"},"sa":{"href":"#Underscore_between_Text"}}'>#Underscore_between_Text</a>
+<a rel="mw:WikiLink" href="./Main_Page#Underscore-Entity_between_Text" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Underscore-Entity_between_Text"},"sa":{"href":"#Underscore-Entity&amp;#95;between&amp;#95;Text"}}'>#Underscore-Entity_between_Text</a></p>
 !! end
 
+# Parsoid html2wt disabled because it adds padding spaces around =
 !! test
 Headers with excess '=' characters
 (Are similar tests necessary beyond the 1st level?)
+!! options
+parsoid=wt2html,wt2wt,html2html
 !! wikitext
 =foo==
 ==foo=
 =''italic'' heading==
 ==''italic'' heading=
-!! html
+!! html/php
 <div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 <ul>
 <li class="toclevel-1 tocsection-1"><a href="#foo.3D"><span class="tocnumber">1</span> <span class="toctext">foo=</span></a></li>
@@ -17024,6 +17147,11 @@ Headers with excess '=' characters
 <h1><span class="mw-headline" id="italic_heading.3D"><i>italic</i> heading=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: italic heading=">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
 <h1><span class="mw-headline" id=".3Ditalic_heading">=<i>italic</i> heading</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=4" title="Edit section: =italic heading">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
 
+!! html/parsoid
+<h1 id="foo="><span id="foo.3D" typeof="mw:FallbackId"></span>foo=</h1>
+<h1 id="=foo"><span id=".3Dfoo" typeof="mw:FallbackId"></span>=foo</h1>
+<h1 id="italic_heading="><span id="italic_heading.3D" typeof="mw:FallbackId"></span><i>italic</i> heading=</h1>
+<h1 id="=italic_heading"><span id=".3Ditalic_heading" typeof="mw:FallbackId"></span>=<i>italic</i> heading</h1>
 !! end
 
 !! test
@@ -17039,7 +17167,7 @@ HTML headers vs TOC (T25393)
 == Header 2.1 ==
 == Header 2.2 ==
 __NOEDITSECTION__
-!! html
+!! html/php
 <div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 <ul>
 <li class="toclevel-1"><a href="#Header_1"><span class="tocnumber">1</span> <span class="toctext">Header 1</span></a>
@@ -17064,6 +17192,16 @@ __NOEDITSECTION__
 <h2><span class="mw-headline" id="Header_2.1">Header 2.1</span></h2>
 <h2><span class="mw-headline" id="Header_2.2">Header 2.2</span></h2>
 
+!! html/parsoid
+<h1 id="Header_1" data-parsoid='{"stx":"html"}'>Header 1</h1>
+<h2 id="Header_1.1" data-parsoid='{}'> Header 1.1 </h2>
+<h2 id="Header_1.2" data-parsoid='{}'> Header 1.2 </h2>
+
+<h1 id="Header_2" data-parsoid='{"stx":"html"}'>Header 2
+</h1>
+<h2 id="Header_2.1" data-parsoid='{}'> Header 2.1 </h2>
+<h2 id="Header_2.2" data-parsoid='{}'> Header 2.2 </h2>
+<meta property="mw:PageProp/noeditsection"/>
 !! end
 
 !! test
@@ -17076,11 +17214,17 @@ parsoid=wt2html,wt2wt
 ==baz==<!--
 c2
 c3-->
-!! html
-<h2><span class="mw-headline" id="foo">foo</span></h2>
-<h2><span class="mw-headline" id="bar">bar</span></h2>
-<h2><span class="mw-headline" id="baz">baz</span></h2>
+!! html/php
+<h2><span class="mw-headline" id="foo">foo</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: foo">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="bar">bar</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: bar">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span class="mw-headline" id="baz">baz</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: baz">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
 
+!! html/parsoid
+<h2 id="foo">foo</h2><!---->
+<h2 id="bar">bar</h2><!--c1-->
+<h2 id="baz">baz</h2><!--
+c2
+c3-->
 !! end
 
 !! test
@@ -17091,7 +17235,7 @@ http://example.com[[File:Foobar.jpg]]
 <p><a rel="nofollow" class="external free" href="http://example.com">http://example.com</a><a href="/wiki/File:Foobar.jpg" class="image"><img alt="Foobar.jpg" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><a rel="mw:ExtLink" href="http://example.com">http://example.com</a><span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></span></p>
+<p><a rel="mw:ExtLink" href="http://example.com">http://example.com</a><figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></a></figure-inline></p>
 !!end
 
 !! test
@@ -17187,15 +17331,17 @@ parsoid=wt2html,html2html
 
 !! test
 div with multiple empty attribute values
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! options
 parsoid=wt2html,html2html
 !! wikitext
 <div id= title=>HTML rocks</div>
 !! html/php
-<div id="title.3D">HTML rocks</div>
+<div id="title=">HTML rocks</div>
 
 !! html/parsoid
-<div id="title.3D" data-parsoid='{"stx":"html"}'>HTML rocks</div>
+<div id="title=" data-parsoid='{"stx":"html"}'>HTML rocks</div>
 !! end
 
 !! test
@@ -17521,7 +17667,7 @@ Image link to nonexistent file (T3850 - good)
 <p><a href="/index.php?title=Special:Upload&amp;wpDestFile=No_such.jpg" class="new" title="File:No such.jpg">File:No such.jpg</a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:No_such.jpg"><img resource="./File:No_such.jpg" src="./Special:FilePath/No_such.jpg" height="220" width="220"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:No_such.jpg"><img resource="./File:No_such.jpg" src="./Special:FilePath/No_such.jpg" height="220" width="220"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -17773,9 +17919,11 @@ T4304: HTML attribute safety (unsafe breakout parameter 2; 2309)
 T4304: HTML attribute safety (link)
 !! wikitext
 <div title="[[Main Page]]"></div>
-!! html
+!! html/php
 <div title="&#91;&#91;Main Page&#93;&#93;"></div>
 
+!! html/parsoid
+<div title="[[Main Page]]"></div>
 !! end
 
 !! test
@@ -17836,9 +17984,11 @@ T4304: HTML attribute safety (web link)
 T4304: HTML attribute safety (named web link)
 !! wikitext
 <div title="[http://example.com/ link]"></div>
-!! html
+!! html/php
 <div title="&#91;http&#58;//example.com/ link&#93;"></div>
 
+!! html/parsoid
+<div title="[http://example.com/ link]"></div>
 !! end
 
 !! test
@@ -18447,13 +18597,26 @@ Table not started</td></tr></table>
 
 !! test
 Sanitizer: Escaping of spaces, multibyte characters, colons & other stuff in id=""
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! wikitext
 <span id="æ: v">byte</span>[[#æ: v|backlink]]
 !! html/php
-<p><span id=".C3.A6:_v">byte</span><a href="#.C3.A6:_v">backlink</a>
+<p><span id="æ:_v">byte</span><a href="#æ:_v">backlink</a>
 </p>
 !! html/parsoid
-<p><span id=".C3.A6:_v" data-parsoid='{"stx":"html","a":{"id":".C3.A6:_v"},"sa":{"id":"æ: v"}}'>byte</span><a rel="mw:WikiLink" href="./Main_Page#.C3.A6:_v" data-parsoid='{"stx":"piped","a":{"href":"./Main_Page#.C3.A6:_v"},"sa":{"href":"#æ: v"}}'>backlink</a></p>
+<p><span id="æ:_v" data-parsoid='{"stx":"html","a":{"id":"æ:_v"},"sa":{"id":"æ: v"}}'>byte</span><a rel="mw:WikiLink" href="./Main_Page#æ:_v" data-parsoid='{"stx":"piped","a":{"href":"./Main_Page#æ:_v"},"sa":{"href":"#æ: v"}}'>backlink</a></p>
+!! end
+
+!! test
+Sanitizer: Escaping of spaces, multibyte characters, colons & other stuff in id="" (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+<span id="æ: v">byte</span>[[#æ: v|backlink]]
+!! html/php
+<p><span id=".C3.A6:_v">byte</span><a href="#.C3.A6:_v">backlink</a>
+</p>
 !! end
 
 # In HTML5, the restrictions are that id must contain at least one character,
@@ -18516,6 +18679,37 @@ parsoid=wt2html,wt2wt
 <p><span style="margin: -0.125em -0.45em;  rgba(255, 0, 0, 0.3)">2013</span></p>
 !! end
 
+!! test
+Sanitizer: Avoid unnecessary percent encoded characters in interwiki links
+!! wikitext
+[[meatball:Soft"Security]]
+!! html/php
+<p><a href="http://www.usemod.com/cgi-bin/mb.pl?Soft%22Security" class="extiw" title="meatball:Soft&quot;Security">meatball:Soft"Security</a>
+</p>
+!! html/parsoid
+<p><a rel="mw:WikiLink/Interwiki" href='http://www.usemod.com/cgi-bin/mb.pl?Soft"Security' title='meatball:Soft"Security'>meatball:Soft"Security</a></p>
+!! end
+
+!! test
+Sanitizer: angle brackets are invalid, even in interwiki links (T182338)
+!! wikitext
+[[meatball:Foo<Bar]]
+[[meatball:Foo>Bar]]
+[[meatball:Foo&lt;bar]]
+[[meatball:Foo&gt;bar]]
+!! html/php
+<p>[[meatball:Foo&lt;Bar]]
+[[meatball:Foo&gt;Bar]]
+[[meatball:Foo&lt;bar]]
+[[meatball:Foo&gt;bar]]
+</p>
+!! html/parsoid
+<p>[[meatball:Foo&lt;Bar]]
+[[meatball:Foo>Bar]]
+[[meatball:Foo<span typeof="mw:Entity" data-parsoid='{"src":"&amp;lt;","srcContent":"&lt;"}'>&lt;</span>bar]]
+[[meatball:Foo<span typeof="mw:Entity" data-parsoid='{"src":"&amp;gt;","srcContent":">"}'>></span>bar]]</p>
+!! end
+
 !! test
 Language converter: output gets cut off unexpectedly (T7757)
 !! options
@@ -18877,12 +19071,15 @@ Fuzz testing: Parser13
 
 !! end
 
+# Note that Parsoid output differs from the PHP parser here: the PHP
+# parser breaks the URL for the magic word, while in Parsoid the URL
+# production takes precedence.
 !! test
 Fuzz testing: Parser14
 !! wikitext
 == onmouseover= ==
 http://__TOC__
-!! html
+!! html/php
 <h2><span class="mw-headline" id="onmouseover.3D">onmouseover=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: onmouseover=">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
 http://<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 <ul>
@@ -18891,7 +19088,7 @@ http://<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 </div>
 
 
-!! html+tidy
+!! html/php+tidy
 <h2><span class="mw-headline" id="onmouseover.3D">onmouseover=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: onmouseover=">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
 <p>http://</p>
 <div id="toc" class="toc">
@@ -18903,6 +19100,9 @@ http://<div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 </ul>
 </div>
 <p></p>
+!! html/parsoid
+<h2 id="onmouseover="><span id="onmouseover.3D" typeof="mw:FallbackId"></span> onmouseover= </h2>
+<p><a rel="mw:ExtLink" href="http://__TOC__" data-parsoid='{"stx":"url"}'>http://__TOC__</a></p>
 !! end
 
 !! test
@@ -18926,7 +19126,7 @@ parsoid=wt2html,html2html
 </tr>
 </table>
 !! html/parsoid
-<h2>a</h2>
+<h2 id="a">a</h2>
 <table style="__TOC__"></table>
 !! end
 
@@ -19101,15 +19301,45 @@ Fuzz testing: image with bogus manual thumbnail
 <figure class="mw-default-size" typeof="mw:Error mw:Image/Thumb" data-parsoid='{"optList":[{"ck":"manualthumb","ak":"thumbnail= "}]}' data-mw='{"errors":[{"key":"apierror-invalidtitle","message":"Invalid thumbnail title.","params":{"name":""}}],"thumb":""}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{"href":"Image:foobar.jpg"}}'><img resource="./File:Foobar.jpg" src="./Special:FilePath/Foobar.jpg" height="220" width="220" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"220"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure>
 !! end
 
+# Parsoid will emit the newline literally in wt2wt; see next test case.
 !! test
 Fuzz testing: encoded newline in generated HTML replacements (T8577)
+!! options
+parsoid=wt2html
 !! wikitext
 <pre dir="&#10;"></pre>
 !! html/php
 <pre dir="&#10;"></pre>
 
 !! html/parsoid
-<pre typeof="mw:Extension/pre" about="#mwt2" dir="&amp;#10;" data-mw='{"name":"pre","attrs":{"dir":"&amp;#10;"},"body":{"extsrc":""}}'></pre>
+<pre typeof="mw:Extension/pre" about="#mwt2" dir="
+" data-mw='{"name":"pre","attrs":{"dir":"\n"},"body":{"extsrc":""}}'></pre>
+!! end
+
+!! test
+Fuzz testing: encoded newline in generated HTML replacements, html2wt (T8577)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" dir="
+" data-mw='{"name":"pre","attrs":{"dir":"\n"},"body":{"extsrc":""}}'></pre>
+!! wikitext
+<pre dir="
+"></pre>
+!! html/php
+<pre dir=""></pre>
+
+!! end
+
+!! test
+Templates in extension attributes are not expanded
+!! wikitext
+<pre dir="{{echo|ltr}}"></pre>
+!! html/php
+<pre dir="{{echo|ltr}}"></pre>
+
+!! html/parsoid
+<pre typeof="mw:Extension/pre" about="#mwt2" dir="{{echo|ltr}}" data-mw='{"name":"pre","attrs":{"dir":"{{echo|ltr}}"},"body":{"extsrc":""}}'></pre>
 !! end
 
 !! test
@@ -20263,7 +20493,7 @@ File:File:Foobar.jpg
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" type="123" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{"type":"123","summary":"345"},"body":{"extsrc":"\nFile:File:Foobar.jpg\n"}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:File:Foobar.jpg"><img resource="./File:File:Foobar.jpg" src="./Special:FilePath/File:Foobar.jpg" height="120" width="120"/></a></span></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:File:Foobar.jpg"><img resource="./File:File:Foobar.jpg" src="./Special:FilePath/File:Foobar.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
 </ul>
 !! end
 
@@ -20326,12 +20556,12 @@ image4    |300px| centre
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt3" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:Image1.png"><img resource="./File:Image1.png" src="./Special:FilePath/Image1.png" height="120" width="120"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:Image2.gif"><img resource="./File:Image2.gif" src="./Special:FilePath/Image2.gif" height="120" width="120"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:Image3"><img resource="./File:Image3" src="./Special:FilePath/Image3" height="120" width="120"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:Image4"><img resource="./File:Image4" src="./Special:FilePath/Image4" height="300" width="300"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:Image5.svg"><img resource="./File:Image5.svg" src="./Special:FilePath/Image5.svg" height="120" width="120"/></a></span></div><div class="gallerytext"> <a rel="mw:ExtLink" href="http://///////">http://///////</a></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:*_image6"><img resource="./File:*_image6" src="./Special:FilePath/*_image6" height="120" width="120"/></a></span></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image1.png"><img resource="./File:Image1.png" src="./Special:FilePath/Image1.png" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image2.gif"><img resource="./File:Image2.gif" src="./Special:FilePath/Image2.gif" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image3"><img resource="./File:Image3" src="./Special:FilePath/Image3" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image4"><img resource="./File:Image4" src="./Special:FilePath/Image4" height="300" width="300"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Image5.svg"><img resource="./File:Image5.svg" src="./Special:FilePath/Image5.svg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"> <a rel="mw:ExtLink" href="http://///////">http://///////</a></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:*_image6"><img resource="./File:*_image6" src="./Special:FilePath/*_image6" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
 </ul>
 !! end
 
@@ -20389,11 +20619,11 @@ image:foobar.jpg|Blabla|alt=This is a foo-bar.|blabla.
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" style="max-width: 226px; _width: 226px;" typeof="mw:Extension/gallery" about="#mwt3" data-mw='{"name":"gallery","attrs":{"widths":"70px","heights":"40px","perrow":"2"},"body":{}}'>
 <li class="gallerycaption">Foo <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></span></div><div class="gallerytext">caption</div></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></span></div><div class="gallerytext">some <b>caption</b> <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></div></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="This is a foo-bar." resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></span></div><div class="gallerytext">blabla.</div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></figure-inline></div><div class="gallerytext">caption</div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext">some <b>caption</b> <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="This is a foo-bar." resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext">blabla.</div></li>
 </ul>
 !! end
 
@@ -20450,11 +20680,11 @@ image:foobar.jpg|Blabla|alt=This is a foo-bar.|blabla.
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" style="max-width: 226px; _width: 226px;" typeof="mw:Extension/gallery" about="#mwt3" data-mw='{"name":"gallery","attrs":{"widths":"70px","heights":"40px","perrow":"2","caption":"Foo [[Main Page]]"},"body":{"extsrc":"\nFile:Nonexistent.jpg|caption\nFile:Nonexistent.jpg\nimage:foobar.jpg|some &#39;&#39;&#39;caption&#39;&#39;&#39; [[Main Page]]\nimage:foobar.jpg\nimage:foobar.jpg|Blabla|alt=This is a foo-bar.|blabla.\n"}}'>
 <li class="gallerycaption">Foo <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></span></div><div class="gallerytext">caption</div></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></span></div><div class="gallerytext">some <b>caption</b> <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></div></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="This is a foo-bar." resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></span></div><div class="gallerytext">blabla.</div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></figure-inline></div><div class="gallerytext">caption</div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="40" width="70"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext">some <b>caption</b> <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 105px;"><div class="thumb" style="width: 100px; height: 70px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="This is a foo-bar." resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/70px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="8" width="70"/></a></figure-inline></div><div class="gallerytext">blabla.</div></li>
 </ul>
 !! end
 
@@ -20494,9 +20724,9 @@ image:foobar.jpg|link=Main Page#section|caption
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./Main_Page#section"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./Main_Page#section"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext">caption</div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./Main_Page"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./Main_Page#section"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./Main_Page#section"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext">caption</div></li>
 </ul>
 !! end
 
@@ -20526,7 +20756,7 @@ File:Foobar.jpg|{{echo|ho}}
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt6" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
 <li class="gallerycaption"><span about="#mwt3" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"hi"}},"i":0}}]}'>hi</span></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"><span about="#mwt5" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"ho"}},"i":0}}]}'>ho</span></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><span about="#mwt5" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"ho"}},"i":0}}]}'>ho</span></div></li>
 </ul>
 !! end
 
@@ -20561,8 +20791,8 @@ File:Foobar.jpg|alt=galleryalt|{{Test|unamedParam|alt=param}}
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt6" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"><span typeof="mw:Image" data-mw='{"caption":"desc"}'><a href="./File:Foobar.jpg"><img alt="inneralt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="2" width="20"/></a></span></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"><span about="#mwt4" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"Test","href":"./Template:Test"},"params":{"1":{"wt":"unamedParam"},"alt":{"wt":"param"}},"i":0}}]}'>This is a test template</span></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><figure-inline typeof="mw:Image" data-mw='{"caption":"desc"}'><a href="./File:Foobar.jpg"><img alt="inneralt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="2" width="20"/></a></figure-inline></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><span about="#mwt4" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"Test","href":"./Template:Test"},"params":{"1":{"wt":"unamedParam"},"alt":{"wt":"param"}},"i":0}}]}'>This is a test template</span></div></li>
 </ul>
 !! end
 
@@ -20615,10 +20845,10 @@ some <b>caption</b> <a href="/wiki/Main_Page" title="Main Page">Main Page</a>
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt3" data-mw='{"name":"gallery","attrs":{"showfilename":""},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></span></div><div class="gallerytext"><a href="./File:Nonexistent.jpg" class="galleryfilename galleryfilename-truncate" title="File:Nonexistent.jpg">File:Nonexistent.jpg</a>caption</div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></span></div><div class="gallerytext"><a href="./File:Nonexistent.jpg" class="galleryfilename galleryfilename-truncate" title="File:Nonexistent.jpg">File:Nonexistent.jpg</a></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"><a href="./File:Foobar.jpg" class="galleryfilename galleryfilename-truncate" title="File:Foobar.jpg">File:Foobar.jpg</a>some <b>caption</b> <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"><a href="./File:Foobar.jpg" class="galleryfilename galleryfilename-truncate" title="File:Foobar.jpg">File:Foobar.jpg</a></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"><a href="./File:Nonexistent.jpg" class="galleryfilename galleryfilename-truncate" title="File:Nonexistent.jpg">File:Nonexistent.jpg</a>caption</div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"><a href="./File:Nonexistent.jpg" class="galleryfilename galleryfilename-truncate" title="File:Nonexistent.jpg">File:Nonexistent.jpg</a></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><a href="./File:Foobar.jpg" class="galleryfilename galleryfilename-truncate" title="File:Foobar.jpg">File:Foobar.jpg</a>some <b>caption</b> <a rel="mw:WikiLink" href="./Main_Page" title="Main Page">Main Page</a></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><a href="./File:Foobar.jpg" class="galleryfilename galleryfilename-truncate" title="File:Foobar.jpg">File:Foobar.jpg</a></div></li>
 </ul>
 !! end
 
@@ -20663,27 +20893,27 @@ foobar.jpg
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"></div></li>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Error mw:Image"><a href="./File:Nonexistent.jpg"><img resource="./File:Nonexistent.jpg" src="./Special:FilePath/Nonexistent.jpg" height="120" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
 </ul>
 !! end
 
 !! test
-Gallery override link with WikiLink (T36852)
+Gallery override link with wikilink (T36852)
 !! options
 parsoid={
   "nativeGallery": true
 }
 !! wikitext
 <gallery>
-File:Foobar.jpg|alt=galleryalt|link=InterWikiLink
+File:Foobar.jpg|alt=galleryalt|link=Wikilink
 </gallery>
 !! html/php
 <ul class="gallery mw-gallery-traditional">
                <li class="gallerybox" style="width: 155px"><div style="width: 155px">
-                       <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/InterWikiLink"><img alt="galleryalt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
+                       <div class="thumb" style="width: 150px;"><div style="margin:68px auto;"><a href="/wiki/Wikilink"><img alt="galleryalt" src="http://example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" width="120" height="14" srcset="http://example.com/images/thumb/3/3a/Foobar.jpg/180px-Foobar.jpg 1.5x, http://example.com/images/thumb/3/3a/Foobar.jpg/240px-Foobar.jpg 2x" /></a></div></div>
                        <div class="gallerytext">
                        </div>
                </div></li>
@@ -20691,7 +20921,7 @@ File:Foobar.jpg|alt=galleryalt|link=InterWikiLink
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-parsoid='{"dsr":[0,70,2,2]}' data-mw='{"name":"gallery","attrs":{},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./InterWikiLink"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./Wikilink"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
 </ul>
 !! end
 
@@ -20716,7 +20946,7 @@ File:Foobar.jpg|alt=galleryalt|link=http://www.example.org
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="http://www.example.org"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="http://www.example.org"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
 </ul>
 !! end
 
@@ -20763,7 +20993,7 @@ File:Foobar.jpg|alt=galleryalt|link=" onclick="alert('malicious javascript code!
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./%22_onclick=%22alert('malicious_javascript_code!');"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./%22_onclick=%22alert('malicious_javascript_code!');"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
 </ul>
 !! end
 
@@ -20790,7 +21020,7 @@ File:Foobar.jpg|link=<
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext">link=&lt;</div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext">link=&lt;</div></li>
 </ul>
 !! end
 
@@ -20833,7 +21063,7 @@ File:Foobar.jpg
 
 !! html/parsoid
 <ul class="gallery mw-gallery-traditional center" style="text-align: center;" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{"class":"center","style":"text-align: center;"},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
 </ul>
 !! end
 
@@ -20858,7 +21088,7 @@ File:Foobar.jpg
 
 !! html/parsoid
 <ul class="gallery mw-gallery-slideshow" data-showthumbnails="1" typeof="mw:Extension/gallery" about="#mwt2" data-mw='{"name":"gallery","attrs":{"mode":"slideshow","showthumbnails":""},"body":{}}'>
-<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px;"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div><div class="gallerytext"></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"></div></li>
 </ul>
 !! end
 
@@ -21120,7 +21350,7 @@ djvu
 <p><a href="/index.php?title=File:LoremIpsum.djvu&amp;page=2" class="image"><img alt="LoremIpsum.djvu" src="http://example.com/images/thumb/5/5f/LoremIpsum.djvu/page2-2480px-LoremIpsum.djvu.jpg" width="2480" height="3508" srcset="http://example.com/images/thumb/5/5f/LoremIpsum.djvu/page2-3720px-LoremIpsum.djvu.jpg 1.5x, http://example.com/images/thumb/5/5f/LoremIpsum.djvu/page2-4960px-LoremIpsum.djvu.jpg 2x" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"page","ak":"page=2"}]}' data-mw='{"page":"2"}'><a href="./File:LoremIpsum.djvu" data-parsoid='{"a":{"href":"./File:LoremIpsum.djvu"},"sa":{"href":"File:LoremIpsum.djvu"}}'><img resource="./File:LoremIpsum.djvu" src="//example.com/images/5/5f/LoremIpsum.djvu" data-file-width="2480" data-file-height="3508" data-file-type="bitmap" height="3508" width="2480" data-parsoid='{"a":{"resource":"./File:LoremIpsum.djvu","height":"3508","width":"2480"},"sa":{"resource":"File:LoremIpsum.djvu"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"page","ak":"page=2"}]}' data-mw='{"page":"2"}'><a href="./File:LoremIpsum.djvu" data-parsoid='{"a":{"href":"./File:LoremIpsum.djvu"},"sa":{"href":"File:LoremIpsum.djvu"}}'><img resource="./File:LoremIpsum.djvu" src="//example.com/images/5/5f/LoremIpsum.djvu" data-file-width="2480" data-file-height="3508" data-file-type="bitmap" height="3508" width="2480" data-parsoid='{"a":{"resource":"./File:LoremIpsum.djvu","height":"3508","width":"2480"},"sa":{"resource":"File:LoremIpsum.djvu"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -21478,47 +21708,94 @@ ISBN 1 234 56789 0 - 2006
 
 !! test
 anchorencode
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! wikitext
 {{anchorencode:foo bar©#%n}}
-!! html
+!! html/php
+<p>foo_bar©#%n
+</p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode:foo bar©#%n","function":"anchorencode"},"params":{},"i":0}}]}'>foo_bar©#%n</p>
+!! end
+
+!! test
+anchorencode (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
+!! wikitext
+{{anchorencode:foo bar©#%n}}
+!! html/php
 <p>foo_bar.C2.A9.23.25n
 </p>
 !! end
 
 !! test
 anchorencode trims spaces
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! wikitext
 {{anchorencode: __pretty__please__}}
-!! html
+!! html/php
 <p>pretty_please
 </p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode: __pretty__please__","function":"anchorencode"},"params":{},"i":0}}]}'>pretty_please</p>
 !! end
 
 !! test
 anchorencode deals with links
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! wikitext
 {{anchorencode: [[hello|world]] [[hi]]}}
-!! html
+!! html/php
 <p>world_hi
 </p>
+!! html/parsoid
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode: [[hello|world]] [[hi]]","function":"anchorencode"},"params":{},"i":0}}]}'>world_hi</p>
 !! end
 
 !! test
 anchorencode deals with templates
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! wikitext
-{{anchorencode: {{Foo}} }}
-!! html
-<p>FOO
+{{anchorencode: {{Foo}} x}}
+!! html/php
+<p>FOO_x
 </p>
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode: {{Foo}} x","function":"anchorencode"},"params":{},"i":0}}]}'>FOO_x</p>
 !! end
 
 !! test
 anchorencode encodes like the TOC generator: (T20431)
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+=== _ +:.3A%3A _ &&amp;]] x ===
+{{anchorencode: _ +:.3A%3A _ &&amp;]] x}}
+__NOEDITSECTION__
+!! html/php
+<h3><span id=".2B:.3A.253A_.26.26.5D.5D_x"></span><span class="mw-headline" id="+:.3A%3A_&amp;&amp;]]_x">_ +:.3A%3A _ &amp;&amp;]] x</span></h3>
+<p>+:.3A%3A_&amp;&amp;&#93;&#93;_x
+</p>
+!! html/parsoid
+<h3 id="+:.3A%3A_&amp;&amp;]]_x"><span id=".2B:.3A.253A_.26.26.5D.5D_x" typeof="mw:FallbackId"></span> _ +:.3A%3A _ &amp;<span typeof="mw:Entity" data-parsoid='{"src":"&amp;amp;","srcContent":"&amp;","dsr":[18,23,null,null]}'>&amp;</span>]] x </h3>
+<p about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode: _ +:.3A%3A _ &amp;&amp;amp;]] x","function":"anchorencode"},"params":{},"i":0}}]}'>+:.3A%3A_&amp;&amp;<span typeof="mw:Entity">]</span><span typeof="mw:Entity">]</span>_x</p>
+<meta property="mw:PageProp/noeditsection"/>
+!! end
+
+!! test
+anchorencode encodes like the TOC generator: (T20431) (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
 !! wikitext
 === _ +:.3A%3A&&amp;]] ===
 {{anchorencode: _ +:.3A%3A&&amp;]] }}
 __NOEDITSECTION__
-!! html
+!! html/php
 <h3><span class="mw-headline" id=".2B:.3A.253A.26.26.5D.5D">_ +:.3A%3A&amp;&amp;]]</span></h3>
 <p>.2B:.3A.253A.26.26.5D.5D
 </p>
@@ -21794,6 +22071,8 @@ language=sr variant=sr-ec
 
 !! test
 -{}- tags within headlines (within html for parserConvert())
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! options
 language=sr variant=sr-ec
 !! wikitext
@@ -21804,14 +22083,14 @@ conversion:
 
 == Latinski ==
 !! html/php
-<h2><span class="mw-headline" id="-.7BNaslov.7D-">Naslov</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Уреди одељак „Naslov“">уреди</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id="-.7BNaslov.7D-"></span><span class="mw-headline" id="-{Naslov}-">Naslov</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Уреди одељак „Naslov“">уреди</a><span class="mw-editsection-bracket">]</span></span></h2>
 <p>Ноте тхат евен ан унпротецтед хеадлине ИД ис нот аффецтед бy лангуаге
 цонверсион:
 </p>
 <h2><span class="mw-headline" id="Latinski">Латински</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Уреди одељак „Латински“">уреди</a><span class="mw-editsection-bracket">]</span></span></h2>
 
 !! html/parsoid
-<h2 id="-.7BNaslov.7D-"><span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"Naslov"}}'></span></h2>
+<h2 id="-{Naslov}-"><span id="-.7BNaslov.7D-" typeof="mw:FallbackId"></span> <span typeof="mw:LanguageVariant" data-mw-variant='{"disabled":{"t":"Naslov"}}'></span> </h2>
 
 <p>Note that even an unprotected headline ID is not affected by language
 conversion:</p>
@@ -22624,15 +22903,9 @@ File:foobar.jpg|{{Test|unamedParam|alt=-{R|param}-}}|alt=galleryalt
 </ul>
 
 !! html/parsoid
-<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" data-mw='{"name":"gallery","attrs":{},"body":{"extsrc":"\nFile:foobar.jpg|[[File:foobar.jpg|20px|desc|alt=-{R|foo}-|-{R|bar}-]]|alt=-{R|bat}-\nFile:foobar.jpg|{{Test|unamedParam|alt=-{R|param}-}}|alt=galleryalt\n"}}'>
-<li class="gallerybox">
-<div class="thumb"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div>
-<div class="gallerytext"><span typeof="mw:Image" data-mw='{"caption":"&lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"disabled\":{\"t\":\"bar\"}}&#39; data-parsoid=&#39;{\"fl\":[\"R\"],\"dsr\":[68,77,null,2]}&#39;>&lt;/span>"}'><a href="./File:Foobar.jpg"><img alt="" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="2" width="20"/></a></span></div>
-</li>
-<li class="gallerybox">
-<div class="thumb"><span typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></span></div>
-<div class="gallerytext"><span typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"Test","href":"./Template:Test"},"params":{"1":{"wt":"unamedParam"},"alt":{"wt":"-{R|param}-"}},"i":0}}]}'>This is a test template</span></div>
-</li>
+<ul class="gallery mw-gallery-traditional" typeof="mw:Extension/gallery" about="#mwt6" data-mw='{"name":"gallery","attrs":{},"body":{"extsrc":"\nFile:foobar.jpg|[[File:foobar.jpg|20px|desc|alt=-{R|foo}-|-{R|bar}-]]|alt=-{R|bat}-\nFile:foobar.jpg|{{Test|unamedParam|alt=-{R|param}-}}|alt=galleryalt\n"}}'>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><figure-inline typeof="mw:Image" data-mw='{"caption":"&lt;span typeof=\"mw:LanguageVariant\" data-mw-variant=&#39;{\"disabled\":{\"t\":\"bar\"}}&#39; data-parsoid=&#39;{\"fl\":[\"R\"],\"dsr\":[68,77,null,2]}&#39;>&lt;/span>"}'><a href="./File:Foobar.jpg"><img alt="" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/20px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="2" width="20"/></a></figure-inline></div></li>
+<li class="gallerybox" style="width: 155px;"><div class="thumb" style="width: 150px; height: 150px;"><figure-inline typeof="mw:Image"><a href="./File:Foobar.jpg"><img alt="galleryalt" resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/120px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="14" width="120"/></a></figure-inline></div><div class="gallerytext"><span about="#mwt4" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"Test","href":"./Template:Test"},"params":{"1":{"wt":"unamedParam"},"alt":{"wt":"-{R|param}-"}},"i":0}}]}'>This is a test template</span></div></li>
 </ul>
 !! end
 
@@ -23817,7 +24090,7 @@ percent-encoding and + signs in internal links (T28410)
 !! html/parsoid
 <p><a rel="mw:WikiLink" href="./User:+%25" title="User:+%" data-parsoid='{"stx":"simple","a":{"href":"./User:+%25"},"sa":{"href":"User:+%"}}'>User:+%</a> <a rel="mw:WikiLink" href="./Page+title%25" title="Page+title%" data-parsoid='{"stx":"simple","a":{"href":"./Page+title%25"},"sa":{"href":"Page+title%"}}'>Page+title%</a>
 <a rel="mw:WikiLink" href="./%25+" title="%+" data-parsoid='{"stx":"simple","a":{"href":"./%25+"},"sa":{"href":"%+"}}'>%+</a> <a rel="mw:WikiLink" href="./%25+" title="%+" data-parsoid='{"stx":"piped","a":{"href":"./%25+"},"sa":{"href":"%+"}}'>%20</a> <a rel="mw:WikiLink" href="./%25+" title="%+" data-parsoid='{"stx":"simple","a":{"href":"./%25+"},"sa":{"href":"%+ "}}'>%+ </a> <a rel="mw:WikiLink" href="./%25+r" title="%+r" data-parsoid='{"stx":"simple","a":{"href":"./%25+r"},"sa":{"href":"%+r"}}'>%+r</a>
-<a rel="mw:WikiLink" href="./%25" title="%" data-parsoid='{"stx":"simple","a":{"href":"./%25"},"sa":{"href":"%"}}'>%</a> <a rel="mw:WikiLink" href="./+" title="+" data-parsoid='{"stx":"simple","a":{"href":"./+"},"sa":{"href":"+"}}'>+</a> <span class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"bogus","ak":"foo"},{"ck":"caption","ak":"[[bar]]"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&lt;a rel=\"mw:WikiLink\" href=\"./Bar\" title=\"Bar\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Bar\"},\"sa\":{\"href\":\"bar\"},\"dsr\":[94,101,2,2]}&#39;>bar&lt;/a>"}'><a href="./File:%25+abc9" data-parsoid='{"a":{"href":"./File:%25+abc9"},"sa":{}}'><img resource="./File:%25+abc9" src="./Special:FilePath/%25+abc9" height="220" width="220" data-parsoid='{"a":{"resource":"./File:%25+abc9","height":"220","width":"220"},"sa":{"resource":"File:%+abc%39"}}'/></a></span>
+<a rel="mw:WikiLink" href="./%25" title="%" data-parsoid='{"stx":"simple","a":{"href":"./%25"},"sa":{"href":"%"}}'>%</a> <a rel="mw:WikiLink" href="./+" title="+" data-parsoid='{"stx":"simple","a":{"href":"./+"},"sa":{"href":"+"}}'>+</a> <figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-parsoid='{"optList":[{"ck":"bogus","ak":"foo"},{"ck":"caption","ak":"[[bar]]"}]}' data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"&lt;a rel=\"mw:WikiLink\" href=\"./Bar\" title=\"Bar\" data-parsoid=&#39;{\"stx\":\"simple\",\"a\":{\"href\":\"./Bar\"},\"sa\":{\"href\":\"bar\"},\"dsr\":[94,101,2,2]}&#39;>bar&lt;/a>"}'><a href="./File:%25+abc9" data-parsoid='{"a":{"href":"./File:%25+abc9"},"sa":{}}'><img resource="./File:%25+abc9" src="./Special:FilePath/%25+abc9" height="220" width="220" data-parsoid='{"a":{"resource":"./File:%25+abc9","height":"220","width":"220"},"sa":{"resource":"File:%+abc%39"}}'/></a></figure-inline>
 <a rel="mw:WikiLink" href="./3E" title="3E" data-parsoid='{"stx":"simple","a":{"href":"./3E"},"sa":{"href":"%33%45"}}'>3E</a> <a rel="mw:WikiLink" href="./3E+" title="3E+" data-parsoid='{"stx":"simple","a":{"href":"./3E+"},"sa":{"href":"%33%45+"}}'>3E+</a></p>
 !! end
 
@@ -23831,8 +24104,8 @@ Special characters in embedded file links (T29679)
 <a href="/index.php?title=Special:Upload&amp;wpDestFile=Does_not_exist.jpg" class="new" title="File:Does not exist.jpg">Title with &amp; ampersand</a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Contains_&amp;_ampersand.jpg"><img resource="./File:Contains_&amp;_ampersand.jpg" src="./Special:FilePath/Contains_&amp;_ampersand.jpg" height="220" width="220"/></a></span>
-<span class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"Title with &amp;amp; ampersand"}'><a href="./File:Does_not_exist.jpg"><img resource="./File:Does_not_exist.jpg" src="./Special:FilePath/Does_not_exist.jpg" height="220" width="220"/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}]}'><a href="./File:Contains_&amp;_ampersand.jpg"><img resource="./File:Contains_&amp;_ampersand.jpg" src="./Special:FilePath/Contains_&amp;_ampersand.jpg" height="220" width="220"/></a></figure-inline>
+<figure-inline class="mw-default-size" typeof="mw:Error mw:Image" data-mw='{"errors":[{"key":"apierror-filedoesnotexist","message":"This image does not exist."}],"caption":"Title with &amp;amp; ampersand"}'><a href="./File:Does_not_exist.jpg"><img resource="./File:Does_not_exist.jpg" src="./Special:FilePath/Does_not_exist.jpg" height="220" width="220"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -23979,7 +24252,7 @@ __TOC__
 
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> <i>Lost</i> episodes </h2>
+<h2 id="Lost_episodes" data-parsoid='{}'> <i>Lost</i> episodes </h2>
 !! end
 
 !! test
@@ -24000,7 +24273,7 @@ __TOC__
 
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> <b>should be bold</b> then normal text </h2>
+<h2 id="should_be_bold_then_normal_text" data-parsoid='{}'> <b>should be bold</b> then normal text </h2>
 !! end
 
 !! test
@@ -24021,7 +24294,7 @@ __TOC__
 
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> Image <span class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></span> </h2>
+<h2 id="Image" data-parsoid='{}'> Image <figure-inline class="mw-default-size" typeof="mw:Image"><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"Image:foobar.jpg"}}'/></a></figure-inline> </h2>
 !! end
 
 !! test
@@ -24058,11 +24331,13 @@ __TOC__
 <p><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: Quote">edit</a><span class="mw-editsection-bracket">]</span></span></p>
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> <blockquote>Quote</blockquote> </h2>
+<h2 id="Quote" data-parsoid='{}'> <blockquote>Quote</blockquote> </h2>
 !! end
 
 !! test
 Unclosed tags in TOC
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
 !! options
 title=[[Main Page]]
 !! wikitext
@@ -24073,17 +24348,17 @@ QED
 !! html/php
 <div id="toc" class="toc"><div class="toctitle"><h2>Contents</h2></div>
 <ul>
-<li class="toclevel-1 tocsection-1"><a href="#Proof:_2_.3C_3"><span class="tocnumber">1</span> <span class="toctext">Proof: 2 &lt; 3</span></a></li>
+<li class="toclevel-1 tocsection-1"><a href="#Proof:_2_&lt;_3"><span class="tocnumber">1</span> <span class="toctext">Proof: 2 &lt; 3</span></a></li>
 </ul>
 </div>
 
-<h2><span class="mw-headline" id="Proof:_2_.3C_3">Proof: 2 &lt; 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: Proof: 2 &lt; 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<h2><span id="Proof:_2_.3C_3"></span><span class="mw-headline" id="Proof:_2_&lt;_3">Proof: 2 &lt; 3</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Main_Page&amp;action=edit&amp;section=1" title="Edit section: Proof: 2 &lt; 3">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
 <p><small>Hanc marginis exiguitas non caperet.</small>
 QED
 </p>
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> Proof: 2 &lt; 3 </h2>
+<h2 id="Proof:_2_&lt;_3" data-parsoid='{}'><span id="Proof:_2_.3C_3" typeof="mw:FallbackId"></span> Proof: 2 &lt; 3 </h2>
 <p><small>Hanc marginis exiguitas non caperet.</small>
 QED</p>
 !! end
@@ -24126,8 +24401,9 @@ __TOC__
 <p><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: Foo Bar">edit</a><span class="mw-editsection-bracket">]</span></span></p>
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> <i data-parsoid='{"stx":"html"}'>Foo</i> <b data-parsoid='{"stx":"html"}'>Bar</b> </h2>
-<h2> <i data-parsoid='{"stx":"html"}'>Foo</i> <blockquote>Bar</blockquote> </h2>
+<h2 id="Foo_Bar" data-parsoid='{}'> <i data-parsoid='{"stx":"html"}'>Foo</i> <b data-parsoid='{"stx":"html"}'>Bar</b> </h2>
+
+<h2 id="Foo_Bar_2" data-parsoid='{}'> <i data-parsoid='{"stx":"html"}'>Foo</i> <blockquote>Bar</blockquote> </h2>
 !! end
 
 # Don't expect Parsoid to roundtrip this until the php parser comes closer to
@@ -24154,9 +24430,9 @@ __TOC__
 
 !! html/parsoid
 <meta property="mw:PageProp/toc" />
-<h2> <sup class="in-h2" data-parsoid='{"stx":"html"}'>Hello</sup> </h2>
+<h2 id="Hello"> <sup class="in-h2" data-parsoid='{"stx":"html"}'>Hello</sup> </h2>
 
-<h2> <sup class="a " data-parsoid='{"stx":"html"}'> b">Evilbye</sup> </h2>
+<h2 id='b">Evilbye'><span id="b.22.3EEvilbye" typeof="mw:FallbackId"></span> <sup class="a " data-parsoid='{"stx":"html"}'> b">Evilbye</sup> </h2>
 !! end
 
 !! test
@@ -24191,11 +24467,11 @@ __TOC__
 
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> <span dir="ltr">C++</span> </h2>
-<h2> <span dir="rtl">זבנג!</span> </h2>
-<h2> <span style="font-style: italic">The attributes on these span tags must be deleted from the TOC</span> </h2>
-<h2> <span style="font-style: italic" dir="ltr">All attributes on these span tags must be deleted from the TOC</span> </h2>
-<h2> <span dir="ltr" style="font-style: italic">Attributes after dir on these span tags must be deleted from the TOC</span> </h2>
+<h2 id="C++" data-parsoid='{}'><span id="C.2B.2B" typeof="mw:FallbackId"></span> <span dir="ltr">C++</span> </h2>
+<h2 id="זבנג!"><span id=".D7.96.D7.91.D7.A0.D7.92.21" typeof="mw:FallbackId"></span> <span dir="rtl">זבנג!</span> </h2>
+<h2 id="The_attributes_on_these_span_tags_must_be_deleted_from_the_TOC"> <span style="font-style: italic">The attributes on these span tags must be deleted from the TOC</span> </h2>
+<h2 id="All_attributes_on_these_span_tags_must_be_deleted_from_the_TOC"> <span style="font-style: italic" dir="ltr">All attributes on these span tags must be deleted from the TOC</span> </h2>
+<h2 id="Attributes_after_dir_on_these_span_tags_must_be_deleted_from_the_TOC"> <span dir="ltr" style="font-style: italic">Attributes after dir on these span tags must be deleted from the TOC</span> </h2>
 !! end
 
 !! test
@@ -24214,7 +24490,7 @@ __TOC__
 
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> <bdi>test</bdi> </h2>
+<h2 id="test" data-parsoid='{}'> <bdi>test</bdi> </h2>
 !! end
 
 !! test
@@ -24233,7 +24509,7 @@ __TOC__
 
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> <s>test</s> test <strike>test</strike> </h2>
+<h2 id="test_test_test" data-parsoid='{}'> <s>test</s> test <strike>test</strike> </h2>
 !! end
 
 # Note that the html output does not have the <p></p>, but the
@@ -24267,7 +24543,7 @@ __TOC__
 <h2><span class="mw-headline" id="x">x</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: x">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
 !! html/parsoid
 <meta property="mw:PageProp/toc" data-parsoid='{}'/>
-<h2 data-parsoid='{}'> x </h2>
+<h2 id="x" data-parsoid='{}'> x </h2>
 !! end
 
 !! article
@@ -24390,9 +24666,11 @@ Strip marker in padright
 Strip marker in anchorencode
 !! wikitext
 {{anchorencode:x<nowiki/>y}}
-!! html
+!! html/php
 <p>xy
 </p>
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-parsoid='{"pi":[[]]}' data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode:x&lt;nowiki/>y","function":"anchorencode"},"params":{},"i":0}}]}'>xy</p>
 !! end
 
 !! test
@@ -24851,16 +25129,59 @@ Headings: 4b. No escaping needed (inside p-tags)
 !! options
 parsoid=html2wt
 !! html/parsoid
-<p>===
-=foo= x
+<p>=foo= x
 =foo= <s></s>
 </p>
 !! wikitext
-===
 =foo= x
 =foo= <s></s>
+!! html/php
+<p>=foo= x
+=foo= <s></s>
+</p>
 !!end
 
+!! test
+Headings: 4c. Short headings (1)
+!! options
+parsoid=html2wt
+!! html/parsoid
+<p>===
+</p>
+!! wikitext
+<nowiki>===</nowiki>
+!! html/php
+<p>===
+</p>
+!! end
+
+# in the html2wt direction we emit '= = =' or '=<nowiki>=</nowiki>='
+!! test
+Headings: 4d. Short headings (2)
+!! options
+parsoid=wt2html,html2html
+!! wikitext
+=
+==
+===
+====
+=====
+!! html/php
+<p>=
+==
+</p>
+<h1><span class="mw-headline" id=".3D">=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: =">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<h1><span class="mw-headline" id=".3D.3D">==</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=2" title="Edit section: ==">edit</a><span class="mw-editsection-bracket">]</span></span></h1>
+<h2><span class="mw-headline" id=".3D_2">=</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=3" title="Edit section: =">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+
+!! html/parsoid
+<p>=
+==</p>
+<h1 id="="><span id=".3D" typeof="mw:FallbackId"></span>=</h1>
+<h1 id="=="><span id=".3D.3D" typeof="mw:FallbackId"></span>==</h1>
+<h2 id="=_2"><span id=".3D_2" typeof="mw:FallbackId"></span>=</h2>
+!! end
+
 !! test
 Headings: 5. Empty headings
 !! options
@@ -27447,7 +27768,7 @@ Image: upright option is ignored on inline and frame images (parsoid)
 !! wikitext
 [[File:Foobar.jpg|500x500px|upright=0.5|caption]]
 !! html/parsoid
-<p><span typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/500px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="57" width="500"/></a></span></p>
+<p><figure-inline typeof="mw:Image" data-mw='{"caption":"caption"}'><a href="./File:Foobar.jpg"><img resource="./File:Foobar.jpg" src="//example.com/images/thumb/3/3a/Foobar.jpg/500px-Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="57" width="500"/></a></figure-inline></p>
 !! end
 
 !! test
@@ -27455,7 +27776,7 @@ Image: in template parameter with empty parameter
 !! wikitext
 {{echo|[[File:Foobar.jpg|link=]]}}
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Transclusion mw:Image" about="#mwt1" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[File:Foobar.jpg|link=]]"}},"i":0}}]}'><span><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></span></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Transclusion mw:Image" about="#mwt1" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"[[File:Foobar.jpg|link=]]"}},"i":0}}]}'><span><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941"/></span></figure-inline></p>
 !! end
 
 !! test
@@ -27508,7 +27829,7 @@ Image: Invalid title as link
 <p><a href="/wiki/File:Foobar.jpg" class="image" title="link=&lt;"><img alt="link=&lt;" src="http://example.com/images/3/3a/Foobar.jpg" width="1941" height="220" /></a>
 </p>
 !! html/parsoid
-<p><span class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"link","ak":"link=&lt;"}]}' data-mw='{"caption":"link=&amp;lt;"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></span></p>
+<p><figure-inline class="mw-default-size" typeof="mw:Image" data-parsoid='{"optList":[{"ck":"link","ak":"link=&lt;"}]}' data-mw='{"caption":"link=&amp;lt;"}'><a href="./File:Foobar.jpg" data-parsoid='{"a":{"href":"./File:Foobar.jpg"},"sa":{}}'><img resource="./File:Foobar.jpg" src="//example.com/images/3/3a/Foobar.jpg" data-file-width="1941" data-file-height="220" data-file-type="bitmap" height="220" width="1941" data-parsoid='{"a":{"resource":"./File:Foobar.jpg","height":"220","width":"1941"},"sa":{"resource":"File:Foobar.jpg"}}'/></a></figure-inline></p>
 !! end
 
 !! test
@@ -28534,6 +28855,14 @@ parsoid=wt2html
 <p><span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"hi"}},"i":0}}]}'>hi</span><a rel="mw:ExtLink" href="http://example.com"></a><a rel="mw:WikiLink" href="./Ho" title="Ho" data-parsoid='{"misnested":true}'>ho</a></p>
 !! end
 
+!! test
+Catch regression when unpacking with trailing content
+!! wikitext
+{{echo|Foo <references/> bar}}
+!! html/parsoid
+<p about="#mwt2" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"Foo &lt;references/> bar"}},"i":0}}]}'>Foo </p><ol class="mw-references references" typeof="mw:Extension/references" about="#mwt2" data-mw='{"name":"references","attrs":{}}'></ol><p about="#mwt2"> bar</p>
+!! end
+
 !! test
 Use data-parsoid.firstWikitextNode to compute newline constraints for template content
 !! options
@@ -29535,6 +29864,24 @@ wgRawHtml=1
 
 !! test
 Decoding of HTML entities in headings and links for IDs and link fragments (T103714)
+!! config
+wgFragmentMode=[ 'html5', 'legacy' ]
+!! wikitext
+== A&B&amp;C&amp;amp;D&amp;amp;amp;E ==
+[[#A&B&amp;C&amp;amp;D&amp;amp;amp;E]]
+!! html/php
+<h2><span id="A.26B.26C.26amp.3BD.26amp.3Bamp.3BE"></span><span class="mw-headline" id="A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E">A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E</span><span class="mw-editsection"><span class="mw-editsection-bracket">[</span><a href="/index.php?title=Parser_test&amp;action=edit&amp;section=1" title="Edit section: A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E">edit</a><span class="mw-editsection-bracket">]</span></span></h2>
+<p><a href="#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E">#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E</a>
+</p>
+!! html/parsoid
+<h2 id="A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E"><span id="A.26B.26C.26amp.3BD.26amp.3Bamp.3BE" typeof="mw:FallbackId" data-parsoid="{}"></span> A&amp;B<span typeof="mw:Entity" data-parsoid='{"src":"&amp;amp;","srcContent":"&amp;"}'>&amp;</span>C<span typeof="mw:Entity" data-parsoid='{"src":"&amp;amp;","srcContent":"&amp;"}'>&amp;</span>amp;D<span typeof="mw:Entity" data-parsoid='{"src":"&amp;amp;","srcContent":"&amp;"}'>&amp;</span>amp;amp;E </h2>
+<p><a rel="mw:WikiLink" href="./Main_Page#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E"},"sa":{"href":"#A&amp;B&amp;amp;C&amp;amp;amp;D&amp;amp;amp;amp;E"}}'>#A&amp;B&amp;C&amp;amp;D&amp;amp;amp;E</a></p>
+!! end
+
+!! test
+Decoding of HTML entities in headings and links for IDs and link fragments (T103714) (legacy)
+!! config
+wgFragmentMode=[ 'legacy' ]
 !! wikitext
 == A&B&amp;C&amp;amp;D&amp;amp;amp;E ==
 [[#A&B&amp;C&amp;amp;D&amp;amp;amp;E]]
@@ -29544,17 +29891,46 @@ Decoding of HTML entities in headings and links for IDs and link fragments (T103
 </p>
 !! end
 
+!! test
+Decoding of HTML entities in embedded HTML tags
+!! wikitext
+<table class="1&2&amp;3&amp;amp;4&amp;amp;amp;5"><tr><td>x</td></tr></table>
+!! html/php
+<table class="1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5"><tr><td>x</td></tr></table>
+
+!! html/parsoid
+<table class="1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5" data-parsoid='{"stx":"html","a":{"class":"1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5"},"sa":{"class":"1&amp;2&amp;amp;3&amp;amp;amp;4&amp;amp;amp;amp;5"}}'><tbody><tr data-parsoid='{"stx":"html"}'><td data-parsoid='{"stx":"html"}'>x</td></tr></tbody></table>
+!! end
+
 !! test
 Decoding of HTML entities in indicator names for IDs (T104196)
 !! options
+parsoid=wt2html,html2html
 showindicators
 !! wikitext
 <indicator name="1&2&amp;3&amp;amp;4&amp;amp;amp;5">Indicator</indicator>
 !! html/php
 1&2&3&amp;4&amp;amp;5=Indicator
 
+!! html/parsoid
+<p><span typeof="mw:Extension/indicator" about="#mwt3" data-mw='{"name":"indicator","attrs":{"name":"1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5"},"body":{"extsrc":"Indicator"}}'></span></p>
+!! end
+
+# this version of the test strips out the ambiguity so Parsoid rts cleanly
+!! test
+Decoding of HTML entities in indicator names for IDs (unambiguous) (T104196)
+!! options
+showindicators
+!! wikitext
+<indicator name="1&2&3&amp;amp;4&amp;amp;amp;5">Indicator</indicator>
+!! html/php
+1&2&3&amp;4&amp;amp;5=Indicator
+
+!! html/parsoid
+<p><span typeof="mw:Extension/indicator" about="#mwt3" data-mw='{"name":"indicator","attrs":{"name":"1&amp;2&amp;3&amp;amp;4&amp;amp;amp;5"},"body":{"extsrc":"Indicator"}}'></span></p>
 !! end
 
+# This fragment mode is what Parsoid supports.
 !! test
 HTML5 ids: fallback to legacy
 !! config
@@ -29600,8 +29976,27 @@ wgFragmentMode=[ 'html5', 'legacy' ]
 </p><p>💩 <span id="💩"></span>
 </p><p><a href="#啤酒">#啤酒</a> <a href="#啤酒">#啤酒</a>
 </p>
+!! html/parsoid
+<h2 id="Foo_bar"> Foo bar </h2>
+
+<h2 id="foo_Bar_2"> foo Bar </h2>
+
+<h2 id="Тест"><span id=".D0.A2.D0.B5.D1.81.D1.82" typeof="mw:FallbackId"></span> Тест </h2>
+
+<h2 id="Тест_2"><span id=".D0.A2.D0.B5.D1.81.D1.82_2" typeof="mw:FallbackId"></span> Тест </h2>
+
+<h2 id="тест"><span id=".D1.82.D0.B5.D1.81.D1.82" typeof="mw:FallbackId"></span> тест </h2>
+
+<h2 id="Hey_&lt;_#_&quot;_>_%_:_'"><span id="Hey_.3C_.23_.22_.3E_.25_:_.27" typeof="mw:FallbackId"></span> Hey &lt; # " > %<span typeof="mw:DisplaySpace mw:Placeholder" data-parsoid='{"src":" ","isDisplayHack":true}'> </span>: ' </h2>
+<p><a rel="mw:WikiLink" href="./Main_Page#Foo_bar">#Foo bar</a> <a rel="mw:WikiLink" href="./Main_Page#foo_Bar">#foo Bar</a> <a rel="mw:WikiLink" href="./Main_Page#Тест">#Тест</a> <a rel="mw:WikiLink" href="./Main_Page#тест">#тест</a> <a rel="mw:WikiLink" href="./Main_Page#Hey_&lt;_#_&quot;_>_%_:_'" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#Hey_&lt;_#_\"_>_%_:_&#39;"},"sa":{"href":"#Hey &lt; # \" > % : &#39;"}}'>#Hey &lt; # " > % : '</a></p>
+
+<p><span about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":[{"template":{"target":{"wt":"anchorencode:💩","function":"anchorencode"},"params":{},"i":0}}]}'>💩</span> <span id="💩" about="#mwt3" typeof="mw:ExpandedAttrs" data-mw='{"attribs":[[{"txt":"id"},{"html":"&lt;span about=\"#mwt2\" typeof=\"mw:Transclusion\" data-parsoid=&#39;{\"pi\":[[]],\"dsr\":[190,209,null,null]}&#39; data-mw=&#39;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"anchorencode:💩\",\"function\":\"anchorencode\"},\"params\":{},\"i\":0}}]}&#39;>💩&lt;/span>"}]]}'></span></p>
+
+<!-- These two links should produce identical HTML -->
+<p><a rel="mw:WikiLink" href="./Main_Page#啤酒">#啤酒</a> <a rel="mw:WikiLink" href="./Main_Page#啤酒" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#啤酒"},"sa":{"href":"#%E5%95%A4%E9%85%92"}}'>#啤酒</a></p>
 !! end
 
+# Parsoid doesn't support this mode
 !! test
 HTML5 ids: legacy with a fallback to modern
 !! config
@@ -29649,6 +30044,7 @@ wgFragmentMode=[ 'legacy', 'html5' ]
 </p>
 !! end
 
+# Parsoid doesn't support this mode.
 !! test
 HTML5 ids: no legacy
 !! config
@@ -29720,6 +30116,9 @@ T51672: Test for brackets in attributes of elements in external link texts
 <p><a rel="nofollow" class="external text" href="http://example.com/">link <span title="title with &#91;brackets&#93;">span</span></a>
 <a rel="nofollow" class="external text" href="http://example.com/">link <span title="title with &#91;brackets&#93;">span</span></a>
 </p>
+!! html/parsoid
+<p><a rel="mw:ExtLink" href="http://example.com/">link <span title="title with [brackets]">span</span></a>
+<a rel="mw:ExtLink" href="http://example.com/">link <span title="title with [brackets]" data-parsoid='{"stx":"html","a":{"title":"title with [brackets]"},"sa":{"title":"title with &amp;#91;brackets&amp;#93;"}}'>span</span></a></p>
 !! end
 
 !! test
@@ -29732,6 +30131,9 @@ T72875: Test for brackets in attributes of elements in internal link texts
 <p><a href="/wiki/Foo" title="Foo">link <span title="title with &#91;&#91;double brackets&#93;&#93;">span</span></a>
 <a href="/wiki/Foo" title="Foo">link <span title="title with &#91;&#91;double brackets&#93;&#93;">span</span></a>
 </p>
+!! html/parsoid
+<p><a rel="mw:WikiLink" href="./Foo" title="Foo">link <span title="title with [[double brackets]]">span</span></a>
+<a rel="mw:WikiLink" href="./Foo" title="Foo">link <span title="title with [[double brackets]]" data-parsoid='{"stx":"html","a":{"title":"title with [[double brackets]]"},"sa":{"title":"title with &amp;#91;&amp;#91;double brackets&amp;#93;&amp;#93;"}}'>span</span></a></p>
 !! end
 
 !! test
@@ -29743,6 +30145,8 @@ wgFragmentMode=[ 'html5' ]
 !! html/php
 <p><span id="&#91;foo&#93;"></span><a href="#[foo]">#&#91;foo&#93;</a>
 </p>
+!! html/parsoid
+<p><span id="[foo]" about="#mwt3" typeof="mw:ExpandedAttrs" data-parsoid='{"stx":"html","a":{"id":"[foo]"},"sa":{"id":"{{anchorencode:[foo]}}"}}' data-mw='{"attribs":[[{"txt":"id"},{"html":"&lt;span typeof=\"mw:Transclusion mw:Entity\" about=\"#mwt1\" data-parsoid=&apos;{\"srcContent\":\"[\",\"dsr\":[10,32,null,null],\"pi\":[[]]}&apos; data-mw=&apos;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"anchorencode:[foo]\",\"function\":\"anchorencode\"},\"params\":{},\"i\":0}}]}&apos;>[&lt;/span>&lt;span about=\"#mwt1\" data-parsoid=\"{}\">foo&lt;/span>&lt;span typeof=\"mw:Entity\" about=\"#mwt1\" data-parsoid=&apos;{\"src\":\"&amp;amp;#x5D;\",\"srcContent\":\"]\"}&apos;>]&lt;/span>"}]]}'></span><a typeof="mw:ExpandedAttrs" about="#mwt4" rel="mw:WikiLink" href="./Main_Page#[foo]" data-parsoid='{"stx":"simple","a":{"href":"./Main_Page#[foo]"},"sa":{"href":"#{{anchorencode:[foo]}}"}}' data-mw='{"attribs":[[{"txt":"href"},{"html":"#&lt;span typeof=\"mw:Transclusion mw:Entity\" about=\"#mwt2\" data-parsoid=&apos;{\"srcContent\":\"[\",\"dsr\":[44,66,null,null],\"pi\":[[]]}&apos; data-mw=&apos;{\"parts\":[{\"template\":{\"target\":{\"wt\":\"anchorencode:[foo]\",\"function\":\"anchorencode\"},\"params\":{},\"i\":0}}]}&apos;>[&lt;/span>&lt;span about=\"#mwt2\" data-parsoid=\"{}\">foo&lt;/span>&lt;span typeof=\"mw:Entity\" about=\"#mwt2\" data-parsoid=&apos;{\"src\":\"&amp;amp;#x5D;\",\"srcContent\":\"]\"}&apos;>]&lt;/span>"}]]}'>#[foo]</a></p>
 !! end
 
 ## ------------------------------
 = 3 =
 f
 !! html/parsoid
-<section data-mw-section-id="1"><h1 id="1"> 1 </h1>
+<section data-mw-section-id="0"></section><section data-mw-section-id="1"><h1 id="1"> 1 </h1>
 <p>a</p>
 
 </section><section data-mw-section-id="2"><h1 id="2"> 2 </h1>
 = 2 =
 d
 !! html/parsoid
-<section data-mw-section-id="1"><h1 id="1"> 1 </h1>
+<section data-mw-section-id="0"></section><section data-mw-section-id="1"><h1 id="1"> 1 </h1>
 <p>a</p>
 
 <section data-mw-section-id="-1"><h2 about="#mwt1" typeof="mw:Transclusion" id="1.1" data-parsoid='{"dsr":[9,33,null,null],"pi":[[{"k":"1","named":true,"spc":["","","\n","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"== 1.1 ==\nb"}},"i":0}}]}'> 1.1 </h2><span about="#mwt1">
 = 2 =
 e
 !! html/parsoid
-<section data-mw-section-id="1"><h1 id="1"> 1 </h1>
+<section data-mw-section-id="0"></section><section data-mw-section-id="1"><h1 id="1"> 1 </h1>
 <p>a</p>
 
 <section data-mw-section-id="-1"><h2 about="#mwt1" typeof="mw:Transclusion" id="1.1" data-parsoid='{"dsr":[9,50,null,null],"pi":[[{"k":"1","named":true,"spc":["","","\n","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"== 1.1 ==\nb\n=== 1.1.1 ===\nd"}},"i":0}},"\n"]}'> 1.1 </h2><span about="#mwt1">
 = 2 =
 e
 !! html/parsoid
-<section data-mw-section-id="1" data-parsoid="{}"><h1 id="1"> 1 </h1>
+<section data-mw-section-id="0"></section><section data-mw-section-id="1" data-parsoid="{}"><h1 id="1"> 1 </h1>
 <p>a</p>
 
 <p about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"dsr":[9,60,0,0],"pi":[[{"k":"1","named":true,"spc":["","","\n","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"x\n== 1.1 ==\nb\n==1.2==\nc\n===1.2.1===\nd"}},"i":0}},"\n"]}'>x</p><span about="#mwt1">
 # Because of section-wrapping and template-wrapping interactions,
 # the scope of the template is expanded so that the template markup
 # is valid in the presence of <section> tags.
+# This exercises the s1 is null scenario in the wrapSections code
 !! test
 Section wrapping with template-generated sections (bad nesting 1)
 !! options
@@ -29949,6 +30354,40 @@ parsoid={
   "wrapSections": true
 }
 !! wikitext
+<div>
+a
+
+{{echo|
+= 1 =
+b
+}}
+
+c
+</div>
+!! html/parsoid
+<section data-mw-section-id="-1"></section><section data-mw-section-id="-2"><div data-parsoid='{"stx":"html"}'>
+<p>a</p>
+
+<span about="#mwt1" typeof="mw:Transclusion" data-parsoid='{"pi":[[{"k":"1"}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"\n= 1 =\nb\n"}},"i":0}},"\n\nc\n"]}'>
+</span><section data-mw-section-id="-1" about="#mwt1"><h1 about="#mwt1" id="1"> 1 </h1><span about="#mwt1">
+</span><p about="#mwt1">b
+</p><span about="#mwt1">
+
+</span><p about="#mwt1">c</p><span about="#mwt1">
+</span></section></div></section>
+!! end
+
+# Because of section-wrapping and template-wrapping interactions,
+# the scope of the template is expanded so that the template markup
+# is valid in the presence of <section> tags.
+# This exercises the s1 is ancestor of s2 scenario in the wrapSections code
+!! test
+Section wrapping with template-generated sections (bad nesting 2)
+!! options
+parsoid={
+  "wrapSections": true
+}
+!! wikitext
 = 1 =
 a
 
 = 3 =
 e
 !! html/parsoid
-<section data-mw-section-id="1"><h1 id="1"> 1 </h1>
+<section data-mw-section-id="0"></section><section data-mw-section-id="1"><h1 id="1"> 1 </h1>
 <p>a</p>
 
 </section><section data-mw-section-id="-1"><h1 about="#mwt1" typeof="mw:Transclusion" id="2" data-parsoid='{"dsr":[9,45,null,null],"pi":[[{"k":"1","named":true,"spc":["","","\n","\n"]}]]}' data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"= 2 =\nb\n== 2.1 ==\nc"}},"i":0}},"\n\nd\n\n"]}'> 2 </h1><span about="#mwt1">
 # so that template wrapping semantics are valid whether section
 # tags are retained or stripped. But, the template scope can expand
 # greatly when accounting for section tags.
+# This exercises the s1 and s2 are in different subtrees scenario
 !! test
-Section wrapping with template-generated sections (bad nesting 2)
+Section wrapping with template-generated sections (bad nesting 3)
 !! options
 parsoid={
   "wrapSections": true,
 = 3 =
 e
 !! html/parsoid
-<section data-mw-section-id="1" about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":["= 1 =\na\n\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"== 1.2 ==\nb\n= 2 =\nc"}},"i":0}},"\n\nd\n\n"]}'><h1 id="1"> 1 </h1>
+<section data-mw-section-id="0"></section><section data-mw-section-id="1" about="#mwt1" typeof="mw:Transclusion" data-mw='{"parts":["= 1 =\na\n\n",{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"== 1.2 ==\nb\n= 2 =\nc"}},"i":0}},"\n\nd\n\n"]}'><h1 id="1"> 1 </h1>
 <p>a</p>
 
 <section data-mw-section-id="-1"><h2 about="#mwt1" typeof="mw:Transclusion" id="1.2" data-mw='{"parts":[{"template":{"target":{"wt":"echo","href":"./Template:Echo"},"params":{"1":{"wt":"== 1.2 ==\nb\n= 2 =\nc"}},"i":0}}]}'> 1.2 </h2><span about="#mwt1">
@@ -30144,3 +30584,27 @@ foo
 
 </section><section data-mw-section-id="2"><h1 id="d"> d </h1></section>
 !! end
+
+!! test
+Lead section containing only whitespace and comments.
+!! options
+parsoid={
+  "wrapSections": true
+}
+!! wikitext
+
+<!-- this is a comment, presumably significant to editors -->
+= 1 =
+a
+
+= 2 =
+b
+!! html/parsoid
+<section data-mw-section-id="0" data-parsoid="{}">
+<!-- this is a comment, presumably significant to editors -->
+</section><section data-mw-section-id="1"><h1 id="1"> 1 </h1>
+<p>a</p>
+
+</section><section data-mw-section-id="2"><h1 id="2"> 2 </h1>
+<p>b</p></section>
+!! end
diff --git a/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php b/tests/phpunit/includes/Storage/MutableRevisionRecordTest.php
new file mode 100644 (file)
index 0000000..79cac5e
--- /dev/null
@@ -0,0 +1,120 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SlotRecord;
+use MediaWikiTestCase;
+use Title;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Storage\MutableRevisionRecord
+ */
+class MutableRevisionRecordTest extends MediaWikiTestCase {
+
+       public function testSimpleSetGetId() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertNull( $record->getId() );
+               $record->setId( 888 );
+               $this->assertSame( 888, $record->getId() );
+       }
+
+       public function testSimpleSetGetUser() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $user = $this->getTestSysop()->getUser();
+               $this->assertNull( $record->getUser() );
+               $record->setUser( $user );
+               $this->assertSame( $user, $record->getUser() );
+       }
+
+       public function testSimpleSetGetPageId() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertSame( 0, $record->getPageId() );
+               $record->setPageId( 999 );
+               $this->assertSame( 999, $record->getPageId() );
+       }
+
+       public function testSimpleSetGetParentId() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertNull( $record->getParentId() );
+               $record->setParentId( 100 );
+               $this->assertSame( 100, $record->getParentId() );
+       }
+
+       public function testSimpleGetMainContentWhenEmpty() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->setExpectedException( RevisionAccessException::class );
+               $this->assertNull( $record->getContent( 'main' ) );
+       }
+
+       public function testSimpleSetGetMainContent() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $content = new WikitextContent( 'Badger' );
+               $record->setContent( 'main', $content );
+               $this->assertSame( $content, $record->getContent( 'main' ) );
+       }
+
+       public function testSimpleGetSlotWhenEmpty() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->setExpectedException( RevisionAccessException::class );
+               $record->getSlot( 'main' );
+       }
+
+       public function testSimpleSetGetSlot() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $slot = new SlotRecord(
+                       (object)[ 'role_name' => 'main' ],
+                       new WikitextContent( 'x' )
+               );
+               $record->setSlot( $slot );
+               $this->assertSame( $slot, $record->getSlot( 'main' ) );
+       }
+
+       public function testSimpleSetGetMinor() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertFalse( $record->isMinor() );
+               $record->setMinorEdit( true );
+               $this->assertSame( true, $record->isMinor() );
+       }
+
+       public function testSimpleSetGetTimestamp() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertNull( $record->getTimestamp() );
+               $record->setTimestamp( '20180101010101' );
+               $this->assertSame( '20180101010101', $record->getTimestamp() );
+       }
+
+       public function testSimpleSetGetVisibility() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertSame( 0, $record->getVisibility() );
+               $record->setVisibility( RevisionRecord::DELETED_USER );
+               $this->assertSame( RevisionRecord::DELETED_USER, $record->getVisibility() );
+       }
+
+       public function testSimpleSetGetSha1() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertSame( 'phoiac9h4m842xq45sp7s6u21eteeq1', $record->getSha1() );
+               $record->setSha1( 'someHash' );
+               $this->assertSame( 'someHash', $record->getSha1() );
+       }
+
+       public function testSimpleSetGetSize() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $this->assertSame( 0, $record->getSize() );
+               $record->setSize( 775 );
+               $this->assertSame( 775, $record->getSize() );
+       }
+
+       public function testSimpleSetGetComment() {
+               $record = new MutableRevisionRecord( Title::newFromText( 'Foo' ) );
+               $comment = new CommentStoreComment( 1, 'foo' );
+               $this->assertNull( $record->getComment() );
+               $record->setComment( $comment );
+               $this->assertSame( $comment, $record->getComment() );
+       }
+
+}
diff --git a/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php b/tests/phpunit/includes/Storage/MutableRevisionSlotsTest.php
new file mode 100644 (file)
index 0000000..c2a275f
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use MediaWiki\Storage\MutableRevisionSlots;
+use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\SlotRecord;
+use MediaWikiTestCase;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Storage\MutableRevisionSlots
+ */
+class MutableRevisionSlotsTest extends MediaWikiTestCase {
+
+       public function testSetMultipleSlots() {
+               $slots = new MutableRevisionSlots();
+
+               $this->assertSame( [], $slots->getSlots() );
+
+               $slotA = SlotRecord::newUnsaved( 'some', new WikitextContent( 'A' ) );
+               $slots->setSlot( $slotA );
+               $this->assertSame( $slotA, $slots->getSlot( 'some' ) );
+               $this->assertSame( [ 'some' => $slotA ], $slots->getSlots() );
+
+               $slotB = SlotRecord::newUnsaved( 'other', new WikitextContent( 'B' ) );
+               $slots->setSlot( $slotB );
+               $this->assertSame( $slotB, $slots->getSlot( 'other' ) );
+               $this->assertSame( [ 'some' => $slotA, 'other' => $slotB ], $slots->getSlots() );
+       }
+
+       public function testSetExistingSlotOverwritesSlot() {
+               $slots = new MutableRevisionSlots();
+
+               $this->assertSame( [], $slots->getSlots() );
+
+               $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+               $slots->setSlot( $slotA );
+               $this->assertSame( $slotA, $slots->getSlot( 'main' ) );
+               $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
+
+               $slotB = SlotRecord::newUnsaved( 'main', new WikitextContent( 'B' ) );
+               $slots->setSlot( $slotB );
+               $this->assertSame( $slotB, $slots->getSlot( 'main' ) );
+               $this->assertSame( [ 'main' => $slotB ], $slots->getSlots() );
+       }
+
+       public function testSetContentOfExistingSlotOverwritesContent() {
+               $slots = new MutableRevisionSlots();
+
+               $this->assertSame( [], $slots->getSlots() );
+
+               $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+               $slots->setSlot( $slotA );
+               $this->assertSame( $slotA, $slots->getSlot( 'main' ) );
+               $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
+
+               $newContent = new WikitextContent( 'B' );
+               $slots->setContent( 'main', $newContent );
+               $this->assertSame( $newContent, $slots->getContent( 'main' ) );
+       }
+
+       public function testRemoveExistingSlot() {
+               $slotA = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+               $slots = new MutableRevisionSlots( [ $slotA ] );
+
+               $this->assertSame( [ 'main' => $slotA ], $slots->getSlots() );
+
+               $slots->removeSlot( 'main' );
+               $this->assertSame( [], $slots->getSlots() );
+               $this->setExpectedException( RevisionAccessException::class );
+               $slots->getSlot( 'main' );
+       }
+
+}
diff --git a/tests/phpunit/includes/Storage/RevisionRecordTest.php b/tests/phpunit/includes/Storage/RevisionRecordTest.php
new file mode 100644 (file)
index 0000000..ea5f209
--- /dev/null
@@ -0,0 +1,15 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use MediaWikiTestCase;
+
+/**
+ * @covers \MediaWiki\Storage\RevisionRecord
+ */
+class RevisionRecordTest extends MediaWikiTestCase {
+
+       public function testUserCanBitfield() {
+       }
+
+}
diff --git a/tests/phpunit/includes/Storage/RevisionSlotsTest.php b/tests/phpunit/includes/Storage/RevisionSlotsTest.php
new file mode 100644 (file)
index 0000000..288bf47
--- /dev/null
@@ -0,0 +1,117 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use MediaWiki\Storage\RevisionAccessException;
+use MediaWiki\Storage\RevisionSlots;
+use MediaWiki\Storage\SlotRecord;
+use MediaWikiTestCase;
+use WikitextContent;
+
+class RevisionSlotsTest extends MediaWikiTestCase {
+
+       /**
+        * @covers RevisionSlots::getSlot
+        */
+       public function testGetSlot() {
+               $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+               $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+               $slots = new RevisionSlots( [ $mainSlot, $auxSlot ] );
+
+               $this->assertSame( $mainSlot, $slots->getSlot( 'main' ) );
+               $this->assertSame( $auxSlot, $slots->getSlot( 'aux' ) );
+               $this->setExpectedException( RevisionAccessException::class );
+               $slots->getSlot( 'nothere' );
+       }
+
+       /**
+        * @covers RevisionSlots::getContent
+        */
+       public function testGetContent() {
+               $mainContent = new WikitextContent( 'A' );
+               $auxContent = new WikitextContent( 'B' );
+               $mainSlot = SlotRecord::newUnsaved( 'main', $mainContent );
+               $auxSlot = SlotRecord::newUnsaved( 'aux', $auxContent );
+               $slots = new RevisionSlots( [ $mainSlot, $auxSlot ] );
+
+               $this->assertSame( $mainContent, $slots->getContent( 'main' ) );
+               $this->assertSame( $auxContent, $slots->getContent( 'aux' ) );
+               $this->setExpectedException( RevisionAccessException::class );
+               $slots->getContent( 'nothere' );
+       }
+
+       /**
+        * @covers RevisionSlots::getSlotRoles
+        */
+       public function testGetSlotRoles_someSlots() {
+               $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+               $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+               $slots = new RevisionSlots( [ $mainSlot, $auxSlot ] );
+
+               $this->assertSame( [ 'main', 'aux' ], $slots->getSlotRoles() );
+       }
+
+       /**
+        * @covers RevisionSlots::getSlotRoles
+        */
+       public function testGetSlotRoles_noSlots() {
+               $slots = new RevisionSlots( [] );
+
+               $this->assertSame( [], $slots->getSlotRoles() );
+       }
+
+       /**
+        * @covers RevisionSlots::getSlots
+        */
+       public function testGetSlots() {
+               $mainSlot = SlotRecord::newUnsaved( 'main', new WikitextContent( 'A' ) );
+               $auxSlot = SlotRecord::newUnsaved( 'aux', new WikitextContent( 'B' ) );
+               $slotsArray = [ $mainSlot, $auxSlot ];
+               $slots = new RevisionSlots( $slotsArray );
+
+               $this->assertEquals( [ 'main' => $mainSlot, 'aux' => $auxSlot ], $slots->getSlots() );
+       }
+
+       public function provideComputeSize() {
+               yield [ 1, [ 'A' ] ];
+               yield [ 2, [ 'AA' ] ];
+               yield [ 4, [ 'AA', 'X', 'H' ] ];
+       }
+
+       /**
+        * @dataProvider provideComputeSize
+        * @covers RevisionSlots::computeSize
+        */
+       public function testComputeSize( $expected, $contentStrings ) {
+               $slotsArray = [];
+               foreach ( $contentStrings as $key => $contentString ) {
+                       $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) );
+               }
+               $slots = new RevisionSlots( $slotsArray );
+
+               $this->assertSame( $expected, $slots->computeSize() );
+       }
+
+       public function provideComputeSha1() {
+               yield [ 'ctqm7794fr2dp1taki8a88ovwnvmnmj', [ 'A' ] ];
+               yield [ 'eyq8wiwlcofnaiy4eid97gyfy60uw51', [ 'AA' ] ];
+               yield [ 'lavctqfpxartyjr31f853drgfl4kj1g', [ 'AA', 'X', 'H' ] ];
+       }
+
+       /**
+        * @dataProvider provideComputeSha1
+        * @covers RevisionSlots::computeSha1
+        * @note this test is a bit brittle as the hashes are hardcoded, perhaps just check that strings
+        *       are returned and different Slots objects return different strings?
+        */
+       public function testComputeSha1( $expected, $contentStrings ) {
+               $slotsArray = [];
+               foreach ( $contentStrings as $key => $contentString ) {
+                       $slotsArray[] = SlotRecord::newUnsaved( strval( $key ), new WikitextContent( $contentString ) );
+               }
+               $slots = new RevisionSlots( $slotsArray );
+
+               $this->assertSame( $expected, $slots->computeSha1() );
+       }
+
+}
diff --git a/tests/phpunit/includes/Storage/RevisionStoreDbTest.php b/tests/phpunit/includes/Storage/RevisionStoreDbTest.php
new file mode 100644 (file)
index 0000000..69a50e2
--- /dev/null
@@ -0,0 +1,991 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use Exception;
+use InvalidArgumentException;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\IncompleteRevisionException;
+use MediaWiki\Storage\MutableRevisionRecord;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\SlotRecord;
+use MediaWikiTestCase;
+use Revision;
+use TestUserRegistry;
+use Title;
+use WikiPage;
+use WikitextContent;
+
+/**
+ * @group Database
+ */
+class RevisionStoreDbTest extends MediaWikiTestCase {
+
+       private function assertLinkTargetsEqual( LinkTarget $l1, LinkTarget $l2 ) {
+               $this->assertEquals( $l1->getDBkey(), $l2->getDBkey() );
+               $this->assertEquals( $l1->getNamespace(), $l2->getNamespace() );
+               $this->assertEquals( $l1->getFragment(), $l2->getFragment() );
+               $this->assertEquals( $l1->getInterwiki(), $l2->getInterwiki() );
+       }
+
+       private function assertRevisionRecordsEqual( RevisionRecord $r1, RevisionRecord $r2 ) {
+               $this->assertEquals( $r1->getUser()->getName(), $r2->getUser()->getName() );
+               $this->assertEquals( $r1->getUser()->getId(), $r2->getUser()->getId() );
+               $this->assertEquals( $r1->getComment(), $r2->getComment() );
+               $this->assertEquals( $r1->getPageAsLinkTarget(), $r2->getPageAsLinkTarget() );
+               $this->assertEquals( $r1->getTimestamp(), $r2->getTimestamp() );
+               $this->assertEquals( $r1->getVisibility(), $r2->getVisibility() );
+               $this->assertEquals( $r1->getSha1(), $r2->getSha1() );
+               $this->assertEquals( $r1->getParentId(), $r2->getParentId() );
+               $this->assertEquals( $r1->getSize(), $r2->getSize() );
+               $this->assertEquals( $r1->getPageId(), $r2->getPageId() );
+               $this->assertEquals( $r1->getSlotRoles(), $r2->getSlotRoles() );
+               $this->assertEquals( $r1->getWikiId(), $r2->getWikiId() );
+               $this->assertEquals( $r1->isMinor(), $r2->isMinor() );
+               foreach ( $r1->getSlotRoles() as $role ) {
+                       $this->assertEquals( $r1->getSlot( $role ), $r2->getSlot( $role ) );
+                       $this->assertEquals( $r1->getContent( $role ), $r2->getContent( $role ) );
+               }
+               foreach ( [
+                       RevisionRecord::DELETED_TEXT,
+                       RevisionRecord::DELETED_COMMENT,
+                       RevisionRecord::DELETED_USER,
+                       RevisionRecord::DELETED_RESTRICTED,
+               ] as $field ) {
+                       $this->assertEquals( $r1->isDeleted( $field ), $r2->isDeleted( $field ) );
+               }
+       }
+
+       /**
+        * @param mixed[] $details
+        *
+        * @return RevisionRecord
+        */
+       private function getRevisionRecordFromDetailsArray( $title, $details = [] ) {
+               // Convert some values that can't be provided by dataProviders
+               $page = WikiPage::factory( $title );
+               if ( isset( $details['user'] ) && $details['user'] === true ) {
+                       $details['user'] = $this->getTestUser()->getUser();
+               }
+               if ( isset( $details['page'] ) && $details['page'] === true ) {
+                       $details['page'] = $page->getId();
+               }
+               if ( isset( $details['parent'] ) && $details['parent'] === true ) {
+                       $details['parent'] = $page->getLatest();
+               }
+
+               // Create the RevisionRecord with any available data
+               $rev = new MutableRevisionRecord( $title );
+               isset( $details['slot'] ) ? $rev->setSlot( $details['slot'] ) : null;
+               isset( $details['parent'] ) ? $rev->setParentId( $details['parent'] ) : null;
+               isset( $details['page'] ) ? $rev->setPageId( $details['page'] ) : null;
+               isset( $details['size'] ) ? $rev->setSize( $details['size'] ) : null;
+               isset( $details['sha1'] ) ? $rev->setSha1( $details['sha1'] ) : null;
+               isset( $details['comment'] ) ? $rev->setComment( $details['comment'] ) : null;
+               isset( $details['timestamp'] ) ? $rev->setTimestamp( $details['timestamp'] ) : null;
+               isset( $details['minor'] ) ? $rev->setMinorEdit( $details['minor'] ) : null;
+               isset( $details['user'] ) ? $rev->setUser( $details['user'] ) : null;
+               isset( $details['visibility'] ) ? $rev->setVisibility( $details['visibility'] ) : null;
+               isset( $details['id'] ) ? $rev->setId( $details['id'] ) : null;
+
+               return $rev;
+       }
+
+       private function getRandomCommentStoreComment() {
+               return CommentStoreComment::newUnsavedComment( __METHOD__ . '.' . rand( 0, 1000 ) );
+       }
+
+       public function provideInsertRevisionOn_successes() {
+               yield 'Bare minimum revision insertion' => [
+                       Title::newFromText( 'UTPage' ),
+                       [
+                               'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+                               'parent' => true,
+                               'comment' => $this->getRandomCommentStoreComment(),
+                               'timestamp' => '20171117010101',
+                               'user' => true,
+                       ],
+               ];
+               yield 'Detailed revision insertion' => [
+                       Title::newFromText( 'UTPage' ),
+                       [
+                               'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+                               'parent' => true,
+                               'page' => true,
+                               'comment' => $this->getRandomCommentStoreComment(),
+                               'timestamp' => '20171117010101',
+                               'user' => true,
+                               'minor' => true,
+                               'visibility' => RevisionRecord::DELETED_RESTRICTED,
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideInsertRevisionOn_successes
+        * @covers RevisionStore::insertRevisionOn
+        */
+       public function testInsertRevisionOn_successes( Title $title, array $revDetails = [] ) {
+               $rev = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $return = $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) );
+
+               $this->assertLinkTargetsEqual( $title, $return->getPageAsLinkTarget() );
+               $this->assertRevisionRecordsEqual( $rev, $return );
+       }
+
+       /**
+        * @covers RevisionStore::insertRevisionOn
+        */
+       public function testInsertRevisionOn_blobAddressExists() {
+               $title = Title::newFromText( 'UTPage' );
+               $revDetails = [
+                       'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+                       'parent' => true,
+                       'comment' => $this->getRandomCommentStoreComment(),
+                       'timestamp' => '20171117010101',
+                       'user' => true,
+               ];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+               // Insert the first revision
+               $revOne = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+               $firstReturn = $store->insertRevisionOn( $revOne, wfGetDB( DB_MASTER ) );
+               $this->assertLinkTargetsEqual( $title, $firstReturn->getPageAsLinkTarget() );
+               $this->assertRevisionRecordsEqual( $revOne, $firstReturn );
+
+               // Insert a second revision inheriting the same blob address
+               $revDetails['slot'] = SlotRecord::newInherited( $firstReturn->getSlot( 'main' ) );
+               $revTwo = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+               $secondReturn = $store->insertRevisionOn( $revTwo, wfGetDB( DB_MASTER ) );
+               $this->assertLinkTargetsEqual( $title, $secondReturn->getPageAsLinkTarget() );
+               $this->assertRevisionRecordsEqual( $revTwo, $secondReturn );
+
+               // Assert that the same blob address has been used.
+               $this->assertEquals(
+                       $firstReturn->getSlot( 'main' )->getAddress(),
+                       $secondReturn->getSlot( 'main' )->getAddress()
+               );
+               // And that different revisions have been created.
+               $this->assertNotSame(
+                       $firstReturn->getId(),
+                       $secondReturn->getId()
+               );
+       }
+
+       public function provideInsertRevisionOn_failures() {
+               yield 'no slot' => [
+                       Title::newFromText( 'UTPage' ),
+                       [
+                               'comment' => $this->getRandomCommentStoreComment(),
+                               'timestamp' => '20171117010101',
+                               'user' => true,
+                       ],
+                       new InvalidArgumentException( 'At least one slot needs to be defined!' )
+               ];
+               yield 'slot that is not main slot' => [
+                       Title::newFromText( 'UTPage' ),
+                       [
+                               'slot' => SlotRecord::newUnsaved( 'lalala', new WikitextContent( 'Chicken' ) ),
+                               'comment' => $this->getRandomCommentStoreComment(),
+                               'timestamp' => '20171117010101',
+                               'user' => true,
+                       ],
+                       new InvalidArgumentException( 'Only the main slot is supported for now!' )
+               ];
+               yield 'no timestamp' => [
+                       Title::newFromText( 'UTPage' ),
+                       [
+                               'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+                               'comment' => $this->getRandomCommentStoreComment(),
+                               'user' => true,
+                       ],
+                       new IncompleteRevisionException( 'timestamp field must not be NULL!' )
+               ];
+               yield 'no comment' => [
+                       Title::newFromText( 'UTPage' ),
+                       [
+                               'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+                               'timestamp' => '20171117010101',
+                               'user' => true,
+                       ],
+                       new IncompleteRevisionException( 'comment must not be NULL!' )
+               ];
+               yield 'no user' => [
+                       Title::newFromText( 'UTPage' ),
+                       [
+                               'slot' => SlotRecord::newUnsaved( 'main', new WikitextContent( 'Chicken' ) ),
+                               'comment' => $this->getRandomCommentStoreComment(),
+                               'timestamp' => '20171117010101',
+                       ],
+                       new IncompleteRevisionException( 'user must not be NULL!' )
+               ];
+       }
+
+       /**
+        * @dataProvider provideInsertRevisionOn_failures
+        * @covers RevisionStore::insertRevisionOn
+        */
+       public function testInsertRevisionOn_failures(
+               Title $title,
+               array $revDetails = [],
+               Exception $exception ) {
+               $rev = $this->getRevisionRecordFromDetailsArray( $title, $revDetails );
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+               $this->setExpectedException(
+                       get_class( $exception ),
+                       $exception->getMessage(),
+                       $exception->getCode()
+               );
+               $store->insertRevisionOn( $rev, wfGetDB( DB_MASTER ) );
+       }
+
+       public function provideNewNullRevision() {
+               yield [
+                       Title::newFromText( 'UTPage' ),
+                       CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment1' ),
+                       true,
+               ];
+               yield [
+                       Title::newFromText( 'UTPage' ),
+                       CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment2', [ 'a' => 1 ] ),
+                       false,
+               ];
+       }
+
+       /**
+        * @dataProvider provideNewNullRevision
+        * @covers RevisionStore::newNullRevision
+        */
+       public function testNewNullRevision( Title $title, $comment, $minor ) {
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $user = TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser();
+               $record = $store->newNullRevision(
+                       wfGetDB( DB_MASTER ),
+                       $title,
+                       $comment,
+                       $minor,
+                       $user
+               );
+
+               $this->assertEquals( $title->getNamespace(), $record->getPageAsLinkTarget()->getNamespace() );
+               $this->assertEquals( $title->getDBkey(), $record->getPageAsLinkTarget()->getDBkey() );
+               $this->assertEquals( $comment, $record->getComment() );
+               $this->assertEquals( $minor, $record->isMinor() );
+               $this->assertEquals( $user->getName(), $record->getUser()->getName() );
+       }
+
+       /**
+        * @covers RevisionStore::newNullRevision
+        */
+       public function testNewNullRevision_nonExistingTitle() {
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $record = $store->newNullRevision(
+                       wfGetDB( DB_MASTER ),
+                       Title::newFromText( __METHOD__ . '.iDontExist!' ),
+                       CommentStoreComment::newUnsavedComment( __METHOD__ . ' comment' ),
+                       false,
+                       TestUserRegistry::getMutableTestUser( __METHOD__ )->getUser()
+               );
+               $this->assertNull( $record );
+       }
+
+       /**
+        * @covers RevisionStore::isUnpatrolled
+        */
+       public function testIsUnpatrolled_returnsRecentChangesId() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               $status = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+               /** @var Revision $rev */
+               $rev = $status->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $revisionRecord = $store->getRevisionById( $rev->getId() );
+               $result = $store->isUnpatrolled( $revisionRecord );
+
+               $this->assertGreaterThan( 0, $result );
+               $this->assertSame(
+                       $page->getRevision()->getRecentChange()->getAttribute( 'rc_id' ),
+                       $result
+               );
+       }
+
+       /**
+        * @covers RevisionStore::isUnpatrolled
+        */
+       public function testIsUnpatrolled_returnsZeroIfPatrolled() {
+               // This assumes that sysops are auto patrolled
+               $sysop = $this->getTestSysop()->getUser();
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               $status = $page->doEditContent(
+                       new WikitextContent( __METHOD__ ),
+                       __METHOD__,
+                       0,
+                       false,
+                       $sysop
+               );
+               /** @var Revision $rev */
+               $rev = $status->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $revisionRecord = $store->getRevisionById( $rev->getId() );
+               $result = $store->isUnpatrolled( $revisionRecord );
+
+               $this->assertSame( 0, $result );
+       }
+
+       public function testGetRecentChange() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               $content = new WikitextContent( __METHOD__ );
+               $status = $page->doEditContent( $content, __METHOD__ );
+               /** @var Revision $rev */
+               $rev = $status->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $revRecord = $store->getRevisionById( $rev->getId() );
+               $recentChange = $store->getRecentChange( $revRecord );
+
+               $this->assertEquals( $rev->getId(), $recentChange->getAttribute( 'rc_this_oldid' ) );
+               $this->assertEquals( $rev->getRecentChange(), $recentChange );
+       }
+
+       /**
+        * @covers RevisionStore::getRevisionById
+        */
+       public function testGetRevisionById() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               $content = new WikitextContent( __METHOD__ );
+               $status = $page->doEditContent( $content, __METHOD__ );
+               /** @var Revision $rev */
+               $rev = $status->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $revRecord = $store->getRevisionById( $rev->getId() );
+
+               $this->assertSame( $rev->getId(), $revRecord->getId() );
+               $this->assertTrue( $revRecord->getSlot( 'main' )->getContent()->equals( $content ) );
+               $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+       }
+
+       /**
+        * @covers RevisionStore::getRevisionByTitle
+        */
+       public function testGetRevisionByTitle() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               $content = new WikitextContent( __METHOD__ );
+               $status = $page->doEditContent( $content, __METHOD__ );
+               /** @var Revision $rev */
+               $rev = $status->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $revRecord = $store->getRevisionByTitle( $page->getTitle() );
+
+               $this->assertSame( $rev->getId(), $revRecord->getId() );
+               $this->assertTrue( $revRecord->getSlot( 'main' )->getContent()->equals( $content ) );
+               $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+       }
+
+       /**
+        * @covers RevisionStore::getRevisionByPageId
+        */
+       public function testGetRevisionByPageId() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               $content = new WikitextContent( __METHOD__ );
+               $status = $page->doEditContent( $content, __METHOD__ );
+               /** @var Revision $rev */
+               $rev = $status->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $revRecord = $store->getRevisionByPageId( $page->getId() );
+
+               $this->assertSame( $rev->getId(), $revRecord->getId() );
+               $this->assertTrue( $revRecord->getSlot( 'main' )->getContent()->equals( $content ) );
+               $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+       }
+
+       /**
+        * @covers RevisionStore::getRevisionFromTimestamp
+        */
+       public function testGetRevisionFromTimestamp() {
+               // Make sure there is 1 second between the last revision and the rev we create...
+               // Otherwise we might not get the correct revision and the test may fail...
+               // :(
+               sleep( 1 );
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               $content = new WikitextContent( __METHOD__ );
+               $status = $page->doEditContent( $content, __METHOD__ );
+               /** @var Revision $rev */
+               $rev = $status->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $revRecord = $store->getRevisionFromTimestamp(
+                       $page->getTitle(),
+                       $rev->getTimestamp()
+               );
+
+               $this->assertSame( $rev->getId(), $revRecord->getId() );
+               $this->assertTrue( $revRecord->getSlot( 'main' )->getContent()->equals( $content ) );
+               $this->assertSame( __METHOD__, $revRecord->getComment()->text );
+       }
+
+       private function revisionToRow( Revision $rev ) {
+               $page = WikiPage::factory( $rev->getTitle() );
+
+               return (object)[
+                       'rev_id' => (string)$rev->getId(),
+                       'rev_page' => (string)$rev->getPage(),
+                       'rev_text_id' => (string)$rev->getTextId(),
+                       'rev_timestamp' => (string)$rev->getTimestamp(),
+                       'rev_user_text' => (string)$rev->getUserText(),
+                       'rev_user' => (string)$rev->getUser(),
+                       'rev_minor_edit' => $rev->isMinor() ? '1' : '0',
+                       'rev_deleted' => (string)$rev->getVisibility(),
+                       'rev_len' => (string)$rev->getSize(),
+                       'rev_parent_id' => (string)$rev->getParentId(),
+                       'rev_sha1' => (string)$rev->getSha1(),
+                       'rev_comment_text' => $rev->getComment(),
+                       'rev_comment_data' => null,
+                       'rev_comment_cid' => null,
+                       'rev_content_format' => $rev->getContentFormat(),
+                       'rev_content_model' => $rev->getContentModel(),
+                       'page_namespace' => (string)$page->getTitle()->getNamespace(),
+                       'page_title' => $page->getTitle()->getDBkey(),
+                       'page_id' => (string)$page->getId(),
+                       'page_latest' => (string)$page->getLatest(),
+                       'page_is_redirect' => $page->isRedirect() ? '1' : '0',
+                       'page_len' => (string)$page->getContent()->getSize(),
+                       'user_name' => (string)$rev->getUserText(),
+               ];
+       }
+
+       private function assertRevisionRecordMatchesRevision(
+               Revision $rev,
+               RevisionRecord $record
+       ) {
+               $this->assertSame( $rev->getId(), $record->getId() );
+               $this->assertSame( $rev->getPage(), $record->getPageId() );
+               $this->assertSame( $rev->getTimestamp(), $record->getTimestamp() );
+               $this->assertSame( $rev->getUserText(), $record->getUser()->getName() );
+               $this->assertSame( $rev->getUser(), $record->getUser()->getId() );
+               $this->assertSame( $rev->isMinor(), $record->isMinor() );
+               $this->assertSame( $rev->getVisibility(), $record->getVisibility() );
+               $this->assertSame( $rev->getSize(), $record->getSize() );
+               /**
+                * @note As of MW 1.31, the database schema allows the parent ID to be
+                * NULL to indicate that it is unknown.
+                */
+               $expectedParent = $rev->getParentId();
+               if ( $expectedParent === null ) {
+                       $expectedParent = 0;
+               }
+               $this->assertSame( $expectedParent, $record->getParentId() );
+               $this->assertSame( $rev->getSha1(), $record->getSha1() );
+               $this->assertSame( $rev->getComment(), $record->getComment()->text );
+               $this->assertSame( $rev->getContentFormat(), $record->getContent( 'main' )->getDefaultFormat() );
+               $this->assertSame( $rev->getContentModel(), $record->getContent( 'main' )->getModel() );
+               $this->assertLinkTargetsEqual( $rev->getTitle(), $record->getPageAsLinkTarget() );
+       }
+
+       /**
+        * @covers RevisionStore::newRevisionFromRow
+        * @covers RevisionStore::newRevisionFromRow_1_29
+        */
+       public function testNewRevisionFromRow_anonEdit() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               /** @var Revision $rev */
+               $rev = $page->doEditContent(
+                       new WikitextContent( __METHOD__. 'a' ),
+                       __METHOD__. 'a'
+               )->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $record = $store->newRevisionFromRow(
+                       $this->revisionToRow( $rev ),
+                       [],
+                       $page->getTitle()
+               );
+               $this->assertRevisionRecordMatchesRevision( $rev, $record );
+       }
+
+       /**
+        * @covers RevisionStore::newRevisionFromRow
+        * @covers RevisionStore::newRevisionFromRow_1_29
+        */
+       public function testNewRevisionFromRow_userEdit() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               /** @var Revision $rev */
+               $rev = $page->doEditContent(
+                       new WikitextContent( __METHOD__. 'b' ),
+                       __METHOD__ . 'b',
+                       0,
+                       false,
+                       $this->getTestUser()->getUser()
+               )->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $record = $store->newRevisionFromRow(
+                       $this->revisionToRow( $rev ),
+                       [],
+                       $page->getTitle()
+               );
+               $this->assertRevisionRecordMatchesRevision( $rev, $record );
+       }
+
+       /**
+        * @covers RevisionStore::newRevisionFromArchiveRow
+        */
+       public function testNewRevisionFromArchiveRow() {
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $title = Title::newFromText( __METHOD__ );
+               $page = WikiPage::factory( $title );
+               /** @var Revision $orig */
+               $orig = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+                       ->value['revision'];
+               $page->doDeleteArticle( __METHOD__ );
+
+               $db = wfGetDB( DB_MASTER );
+               $arQuery = $store->getArchiveQueryInfo();
+               $res = $db->select(
+                       $arQuery['tables'], $arQuery['fields'], [ 'ar_rev_id' => $orig->getId() ],
+                       __METHOD__, [], $arQuery['joins']
+               );
+               $this->assertTrue( is_object( $res ), 'query failed' );
+
+               $row = $res->fetchObject();
+               $res->free();
+               $record = $store->newRevisionFromArchiveRow( $row );
+
+               $this->assertRevisionRecordMatchesRevision( $orig, $record );
+       }
+
+       /**
+        * @covers RevisionStore::loadRevisionFromId
+        */
+       public function testLoadRevisionFromId() {
+               $title = Title::newFromText( __METHOD__ );
+               $page = WikiPage::factory( $title );
+               /** @var Revision $rev */
+               $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+                       ->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $result = $store->loadRevisionFromId( wfGetDB( DB_MASTER ), $rev->getId() );
+               $this->assertRevisionRecordMatchesRevision( $rev, $result );
+       }
+
+       /**
+        * @covers RevisionStore::loadRevisionFromPageId
+        */
+       public function testLoadRevisionFromPageId() {
+               $title = Title::newFromText( __METHOD__ );
+               $page = WikiPage::factory( $title );
+               /** @var Revision $rev */
+               $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+                       ->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $result = $store->loadRevisionFromPageId( wfGetDB( DB_MASTER ), $page->getId() );
+               $this->assertRevisionRecordMatchesRevision( $rev, $result );
+       }
+
+       /**
+        * @covers RevisionStore::loadRevisionFromTitle
+        */
+       public function testLoadRevisionFromTitle() {
+               $title = Title::newFromText( __METHOD__ );
+               $page = WikiPage::factory( $title );
+               /** @var Revision $rev */
+               $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+                       ->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $result = $store->loadRevisionFromTitle( wfGetDB( DB_MASTER ), $title );
+               $this->assertRevisionRecordMatchesRevision( $rev, $result );
+       }
+
+       /**
+        * @covers RevisionStore::loadRevisionFromTimestamp
+        */
+       public function testLoadRevisionFromTimestamp() {
+               $title = Title::newFromText( __METHOD__ );
+               $page = WikiPage::factory( $title );
+               /** @var Revision $revOne */
+               $revOne = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+                       ->value['revision'];
+               // Sleep to ensure different timestamps... )(evil)
+               sleep( 1 );
+               /** @var Revision $revTwo */
+               $revTwo = $page->doEditContent( new WikitextContent( __METHOD__ . 'a' ), '' )
+                       ->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $this->assertNull(
+                       $store->loadRevisionFromTimestamp( wfGetDB( DB_MASTER ), $title, '20150101010101' )
+               );
+               $this->assertSame(
+                       $revOne->getId(),
+                       $store->loadRevisionFromTimestamp(
+                               wfGetDB( DB_MASTER ),
+                               $title,
+                               $revOne->getTimestamp()
+                       )->getId()
+               );
+               $this->assertSame(
+                       $revTwo->getId(),
+                       $store->loadRevisionFromTimestamp(
+                               wfGetDB( DB_MASTER ),
+                               $title,
+                               $revTwo->getTimestamp()
+                       )->getId()
+               );
+       }
+
+       /**
+        * @covers RevisionStore::listRevisionSizes
+        */
+       public function testGetParentLengths() {
+               $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+               /** @var Revision $revOne */
+               $revOne = $page->doEditContent(
+                       new WikitextContent( __METHOD__ ), __METHOD__
+               )->value['revision'];
+               /** @var Revision $revTwo */
+               $revTwo = $page->doEditContent(
+                       new WikitextContent( __METHOD__ . '2' ), __METHOD__
+               )->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $this->assertSame(
+                       [
+                               $revOne->getId() => strlen( __METHOD__ ),
+                       ],
+                       $store->listRevisionSizes(
+                               wfGetDB( DB_MASTER ),
+                               [ $revOne->getId() ]
+                       )
+               );
+               $this->assertSame(
+                       [
+                               $revOne->getId() => strlen( __METHOD__ ),
+                               $revTwo->getId() => strlen( __METHOD__ ) + 1,
+                       ],
+                       $store->listRevisionSizes(
+                               wfGetDB( DB_MASTER ),
+                               [ $revOne->getId(), $revTwo->getId() ]
+                       )
+               );
+       }
+
+       /**
+        * @covers RevisionStore::getPreviousRevision
+        */
+       public function testGetPreviousRevision() {
+               $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+               /** @var Revision $revOne */
+               $revOne = $page->doEditContent(
+                       new WikitextContent( __METHOD__ ), __METHOD__
+               )->value['revision'];
+               /** @var Revision $revTwo */
+               $revTwo = $page->doEditContent(
+                       new WikitextContent( __METHOD__ . '2' ), __METHOD__
+               )->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $this->assertNull(
+                       $store->getPreviousRevision( $store->getRevisionById( $revOne->getId() ) )
+               );
+               $this->assertSame(
+                       $revOne->getId(),
+                       $store->getPreviousRevision( $store->getRevisionById( $revTwo->getId() ) )->getId()
+               );
+       }
+
+       /**
+        * @covers RevisionStore::getNextRevision
+        */
+       public function testGetNextRevision() {
+               $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+               /** @var Revision $revOne */
+               $revOne = $page->doEditContent(
+                       new WikitextContent( __METHOD__ ), __METHOD__
+               )->value['revision'];
+               /** @var Revision $revTwo */
+               $revTwo = $page->doEditContent(
+                       new WikitextContent( __METHOD__ . '2' ), __METHOD__
+               )->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $this->assertSame(
+                       $revTwo->getId(),
+                       $store->getNextRevision( $store->getRevisionById( $revOne->getId() ) )->getId()
+               );
+               $this->assertNull(
+                       $store->getNextRevision( $store->getRevisionById( $revTwo->getId() ) )
+               );
+       }
+
+       /**
+        * @covers RevisionStore::getTimestampFromId
+        */
+       public function testGetTimestampFromId_found() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               /** @var Revision $rev */
+               $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+                       ->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $result = $store->getTimestampFromId(
+                       $page->getTitle(),
+                       $rev->getId()
+               );
+
+               $this->assertSame( $rev->getTimestamp(), $result );
+       }
+
+       /**
+        * @covers RevisionStore::getTimestampFromId
+        */
+       public function testGetTimestampFromId_notFound() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               /** @var Revision $rev */
+               $rev = $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ )
+                       ->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $result = $store->getTimestampFromId(
+                       $page->getTitle(),
+                       $rev->getId() + 1
+               );
+
+               $this->assertFalse( $result );
+       }
+
+       /**
+        * @covers RevisionStore::countRevisionsByPageId
+        */
+       public function testCountRevisionsByPageId() {
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+
+               $this->assertSame(
+                       0,
+                       $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
+               );
+               $page->doEditContent( new WikitextContent( 'a' ), 'a' );
+               $this->assertSame(
+                       1,
+                       $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
+               );
+               $page->doEditContent( new WikitextContent( 'b' ), 'b' );
+               $this->assertSame(
+                       2,
+                       $store->countRevisionsByPageId( wfGetDB( DB_MASTER ), $page->getId() )
+               );
+       }
+
+       /**
+        * @covers RevisionStore::countRevisionsByTitle
+        */
+       public function testCountRevisionsByTitle() {
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $page = WikiPage::factory( Title::newFromText( __METHOD__ ) );
+
+               $this->assertSame(
+                       0,
+                       $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
+               );
+               $page->doEditContent( new WikitextContent( 'a' ), 'a' );
+               $this->assertSame(
+                       1,
+                       $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
+               );
+               $page->doEditContent( new WikitextContent( 'b' ), 'b' );
+               $this->assertSame(
+                       2,
+                       $store->countRevisionsByTitle( wfGetDB( DB_MASTER ), $page->getTitle() )
+               );
+       }
+
+       /**
+        * @covers RevisionStore::userWasLastToEdit
+        */
+       public function testUserWasLastToEdit_false() {
+               $sysop = $this->getTestSysop()->getUser();
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               $page->doEditContent( new WikitextContent( __METHOD__ ), __METHOD__ );
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $result = $store->userWasLastToEdit(
+                       wfGetDB( DB_MASTER ),
+                       $page->getId(),
+                       $sysop->getId(),
+                       '20160101010101'
+               );
+               $this->assertFalse( $result );
+       }
+
+       /**
+        * @covers RevisionStore::userWasLastToEdit
+        */
+       public function testUserWasLastToEdit_true() {
+               $startTime = wfTimestampNow();
+               $sysop = $this->getTestSysop()->getUser();
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               $page->doEditContent(
+                       new WikitextContent( __METHOD__ ),
+                       __METHOD__,
+                       0,
+                       false,
+                       $sysop
+               );
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $result = $store->userWasLastToEdit(
+                       wfGetDB( DB_MASTER ),
+                       $page->getId(),
+                       $sysop->getId(),
+                       $startTime
+               );
+               $this->assertTrue( $result );
+       }
+
+       /**
+        * @covers RevisionStore::getKnownCurrentRevision
+        */
+       public function testGetKnownCurrentRevision() {
+               $page = WikiPage::factory( Title::newFromText( 'UTPage' ) );
+               /** @var Revision $rev */
+               $rev = $page->doEditContent(
+                       new WikitextContent( __METHOD__. 'b' ),
+                       __METHOD__ . 'b',
+                       0,
+                       false,
+                       $this->getTestUser()->getUser()
+               )->value['revision'];
+
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+               $record = $store->getKnownCurrentRevision(
+                       $page->getTitle(),
+                       $rev->getId()
+               );
+
+               $this->assertRevisionRecordMatchesRevision( $rev, $record );
+       }
+
+       public function provideNewMutableRevisionFromArray() {
+               yield 'Basic array, with page & id' => [
+                       [
+                               'id' => 2,
+                               'page' => 1,
+                               'text_id' => 2,
+                               'timestamp' => '20171017114835',
+                               'user_text' => '111.0.1.2',
+                               'user' => 0,
+                               'minor_edit' => false,
+                               'deleted' => 0,
+                               'len' => 46,
+                               'parent_id' => 1,
+                               'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+                               'comment' => 'Goat Comment!',
+                               'content_format' => 'text/x-wiki',
+                               'content_model' => 'wikitext',
+                       ]
+               ];
+               yield 'Basic array, content object' => [
+                       [
+                               'id' => 2,
+                               'page' => 1,
+                               'timestamp' => '20171017114835',
+                               'user_text' => '111.0.1.2',
+                               'user' => 0,
+                               'minor_edit' => false,
+                               'deleted' => 0,
+                               'len' => 46,
+                               'parent_id' => 1,
+                               'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+                               'comment' => 'Goat Comment!',
+                               'content' => new WikitextContent( 'Some Content' ),
+                       ]
+               ];
+               yield 'Basic array, with title' => [
+                       [
+                               'title' => Title::newFromText( 'SomeText' ),
+                               'text_id' => 2,
+                               'timestamp' => '20171017114835',
+                               'user_text' => '111.0.1.2',
+                               'user' => 0,
+                               'minor_edit' => false,
+                               'deleted' => 0,
+                               'len' => 46,
+                               'parent_id' => 1,
+                               'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+                               'comment' => 'Goat Comment!',
+                               'content_format' => 'text/x-wiki',
+                               'content_model' => 'wikitext',
+                       ]
+               ];
+               yield 'Basic array, no user field' => [
+                       [
+                               'id' => 2,
+                               'page' => 1,
+                               'text_id' => 2,
+                               'timestamp' => '20171017114835',
+                               'user_text' => '111.0.1.3',
+                               'minor_edit' => false,
+                               'deleted' => 0,
+                               'len' => 46,
+                               'parent_id' => 1,
+                               'sha1' => 'rdqbbzs3pkhihgbs8qf2q9jsvheag5z',
+                               'comment' => 'Goat Comment!',
+                               'content_format' => 'text/x-wiki',
+                               'content_model' => 'wikitext',
+                       ]
+               ];
+       }
+
+       /**
+        * @dataProvider provideNewMutableRevisionFromArray
+        * @covers RevisionStore::newMutableRevisionFromArray
+        */
+       public function testNewMutableRevisionFromArray( array $array ) {
+               $store = MediaWikiServices::getInstance()->getRevisionStore();
+
+               $result = $store->newMutableRevisionFromArray( $array );
+
+               if ( isset( $array['id'] ) ) {
+                       $this->assertSame( $array['id'], $result->getId() );
+               }
+               if ( isset( $array['page'] ) ) {
+                       $this->assertSame( $array['page'], $result->getPageId() );
+               }
+               $this->assertSame( $array['timestamp'], $result->getTimestamp() );
+               $this->assertSame( $array['user_text'], $result->getUser()->getName() );
+               if ( isset( $array['user'] ) ) {
+                       $this->assertSame( $array['user'], $result->getUser()->getId() );
+               }
+               $this->assertSame( (bool)$array['minor_edit'], $result->isMinor() );
+               $this->assertSame( $array['deleted'], $result->getVisibility() );
+               $this->assertSame( $array['len'], $result->getSize() );
+               $this->assertSame( $array['parent_id'], $result->getParentId() );
+               $this->assertSame( $array['sha1'], $result->getSha1() );
+               $this->assertSame( $array['comment'], $result->getComment()->text );
+               if ( isset( $array['content'] ) ) {
+                       $this->assertTrue(
+                               $result->getSlot( 'main' )->getContent()->equals( $array['content'] )
+                       );
+               } else {
+                       $this->assertSame(
+                               $array['content_format'],
+                               $result->getSlot( 'main' )->getContent()->getDefaultFormat()
+                       );
+                       $this->assertSame( $array['content_model'], $result->getSlot( 'main' )->getModel() );
+               }
+       }
+
+}
diff --git a/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php b/tests/phpunit/includes/Storage/RevisionStoreRecordTest.php
new file mode 100644 (file)
index 0000000..e9f376c
--- /dev/null
@@ -0,0 +1,814 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use CommentStoreComment;
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Storage\RevisionRecord;
+use MediaWiki\Storage\RevisionSlots;
+use MediaWiki\Storage\RevisionStoreRecord;
+use MediaWiki\Storage\SlotRecord;
+use MediaWiki\Storage\SuppressedDataException;
+use MediaWiki\User\UserIdentity;
+use MediaWiki\User\UserIdentityValue;
+use MediaWikiTestCase;
+use TextContent;
+use Title;
+
+/**
+ * @covers \MediaWiki\Storage\RevisionStoreRecord
+ */
+class RevisionStoreRecordTest extends MediaWikiTestCase {
+
+       /**
+        * @param array $rowOverrides
+        *
+        * @return RevisionStoreRecord
+        */
+       public function newRevision( array $rowOverrides = [] ) {
+               $title = Title::newFromText( 'Dummy' );
+               $title->resetArticleID( 17 );
+
+               $user = new UserIdentityValue( 11, 'Tester' );
+               $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+               $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) );
+               $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+               $slots = new RevisionSlots( [ $main, $aux ] );
+
+               $row = [
+                       'rev_id' => '7',
+                       'rev_page' => strval( $title->getArticleID() ),
+                       'rev_timestamp' => '20200101000000',
+                       'rev_deleted' => 0,
+                       'rev_minor_edit' => 0,
+                       'rev_parent_id' => '5',
+                       'rev_len' => $slots->computeSize(),
+                       'rev_sha1' => $slots->computeSha1(),
+                       'page_latest' => '18',
+               ];
+
+               $row = array_merge( $row, $rowOverrides );
+
+               return new RevisionStoreRecord( $title, $user, $comment, (object)$row, $slots );
+       }
+
+       public function provideConstructor() {
+               $title = Title::newFromText( 'Dummy' );
+               $title->resetArticleID( 17 );
+
+               $user = new UserIdentityValue( 11, 'Tester' );
+               $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+               $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) );
+               $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+               $slots = new RevisionSlots( [ $main, $aux ] );
+
+               $protoRow = [
+                       'rev_id' => '7',
+                       'rev_page' => strval( $title->getArticleID() ),
+                       'rev_timestamp' => '20200101000000',
+                       'rev_deleted' => 0,
+                       'rev_minor_edit' => 0,
+                       'rev_parent_id' => '5',
+                       'rev_len' => $slots->computeSize(),
+                       'rev_sha1' => $slots->computeSha1(),
+                       'page_latest' => '18',
+               ];
+
+               $row = $protoRow;
+               yield 'all info' => [
+                       $title,
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots,
+                       'acmewiki'
+               ];
+
+               $row = $protoRow;
+               $row['rev_minor_edit'] = '1';
+               $row['rev_deleted'] = strval( RevisionRecord::DELETED_USER );
+
+               yield 'minor deleted' => [
+                       $title,
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots
+               ];
+
+               $row = $protoRow;
+               $row['page_latest'] = $row['rev_id'];
+
+               yield 'latest' => [
+                       $title,
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots
+               ];
+
+               $row = $protoRow;
+               unset( $row['rev_parent'] );
+
+               yield 'no parent' => [
+                       $title,
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots
+               ];
+
+               $row = $protoRow;
+               unset( $row['rev_len'] );
+               unset( $row['rev_sha1'] );
+
+               yield 'no length, no hash' => [
+                       $title,
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots
+               ];
+
+               $row = $protoRow;
+               yield 'no length, no hash' => [
+                       Title::newFromText( 'DummyDoesNotExist' ),
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots
+               ];
+       }
+
+       /**
+        * @dataProvider provideConstructor
+        *
+        * @param Title $title
+        * @param UserIdentity $user
+        * @param CommentStoreComment $comment
+        * @param object $row
+        * @param RevisionSlots $slots
+        * @param bool $wikiId
+        */
+       public function testConstructorAndGetters(
+               Title $title,
+               UserIdentity $user,
+               CommentStoreComment $comment,
+               $row,
+               RevisionSlots $slots,
+               $wikiId = false
+       ) {
+               $rec = new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId );
+
+               $this->assertSame( $title, $rec->getPageAsLinkTarget(), 'getPageAsLinkTarget' );
+               $this->assertSame( $user, $rec->getUser( RevisionRecord::RAW ), 'getUser' );
+               $this->assertSame( $comment, $rec->getComment(), 'getComment' );
+
+               $this->assertSame( $slots->getSlotRoles(), $rec->getSlotRoles(), 'getSlotRoles' );
+               $this->assertSame( $wikiId, $rec->getWikiId(), 'getWikiId' );
+
+               $this->assertSame( (int)$row->rev_id, $rec->getId(), 'getId' );
+               $this->assertSame( (int)$row->rev_page, $rec->getPageId(), 'getId' );
+               $this->assertSame( $row->rev_timestamp, $rec->getTimestamp(), 'getTimestamp' );
+               $this->assertSame( (int)$row->rev_deleted, $rec->getVisibility(), 'getVisibility' );
+               $this->assertSame( (bool)$row->rev_minor_edit, $rec->isMinor(), 'getIsMinor' );
+
+               if ( isset( $row->rev_parent_id ) ) {
+                       $this->assertSame( (int)$row->rev_parent_id, $rec->getParentId(), 'getParentId' );
+               } else {
+                       $this->assertSame( 0, $rec->getParentId(), 'getParentId' );
+               }
+
+               if ( isset( $row->rev_len ) ) {
+                       $this->assertSame( (int)$row->rev_len, $rec->getSize(), 'getSize' );
+               } else {
+                       $this->assertSame( $slots->computeSize(), $rec->getSize(), 'getSize' );
+               }
+
+               if ( isset( $row->rev_sha1 ) ) {
+                       $this->assertSame( $row->rev_sha1, $rec->getSha1(), 'getSha1' );
+               } else {
+                       $this->assertSame( $slots->computeSha1(), $rec->getSha1(), 'getSha1' );
+               }
+
+               if ( isset( $row->page_latest ) ) {
+                       $this->assertSame(
+                               (int)$row->rev_id === (int)$row->page_latest,
+                               $rec->isCurrent(),
+                               'isCurrent'
+                       );
+               } else {
+                       $this->assertSame(
+                               false,
+                               $rec->isCurrent(),
+                               'isCurrent'
+                       );
+               }
+       }
+
+       public function provideConstructorFailure() {
+               $title = Title::newFromText( 'Dummy' );
+               $title->resetArticleID( 17 );
+
+               $user = new UserIdentityValue( 11, 'Tester' );
+
+               $comment = CommentStoreComment::newUnsavedComment( 'Hello World' );
+
+               $main = SlotRecord::newUnsaved( 'main', new TextContent( 'Lorem Ipsum' ) );
+               $aux = SlotRecord::newUnsaved( 'aux', new TextContent( 'Frumious Bandersnatch' ) );
+               $slots = new RevisionSlots( [ $main, $aux ] );
+
+               $protoRow = [
+                       'rev_id' => '7',
+                       'rev_page' => strval( $title->getArticleID() ),
+                       'rev_timestamp' => '20200101000000',
+                       'rev_deleted' => 0,
+                       'rev_minor_edit' => 0,
+                       'rev_parent_id' => '5',
+                       'rev_len' => $slots->computeSize(),
+                       'rev_sha1' => $slots->computeSha1(),
+                       'page_latest' => '18',
+               ];
+
+               yield 'not a row' => [
+                       $title,
+                       $user,
+                       $comment,
+                       'not a row',
+                       $slots,
+                       'acmewiki'
+               ];
+
+               $row = $protoRow;
+               $row['rev_timestamp'] = 'kittens';
+
+               yield 'bad timestamp' => [
+                       $title,
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots
+               ];
+
+               $row = $protoRow;
+               $row['rev_page'] = 99;
+
+               yield 'page ID mismatch' => [
+                       $title,
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots
+               ];
+
+               $row = $protoRow;
+
+               yield 'bad wiki' => [
+                       $title,
+                       $user,
+                       $comment,
+                       (object)$row,
+                       $slots,
+                       12345
+               ];
+       }
+
+       /**
+        * @dataProvider provideConstructorFailure
+        *
+        * @param Title $title
+        * @param UserIdentity $user
+        * @param CommentStoreComment $comment
+        * @param object $row
+        * @param RevisionSlots $slots
+        * @param bool $wikiId
+        */
+       public function testConstructorFailure(
+               Title $title,
+               UserIdentity $user,
+               CommentStoreComment $comment,
+               $row,
+               RevisionSlots $slots,
+               $wikiId = false
+       ) {
+               $this->setExpectedException( InvalidArgumentException::class );
+               new RevisionStoreRecord( $title, $user, $comment, $row, $slots, $wikiId );
+       }
+
+       private function provideAudienceCheckData( $field ) {
+               yield 'field accessible for oversighter (ALL)' => [
+                       Revisionrecord::SUPPRESSED_ALL,
+                       [ 'oversight' ],
+                       true,
+                       false
+               ];
+
+               yield 'field accessible for oversighter' => [
+                       Revisionrecord::DELETED_RESTRICTED | $field,
+                       [ 'oversight' ],
+                       true,
+                       false
+               ];
+
+               yield 'field not accessible for sysops (ALL)' => [
+                       Revisionrecord::SUPPRESSED_ALL,
+                       [ 'sysop' ],
+                       false,
+                       false
+               ];
+
+               yield 'field not accessible for sysops' => [
+                       Revisionrecord::DELETED_RESTRICTED | $field,
+                       [ 'sysop' ],
+                       false,
+                       false
+               ];
+
+               yield 'field accessible for sysops' => [
+                       $field,
+                       [ 'sysop' ],
+                       true,
+                       false
+               ];
+
+               yield 'field suppressed for logged in users' => [
+                       $field,
+                       [ 'user' ],
+                       false,
+                       false
+               ];
+
+               yield 'unrelated field suppressed' => [
+                       $field === Revisionrecord::DELETED_COMMENT
+                               ? Revisionrecord::DELETED_USER
+                               : Revisionrecord::DELETED_COMMENT,
+                       [ 'user' ],
+                       true,
+                       true
+               ];
+
+               yield 'nothing suppressed' => [
+                       0,
+                       [ 'user' ],
+                       true,
+                       true
+               ];
+       }
+
+       public function testSerialization_fails() {
+               $this->setExpectedException( LogicException::class );
+               $rev = $this->newRevision();
+               serialize( $rev );
+       }
+
+       public function provideGetComment_audience() {
+               return $this->provideAudienceCheckData( RevisionRecord::DELETED_COMMENT );
+       }
+
+       private function forceStandardPermissions() {
+               $this->setMwGlobals(
+                       'wgGroupPermissions',
+                       [
+                               'user' => [
+                                       'viewsuppressed' => false,
+                                       'suppressrevision' => false,
+                                       'deletedtext' => false,
+                                       'deletedhistory' => false,
+                               ],
+                               'sysop' => [
+                                       'viewsuppressed' => false,
+                                       'suppressrevision' => false,
+                                       'deletedtext' => true,
+                                       'deletedhistory' => true,
+                               ],
+                               'oversight' => [
+                                       'deletedtext' => true,
+                                       'deletedhistory' => true,
+                                       'viewsuppressed' => true,
+                                       'suppressrevision' => true,
+                               ],
+                       ]
+               );
+       }
+
+       /**
+        * @dataProvider provideGetComment_audience
+        */
+       public function testGetComment_audience( $visibility, $groups, $userCan, $publicCan ) {
+               $this->forceStandardPermissions();
+
+               $user = $this->getTestUser( $groups )->getUser();
+               $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+               $this->assertNotNull( $rev->getComment( RevisionRecord::RAW ), 'raw can' );
+
+               $this->assertSame(
+                       $publicCan,
+                       $rev->getComment( RevisionRecord::FOR_PUBLIC ) !== null,
+                       'public can'
+               );
+               $this->assertSame(
+                       $userCan,
+                       $rev->getComment( RevisionRecord::FOR_THIS_USER, $user ) !== null,
+                       'user can'
+               );
+       }
+
+       public function provideGetUser_audience() {
+               return $this->provideAudienceCheckData( RevisionRecord::DELETED_USER );
+       }
+
+       /**
+        * @dataProvider provideGetUser_audience
+        */
+       public function testGetUser_audience( $visibility, $groups, $userCan, $publicCan ) {
+               $this->forceStandardPermissions();
+
+               $user = $this->getTestUser( $groups )->getUser();
+               $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+               $this->assertNotNull( $rev->getUser( RevisionRecord::RAW ), 'raw can' );
+
+               $this->assertSame(
+                       $publicCan,
+                       $rev->getUser( RevisionRecord::FOR_PUBLIC ) !== null,
+                       'public can'
+               );
+               $this->assertSame(
+                       $userCan,
+                       $rev->getUser( RevisionRecord::FOR_THIS_USER, $user ) !== null,
+                       'user can'
+               );
+       }
+
+       public function provideGetSlot_audience() {
+               return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT );
+       }
+
+       /**
+        * @dataProvider provideGetSlot_audience
+        */
+       public function testGetSlot_audience( $visibility, $groups, $userCan, $publicCan ) {
+               $this->forceStandardPermissions();
+
+               $user = $this->getTestUser( $groups )->getUser();
+               $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+               // NOTE: slot meta-data is never suppressed, just the content is!
+               $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::RAW ), 'raw can' );
+               $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC ), 'public can' );
+
+               $this->assertNotNull(
+                       $rev->getSlot( 'main', RevisionRecord::FOR_THIS_USER, $user ),
+                       'user can'
+               );
+
+               try {
+                       $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC )->getContent();
+                       $exception = null;
+               } catch ( SuppressedDataException $ex ) {
+                       $exception = $ex;
+               }
+
+               $this->assertSame(
+                       $publicCan,
+                       $exception === null,
+                       'public can'
+               );
+
+               try {
+                       $rev->getSlot( 'main', RevisionRecord::FOR_THIS_USER, $user )->getContent();
+                       $exception = null;
+               } catch ( SuppressedDataException $ex ) {
+                       $exception = $ex;
+               }
+
+               $this->assertSame(
+                       $userCan,
+                       $exception === null,
+                       'user can'
+               );
+       }
+
+       public function provideGetSlot_audience_latest() {
+               return $this->provideAudienceCheckData( RevisionRecord::DELETED_TEXT );
+       }
+
+       /**
+        * @dataProvider provideGetSlot_audience_latest
+        */
+       public function testGetSlot_audience_latest( $visibility, $groups, $userCan, $publicCan ) {
+               $this->forceStandardPermissions();
+
+               $user = $this->getTestUser( $groups )->getUser();
+               $rev = $this->newRevision(
+                       [
+                               'rev_deleted' => $visibility,
+                               'rev_id' => 11,
+                               'page_latest' => 11, // revision is current
+                       ]
+               );
+
+               // sanity check
+               $this->assertTrue( $rev->isCurrent(), 'isCurrent()' );
+
+               // NOTE: slot meta-data is never suppressed, just the content is!
+               $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::RAW ), 'raw can' );
+               $this->assertNotNull( $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC ), 'public can' );
+
+               $this->assertNotNull(
+                       $rev->getSlot( 'main', RevisionRecord::FOR_THIS_USER, $user ),
+                       'user can'
+               );
+
+               // NOTE: the content of the current revision is never suppressed!
+               // Check that getContent() doesn't throw SuppressedDataException
+               $rev->getSlot( 'main', RevisionRecord::RAW )->getContent();
+               $rev->getSlot( 'main', RevisionRecord::FOR_PUBLIC )->getContent();
+               $rev->getSlot( 'main', RevisionRecord::FOR_THIS_USER, $user )->getContent();
+       }
+
+       /**
+        * @dataProvider provideGetSlot_audience
+        */
+       public function testGetContent_audience( $visibility, $groups, $userCan, $publicCan ) {
+               $this->forceStandardPermissions();
+
+               $user = $this->getTestUser( $groups )->getUser();
+               $rev = $this->newRevision( [ 'rev_deleted' => $visibility ] );
+
+               $this->assertNotNull( $rev->getContent( 'main', RevisionRecord::RAW ), 'raw can' );
+
+               $this->assertSame(
+                       $publicCan,
+                       $rev->getContent( 'main', RevisionRecord::FOR_PUBLIC ) !== null,
+                       'public can'
+               );
+               $this->assertSame(
+                       $userCan,
+                       $rev->getContent( 'main', RevisionRecord::FOR_THIS_USER, $user ) !== null,
+                       'user can'
+               );
+       }
+
+       public function testGetSlot() {
+               $rev = $this->newRevision();
+
+               $slot = $rev->getSlot( 'main' );
+               $this->assertNotNull( $slot, 'getSlot()' );
+               $this->assertSame( 'main', $slot->getRole(), 'getRole()' );
+       }
+
+       public function testGetContent() {
+               $rev = $this->newRevision();
+
+               $content = $rev->getSlot( 'main' );
+               $this->assertNotNull( $content, 'getContent()' );
+               $this->assertSame( CONTENT_MODEL_TEXT, $content->getModel(), 'getModel()' );
+       }
+
+       public function provideUserCanBitfield() {
+               yield [ 0, 0, [], null, true ];
+               // Bitfields match, user has no permissions
+               yield [
+                       RevisionRecord::DELETED_TEXT,
+                       RevisionRecord::DELETED_TEXT,
+                       [],
+                       null,
+                       false
+               ];
+               yield [
+                       RevisionRecord::DELETED_COMMENT,
+                       RevisionRecord::DELETED_COMMENT,
+                       [],
+                       null,
+                       false,
+               ];
+               yield [
+                       RevisionRecord::DELETED_USER,
+                       RevisionRecord::DELETED_USER,
+                       [],
+                       null,
+                       false
+               ];
+               yield [
+                       RevisionRecord::DELETED_RESTRICTED,
+                       RevisionRecord::DELETED_RESTRICTED,
+                       [],
+                       null,
+                       false,
+               ];
+               // Bitfields match, user (admin) does have permissions
+               yield [
+                       RevisionRecord::DELETED_TEXT,
+                       RevisionRecord::DELETED_TEXT,
+                       [ 'sysop' ],
+                       null,
+                       true,
+               ];
+               yield [
+                       RevisionRecord::DELETED_COMMENT,
+                       RevisionRecord::DELETED_COMMENT,
+                       [ 'sysop' ],
+                       null,
+                       true,
+               ];
+               yield [
+                       RevisionRecord::DELETED_USER,
+                       RevisionRecord::DELETED_USER,
+                       [ 'sysop' ],
+                       null,
+                       true,
+               ];
+               // Bitfields match, user (admin) does not have permissions
+               yield [
+                       RevisionRecord::DELETED_RESTRICTED,
+                       RevisionRecord::DELETED_RESTRICTED,
+                       [ 'sysop' ],
+                       null,
+                       false,
+               ];
+               // Bitfields match, user (oversight) does have permissions
+               yield [
+                       RevisionRecord::DELETED_RESTRICTED,
+                       RevisionRecord::DELETED_RESTRICTED,
+                       [ 'oversight' ],
+                       null,
+                       true,
+               ];
+               // Check permissions using the title
+               yield [
+                       RevisionRecord::DELETED_TEXT,
+                       RevisionRecord::DELETED_TEXT,
+                       [ 'sysop' ],
+                       Title::newFromText( __METHOD__ ),
+                       true,
+               ];
+               yield [
+                       RevisionRecord::DELETED_TEXT,
+                       RevisionRecord::DELETED_TEXT,
+                       [],
+                       Title::newFromText( __METHOD__ ),
+                       false,
+               ];
+       }
+
+       /**
+        * @dataProvider provideUserCanBitfield
+        * @covers RevisionRecord::userCanBitfield
+        */
+       public function testUserCanBitfield( $bitField, $field, $userGroups, $title, $expected ) {
+               $this->forceStandardPermissions();
+
+               $user = $this->getTestUser( $userGroups )->getUser();
+
+               $this->assertSame(
+                       $expected,
+                       RevisionRecord::userCanBitfield( $bitField, $field, $user, $title )
+               );
+       }
+
+       private function getSlotRecord( $role, $contentString ) {
+               return SlotRecord::newUnsaved( $role, new TextContent( $contentString ) );
+       }
+
+       public function provideHasSameContent() {
+               /**
+                * @param SlotRecord[] $slots
+                * @param int $revId
+                * @return RevisionStoreRecord
+                */
+               $recordCreator = function ( array $slots, $revId ) {
+                       $title = Title::newFromText( 'provideHasSameContent' );
+                       $title->resetArticleID( 19 );
+                       $slots = new RevisionSlots( $slots );
+
+                       return new RevisionStoreRecord(
+                               $title,
+                               new UserIdentityValue( 11, __METHOD__ ),
+                               CommentStoreComment::newUnsavedComment( __METHOD__ ),
+                               (object)[
+                                       'rev_id' => strval( $revId ),
+                                       'rev_page' => strval( $title->getArticleID() ),
+                                       'rev_timestamp' => '20200101000000',
+                                       'rev_deleted' => 0,
+                                       'rev_minor_edit' => 0,
+                                       'rev_parent_id' => '5',
+                                       'rev_len' => $slots->computeSize(),
+                                       'rev_sha1' => $slots->computeSha1(),
+                                       'page_latest' => '18',
+                               ],
+                               $slots
+                       );
+               };
+
+               // Create some slots with content
+               $mainA = SlotRecord::newUnsaved( 'main', new TextContent( 'A' ) );
+               $mainB = SlotRecord::newUnsaved( 'main', new TextContent( 'B' ) );
+               $auxA = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
+               $auxB = SlotRecord::newUnsaved( 'aux', new TextContent( 'A' ) );
+
+               $initialRecord = $recordCreator( [ $mainA ], 12 );
+
+               return [
+                       'same record object' => [
+                               true,
+                               $initialRecord,
+                               $initialRecord,
+                       ],
+                       'same record content, different object' => [
+                               true,
+                               $recordCreator( [ $mainA ], 12 ),
+                               $recordCreator( [ $mainA ], 13 ),
+                       ],
+                       'same record content, aux slot, different object' => [
+                               true,
+                               $recordCreator( [ $auxA ], 12 ),
+                               $recordCreator( [ $auxB ], 13 ),
+                       ],
+                       'different content' => [
+                               false,
+                               $recordCreator( [ $mainA ], 12 ),
+                               $recordCreator( [ $mainB ], 13 ),
+                       ],
+                       'different content and number of slots' => [
+                               false,
+                               $recordCreator( [ $mainA ], 12 ),
+                               $recordCreator( [ $mainA, $mainB ], 13 ),
+                       ],
+               ];
+       }
+
+       /**
+        * @dataProvider provideHasSameContent
+        * @covers RevisionRecord::hasSameContent
+        * @group Database
+        */
+       public function testHasSameContent(
+               $expected,
+               RevisionRecord $record1,
+               RevisionRecord $record2
+       ) {
+               $this->assertSame(
+                       $expected,
+                       $record1->hasSameContent( $record2 )
+               );
+       }
+
+       public function provideIsDeleted() {
+               yield 'no deletion' => [
+                       0,
+                       [
+                               RevisionRecord::DELETED_TEXT => false,
+                               RevisionRecord::DELETED_COMMENT => false,
+                               RevisionRecord::DELETED_USER => false,
+                               RevisionRecord::DELETED_RESTRICTED => false,
+                       ]
+               ];
+               yield 'text deleted' => [
+                       RevisionRecord::DELETED_TEXT,
+                       [
+                               RevisionRecord::DELETED_TEXT => true,
+                               RevisionRecord::DELETED_COMMENT => false,
+                               RevisionRecord::DELETED_USER => false,
+                               RevisionRecord::DELETED_RESTRICTED => false,
+                       ]
+               ];
+               yield 'text and comment deleted' => [
+                       RevisionRecord::DELETED_TEXT + RevisionRecord::DELETED_COMMENT,
+                       [
+                               RevisionRecord::DELETED_TEXT => true,
+                               RevisionRecord::DELETED_COMMENT => true,
+                               RevisionRecord::DELETED_USER => false,
+                               RevisionRecord::DELETED_RESTRICTED => false,
+                       ]
+               ];
+               yield 'all 4 deleted' => [
+                       RevisionRecord::DELETED_TEXT +
+                       RevisionRecord::DELETED_COMMENT +
+                       RevisionRecord::DELETED_RESTRICTED +
+                       RevisionRecord::DELETED_USER,
+                       [
+                               RevisionRecord::DELETED_TEXT => true,
+                               RevisionRecord::DELETED_COMMENT => true,
+                               RevisionRecord::DELETED_USER => true,
+                               RevisionRecord::DELETED_RESTRICTED => true,
+                       ]
+               ];
+       }
+
+       /**
+        * @dataProvider provideIsDeleted
+        * @covers RevisionRecord::isDeleted
+        */
+       public function testIsDeleted( $revDeleted, $assertionMap ) {
+               $rev = $this->newRevision( [ 'rev_deleted' => $revDeleted ] );
+               foreach ( $assertionMap as $deletionLevel => $expected ) {
+                       $this->assertSame( $expected, $rev->isDeleted( $deletionLevel ) );
+               }
+       }
+
+}
diff --git a/tests/phpunit/includes/Storage/RevisionStoreTest.php b/tests/phpunit/includes/Storage/RevisionStoreTest.php
new file mode 100644 (file)
index 0000000..efad1b1
--- /dev/null
@@ -0,0 +1,291 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use MediaWiki\Storage\RevisionStore;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWikiTestCase;
+use WANObjectCache;
+use Wikimedia\Rdbms\LoadBalancer;
+
+class RevisionStoreTest extends MediaWikiTestCase {
+
+       /**
+        * @param LoadBalancer $loadBalancer
+        * @param SqlBlobStore $blobStore
+        * @param WANObjectCache $WANObjectCache
+        *
+        * @return RevisionStore
+        */
+       private function getRevisionStore(
+               $loadBalancer = null,
+               $blobStore = null,
+               $WANObjectCache = null
+       ) {
+               return new RevisionStore(
+                       $loadBalancer ? $loadBalancer : $this->getMockLoadBalancer(),
+                       $blobStore ? $blobStore : $this->getMockSqlBlobStore(),
+                       $WANObjectCache ? $WANObjectCache : $this->getHashWANObjectCache()
+               );
+       }
+
+       /**
+        * @return \PHPUnit_Framework_MockObject_MockObject|LoadBalancer
+        */
+       private function getMockLoadBalancer() {
+               return $this->getMockBuilder( LoadBalancer::class )
+                       ->disableOriginalConstructor()->getMock();
+       }
+
+       /**
+        * @return \PHPUnit_Framework_MockObject_MockObject|SqlBlobStore
+        */
+       private function getMockSqlBlobStore() {
+               return $this->getMockBuilder( SqlBlobStore::class )
+                       ->disableOriginalConstructor()->getMock();
+       }
+
+       private function getHashWANObjectCache() {
+               return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\RevisionStore::getContentHandlerUseDB
+        * @covers \MediaWiki\Storage\RevisionStore::setContentHandlerUseDB
+        */
+       public function testGetSetContentHandlerDb() {
+               $store = $this->getRevisionStore();
+               $this->assertTrue( $store->getContentHandlerUseDB() );
+               $store->setContentHandlerUseDB( false );
+               $this->assertFalse( $store->getContentHandlerUseDB() );
+               $store->setContentHandlerUseDB( true );
+               $this->assertTrue( $store->getContentHandlerUseDB() );
+       }
+
+       private function getDefaultQueryFields() {
+               return [
+                       'rev_id',
+                       'rev_page',
+                       'rev_text_id',
+                       'rev_timestamp',
+                       'rev_user_text',
+                       'rev_user',
+                       'rev_minor_edit',
+                       'rev_deleted',
+                       'rev_len',
+                       'rev_parent_id',
+                       'rev_sha1',
+               ];
+       }
+
+       private function getCommentQueryFields() {
+               return [
+                       'rev_comment_text' => 'rev_comment',
+                       'rev_comment_data' => 'NULL',
+                       'rev_comment_cid' => 'NULL',
+               ];
+       }
+
+       private function getContentHandlerQueryFields() {
+               return [
+                       'rev_content_format',
+                       'rev_content_model',
+               ];
+       }
+
+       public function provideGetQueryInfo() {
+               yield [
+                       true,
+                       [],
+                       [
+                               'tables' => [ 'revision' ],
+                               'fields' => array_merge(
+                                       $this->getDefaultQueryFields(),
+                                       $this->getCommentQueryFields(),
+                                       $this->getContentHandlerQueryFields()
+                               ),
+                               'joins' => [],
+                       ]
+               ];
+               yield [
+                       false,
+                       [],
+                       [
+                               'tables' => [ 'revision' ],
+                               'fields' => array_merge(
+                                       $this->getDefaultQueryFields(),
+                                       $this->getCommentQueryFields()
+                               ),
+                               'joins' => [],
+                       ]
+               ];
+               yield [
+                       false,
+                       [ 'page' ],
+                       [
+                               'tables' => [ 'revision', 'page' ],
+                               'fields' => array_merge(
+                                       $this->getDefaultQueryFields(),
+                                       $this->getCommentQueryFields(),
+                                       [
+                                               'page_namespace',
+                                               'page_title',
+                                               'page_id',
+                                               'page_latest',
+                                               'page_is_redirect',
+                                               'page_len',
+                                       ]
+                               ),
+                               'joins' => [
+                                       'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+                               ],
+                       ]
+               ];
+               yield [
+                       false,
+                       [ 'user' ],
+                       [
+                               'tables' => [ 'revision', 'user' ],
+                               'fields' => array_merge(
+                                       $this->getDefaultQueryFields(),
+                                       $this->getCommentQueryFields(),
+                                       [
+                                               'user_name',
+                                       ]
+                               ),
+                               'joins' => [
+                                       'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+                               ],
+                       ]
+               ];
+               yield [
+                       false,
+                       [ 'text' ],
+                       [
+                               'tables' => [ 'revision', 'text' ],
+                               'fields' => array_merge(
+                                       $this->getDefaultQueryFields(),
+                                       $this->getCommentQueryFields(),
+                                       [
+                                               'old_text',
+                                               'old_flags',
+                                       ]
+                               ),
+                               'joins' => [
+                                       'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
+                               ],
+                       ]
+               ];
+               yield [
+                       true,
+                       [ 'page', 'user', 'text' ],
+                       [
+                               'tables' => [ 'revision', 'page', 'user', 'text' ],
+                               'fields' => array_merge(
+                                       $this->getDefaultQueryFields(),
+                                       $this->getCommentQueryFields(),
+                                       $this->getContentHandlerQueryFields(),
+                                       [
+                                               'page_namespace',
+                                               'page_title',
+                                               'page_id',
+                                               'page_latest',
+                                               'page_is_redirect',
+                                               'page_len',
+                                               'user_name',
+                                               'old_text',
+                                               'old_flags',
+                                       ]
+                               ),
+                               'joins' => [
+                                       'page' => [ 'INNER JOIN', [ 'page_id = rev_page' ] ],
+                                       'user' => [ 'LEFT JOIN', [ 'rev_user != 0', 'user_id = rev_user' ] ],
+                                       'text' => [ 'INNER JOIN', [ 'rev_text_id=old_id' ] ],
+                               ],
+                       ]
+               ];
+       }
+
+       /**
+        * @dataProvider provideGetQueryInfo
+        * @covers \MediaWiki\Storage\RevisionStore::getQueryInfo
+        */
+       public function testGetQueryInfo( $contentHandlerUseDb, $options, $expected ) {
+               $store = $this->getRevisionStore();
+               $store->setContentHandlerUseDB( $contentHandlerUseDb );
+               $this->assertEquals( $expected, $store->getQueryInfo( $options ) );
+       }
+
+       private function getDefaultArchiveFields() {
+               return [
+                       'ar_id',
+                       'ar_page_id',
+                       'ar_namespace',
+                       'ar_title',
+                       'ar_rev_id',
+                       'ar_text',
+                       'ar_text_id',
+                       'ar_timestamp',
+                       'ar_user_text',
+                       'ar_user',
+                       'ar_minor_edit',
+                       'ar_deleted',
+                       'ar_len',
+                       'ar_parent_id',
+                       'ar_sha1',
+               ];
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\RevisionStore::getArchiveQueryInfo
+        */
+       public function testGetArchiveQueryInfo_contentHandlerDb() {
+               $store = $this->getRevisionStore();
+               $store->setContentHandlerUseDB( true );
+               $this->assertEquals(
+                       [
+                               'tables' => [
+                                       'archive'
+                               ],
+                               'fields' => array_merge(
+                                       $this->getDefaultArchiveFields(),
+                                       [
+                                               'ar_comment_text' => 'ar_comment',
+                                               'ar_comment_data' => 'NULL',
+                                               'ar_comment_cid' => 'NULL',
+                                               'ar_content_format',
+                                               'ar_content_model',
+                                       ]
+                               ),
+                               'joins' => [],
+                       ],
+                       $store->getArchiveQueryInfo()
+               );
+       }
+
+       /**
+        * @covers \MediaWiki\Storage\RevisionStore::getArchiveQueryInfo
+        */
+       public function testGetArchiveQueryInfo_noContentHandlerDb() {
+               $store = $this->getRevisionStore();
+               $store->setContentHandlerUseDB( false );
+               $this->assertEquals(
+                       [
+                               'tables' => [
+                                       'archive'
+                               ],
+                               'fields' => array_merge(
+                                       $this->getDefaultArchiveFields(),
+                                       [
+                                               'ar_comment_text' => 'ar_comment',
+                                               'ar_comment_data' => 'NULL',
+                                               'ar_comment_cid' => 'NULL',
+                                       ]
+                               ),
+                               'joins' => [],
+                       ],
+                       $store->getArchiveQueryInfo()
+               );
+       }
+
+}
diff --git a/tests/phpunit/includes/Storage/SlotRecordTest.php b/tests/phpunit/includes/Storage/SlotRecordTest.php
new file mode 100644 (file)
index 0000000..27fcd0c
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use MediaWiki\Storage\SlotRecord;
+use MediaWikiTestCase;
+use RuntimeException;
+use Wikimedia\Assert\ParameterTypeException;
+use WikitextContent;
+
+/**
+ * @covers \MediaWiki\Storage\SlotRecord
+ */
+class SlotRecordTest extends MediaWikiTestCase {
+
+       public function provideAContent() {
+               yield [ new WikitextContent( 'A' ) ];
+               yield [
+                       function ( SlotRecord $slotRecord ) {
+                               if ( $slotRecord->getAddress() === 'tt:456' ) {
+                                       return new WikitextContent( 'A' );
+                               }
+                               throw new RuntimeException( 'Got Wrong SlotRecord for callback' );
+                       },
+               ];
+       }
+
+       /**
+        * @dataProvider provideAContent
+        */
+       public function testValidConstruction( $content ) {
+               $row = (object)[
+                       'cont_size' => '1',
+                       'cont_sha1' => 'someHash',
+                       'cont_address' => 'tt:456',
+                       'model_name' => 'aModelname',
+                       'slot_revision' => '2',
+                       'format_name' => 'someFormatName',
+                       'role_name' => 'myRole',
+                       'slot_inherited' => '99'
+               ];
+
+               $record = new SlotRecord( $row, $content );
+
+               $this->assertSame( 'A', $record->getContent()->getNativeData() );
+               $this->assertSame( 1, $record->getSize() );
+               $this->assertSame( 'someHash', $record->getSha1() );
+               $this->assertSame( 'aModelname', $record->getModel() );
+               $this->assertSame( 2, $record->getRevision() );
+               $this->assertSame( 'tt:456', $record->getAddress() );
+               $this->assertSame( 'someFormatName', $record->getFormat() );
+               $this->assertSame( 'myRole', $record->getRole() );
+               $this->assertTrue( $record->hasAddress() );
+               $this->assertTrue( $record->hasRevision() );
+               $this->assertTrue( $record->isInherited() );
+       }
+
+       public function provideInvalidConstruction() {
+               yield 'both null' => [ null, null ];
+               yield 'null row' => [ null, new WikitextContent( 'A' ) ];
+               yield 'array row' => [ null, new WikitextContent( 'A' ) ];
+               yield 'null content' => [ (object)[], null ];
+       }
+
+       /**
+        * @dataProvider provideInvalidConstruction
+        */
+       public function testInvalidConstruction( $row, $content ) {
+               $this->setExpectedException( ParameterTypeException::class );
+               new SlotRecord( $row, $content );
+       }
+
+       public function testHasAddress_false() {
+               $record = new SlotRecord( (object)[], new WikitextContent( 'A' ) );
+               $this->assertFalse( $record->hasAddress() );
+       }
+
+       public function testHasRevision_false() {
+               $record = new SlotRecord( (object)[], new WikitextContent( 'A' ) );
+               $this->assertFalse( $record->hasRevision() );
+       }
+
+       public function testInInherited_false() {
+               // TODO unskip me once fixed.
+               $this->markTestSkipped( 'Should probably return false, needs fixing?' );
+               $record = new SlotRecord( (object)[], new WikitextContent( 'A' ) );
+               $this->assertFalse( $record->isInherited() );
+       }
+
+}
diff --git a/tests/phpunit/includes/Storage/SqlBlobStoreTest.php b/tests/phpunit/includes/Storage/SqlBlobStoreTest.php
new file mode 100644 (file)
index 0000000..12d8119
--- /dev/null
@@ -0,0 +1,206 @@
+<?php
+
+namespace MediaWiki\Tests\Storage;
+
+use Language;
+use MediaWiki\MediaWikiServices;
+use MediaWiki\Storage\SqlBlobStore;
+use MediaWikiTestCase;
+use stdClass;
+use TitleValue;
+
+/**
+ * @covers \MediaWiki\Storage\SqlBlobStore
+ * @group Database
+ */
+class SqlBlobStoreTest extends MediaWikiTestCase {
+
+       /**
+        * @return SqlBlobStore
+        */
+       public function getBlobStore( $legacyEncoding = false, $compressRevisions = false ) {
+               $services = MediaWikiServices::getInstance();
+
+               $store = new SqlBlobStore(
+                       $services->getDBLoadBalancer(),
+                       $services->getMainWANObjectCache()
+               );
+
+               if ( $compressRevisions ) {
+                       $store->setCompressBlobs( $compressRevisions );
+               }
+               if ( $legacyEncoding ) {
+                       $store->setLegacyEncoding( $legacyEncoding, Language::factory( 'en' ) );
+               }
+
+               return $store;
+       }
+
+       /**
+        * @covers SqlBlobStore::getCompressBlobs()
+        * @covers SqlBlobStore::setCompressBlobs()
+        */
+       public function testGetSetCompressRevisions() {
+               $store = $this->getBlobStore();
+               $this->assertFalse( $store->getCompressBlobs() );
+               $store->setCompressBlobs( true );
+               $this->assertTrue( $store->getCompressBlobs() );
+       }
+
+       /**
+        * @covers SqlBlobStore::getLegacyEncoding()
+        * @covers SqlBlobStore::getLegacyEncodingConversionLang()
+        * @covers SqlBlobStore::setLegacyEncoding()
+        */
+       public function testGetSetLegacyEncoding() {
+               $store = $this->getBlobStore();
+               $this->assertFalse( $store->getLegacyEncoding() );
+               $this->assertNull( $store->getLegacyEncodingConversionLang() );
+               $en = Language::factory( 'en' );
+               $store->setLegacyEncoding( 'foo', $en );
+               $this->assertSame( 'foo', $store->getLegacyEncoding() );
+               $this->assertSame( $en, $store->getLegacyEncodingConversionLang() );
+       }
+
+       /**
+        * @covers SqlBlobStore::getCacheExpiry()
+        * @covers SqlBlobStore::setCacheExpiry()
+        */
+       public function testGetSetCacheExpiry() {
+               $store = $this->getBlobStore();
+               $this->assertSame( 604800, $store->getCacheExpiry() );
+               $store->setCacheExpiry( 12 );
+               $this->assertSame( 12, $store->getCacheExpiry() );
+       }
+
+       /**
+        * @covers SqlBlobStore::getUseExternalStore()
+        * @covers SqlBlobStore::setUseExternalStore()
+        */
+       public function testGetSetUseExternalStore() {
+               $store = $this->getBlobStore();
+               $this->assertFalse( $store->getUseExternalStore() );
+               $store->setUseExternalStore( true );
+               $this->assertTrue( $store->getUseExternalStore() );
+       }
+
+       public function provideDecompress() {
+               yield '(no legacy encoding), false in false out' => [ false, false, [], false ];
+               yield '(no legacy encoding), empty in empty out' => [ false, '', [], '' ];
+               yield '(no legacy encoding), empty in empty out' => [ false, 'A', [], 'A' ];
+               yield '(no legacy encoding), string in with gzip flag returns string' => [
+                       // gzip string below generated with gzdeflate( 'AAAABBAAA' )
+                       false, "sttttr\002\022\000", [ 'gzip' ], 'AAAABBAAA',
+               ];
+               yield '(no legacy encoding), string in with object flag returns false' => [
+                       // gzip string below generated with serialize( 'JOJO' )
+                       false, "s:4:\"JOJO\";", [ 'object' ], false,
+               ];
+               yield '(no legacy encoding), serialized object in with object flag returns string' => [
+                       false,
+                       // Using a TitleValue object as it has a getText method (which is needed)
+                       serialize( new TitleValue( 0, 'HHJJDDFF' ) ),
+                       [ 'object' ],
+                       'HHJJDDFF',
+               ];
+               yield '(no legacy encoding), serialized object in with object & gzip flag returns string' => [
+                       false,
+                       // Using a TitleValue object as it has a getText method (which is needed)
+                       gzdeflate( serialize( new TitleValue( 0, '8219JJJ840' ) ) ),
+                       [ 'object', 'gzip' ],
+                       '8219JJJ840',
+               ];
+               yield '(ISO-8859-1 encoding), string in string out' => [
+                       'ISO-8859-1',
+                       iconv( 'utf8', 'ISO-8859-1', "1®Àþ1" ),
+                       [],
+                       '1®Àþ1',
+               ];
+               yield '(ISO-8859-1 encoding), serialized object in with gzip flags returns string' => [
+                       'ISO-8859-1',
+                       gzdeflate( iconv( 'utf8', 'ISO-8859-1', "4®Àþ4" ) ),
+                       [ 'gzip' ],
+                       '4®Àþ4',
+               ];
+               yield '(ISO-8859-1 encoding), serialized object in with object flags returns string' => [
+                       'ISO-8859-1',
+                       serialize( new TitleValue( 0, iconv( 'utf8', 'ISO-8859-1', "3®Àþ3" ) ) ),
+                       [ 'object' ],
+                       '3®Àþ3',
+               ];
+               yield '(ISO-8859-1 encoding), serialized object in with object & gzip flags returns string' => [
+                       'ISO-8859-1',
+                       gzdeflate( serialize( new TitleValue( 0, iconv( 'utf8', 'ISO-8859-1', "2®Àþ2" ) ) ) ),
+                       [ 'gzip', 'object' ],
+                       '2®Àþ2',
+               ];
+       }
+
+       /**
+        * @dataProvider provideDecompress
+        * @covers SqlBlobStore::decompressData
+        *
+        * @param string|bool $legacyEncoding
+        * @param mixed $data
+        * @param array $flags
+        * @param mixed $expected
+        */
+       public function testDecompressData( $legacyEncoding, $data, $flags, $expected ) {
+               $store = $this->getBlobStore( $legacyEncoding );
+               $this->assertSame(
+                       $expected,
+                       $store->decompressData( $data, $flags )
+               );
+       }
+
+       /**
+        * @covers SqlBlobStore::compressData
+        */
+       public function testCompressRevisionTextUtf8() {
+               $store = $this->getBlobStore();
+               $row = new stdClass;
+               $row->old_text = "Wiki est l'\xc3\xa9cole superieur !";
+               $row->old_flags = $store->compressData( $row->old_text );
+               $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ),
+                       "Flags should contain 'utf-8'" );
+               $this->assertFalse( false !== strpos( $row->old_flags, 'gzip' ),
+                       "Flags should not contain 'gzip'" );
+               $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
+                       $row->old_text, "Direct check" );
+       }
+
+       /**
+        * @covers SqlBlobStore::compressData
+        */
+       public function testCompressRevisionTextUtf8Gzip() {
+               $store = $this->getBlobStore( false, true );
+               $this->checkPHPExtension( 'zlib' );
+
+               $row = new stdClass;
+               $row->old_text = "Wiki est l'\xc3\xa9cole superieur !";
+               $row->old_flags = $store->compressData( $row->old_text );
+               $this->assertTrue( false !== strpos( $row->old_flags, 'utf-8' ),
+                       "Flags should contain 'utf-8'" );
+               $this->assertTrue( false !== strpos( $row->old_flags, 'gzip' ),
+                       "Flags should contain 'gzip'" );
+               $this->assertEquals( "Wiki est l'\xc3\xa9cole superieur !",
+                       gzinflate( $row->old_text ), "Direct check" );
+       }
+
+       public function provideBlobs() {
+               yield [ '' ];
+               yield [ 'someText' ];
+       }
+
+       /**
+        * @dataProvider provideBlobs
+        * @covers SqlBlobStore::storeBlob
+        * @covers SqlBlobStore::getBlob
+        */
+       public function testSimpleStoreGetBlobSimpleRoundtrip( $blob ) {
+               $store = $this->getBlobStore();
+               $address = $store->storeBlob( $blob );
+               $this->assertSame( $blob, $store->getBlob( $address ) );
+       }
+
+}
index 674bf07..fbd159c 100644 (file)
                                        output: 'http://host/wiki/Special:RecentChangesLinked?target=Moai',
                                        message: 'Target as subpage in path'
                                },
+                               {
+                                       input: 'http://host/wiki/Special:RecentChangesLinked/Château',
+                                       output: 'http://host/wiki/Special:RecentChangesLinked?target=Château',
+                                       message: 'Target as subpage in path with special characters'
+                               },
+                               {
+                                       input: 'http://host/wiki/Special:RecentChangesLinked/Moai/Sub1',
+                                       output: 'http://host/wiki/Special:RecentChangesLinked?target=Moai/Sub1',
+                                       message: 'Target as subpage also has a subpage'
+                               },
                                {
                                        input: 'http://host/wiki/Special:RecentChangesLinked/Category:Foo',
                                        output: 'http://host/wiki/Special:RecentChangesLinked?target=Category:Foo',
                                        message: 'Target as subpage in path (with namespace)'
                                },
+                               {
+                                       input: 'http://host/wiki/Special:RecentChangesLinked/Category:Foo/Bar',
+                                       output: 'http://host/wiki/Special:RecentChangesLinked?target=Category:Foo/Bar',
+                                       message: 'Target as subpage in path also has a subpage (with namespace)'
+                               },
                                {
                                        input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Moai',
                                        output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Moai',
                                        message: 'Target as subpage in title param'
                                },
+                               {
+                                       input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Moai/Sub1',
+                                       output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Moai/Sub1',
+                                       message: 'Target as subpage in title param also has a subpage'
+                               },
+                               {
+                                       input: 'http://host/w/index.php?title=Special:RecentChangesLinked/Category:Foo/Bar',
+                                       output: 'http://host/w/index.php?title=Special:RecentChangesLinked&target=Category:Foo/Bar',
+                                       message: 'Target as subpage in title param also has a subpage (with namespace)'
+                               },
                                {
                                        input: 'http://host/wiki/Special:Watchlist',
                                        output: 'http://host/wiki/Special:Watchlist',