[MCR] Introduce SlotRoleHandler and SlotRoleRegistry
authordaniel <dkinzler@wikimedia.org>
Mon, 19 Nov 2018 11:39:56 +0000 (12:39 +0100)
committerdaniel <dkinzler@wikimedia.org>
Fri, 30 Nov 2018 20:29:05 +0000 (12:29 -0800)
These new classes provide a mechanism for defining the
behavior of slots, like the content models it supports.
This acts as an extension point for extensions that need
to define custom slots, like the MediaInfo extension
for the SDC project.

Bug: T194046
Change-Id: Ia20c98eee819293199e541be75b5521f6413bc2f

39 files changed:
includes/DefaultSettings.php
includes/MWNamespace.php
includes/MediaWikiServices.php
includes/MovePage.php
includes/Revision/FallbackSlotRoleHandler.php [new file with mode: 0644]
includes/Revision/MainSlotRoleHandler.php [new file with mode: 0644]
includes/Revision/RenderedRevision.php
includes/Revision/RevisionRenderer.php
includes/Revision/RevisionStore.php
includes/Revision/RevisionStoreFactory.php
includes/Revision/SlotRoleHandler.php [new file with mode: 0644]
includes/Revision/SlotRoleRegistry.php [new file with mode: 0644]
includes/ServiceWiring.php
includes/Storage/DerivedPageDataUpdater.php
includes/Storage/PageUpdater.php
includes/Title.php
includes/api/ApiComparePages.php
includes/api/ApiQueryRevisionsBase.php
includes/content/Content.php
includes/content/ContentHandler.php
includes/diff/DifferenceEngine.php
includes/page/WikiPage.php
languages/i18n/en.json
languages/i18n/qqq.json
tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php [new file with mode: 0644]
tests/phpunit/includes/Revision/MainSlotRoleHandlerTest.php [new file with mode: 0644]
tests/phpunit/includes/Revision/RevisionRendererTest.php
tests/phpunit/includes/Revision/RevisionStoreDbTestBase.php
tests/phpunit/includes/Revision/RevisionStoreFactoryTest.php
tests/phpunit/includes/Revision/RevisionStoreTest.php
tests/phpunit/includes/Revision/SlotRoleHandlerTest.php [new file with mode: 0644]
tests/phpunit/includes/Revision/SlotRoleRegistryTest.php [new file with mode: 0644]
tests/phpunit/includes/RevisionDbTestBase.php
tests/phpunit/includes/RevisionTest.php
tests/phpunit/includes/Storage/DerivedPageDataUpdaterTest.php
tests/phpunit/includes/Storage/PageUpdaterTest.php
tests/phpunit/includes/jobqueue/jobs/RefreshLinksJobTest.php
tests/phpunit/includes/page/WikiPageDbTestBase.php
tests/phpunit/includes/page/WikiPageMcrReadNewDbTest.php

index 2d1681c..cfa8afe 100644 (file)
@@ -8606,6 +8606,9 @@ $wgUploadMaintenance = false;
  * defined for a given namespace, pages in that namespace will use the CONTENT_MODEL_WIKITEXT
  * (except for the special case of JS and CS pages).
  *
+ * @note To determine the default model for a new page's main slot, or any slot in general,
+ * use SlotRoleHandler::getDefaultModel() together with SlotRoleRegistry::getRoleHandler().
+ *
  * @since 1.21
  */
 $wgNamespaceContentModels = [];
index e03a29b..98e70bf 100644 (file)
@@ -19,6 +19,7 @@
  *
  * @file
  */
+use MediaWiki\MediaWikiServices;
 
 /**
  * This is a utility class with only static functions
@@ -462,13 +463,17 @@ class MWNamespace {
         * Get the default content model for a namespace
         * This does not mean that all pages in that namespace have the model
         *
+        * @note To determine the default model for a new page's main slot, or any slot in general,
+        * use SlotRoleHandler::getDefaultModel() together with SlotRoleRegistry::getRoleHandler().
+        *
         * @since 1.21
         * @param int $index Index to check
         * @return null|string Default model name for the given namespace, if set
         */
        public static function getNamespaceContentModel( $index ) {
-               global $wgNamespaceContentModels;
-               return $wgNamespaceContentModels[$index] ?? null;
+               $config = MediaWikiServices::getInstance()->getMainConfig();
+               $models = $config->get( 'NamespaceContentModels' );
+               return $models[$index] ?? null;
        }
 
        /**
index f3ca7d4..0e36b22 100644 (file)
@@ -17,6 +17,7 @@ use MediaWiki\Http\HttpRequestFactory;
 use MediaWiki\Preferences\PreferencesFactory;
 use MediaWiki\Shell\CommandFactory;
 use MediaWiki\Revision\RevisionRenderer;
+use MediaWiki\Revision\SlotRoleRegistry;
 use MediaWiki\Special\SpecialPageFactory;
 use MediaWiki\Storage\BlobStore;
 use MediaWiki\Storage\BlobStoreFactory;
@@ -840,6 +841,14 @@ class MediaWikiServices extends ServiceContainer {
                return $this->getService( 'SkinFactory' );
        }
 
+       /**
+        * @since 1.33
+        * @return SlotRoleRegistry
+        */
+       public function getSlotRoleRegistry() {
+               return $this->getService( 'SlotRoleRegistry' );
+       }
+
        /**
         * @since 1.31
         * @return NameTableStore
index 0fd697b..bb76395 100644 (file)
@@ -20,6 +20,7 @@
  */
 
 use MediaWiki\MediaWikiServices;
+use MediaWiki\Revision\SlotRecord;
 
 /**
  * Handles the backend logic of moving a page from one title
@@ -137,7 +138,8 @@ class MovePage {
                        $status->fatal(
                                'content-not-allowed-here',
                                ContentHandler::getLocalizedName( $this->oldTitle->getContentModel() ),
-                               $this->newTitle->getPrefixedText()
+                               $this->newTitle->getPrefixedText(),
+                               SlotRecord::MAIN
                        );
                }
 
diff --git a/includes/Revision/FallbackSlotRoleHandler.php b/includes/Revision/FallbackSlotRoleHandler.php
new file mode 100644 (file)
index 0000000..78dfd39
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * A SlotRoleHandler for providing basic functionality for undefined slot roles.
+ *
+ * This class is intended to be used when encountering slots with a role that used to be defined
+ * by an extension, but no longer is backed by hany specific handler, since the extension in
+ * question has been uninstalled. It may also be used for pages imported from another wiki.
+ *
+ * @since 1.33
+ */
+class FallbackSlotRoleHandler extends SlotRoleHandler {
+
+       public function __construct( $role ) {
+               // treat unknown content as plain text
+               parent::__construct( $role, CONTENT_MODEL_TEXT );
+       }
+
+       /**
+        * @param LinkTarget $page
+        *
+        * @return bool Always false, to prevent undefined slots from being used in new revisions.
+        */
+       public function isAllowedOn( LinkTarget $page ) {
+               return false;
+       }
+
+       /**
+        * @param string $model
+        * @param LinkTarget $page
+        *
+        * @return bool Always false, to prevent undefined slots from being used for
+        *         arbitrary content.
+        */
+       public function isAllowedModel( $model, LinkTarget $page ) {
+               return false;
+       }
+
+       public function getOutputLayoutHints() {
+               // TODO: should be return [ 'display' => 'none'] here, causing undefined slots
+               // to be hidden? We'd still need some place to surface the content of such
+               // slots, see T209923.
+
+               return parent::getOutputLayoutHints(); // TODO: Change the autogenerated stub
+       }
+
+}
diff --git a/includes/Revision/MainSlotRoleHandler.php b/includes/Revision/MainSlotRoleHandler.php
new file mode 100644 (file)
index 0000000..6c6fdd6
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use ContentHandler;
+use Hooks;
+use MediaWiki\Linker\LinkTarget;
+use Title;
+
+/**
+ * A SlotRoleHandler for the main slot. While most slot roles serve a specific purpose and
+ * thus typically exhibit the same behaviour on all pages, the main slot is used for different
+ * things in different pages, typically depending on the namespace, a "file extension" in
+ * the page name, or the content model of the slot's content.
+ *
+ * MainSlotRoleHandler implements some of the per-namespace and per-model behavior that was
+ * supported prior to MediaWiki Version 1.33.
+ *
+ * @since 1.33
+ */
+class MainSlotRoleHandler extends SlotRoleHandler {
+
+       /**
+        * @var string[] A mapping of namespaces to content models.
+        * @see $wgNamespaceContentModels
+        */
+       private $namespaceContentModels;
+
+       /**
+        * @param string[] $namespaceContentModels A mapping of namespaces to content models,
+        *        typically from $wgNamespaceContentModels.
+        */
+       public function __construct( array $namespaceContentModels ) {
+               parent::__construct( 'main', CONTENT_MODEL_WIKITEXT );
+               $this->namespaceContentModels = $namespaceContentModels;
+       }
+
+       public function supportsArticleCount() {
+               return true;
+       }
+
+       /**
+        * @param string $model
+        * @param LinkTarget $page
+        *
+        * @return bool
+        */
+       public function isAllowedModel( $model, LinkTarget $page ) {
+               $title = Title::newFromLinkTarget( $page );
+               $handler = ContentHandler::getForModelID( $model );
+               return $handler->canBeUsedOn( $title );
+       }
+
+       /**
+        * @param LinkTarget $page
+        *
+        * @return string
+        */
+       public function getDefaultModel( LinkTarget $page ) {
+               // NOTE: this method must not rely on $title->getContentModel() directly or indirectly,
+               //       because it is used to initialize the mContentModel member.
+
+               $ext = '';
+               $ns = $page->getNamespace();
+               $model = $this->namespaceContentModels[$ns] ?? null;
+
+               // Hook can determine default model
+               $title = Title::newFromLinkTarget( $page );
+               if ( !Hooks::run( 'ContentHandlerDefaultModelFor', [ $title, &$model ] ) ) {
+                       if ( !is_null( $model ) ) {
+                               return $model;
+                       }
+               }
+
+               // Could this page contain code based on the title?
+               $isCodePage = $ns === NS_MEDIAWIKI && preg_match( '!\.(css|js|json)$!u', $title->getText(), $m );
+               if ( $isCodePage ) {
+                       $ext = $m[1];
+               }
+
+               // Is this a user subpage containing code?
+               $isCodeSubpage = $ns === NS_USER
+                       && !$isCodePage
+                       && preg_match( "/\\/.*\\.(js|css|json)$/", $title->getText(), $m );
+
+               if ( $isCodeSubpage ) {
+                       $ext = $m[1];
+               }
+
+               // Is this wikitext, according to $wgNamespaceContentModels or the DefaultModelFor hook?
+               $isWikitext = is_null( $model ) || $model == CONTENT_MODEL_WIKITEXT;
+               $isWikitext = $isWikitext && !$isCodePage && !$isCodeSubpage;
+
+               if ( !$isWikitext ) {
+                       switch ( $ext ) {
+                               case 'js':
+                                       return CONTENT_MODEL_JAVASCRIPT;
+                               case 'css':
+                                       return CONTENT_MODEL_CSS;
+                               case 'json':
+                                       return CONTENT_MODEL_JSON;
+                               default:
+                                       return is_null( $model ) ? CONTENT_MODEL_TEXT : $model;
+                       }
+               }
+
+               // We established that it must be wikitext
+
+               return CONTENT_MODEL_WIKITEXT;
+       }
+
+}
index 6eee3c4..8e50a1b 100644 (file)
@@ -208,6 +208,7 @@ class RenderedRevision implements SlotRenderingProvider {
                                        'Access to the content has been suppressed for this audience'
                                );
                        } else {
+                               // XXX: allow SlotRoleHandler to control the ParserOutput?
                                $output = $this->getSlotParserOutputUncached( $content, $withHtml );
 
                                if ( $withHtml && !$output->hasText() ) {
index e2e84b6..eb3f231 100644 (file)
@@ -50,15 +50,24 @@ class RevisionRenderer {
        /** @var ILoadBalancer */
        private $loadBalancer;
 
+       /** @var SlotRoleRegistry */
+       private $roleRegistery;
+
        /** @var string|bool */
        private $wikiId;
 
        /**
         * @param ILoadBalancer $loadBalancer
+        * @param SlotRoleRegistry $roleRegistry
         * @param bool|string $wikiId
         */
-       public function __construct( ILoadBalancer $loadBalancer, $wikiId = false ) {
+       public function __construct(
+               ILoadBalancer $loadBalancer,
+               SlotRoleRegistry $roleRegistry,
+               $wikiId = false
+       ) {
                $this->loadBalancer = $loadBalancer;
+               $this->roleRegistery = $roleRegistry;
                $this->wikiId = $wikiId;
 
                $this->saveParseLogger = new NullLogger();
@@ -175,8 +184,6 @@ class RevisionRenderer {
                        return $rrev->getSlotParserOutput( SlotRecord::MAIN );
                }
 
-               // TODO: put fancy layout logic here, see T200915.
-
                // move main slot to front
                if ( isset( $slots[SlotRecord::MAIN] ) ) {
                        $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
@@ -192,6 +199,7 @@ class RevisionRenderer {
                        $out = $rrev->getSlotParserOutput( $role, $hints );
                        $slotOutput[$role] = $out;
 
+                       // XXX: should the SlotRoleHandler be able to intervene here?
                        $combinedOutput->mergeInternalMetaDataFrom( $out, $role );
                        $combinedOutput->mergeTrackingMetaDataFrom( $out );
                }
@@ -201,6 +209,16 @@ class RevisionRenderer {
                        $first = true;
                        /** @var ParserOutput $out */
                        foreach ( $slotOutput as $role => $out ) {
+                               $roleHandler = $this->roleRegistery->getRoleHandler( $role );
+
+                               // TODO: put more fancy layout logic here, see T200915.
+                               $layout = $roleHandler->getOutputLayoutHints();
+                               $display = $layout['display'] ?? 'section';
+
+                               if ( $display === 'none' ) {
+                                       continue;
+                               }
+
                                if ( $first ) {
                                        // skip header for the first slot
                                        $first = false;
@@ -210,6 +228,8 @@ class RevisionRenderer {
                                        $html .= Html::rawElement( 'h1', [ 'class' => 'mw-slot-header' ], $headText );
                                }
 
+                               // XXX: do we want to put a wrapper div around the output?
+                               // Do we want to let $roleHandler do that?
                                $html .= $out->getRawText();
                                $combinedOutput->mergeHtmlMetaDataFrom( $out );
                        }
index 6d3b72c..b049945 100644 (file)
@@ -132,6 +132,9 @@ class RevisionStore
        /** @var int An appropriate combination of SCHEMA_COMPAT_XXX flags. */
        private $mcrMigrationStage;
 
+       /** @var SlotRoleRegistry */
+       private $slotRoleRegistry;
+
        /**
         * @todo $blobStore should be allowed to be any BlobStore!
         *
@@ -146,11 +149,11 @@ class RevisionStore
         * @param CommentStore $commentStore
         * @param NameTableStore $contentModelStore
         * @param NameTableStore $slotRoleStore
+        * @param SlotRoleRegistry $slotRoleRegistry
         * @param int $mcrMigrationStage An appropriate combination of SCHEMA_COMPAT_XXX flags
         * @param ActorMigration $actorMigration
         * @param bool|string $wikiId
         *
-        * @throws MWException if $mcrMigrationStage or $wikiId is invalid.
         */
        public function __construct(
                ILoadBalancer $loadBalancer,
@@ -159,6 +162,7 @@ class RevisionStore
                CommentStore $commentStore,
                NameTableStore $contentModelStore,
                NameTableStore $slotRoleStore,
+               SlotRoleRegistry $slotRoleRegistry,
                $mcrMigrationStage,
                ActorMigration $actorMigration,
                $wikiId = false
@@ -199,6 +203,7 @@ class RevisionStore
                $this->commentStore = $commentStore;
                $this->contentModelStore = $contentModelStore;
                $this->slotRoleStore = $slotRoleStore;
+               $this->slotRoleRegistry = $slotRoleRegistry;
                $this->mcrMigrationStage = $mcrMigrationStage;
                $this->actorMigration = $actorMigration;
                $this->wikiId = $wikiId;
@@ -923,7 +928,7 @@ class RevisionStore
                $format = $content->getDefaultFormat();
                $model = $content->getModel();
 
-               $this->checkContent( $content, $title );
+               $this->checkContent( $content, $title, $slot->getRole() );
 
                return $this->blobStore->storeBlob(
                        $content->serialize( $format ),
@@ -982,11 +987,12 @@ class RevisionStore
         *
         * @param Content $content
         * @param Title $title
+        * @param string $role
         *
         * @throws MWException
         * @throws MWUnknownContentModelException
         */
-       private function checkContent( Content $content, Title $title ) {
+       private function checkContent( Content $content, Title $title, $role ) {
                // Note: may return null for revisions that have not yet been inserted
 
                $model = $content->getModel();
@@ -1005,7 +1011,8 @@ class RevisionStore
 
                        $this->assertCrossWikiContentLoadingIsSafe();
 
-                       $defaultModel = ContentHandler::getDefaultModelFor( $title );
+                       $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
+                       $defaultModel = $roleHandler->getDefaultModel( $title );
                        $defaultHandler = ContentHandler::getForModelID( $defaultModel );
                        $defaultFormat = $defaultHandler->getDefaultFormat();
 
@@ -1350,9 +1357,8 @@ class RevisionStore
                        $mainSlotRow->model_name = function ( SlotRecord $slot ) use ( $title ) {
                                $this->assertCrossWikiContentLoadingIsSafe();
 
-                               // TODO: MCR: consider slot role in getDefaultModelFor()! Use LinkTarget!
-                               // TODO: MCR: deprecate $title->getModel().
-                               return ContentHandler::getDefaultModelFor( $title );
+                               return $this->slotRoleRegistry->getRoleHandler( $slot->getRole() )
+                                       ->getDefaultModel( $title );
                        };
                }
 
index 30ffc99..6b3117f 100644 (file)
@@ -72,10 +72,14 @@ class RevisionStoreFactory {
        /** @var NameTableStoreFactory */
        private $nameTables;
 
+       /** @var SlotRoleRegistry */
+       private $slotRoleRegistry;
+
        /**
         * @param ILBFactory $dbLoadBalancerFactory
         * @param BlobStoreFactory $blobStoreFactory
         * @param NameTableStoreFactory $nameTables
+        * @param SlotRoleRegistry $slotRoleRegistry
         * @param WANObjectCache $cache
         * @param CommentStore $commentStore
         * @param ActorMigration $actorMigration
@@ -88,6 +92,7 @@ class RevisionStoreFactory {
                ILBFactory $dbLoadBalancerFactory,
                BlobStoreFactory $blobStoreFactory,
                NameTableStoreFactory $nameTables,
+               SlotRoleRegistry $slotRoleRegistry,
                WANObjectCache $cache,
                CommentStore $commentStore,
                ActorMigration $actorMigration,
@@ -98,6 +103,7 @@ class RevisionStoreFactory {
                Assert::parameterType( 'integer', $migrationStage, '$migrationStage' );
                $this->dbLoadBalancerFactory = $dbLoadBalancerFactory;
                $this->blobStoreFactory = $blobStoreFactory;
+               $this->slotRoleRegistry = $slotRoleRegistry;
                $this->nameTables = $nameTables;
                $this->cache = $cache;
                $this->commentStore = $commentStore;
@@ -124,6 +130,7 @@ class RevisionStoreFactory {
                        $this->commentStore,
                        $this->nameTables->getContentModels( $wikiId ),
                        $this->nameTables->getSlotRoles( $wikiId ),
+                       $this->slotRoleRegistry,
                        $this->mcrMigrationStage,
                        $this->actorMigration,
                        $wikiId
diff --git a/includes/Revision/SlotRoleHandler.php b/includes/Revision/SlotRoleHandler.php
new file mode 100644 (file)
index 0000000..85b4c5a
--- /dev/null
@@ -0,0 +1,159 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use MediaWiki\Linker\LinkTarget;
+
+/**
+ * SlotRoleHandler instances are used to declare the existence and behavior of slot roles.
+ * Most importantly, they control which content model can be used for the slot, and how it is
+ * represented in the rendered verswion of page content.
+ *
+ * @since 1.33
+ */
+class SlotRoleHandler {
+
+       /**
+        * @var string
+        */
+       private $role;
+
+       /**
+        * @var array
+        * @see getOutputLayoutHints
+        */
+       private $layout = [
+               'display' => 'section', // use 'none' to suppress
+               'region' => 'center',
+               'placement' => 'append'
+       ];
+
+       /**
+        * @var string
+        */
+       private $contentModel;
+
+       /**
+        * @param string $role The name of the slot role defined by this SlotRoleHandler. See
+        *        SlotRoleRegistry::defineRole for more information.
+        * @param string $contentModel The default content model for this slot. As per the default
+        *        implementation of isAllowedModel(), also the only content model allowed for the
+        *        slot. Subclasses may however handle default and allowed models differently.
+        * @param array $layout Layout hints, for use by RevisionRenderer. See getOutputLayoutHints.
+        */
+       public function __construct( $role, $contentModel, $layout = [] ) {
+               $this->role = $role;
+               $this->contentModel = $contentModel;
+               $this->layout = array_merge( $this->layout, $layout );
+       }
+
+       /**
+        * @return string The role this SlotRoleHandler applies to
+        */
+       public function getRole() {
+               return $this->role;
+       }
+
+       /**
+        * Layout hints for use while laying out the combined output of all slots, typically by
+        * RevisionRenderer. The layout hints are given as an associative array. Well-known keys
+        * to use:
+        *
+        * * "display": how the output of this slot should be represented. Supported values:
+        *   - "section": show as a top level section of the region.
+        *   - "none": do not show at all
+        *   Further values that may be supported in the future include "box" and "banner".
+        * * "region": in which region of the page the output should be placed. Supported values:
+        *   - "center": the central content area.
+        *   Further values that may be supported in the future include "top" and "bottom", "left"
+        *   and "right", "header" and "footer".
+        * * "placement": placement relative to other content of the same area.
+        *   - "append": place at the end, after any output processed previously.
+        *   Further values that may be supported in the future include "prepend". A "weight" key
+        *   may be introduced for more fine grained control.
+        *
+        * @return array an associative array of hints
+        */
+       public function getOutputLayoutHints() {
+               return $this->layout;
+       }
+
+       /**
+        * The message key for the translation of the slot name.
+        *
+        * @return string
+        */
+       public function getNameMessageKey() {
+               return 'slot-name-' . $this->role;
+       }
+
+       /**
+        * Determines the content model to use per default for this slot on the given page.
+        *
+        * The default implementation always returns the content model provided to the constructor.
+        * Subclasses may base the choice on default model on the page title or namespace.
+        * The choice should not depend on external state, such as the page content.
+        *
+        * @param LinkTarget $page
+        *
+        * @return string
+        */
+       public function getDefaultModel( LinkTarget $page ) {
+               return $this->contentModel;
+       }
+
+       /**
+        * Determines whether the given model can be used on this slot on the given page.
+        *
+        * The default implementation checks whether $model is the content model provided to the
+        * constructor. Subclasses may allow other models and may base the decision on the page title
+        * or namespace. The choice should not depend on external state, such as the page content.
+        *
+        * @note This should be checked when creating new revisions. Existing revisions
+        *       are not guaranteed to comply with the return value.
+        *
+        * @param string $model
+        * @param LinkTarget $page
+        *
+        * @return bool
+        */
+       public function isAllowedModel( $model, LinkTarget $page ) {
+               return ( $model === $this->contentModel );
+       }
+
+       /**
+        * Whether this slot should be considered when determining whether a page should be counted
+        * as an "article" in the site statistics.
+        *
+        * For a page to be considered countable, one of the page's slots must return true from this
+        * method, and Content::isCountable() must return true for the content of that slot.
+        *
+        * The default implementation always returns false.
+        *
+        * @return string
+        */
+       public function supportsArticleCount() {
+               return false;
+       }
+
+}
diff --git a/includes/Revision/SlotRoleRegistry.php b/includes/Revision/SlotRoleRegistry.php
new file mode 100644 (file)
index 0000000..b108b98
--- /dev/null
@@ -0,0 +1,236 @@
+<?php
+/**
+ * This file is part of MediaWiki.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, write to the Free Software Foundation, Inc.,
+ * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ * http://www.gnu.org/copyleft/gpl.html
+ *
+ * @file
+ */
+
+namespace MediaWiki\Revision;
+
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Linker\LinkTarget;
+use MediaWiki\Storage\NameTableStore;
+use Wikimedia\Assert\Assert;
+
+/**
+ * A registry service for SlotRoleHandlers, used to define which slot roles are available on
+ * which page.
+ *
+ * Extensions may use the SlotRoleRegistry to register the slots they define.
+ *
+ * In the context of the SlotRoleRegistry, it is useful to distinguish between "defined" and "known"
+ * slot roles: A slot role is "defined" if defineRole() or defineRoleWithModel() was called for
+ * that role. A slot role is "known" if the NameTableStore provided to the constructor as the
+ * $roleNamesStore parameter has an ID associated with that role, which essentially means that
+ * the role at some point has been used on the wiki. Roles that are not "defined" but are
+ * "known" typically belong to extensions that used to be installed on the wiki, but no longer are.
+ * Such slots should be considered ok for display and administrative operations, but only "defined"
+ * slots should be supported for editing.
+ *
+ * @since 1.33
+ */
+class SlotRoleRegistry {
+
+       /**
+        * @var NameTableStore
+        */
+       private $roleNamesStore;
+
+       /**
+        * @var callable[]
+        */
+       private $instantiators = [];
+
+       /**
+        * @var SlotRoleHandler[]
+        */
+       private $handlers;
+
+       /**
+        * SlotRoleRegistry constructor.
+        *
+        * @param NameTableStore $roleNamesStore
+        */
+       public function __construct( NameTableStore $roleNamesStore ) {
+               $this->roleNamesStore = $roleNamesStore;
+       }
+
+       /**
+        * Defines a slot role.
+        *
+        * For use by extensions that wish to define roles beyond the main slot role.
+        *
+        * @see defineRoleWithModel()
+        *
+        * @param string $role The role name of the slot to define. This should follow the
+        *        same convention as message keys:
+        * @param callable $instantiator called with $role as a parameter;
+        *        Signature: function ( string $role ): SlotRoleHandler
+        */
+       public function defineRole( $role, callable $instantiator ) {
+               if ( $this->isDefinedRole( $role ) ) {
+                       throw new LogicException( "Role $role is already defined" );
+               }
+
+               $this->instantiators[$role] = $instantiator;
+       }
+
+       /**
+        * Defines a slot role that allows only the given content model, and has no special
+        * behavior.
+        *
+        * For use by extensions that wish to define roles beyond the main slot role, but have
+        * no need to implement any special behavior for that slot.
+        *
+        * @see defineRole()
+        *
+        * @param string $role The role name of the slot to define, see defineRole()
+        *        for more information.
+        * @param string $model A content model name, see ContentHandler
+        * @param array $layout See SlotRoleHandler getOutputLayoutHints
+        */
+       public function defineRoleWithModel( $role, $model, $layout = [] ) {
+               $this->defineRole(
+                       $role,
+                       function ( $role ) use ( $model, $layout ) {
+                               return new SlotRoleHandler( $role, $model, $layout );
+                       }
+               );
+       }
+
+       /**
+        * Gets the SlotRoleHandler that should be used when processing content of the given role.
+        *
+        * @param string $role
+        *
+        * @throws InvalidArgumentException If $role is not a known slot role.
+        * @return SlotRoleHandler The handler to be used for $role. This may be a
+        *         FallbackSlotRoleHandler if the slot is "known" but not "defined".
+        */
+       public function getRoleHandler( $role ) {
+               if ( !isset( $this->handlers[$role] ) ) {
+                       if ( !$this->isDefinedRole( $role ) ) {
+                               if ( $this->isKnownRole( $role ) ) {
+                                       // The role has no handler defined, but is represented in the database.
+                                       // This may happen e.g. when the extension that defined the role was uninstalled.
+                                       wfWarn( __METHOD__ . ": known but undefined slot role $role" );
+                                       $this->handlers[$role] = new FallbackSlotRoleHandler( $role );
+                               } else {
+                                       // The role doesn't have a handler defined, and is not represented in
+                                       // the database. Something must be quite wrong.
+                                       throw new InvalidArgumentException( "Unknown role $role" );
+                               }
+                       } else {
+                               $handler = call_user_func( $this->instantiators[$role], $role );
+
+                               Assert::postcondition(
+                                       $handler instanceof SlotRoleHandler,
+                                       "Instantiator for $role role must return a SlotRoleHandler"
+                               );
+
+                               $this->handlers[$role] = $handler;
+                       }
+               }
+
+               return $this->handlers[$role];
+       }
+
+       /**
+        * Returns the list of roles allowed when creating a new revision on the given page.
+        * The choice should not depend on external state, such as the page content.
+        * Note that existing revisions of that page are not guaranteed to comply with this list.
+        *
+        * All implementations of this method are required to return at least all "required" roles.
+        *
+        * @param LinkTarget $title
+        *
+        * @return string[]
+        */
+       public function getAllowedRoles( LinkTarget $title ) {
+               // TODO: allow this to be overwritten per namespace (or page type)
+               // TODO: decide how to control which slots are offered for editing per default (T209927)
+               return $this->getDefinedRoles();
+       }
+
+       /**
+        * Returns the list of roles required when creating a new revision on the given page.
+        * The should not depend on external state, such as the page content.
+        * Note that existing revisions of that page are not guaranteed to comply with this list.
+        *
+        * All required roles are implicitly considered "allowed", so any roles
+        * returned by this method will also be returned by getAllowedRoles().
+        *
+        * @param LinkTarget $title
+        *
+        * @return string[]
+        */
+       public function getRequiredRoles( LinkTarget $title ) {
+               // TODO: allow this to be overwritten per namespace (or page type)
+               return [ 'main' ];
+       }
+
+       /**
+        * Returns the list of roles defined by calling defineRole().
+        *
+        * This list should be used when enumerating slot roles that can be used for editing.
+        *
+        * @return string[]
+        */
+       public function getDefinedRoles() {
+               return array_keys( $this->instantiators );
+       }
+
+       /**
+        * Returns the list of known roles, including the ones returned by getDefinedRoles(),
+        * and roles that exist according to the NameTableStore provided to the constructor.
+        *
+        * This list should be used when enumerating slot roles that can be used in queries or
+        * for display.
+        *
+        * @return string[]
+        */
+       public function getKnownRoles() {
+               return array_unique( array_merge(
+                       $this->getDefinedRoles(),
+                       $this->roleNamesStore->getMap()
+               ) );
+       }
+
+       /**
+        * Whether the given role is defined, that is, it was defined by calling defineRole().
+        *
+        * @param string $role
+        * @return bool
+        */
+       public function isDefinedRole( $role ) {
+               return in_array( $role, $this->getDefinedRoles(), true );
+       }
+
+       /**
+        * Whether the given role is known, that is, it's either defined or exist according to
+        * the NameTableStore provided to the constructor.
+        *
+        * @param string $role
+        * @return bool
+        */
+       public function isKnownRole( $role ) {
+               return in_array( $role, $this->getKnownRoles(), true );
+       }
+
+}
index 33517a0..9a94389 100644 (file)
@@ -48,8 +48,10 @@ use MediaWiki\Logger\LoggerFactory;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Preferences\PreferencesFactory;
 use MediaWiki\Preferences\DefaultPreferencesFactory;
+use MediaWiki\Revision\MainSlotRoleHandler;
 use MediaWiki\Revision\RevisionFactory;
 use MediaWiki\Revision\RevisionLookup;
+use MediaWiki\Revision\SlotRoleRegistry;
 use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Revision\RevisionStore;
 use MediaWiki\Revision\RevisionStoreFactory;
@@ -420,9 +422,12 @@ return [
        },
 
        'RevisionRenderer' => function ( MediaWikiServices $services ) : RevisionRenderer {
-               $renderer = new RevisionRenderer( $services->getDBLoadBalancer() );
-               $renderer->setLogger( LoggerFactory::getInstance( 'SaveParse' ) );
+               $renderer = new RevisionRenderer(
+                       $services->getDBLoadBalancer(),
+                       $services->getSlotRoleRegistry()
+               );
 
+               $renderer->setLogger( LoggerFactory::getInstance( 'SaveParse' ) );
                return $renderer;
        },
 
@@ -436,6 +441,7 @@ return [
                        $services->getDBLoadBalancerFactory(),
                        $services->getBlobStoreFactory(),
                        $services->getNameTableStoreFactory(),
+                       $services->getSlotRoleRegistry(),
                        $services->getMainWANObjectCache(),
                        $services->getCommentStore(),
                        $services->getActorMigration(),
@@ -519,6 +525,22 @@ return [
                return $factory;
        },
 
+       'SlotRoleRegistry' => function ( MediaWikiServices $services ) : SlotRoleRegistry {
+               $config = $services->getMainConfig();
+
+               $registry = new SlotRoleRegistry(
+                       $services->getNameTableStoreFactory()->getSlotRoles()
+               );
+
+               $registry->defineRole( 'main', function () use ( $config ) {
+                       return new MainSlotRoleHandler(
+                               $config->get( 'NamespaceContentModels' )
+                       );
+               } );
+
+               return $registry;
+       },
+
        'SpecialPageFactory' => function ( MediaWikiServices $services ) : SpecialPageFactory {
                return new SpecialPageFactory(
                        $services->getMainConfig(),
index ad29f91..552dbae 100644 (file)
@@ -44,6 +44,7 @@ use MediaWiki\Revision\RevisionRecord;
 use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Revision\RevisionSlots;
 use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRoleRegistry;
 use MediaWiki\Revision\SlotRecord;
 use MediaWiki\User\UserIdentity;
 use MessageCache;
@@ -209,6 +210,9 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         */
        private $revisionRenderer;
 
+       /** @var SlotRoleRegistry */
+       private $slotRoleRegistry;
+
        /**
         * A stage identifier for managing the life cycle of this instance.
         * Possible stages are 'new', 'knows-current', 'has-content', 'has-revision', and 'done'.
@@ -255,6 +259,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         * @param WikiPage $wikiPage ,
         * @param RevisionStore $revisionStore
         * @param RevisionRenderer $revisionRenderer
+        * @param SlotRoleRegistry $slotRoleRegistry
         * @param ParserCache $parserCache
         * @param JobQueueGroup $jobQueueGroup
         * @param MessageCache $messageCache
@@ -265,6 +270,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                WikiPage $wikiPage,
                RevisionStore $revisionStore,
                RevisionRenderer $revisionRenderer,
+               SlotRoleRegistry $slotRoleRegistry,
                ParserCache $parserCache,
                JobQueueGroup $jobQueueGroup,
                MessageCache $messageCache,
@@ -276,6 +282,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                $this->parserCache = $parserCache;
                $this->revisionStore = $revisionStore;
                $this->revisionRenderer = $revisionRenderer;
+               $this->slotRoleRegistry = $slotRoleRegistry;
                $this->jobQueueGroup = $jobQueueGroup;
                $this->messageCache = $messageCache;
                $this->contLang = $contLang;
@@ -660,12 +667,26 @@ class DerivedPageDataUpdater implements IDBAccessObject {
                $hasLinks = null;
 
                if ( $this->articleCountMethod === 'link' ) {
+                       // NOTE: it would be more appropriate to determine for each slot separately
+                       // whether it has links, and use that information with that slot's
+                       // isCountable() method. However, that would break parity with
+                       // WikiPage::isCountable, which uses the pagelinks table to determine
+                       // whether the current revision has links.
                        $hasLinks = (bool)count( $this->getCanonicalParserOutput()->getLinks() );
                }
 
-               // TODO: MCR: ask all slots if they have links [SlotHandler/PageTypeHandler]
-               $mainContent = $this->getRawContent( SlotRecord::MAIN );
-               return $mainContent->isCountable( $hasLinks );
+               foreach ( $this->getModifiedSlotRoles() as $role ) {
+                       $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
+                       if ( $roleHandler->supportsArticleCount() ) {
+                               $content = $this->getRawContent( $role );
+
+                               if ( $content->isCountable( $hasLinks ) ) {
+                                       return true;
+                               }
+                       }
+               }
+
+               return false;
        }
 
        /**
@@ -673,6 +694,7 @@ class DerivedPageDataUpdater implements IDBAccessObject {
         */
        public function isRedirect() {
                // NOTE: main slot determines redirect status
+               // TODO: MCR: this should be controlled by a PageTypeHandler
                $mainContent = $this->getRawContent( SlotRecord::MAIN );
 
                return $mainContent->isRedirect();
index 043e00e..6cbdcc6 100644 (file)
@@ -31,7 +31,6 @@ use Content;
 use ContentHandler;
 use DeferredUpdates;
 use Hooks;
-use InvalidArgumentException;
 use LogicException;
 use ManualLogEntry;
 use MediaWiki\Linker\LinkTarget;
@@ -39,6 +38,7 @@ use MediaWiki\Revision\MutableRevisionRecord;
 use MediaWiki\Revision\RevisionAccessException;
 use MediaWiki\Revision\RevisionRecord;
 use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRoleRegistry;
 use MediaWiki\Revision\SlotRecord;
 use MWException;
 use RecentChange;
@@ -96,6 +96,11 @@ class PageUpdater {
         */
        private $revisionStore;
 
+       /**
+        * @var SlotRoleRegistry
+        */
+       private $slotRoleRegistry;
+
        /**
         * @var boolean see $wgUseAutomaticEditSummaries
         * @see $wgUseAutomaticEditSummaries
@@ -148,13 +153,15 @@ class PageUpdater {
         * @param DerivedPageDataUpdater $derivedDataUpdater
         * @param LoadBalancer $loadBalancer
         * @param RevisionStore $revisionStore
+        * @param SlotRoleRegistry $slotRoleRegistry
         */
        public function __construct(
                User $user,
                WikiPage $wikiPage,
                DerivedPageDataUpdater $derivedDataUpdater,
                LoadBalancer $loadBalancer,
-               RevisionStore $revisionStore
+               RevisionStore $revisionStore,
+               SlotRoleRegistry $slotRoleRegistry
        ) {
                $this->user = $user;
                $this->wikiPage = $wikiPage;
@@ -162,6 +169,7 @@ class PageUpdater {
 
                $this->loadBalancer = $loadBalancer;
                $this->revisionStore = $revisionStore;
+               $this->slotRoleRegistry = $slotRoleRegistry;
 
                $this->slotsUpdate = new RevisionSlotsUpdate();
        }
@@ -317,14 +325,6 @@ class PageUpdater {
                return $this->derivedDataUpdater->grabCurrentRevision();
        }
 
-       /**
-        * @return string
-        */
-       private function getTimestampNow() {
-               // TODO: allow an override to be injected for testing
-               return wfTimestampNow();
-       }
-
        /**
         * Check flags and add EDIT_NEW or EDIT_UPDATE to them as needed.
         *
@@ -346,8 +346,7 @@ class PageUpdater {
         * @param Content $content
         */
        public function setContent( $role, Content $content ) {
-               // TODO: MCR: check the role and the content's model against the list of supported
-               // roles, see T194046.
+               $this->ensureRoleAllowed( $role );
 
                $this->slotsUpdate->modifyContent( $role, $content );
        }
@@ -358,6 +357,8 @@ class PageUpdater {
         * @param SlotRecord $slot
         */
        public function setSlot( SlotRecord $slot ) {
+               $this->ensureRoleAllowed( $slot->getRole() );
+
                $this->slotsUpdate->modifySlot( $slot );
        }
 
@@ -376,6 +377,7 @@ class PageUpdater {
         *        by the new revision.
         */
        public function inheritSlot( SlotRecord $originalSlot ) {
+               // NOTE: slots can be inherited even if the role is not "allowed" on the title.
                // NOTE: this slot is inherited from some other revision, but it's
                // a "modified" slot for the RevisionSlotsUpdate and DerivedPageDataUpdater,
                // since it's not implicitly inherited from the parent revision.
@@ -393,9 +395,7 @@ class PageUpdater {
         * @param string $role A slot role name (but not "main")
         */
        public function removeSlot( $role ) {
-               if ( $role === SlotRecord::MAIN ) {
-                       throw new InvalidArgumentException( 'Cannot remove the main slot!' );
-               }
+               $this->ensureRoleNotRequired( $role );
 
                $this->slotsUpdate->removeSlot( $role );
        }
@@ -635,20 +635,38 @@ class PageUpdater {
                        throw new RuntimeException( 'Something is trying to edit an article with an empty title' );
                }
 
-               // TODO: MCR: check the role and the content's model against the list of supported
-               // and required roles, see T194046.
+               // NOTE: slots can be inherited even if the role is not "allowed" on the title.
+               $status = Status::newGood();
+               $this->checkAllRolesAllowed(
+                       $this->slotsUpdate->getModifiedRoles(),
+                       $status
+               );
+               $this->checkNoRolesRequired(
+                       $this->slotsUpdate->getRemovedRoles(),
+                       $status
+               );
 
-               // Make sure the given content type is allowed for this page
-               // TODO: decide: Extend check to other slots? Consider the role in check? [PageType]
-               $mainContentHandler = $this->getContentHandler( SlotRecord::MAIN );
-               if ( !$mainContentHandler->canBeUsedOn( $this->getTitle() ) ) {
-                       $this->status = Status::newFatal( 'content-not-allowed-here',
-                               ContentHandler::getLocalizedName( $mainContentHandler->getModelID() ),
-                               $this->getTitle()->getPrefixedText()
-                       );
+               if ( !$status->isOK() ) {
                        return null;
                }
 
+               // Make sure the given content is allowed in the respective slots of this page
+               foreach ( $this->slotsUpdate->getModifiedRoles() as $role ) {
+                       $slot = $this->slotsUpdate->getModifiedSlot( $role );
+                       $roleHandler = $this->slotRoleRegistry->getRoleHandler( $role );
+
+                       if ( !$roleHandler->isAllowedModel( $slot->getModel(), $this->getTitle() ) ) {
+                               $contentHandler = ContentHandler::getForModelID( $slot->getModel() );
+                               $this->status = Status::newFatal( 'content-not-allowed-here',
+                                       ContentHandler::getLocalizedName( $contentHandler->getModelID() ),
+                                       $this->getTitle()->getPrefixedText(),
+                                       wfMessage( $roleHandler->getNameMessageKey() )
+                                       // TODO: defer message lookup to caller
+                               );
+                               return null;
+                       }
+               }
+
                // Load the data from the master database if needed. Needed to check flags.
                // NOTE: This grabs the parent revision as the CAS token, if grabParentRevision
                // wasn't called yet. If the page is modified by another process before we are done with
@@ -882,13 +900,19 @@ class PageUpdater {
                        $content = $slot->getContent();
 
                        // XXX: We may push this up to the "edit controller" level, see T192777.
-                       // TODO: change the signature of PrepareSave to not take a WikiPage!
+                       // XXX: prepareSave() and isValid() could live in SlotRoleHandler
+                       // XXX: PrepareSave should not take a WikiPage!
                        $prepStatus = $content->prepareSave( $wikiPage, $flags, $oldid, $user );
 
                        // TODO: MCR: record which problem arose in which slot.
                        $status->merge( $prepStatus );
                }
 
+               $this->checkAllRequiredRoles(
+                       $rev->getSlotRoles(),
+                       $status
+               );
+
                return $rev;
        }
 
@@ -1216,4 +1240,71 @@ class PageUpdater {
                );
        }
 
+       /**
+        * @return string[] Slots required for this page update, as a list of role names.
+        */
+       private function getRequiredSlotRoles() {
+               return $this->slotRoleRegistry->getRequiredRoles( $this->getTitle() );
+       }
+
+       /**
+        * @return string[] Slots allowed for this page update, as a list of role names.
+        */
+       private function getAllowedSlotRoles() {
+               return $this->slotRoleRegistry->getAllowedRoles( $this->getTitle() );
+       }
+
+       private function ensureRoleAllowed( $role ) {
+               $allowedRoles = $this->getAllowedSlotRoles();
+               if ( !in_array( $role, $allowedRoles ) ) {
+                       throw new PageUpdateException( "Slot role `$role` is not allowed." );
+               }
+       }
+
+       private function ensureRoleNotRequired( $role ) {
+               $requiredRoles = $this->getRequiredSlotRoles();
+               if ( in_array( $role, $requiredRoles ) ) {
+                       throw new PageUpdateException( "Slot role `$role` is required." );
+               }
+       }
+
+       private function checkAllRolesAllowed( array $roles, Status $status ) {
+               $allowedRoles = $this->getAllowedSlotRoles();
+
+               $forbidden = array_diff( $roles, $allowedRoles );
+               if ( !empty( $forbidden ) ) {
+                       $status->error(
+                               'edit-slots-cannot-add',
+                               count( $forbidden ),
+                               implode( ', ', $forbidden )
+                       );
+               }
+       }
+
+       private function checkNoRolesRequired( array $roles, Status $status ) {
+               $requiredRoles = $this->getRequiredSlotRoles();
+
+               $needed = array_diff( $roles, $requiredRoles );
+               if ( !empty( $needed ) ) {
+                       $status->error(
+                               'edit-slots-cannot-remove',
+                               count( $needed ),
+                               implode( ', ', $needed )
+                       );
+               }
+       }
+
+       private function checkAllRequiredRoles( array $roles, Status $status ) {
+               $requiredRoles = $this->getRequiredSlotRoles();
+
+               $missing = array_diff( $requiredRoles, $roles );
+               if ( !empty( $missing ) ) {
+                       $status->error(
+                               'edit-slots-missing',
+                               count( $missing ),
+                               implode( ', ', $missing )
+                       );
+               }
+       }
+
 }
index 997063b..8b4075b 100644 (file)
@@ -978,6 +978,8 @@ class Title implements LinkTarget {
        /**
         * Get the page's content model id, see the CONTENT_MODEL_XXX constants.
         *
+        * @todo Deprecate this in favor of SlotRecord::getModel()
+        *
         * @param int $flags A bit field; may be Title::GAID_FOR_UPDATE to select for update
         * @return string Content model id
         */
index 76b7bce..393f435 100644 (file)
@@ -30,11 +30,15 @@ class ApiComparePages extends ApiBase {
        /** @var RevisionStore */
        private $revisionStore;
 
+       /** @var \MediaWiki\Revision\SlotRoleRegistry */
+       private $slotRoleRegistry;
+
        private $guessedTitle = false, $props;
 
        public function __construct( ApiMain $mainModule, $moduleName, $modulePrefix = '' ) {
                parent::__construct( $mainModule, $moduleName, $modulePrefix );
                $this->revisionStore = MediaWikiServices::getInstance()->getRevisionStore();
+               $this->slotRoleRegistry = MediaWikiServices::getInstance()->getSlotRoleRegistry();
        }
 
        public function execute() {
@@ -272,9 +276,8 @@ class ApiComparePages extends ApiBase {
                }
 
                $guessedTitle = $this->guessTitle();
-               if ( $guessedTitle && $role === SlotRecord::MAIN ) {
-                       // @todo: Use SlotRoleRegistry and do this for all slots
-                       return $guessedTitle->getContentModel();
+               if ( $guessedTitle ) {
+                       return $this->slotRoleRegistry->getRoleHandler( $role )->getDefaultModel( $guessedTitle );
                }
 
                if ( isset( $params["fromcontentmodel-$role"] ) ) {
@@ -582,10 +585,7 @@ class ApiComparePages extends ApiBase {
        }
 
        public function getAllowedParams() {
-               $slotRoles = MediaWikiServices::getInstance()->getSlotRoleStore()->getMap();
-               if ( !in_array( SlotRecord::MAIN, $slotRoles, true ) ) {
-                       $slotRoles[] = SlotRecord::MAIN;
-               }
+               $slotRoles = $this->slotRoleRegistry->getKnownRoles();
                sort( $slotRoles, SORT_STRING );
 
                // Parameters for the 'from' and 'to' content
index c00010a..3d0a0fb 100644 (file)
@@ -616,10 +616,7 @@ abstract class ApiQueryRevisionsBase extends ApiQueryGeneratorBase {
        }
 
        public function getAllowedParams() {
-               $slotRoles = MediaWikiServices::getInstance()->getSlotRoleStore()->getMap();
-               if ( !in_array( SlotRecord::MAIN, $slotRoles, true ) ) {
-                       $slotRoles[] = SlotRecord::MAIN;
-               }
+               $slotRoles = MediaWikiServices::getInstance()->getSlotRoleRegistry()->getKnownRoles();
                sort( $slotRoles, SORT_STRING );
 
                return [
index bb3fb10..1bb43f8 100644 (file)
@@ -241,6 +241,8 @@ interface Content {
         * that it's also in a countable location (e.g. a current revision in the
         * main namespace).
         *
+        * @see SlotRoleHandler::supportsArticleCount
+        *
         * @since 1.21
         *
         * @param bool|null $hasLinks If it is known whether this content contains
@@ -352,6 +354,8 @@ interface Content {
         * Returns whether this Content represents a redirect.
         * Shorthand for getRedirectTarget() !== null.
         *
+        * @see SlotRoleHandler::supportsRedirects
+        *
         * @since 1.21
         *
         * @return bool
index fab043a..5c18a33 100644 (file)
@@ -174,62 +174,17 @@ abstract class ContentHandler {
         * Note: this is used by, and may thus not use, Title::getContentModel()
         *
         * @since 1.21
+        * @deprecated since 1.33, use SlotRoleHandler::getDefaultModel() together with
+        * SlotRoleRegistry::getRoleHandler().
         *
         * @param Title $title
         *
         * @return string Default model name for the page given by $title
         */
        public static function getDefaultModelFor( Title $title ) {
-               // NOTE: this method must not rely on $title->getContentModel() directly or indirectly,
-               //       because it is used to initialize the mContentModel member.
-
-               $ns = $title->getNamespace();
-
-               $ext = false;
-               $m = null;
-               $model = MWNamespace::getNamespaceContentModel( $ns );
-
-               // Hook can determine default model
-               if ( !Hooks::run( 'ContentHandlerDefaultModelFor', [ $title, &$model ] ) ) {
-                       if ( !is_null( $model ) ) {
-                               return $model;
-                       }
-               }
-
-               // Could this page contain code based on the title?
-               $isCodePage = NS_MEDIAWIKI == $ns && preg_match( '!\.(css|js|json)$!u', $title->getText(), $m );
-               if ( $isCodePage ) {
-                       $ext = $m[1];
-               }
-
-               // Is this a user subpage containing code?
-               $isCodeSubpage = NS_USER == $ns
-                       && !$isCodePage
-                       && preg_match( "/\\/.*\\.(js|css|json)$/", $title->getText(), $m );
-               if ( $isCodeSubpage ) {
-                       $ext = $m[1];
-               }
-
-               // Is this wikitext, according to $wgNamespaceContentModels or the DefaultModelFor hook?
-               $isWikitext = is_null( $model ) || $model == CONTENT_MODEL_WIKITEXT;
-               $isWikitext = $isWikitext && !$isCodePage && !$isCodeSubpage;
-
-               if ( !$isWikitext ) {
-                       switch ( $ext ) {
-                               case 'js':
-                                       return CONTENT_MODEL_JAVASCRIPT;
-                               case 'css':
-                                       return CONTENT_MODEL_CSS;
-                               case 'json':
-                                       return CONTENT_MODEL_JSON;
-                               default:
-                                       return is_null( $model ) ? CONTENT_MODEL_TEXT : $model;
-                       }
-               }
-
-               // We established that it must be wikitext
-
-               return CONTENT_MODEL_WIKITEXT;
+               $slotRoleregistry = MediaWikiServices::getInstance()->getSlotRoleRegistry();
+               $mainSlotHandler = $slotRoleregistry->getRoleHandler( 'main' );
+               return $mainSlotHandler->getDefaultModel( $title );
        }
 
        /**
@@ -777,7 +732,7 @@ abstract class ContentHandler {
 
        /**
         * Determines whether the content type handled by this ContentHandler
-        * can be used on the given page.
+        * can be used for the main slot of the given page.
         *
         * This default implementation always returns true.
         * Subclasses may override this to restrict the use of this content model to specific locations,
@@ -787,6 +742,8 @@ abstract class ContentHandler {
         * @note this calls the ContentHandlerCanBeUsedOn hook which may be used to override which
         * content model can be used where.
         *
+        * @see SlotRoleHandler::isAllowedModel
+        *
         * @param Title $title The page's title.
         *
         * @return bool True if content of this kind can be used on the given page, false otherwise.
index 8d0971e..63cc2a8 100644 (file)
@@ -1055,7 +1055,7 @@ class DifferenceEngine extends ContextSource {
                        $slotDiff = $slotDiffRenderer->getDiff( $slotContents[$role]['old'],
                                $slotContents[$role]['new'] );
                        if ( $slotDiff && $role !== SlotRecord::MAIN ) {
-                               // TODO use human-readable role name at least
+                               // FIXME: ask SlotRoleHandler::getSlotNameMessage
                                $slotTitle = $role;
                                $difftext .= $this->getSlotHeader( $slotTitle );
                        }
index 6a6b2a6..62ed0a9 100644 (file)
@@ -26,6 +26,7 @@ use MediaWiki\MediaWikiServices;
 use MediaWiki\Revision\RevisionRecord;
 use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRoleRegistry;
 use MediaWiki\Revision\SlotRecord;
 use MediaWiki\Storage\DerivedPageDataUpdater;
 use MediaWiki\Storage\PageUpdater;
@@ -232,6 +233,13 @@ class WikiPage implements Page, IDBAccessObject {
                return MediaWikiServices::getInstance()->getRevisionRenderer();
        }
 
+       /**
+        * @return SlotRoleRegistry
+        */
+       private function getSlotRoleRegistry() {
+               return MediaWikiServices::getInstance()->getSlotRoleRegistry();
+       }
+
        /**
         * @return ParserCache
         */
@@ -952,12 +960,17 @@ class WikiPage implements Page, IDBAccessObject {
                                // links.
                                $hasLinks = (bool)count( $editInfo->output->getLinks() );
                        } else {
-                               // NOTE: keep in sync with revisionRenderer::getLinkCount
+                               // NOTE: keep in sync with RevisionRenderer::getLinkCount
+                               // NOTE: keep in sync with DerivedPageDataUpdater::isCountable
                                $hasLinks = (bool)wfGetDB( DB_REPLICA )->selectField( 'pagelinks', 1,
                                        [ 'pl_from' => $this->getId() ], __METHOD__ );
                        }
                }
 
+               // TODO: MCR: determine $hasLinks for each slot, and use that info
+               // with that slot's Content's isCountable method. That requires per-
+               // slot ParserOutput in the ParserCache, or per-slot info in the
+               // pagelinks table.
                return $content->isCountable( $hasLinks );
        }
 
@@ -1665,6 +1678,7 @@ class WikiPage implements Page, IDBAccessObject {
                        $this, // NOTE: eventually, PageUpdater should not know about WikiPage
                        $this->getRevisionStore(),
                        $this->getRevisionRenderer(),
+                       $this->getSlotRoleRegistry(),
                        $this->getParserCache(),
                        JobQueueGroup::singleton(),
                        MessageCache::singleton(),
@@ -1769,7 +1783,8 @@ class WikiPage implements Page, IDBAccessObject {
                        $this, // NOTE: eventually, PageUpdater should not know about WikiPage
                        $this->getDerivedDataUpdater( $user, null, $forUpdate, true ),
                        $this->getDBLoadBalancer(),
-                       $this->getRevisionStore()
+                       $this->getRevisionStore(),
+                       $this->getSlotRoleRegistry()
                );
 
                $pageUpdater->setUsePageCreationLog( $wgPageCreationLog );
index ba6353f..daabc52 100644 (file)
        "edit-gone-missing": "Could not update the page.\nIt appears to have been deleted.",
        "edit-conflict": "Edit conflict.",
        "edit-no-change": "Your edit was ignored because no change was made to the text.",
+       "edit-slots-cannot-add": "The following {{PLURAL:$1|slot is|slots are}} not supported here: $2.",
+       "edit-slots-cannot-remove": "The following {{PLURAL:$1|slot is|slots are}} required and cannot be removed: $2.",
+       "edit-slots-missing": "The following {{PLURAL:$1|slot is|slots are}} missing: $2.",
        "postedit-confirmation-created": "The page has been created.",
        "postedit-confirmation-restored": "The page has been restored.",
        "postedit-confirmation-saved": "Your edit was saved.",
        "defaultmessagetext": "Default message text",
        "content-failed-to-parse": "Failed to parse $2 content for $1 model: $3",
        "invalid-content-data": "Invalid content data",
-       "content-not-allowed-here": "\"$1\" content is not allowed on page [[$2]]",
+       "content-not-allowed-here": "\"$1\" content is not allowed on page [[$2]] in slot \"$3\"",
        "editwarning-warning": "Leaving this page may cause you to lose any changes you have made.\nIf you are logged in, you can disable this warning in the \"{{int:prefs-editing}}\" section of your preferences.",
        "editpage-invalidcontentmodel-title": "Content model not supported",
        "editpage-invalidcontentmodel-text": "The content model \"$1\" is not supported.",
index 1d05889..4b07586 100644 (file)
        "edit-gone-missing": "Used as error message.\n\nSee also:\n* {{msg-mw|edit-hook-aborted}}\n* {{msg-mw|edit-conflict}}\n* {{msg-mw|edit-no-change}}\n* {{msg-mw|edit-already-exists}}",
        "edit-conflict": "An 'Edit conflict' happens when more than one edit is being made to a page at the same time. This would usually be caused by separate individuals working on the same page. However, if the system is slow, several edits from one individual could back up and attempt to apply simultaneously - causing the conflict.\n\nSee also:\n* {{msg-mw|edit-hook-aborted}}\n* {{msg-mw|edit-gone-missing}}\n* {{msg-mw|edit-no-change}}\n* {{msg-mw|edit-already-exists}}",
        "edit-no-change": "Used as error message.\n\nSee also:\n* {{msg-mw|edit-hook-aborted}}\n* {{msg-mw|edit-gone-missing}}\n* {{msg-mw|edit-conflict}}\n* {{msg-mw|edit-already-exists}}",
+       "edit-slots-cannot-add": "An error message shown when trying to save an edit, if the edit tries to add a {{Identical|slot}} that is not allowed on the page.\n* $1 - the number of slots\n* $2 - the slots that were attempted to be added but are not allowed",
+       "edit-slots-cannot-remove": "An error message shown when trying to save an edit, if the edit tries to remove a {{Identical|slot}} that is required on the page.\n* $1 - the number of slots\n* $2 - the slots that were attempted to be removed but are required",
+       "edit-slots-missing": "An error message shown when trying to save an edit, if the edit is missing some required {{Identical|slot}}, which could not be inherited from a parent revision.\n* $1 - the number of slots\n* $2 - the slots that are required but missing from the new revision",
        "postedit-confirmation-created": "{{gender}}\nShown after a user creates a new page. Parameters:\n* $1 - the current user, for GENDER support",
        "postedit-confirmation-restored": "{{gender}}\nShown after a user restores a page to a previous revision. Parameters:\n* $1 - the current user, for GENDER support",
        "postedit-confirmation-saved": "{{gender}}\nShown after a user saves a page. Parameters:\n* $1 - the current user, for GENDER support",
        "defaultmessagetext": "Caption above the default message text shown on the left-hand side of a diff displayed after clicking \"Show changes\" when creating a new page in the MediaWiki: namespace",
        "content-failed-to-parse": "Error message indicating that the page's content can not be saved because it is syntactically invalid. This may occurr for content types using serialization or a strict markup syntax.\n\nParameters:\n* $1 â€“ content model, any one of the following messages:\n** {{msg-mw|Content-model-wikitext}}\n** {{msg-mw|Content-model-javascript}}\n** {{msg-mw|Content-model-css}}\n** {{msg-mw|Content-model-json}}\n** {{msg-mw|Content-model-text}}\n* $2 â€“ content format as MIME type (e.g. <code>text/css</code>)\n* $3 â€“ specific error message",
        "invalid-content-data": "Error message indicating that the page's content can not be saved because it is invalid. This may occurr for content types with internal consistency constraints.",
-       "content-not-allowed-here": "Error message indicating that the desired content model is not supported in given localtion.\n* $1 - the human readable name of the content model: {{msg-mw|Content-model-wikitext}}, {{msg-mw|Content-model-javascript}}, {{msg-mw|Content-model-json}}, {{msg-mw|Content-model-css}} or {{msg-mw|Content-model-text}}\n* $2 - the title of the page in question",
+       "content-not-allowed-here": "Error message indicating that the desired content model is not supported in given localtion.\n* $1 - the human readable name of the content model: {{msg-mw|Content-model-wikitext}}, {{msg-mw|Content-model-javascript}}, {{msg-mw|Content-model-json}}, {{msg-mw|Content-model-css}} or {{msg-mw|Content-model-text}}\n* $2 - the title of the page in question\n* $3 - the role name of the slot the content is not allowed in",
        "editwarning-warning": "Uses {{msg-mw|Prefs-editing}}",
        "editpage-invalidcontentmodel-title": "Title of error page shown when using an unrecognized content model on EditPage",
        "editpage-invalidcontentmodel-text": "Error message shown when using an unrecognized content model on EditPage. $1 is the user's invalid input",
diff --git a/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/FallbackSlotRoleHandlerTest.php
new file mode 100644 (file)
index 0000000..aedf292
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use MediaWiki\Revision\FallbackSlotRoleHandler;
+use MediaWikiTestCase;
+use Title;
+
+/**
+ * @covers \MediaWiki\Revision\FallbackSlotRoleHandler
+ */
+class FallbackSlotRoleHandlerTest extends MediaWikiTestCase {
+
+       private function makeBlankTitleObject() {
+               /** @var Title $title */
+               $title = $this->getMockBuilder( Title::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               return $title;
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::__construct
+        * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getRole()
+        * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getNameMessageKey()
+        * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getDefaultModel()
+        * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::getOutputLayoutHints()
+        */
+       public function testConstruction() {
+               $handler = new FallbackSlotRoleHandler( 'foo' );
+               $this->assertSame( 'foo', $handler->getRole() );
+               $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() );
+
+               $title = $this->makeBlankTitleObject();
+               $this->assertSame( CONTENT_MODEL_TEXT, $handler->getDefaultModel( $title ) );
+
+               $hints = $handler->getOutputLayoutHints();
+               $this->assertArrayHasKey( 'display', $hints );
+               $this->assertArrayHasKey( 'region', $hints );
+               $this->assertArrayHasKey( 'placement', $hints );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::isAllowedModel()
+        */
+       public function testIsAllowedModel() {
+               $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' );
+
+               // For the fallback handler, no models are allowed
+               $title = $this->makeBlankTitleObject();
+               $this->assertFalse( $handler->isAllowedModel( 'FooModel', $title ) );
+               $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel()
+        */
+       public function testIsAllowedOn() {
+               $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' );
+
+               $title = $this->makeBlankTitleObject();
+               $this->assertFalse( $handler->isAllowedOn( $title ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\FallbackSlotRoleHandler::supportsArticleCount()
+        */
+       public function testSupportsArticleCount() {
+               $handler = new FallbackSlotRoleHandler( 'foo', 'FooModel' );
+
+               $this->assertFalse( $handler->supportsArticleCount() );
+       }
+
+}
diff --git a/tests/phpunit/includes/Revision/MainSlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/MainSlotRoleHandlerTest.php
new file mode 100644 (file)
index 0000000..f2f3da8
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use MediaWiki\Revision\MainSlotRoleHandler;
+use MediaWikiTestCase;
+use PHPUnit\Framework\MockObject\MockObject;
+use Title;
+
+/**
+ * @covers \MediaWiki\Revision\MainSlotRoleHandler
+ */
+class MainSlotRoleHandlerTest extends MediaWikiTestCase {
+
+       private function makeTitleObject( $ns ) {
+               /** @var Title|MockObject $title */
+               $title = $this->getMockBuilder( Title::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               $title->method( 'getNamespace' )
+                       ->willReturn( $ns );
+
+               return $title;
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\MainSlotRoleHandler::__construct
+        * @covers \MediaWiki\Revision\MainSlotRoleHandler::getRole()
+        * @covers \MediaWiki\Revision\MainSlotRoleHandler::getNameMessageKey()
+        * @covers \MediaWiki\Revision\MainSlotRoleHandler::getOutputLayoutHints()
+        */
+       public function testConstruction() {
+               $handler = new MainSlotRoleHandler( [] );
+               $this->assertSame( 'main', $handler->getRole() );
+               $this->assertSame( 'slot-name-main', $handler->getNameMessageKey() );
+
+               $hints = $handler->getOutputLayoutHints();
+               $this->assertArrayHasKey( 'display', $hints );
+               $this->assertArrayHasKey( 'region', $hints );
+               $this->assertArrayHasKey( 'placement', $hints );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\MainSlotRoleHandler::getDefaultModel()
+        */
+       public function testFetDefaultModel() {
+               $handler = new MainSlotRoleHandler( [ 100 => CONTENT_MODEL_TEXT ] );
+
+               // For the main handler, the namespace determins the defualt model
+               $titleMain = $this->makeTitleObject( NS_MAIN );
+               $this->assertSame( CONTENT_MODEL_WIKITEXT, $handler->getDefaultModel( $titleMain ) );
+
+               $title100 = $this->makeTitleObject( 100 );
+               $this->assertSame( CONTENT_MODEL_TEXT, $handler->getDefaultModel( $title100 ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\MainSlotRoleHandler::isAllowedModel()
+        */
+       public function testIsAllowedModel() {
+               $handler = new MainSlotRoleHandler( [] );
+
+               // For the main handler, (nearly) all models are allowed
+               $title = $this->makeTitleObject( NS_MAIN );
+               $this->assertTrue( $handler->isAllowedModel( CONTENT_MODEL_WIKITEXT, $title ) );
+               $this->assertTrue( $handler->isAllowedModel( CONTENT_MODEL_TEXT, $title ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\MainSlotRoleHandler::supportsArticleCount()
+        */
+       public function testSupportsArticleCount() {
+               $handler = new MainSlotRoleHandler( [] );
+
+               $this->assertTrue( $handler->supportsArticleCount() );
+       }
+
+}
index 469f281..d797515 100644 (file)
@@ -7,9 +7,12 @@ use Content;
 use Language;
 use LogicException;
 use MediaWiki\Revision\MutableRevisionRecord;
+use MediaWiki\Revision\MainSlotRoleHandler;
 use MediaWiki\Revision\RevisionRecord;
 use MediaWiki\Revision\RevisionRenderer;
 use MediaWiki\Revision\SlotRecord;
+use MediaWiki\Revision\SlotRoleRegistry;
+use MediaWiki\Storage\NameTableStore;
 use MediaWikiTestCase;
 use MediaWiki\User\UserIdentityValue;
 use ParserOptions;
@@ -126,7 +129,20 @@ class RevisionRendererTest extends MediaWikiTestCase {
                        ->with( $dbIndex )
                        ->willReturn( $db );
 
-               return new RevisionRenderer( $lb );
+               /** @var NameTableStore|MockObject $slotRoles */
+               $slotRoles = $this->getMockBuilder( NameTableStore::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $slotRoles->method( 'getMap' )
+                       ->willReturn( [] );
+
+               $roleReg = new SlotRoleRegistry( $slotRoles );
+               $roleReg->defineRole( 'main', function () {
+                       return new MainSlotRoleHandler( [] );
+               } );
+               $roleReg->defineRoleWithModel( 'aux', CONTENT_MODEL_WIKITEXT );
+
+               return new RevisionRenderer( $lb, $roleReg );
        }
 
        private function selectFieldCallback( $table, $fields, $cond, $maxRev ) {
index 0d6a439..61187ee 100644 (file)
@@ -231,6 +231,7 @@ abstract class RevisionStoreDbTestBase extends MediaWikiTestCase {
                        MediaWikiServices::getInstance()->getCommentStore(),
                        MediaWikiServices::getInstance()->getContentModelStore(),
                        MediaWikiServices::getInstance()->getSlotRoleStore(),
+                       MediaWikiServices::getInstance()->getSlotRoleRegistry(),
                        $this->getMcrMigrationStage(),
                        MediaWikiServices::getInstance()->getActorMigration(),
                        $wikiId
index 9904b3b..2e61745 100644 (file)
@@ -7,6 +7,7 @@ use CommentStore;
 use MediaWiki\Logger\Spi as LoggerSpi;
 use MediaWiki\Revision\RevisionStore;
 use MediaWiki\Revision\RevisionStoreFactory;
+use MediaWiki\Revision\SlotRoleRegistry;
 use MediaWiki\Storage\BlobStore;
 use MediaWiki\Storage\BlobStoreFactory;
 use MediaWiki\Storage\NameTableStore;
@@ -27,6 +28,7 @@ class RevisionStoreFactoryTest extends MediaWikiTestCase {
                        $this->getMockLoadBalancerFactory(),
                        $this->getMockBlobStoreFactory(),
                        $this->getNameTableStoreFactory(),
+                       $this->getMockSlotRoleRegistry(),
                        $this->getHashWANObjectCache(),
                        $this->getMockCommentStore(),
                        ActorMigration::newMigration(),
@@ -56,6 +58,7 @@ class RevisionStoreFactoryTest extends MediaWikiTestCase {
                $lbFactory = $this->getMockLoadBalancerFactory();
                $blobStoreFactory = $this->getMockBlobStoreFactory();
                $nameTableStoreFactory = $this->getNameTableStoreFactory();
+               $slotRoleRegistry = $this->getMockSlotRoleRegistry();
                $cache = $this->getHashWANObjectCache();
                $commentStore = $this->getMockCommentStore();
                $actorMigration = ActorMigration::newMigration();
@@ -65,6 +68,7 @@ class RevisionStoreFactoryTest extends MediaWikiTestCase {
                        $lbFactory,
                        $blobStoreFactory,
                        $nameTableStoreFactory,
+                       $slotRoleRegistry,
                        $cache,
                        $commentStore,
                        $actorMigration,
@@ -142,6 +146,16 @@ class RevisionStoreFactoryTest extends MediaWikiTestCase {
                return $mock;
        }
 
+       /**
+        * @return \PHPUnit_Framework_MockObject_MockObject|SlotRoleRegistry
+        */
+       private function getMockSlotRoleRegistry() {
+               $mock = $this->getMockBuilder( SlotRoleRegistry::class )
+                       ->disableOriginalConstructor()->getMock();
+
+               return $mock;
+       }
+
        /**
         * @return NameTableStoreFactory
         */
index 2093b41..efc2952 100644 (file)
@@ -9,6 +9,7 @@ use Language;
 use MediaWiki\MediaWikiServices;
 use MediaWiki\Revision\RevisionAccessException;
 use MediaWiki\Revision\RevisionStore;
+use MediaWiki\Revision\SlotRoleRegistry;
 use MediaWiki\Revision\SlotRecord;
 use MediaWiki\Storage\SqlBlobStore;
 use MediaWikiTestCase;
@@ -51,6 +52,7 @@ class RevisionStoreTest extends MediaWikiTestCase {
                        MediaWikiServices::getInstance()->getCommentStore(),
                        MediaWikiServices::getInstance()->getContentModelStore(),
                        MediaWikiServices::getInstance()->getSlotRoleStore(),
+                       MediaWikiServices::getInstance()->getSlotRoleRegistry(),
                        $wgMultiContentRevisionSchemaMigrationStage,
                        MediaWikiServices::getInstance()->getActorMigration()
                );
@@ -88,6 +90,14 @@ class RevisionStoreTest extends MediaWikiTestCase {
                        ->disableOriginalConstructor()->getMock();
        }
 
+       /**
+        * @return \PHPUnit_Framework_MockObject_MockObject|SlotRoleRegistry
+        */
+       private function getMockSlotRoleRegistry() {
+               return $this->getMockBuilder( SlotRoleRegistry::class )
+                       ->disableOriginalConstructor()->getMock();
+       }
+
        private function getHashWANObjectCache() {
                return new WANObjectCache( [ 'cache' => new \HashBagOStuff() ] );
        }
@@ -127,6 +137,7 @@ class RevisionStoreTest extends MediaWikiTestCase {
                        $this->getMockCommentStore(),
                        $nameTables->getContentModels(),
                        $nameTables->getSlotRoles(),
+                       $this->getMockSlotRoleRegistry(),
                        $migrationMode,
                        MediaWikiServices::getInstance()->getActorMigration()
                );
@@ -541,6 +552,7 @@ class RevisionStoreTest extends MediaWikiTestCase {
                $nameTables = $services->getNameTableStoreFactory();
                $contentModelStore = $nameTables->getContentModels();
                $slotRoleStore = $nameTables->getSlotRoles();
+               $slotRoleRegistry = $services->getSlotRoleRegistry();
                $store = new RevisionStore(
                        $loadBalancer,
                        $blobStore,
@@ -548,6 +560,7 @@ class RevisionStoreTest extends MediaWikiTestCase {
                        $commentStore,
                        $nameTables->getContentModels(),
                        $nameTables->getSlotRoles(),
+                       $slotRoleRegistry,
                        $migration,
                        $services->getActorMigration()
                );
diff --git a/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php b/tests/phpunit/includes/Revision/SlotRoleHandlerTest.php
new file mode 100644 (file)
index 0000000..67e9464
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use MediaWiki\Revision\SlotRoleHandler;
+use MediaWikiTestCase;
+use Title;
+
+/**
+ * @covers \MediaWiki\Revision\SlotRoleHandler
+ */
+class SlotRoleHandlerTest extends MediaWikiTestCase {
+
+       private function makeBlankTitleObject() {
+               /** @var Title $title */
+               $title = $this->getMockBuilder( Title::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               return $title;
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleHandler::__construct
+        * @covers \MediaWiki\Revision\SlotRoleHandler::getRole()
+        * @covers \MediaWiki\Revision\SlotRoleHandler::getNameMessageKey()
+        * @covers \MediaWiki\Revision\SlotRoleHandler::getDefaultModel()
+        * @covers \MediaWiki\Revision\SlotRoleHandler::getOutputLayoutHints()
+        */
+       public function testConstruction() {
+               $handler = new SlotRoleHandler( 'foo', 'FooModel', [ 'frob' => 'niz' ] );
+               $this->assertSame( 'foo', $handler->getRole() );
+               $this->assertSame( 'slot-name-foo', $handler->getNameMessageKey() );
+
+               $title = $this->makeBlankTitleObject();
+               $this->assertSame( 'FooModel', $handler->getDefaultModel( $title ) );
+
+               $hints = $handler->getOutputLayoutHints();
+               $this->assertArrayHasKey( 'frob', $hints );
+               $this->assertSame( 'niz', $hints['frob'] );
+
+               $this->assertArrayHasKey( 'display', $hints );
+               $this->assertArrayHasKey( 'region', $hints );
+               $this->assertArrayHasKey( 'placement', $hints );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleHandler::isAllowedModel()
+        */
+       public function testIsAllowedModel() {
+               $handler = new SlotRoleHandler( 'foo', 'FooModel' );
+
+               $title = $this->makeBlankTitleObject();
+               $this->assertTrue( $handler->isAllowedModel( 'FooModel', $title ) );
+               $this->assertFalse( $handler->isAllowedModel( 'QuaxModel', $title ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleHandler::supportsArticleCount()
+        */
+       public function testSupportsArticleCount() {
+               $handler = new SlotRoleHandler( 'foo', 'FooModel' );
+
+               $this->assertFalse( $handler->supportsArticleCount() );
+       }
+
+}
diff --git a/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php b/tests/phpunit/includes/Revision/SlotRoleRegistryTest.php
new file mode 100644 (file)
index 0000000..4d8030d
--- /dev/null
@@ -0,0 +1,194 @@
+<?php
+
+namespace MediaWiki\Tests\Revision;
+
+use InvalidArgumentException;
+use LogicException;
+use MediaWiki\Revision\MainSlotRoleHandler;
+use MediaWiki\Revision\SlotRoleHandler;
+use MediaWiki\Revision\SlotRoleRegistry;
+use MediaWiki\Storage\NameTableStore;
+use MediaWikiTestCase;
+use Title;
+use Wikimedia\Assert\PostconditionException;
+
+/**
+ * @covers \MediaWiki\Revision\SlotRoleRegistry
+ */
+class SlotRoleRegistryTest extends MediaWikiTestCase {
+
+       private function makeBlankTitleObject() {
+               /** @var Title $title */
+               $title = $this->getMockBuilder( Title::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               return $title;
+       }
+
+       private function makeNameTableStore( array $names = [] ) {
+               $mock = $this->getMockBuilder( NameTableStore::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+
+               $mock->method( 'getMap' )
+                       ->willReturn( $names );
+
+               return $mock;
+       }
+
+       private function newSlotRoleRegistry( NameTableStore $roleNameStore = null ) {
+               if ( !$roleNameStore ) {
+                       $roleNameStore = $this->makeNameTableStore();
+               }
+
+               return new SlotRoleRegistry( $roleNameStore );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::defineRole()
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getDefinedRoles()
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getKnownRoles()
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getRoleHandler()
+        */
+       public function testDefineRole() {
+               $registry = $this->newSlotRoleRegistry();
+               $registry->defineRole( 'foo', function ( $role ) {
+                       return new SlotRoleHandler( $role, 'FooModel' );
+               } );
+
+               $this->assertTrue( $registry->isDefinedRole( 'foo' ) );
+               $this->assertContains( 'foo', $registry->getDefinedRoles() );
+               $this->assertContains( 'foo', $registry->getKnownRoles() );
+
+               $handler = $registry->getRoleHandler( 'foo' );
+               $this->assertSame( 'foo', $handler->getRole() );
+
+               $title = $this->makeBlankTitleObject();
+               $this->assertSame( 'FooModel', $handler->getDefaultModel( $title ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::defineRole()
+        */
+       public function testDefineRoleFailsForDupe() {
+               $registry = $this->newSlotRoleRegistry();
+               $registry->defineRole( 'foo', function ( $role ) {
+                       return new SlotRoleHandler( $role, 'FooModel' );
+               } );
+
+               $this->setExpectedException( LogicException::class );
+               $registry->defineRole( 'foo', function ( $role ) {
+                       return new SlotRoleHandler( $role, 'FooModel' );
+               } );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::defineRoleWithModel()
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getDefinedRoles()
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getKnownRoles()
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getRoleHandler()
+        */
+       public function testDefineRoleWithContentModel() {
+               $registry = $this->newSlotRoleRegistry();
+               $registry->defineRoleWithModel( 'foo', 'FooModel' );
+
+               $this->assertTrue( $registry->isDefinedRole( 'foo' ) );
+               $this->assertContains( 'foo', $registry->getDefinedRoles() );
+               $this->assertContains( 'foo', $registry->getKnownRoles() );
+
+               $handler = $registry->getRoleHandler( 'foo' );
+               $this->assertSame( 'foo', $handler->getRole() );
+
+               /** @var Title $title */
+               $title = $this->getMockBuilder( Title::class )
+                       ->disableOriginalConstructor()
+                       ->getMock();
+               $this->assertSame( 'FooModel', $handler->getDefaultModel( $title ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getRoleHandler()
+        */
+       public function testGetRoleHandlerForUnknownModel() {
+               $registry = $this->newSlotRoleRegistry();
+
+               $this->setExpectedException( InvalidArgumentException::class );
+
+               $registry->getRoleHandler( 'foo' );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getRoleHandler()
+        */
+       public function testGetRoleHandlerFallbackHandler() {
+               $registry = $this->newSlotRoleRegistry(
+                       $this->makeNameTableStore( [ 1 => 'foo' ] )
+               );
+
+               \Wikimedia\suppressWarnings();
+               $handler = $registry->getRoleHandler( 'foo' );
+               $this->assertSame( 'foo', $handler->getRole() );
+
+               \Wikimedia\restoreWarnings();
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getRoleHandler()
+        */
+       public function testGetRoleHandlerWithBadInstantiator() {
+               $registry = $this->newSlotRoleRegistry();
+               $registry->defineRole( 'foo', function ( $role ) {
+                       return 'Not a SlotRoleHandler instance';
+               } );
+
+               $this->setExpectedException( PostconditionException::class );
+               $registry->getRoleHandler( 'foo' );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getRequiredRoles()
+        */
+       public function testGetRequiredRoles() {
+               $registry = $this->newSlotRoleRegistry();
+               $registry->defineRole( 'main', function ( $role ) {
+                       return new MainSlotRoleHandler( [] );
+               } );
+
+               $title = $this->makeBlankTitleObject();
+               $this->assertEquals( [ 'main' ], $registry->getRequiredRoles( $title ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getAllowedRoles()
+        */
+       public function testGetAllowedRoles() {
+               $registry = $this->newSlotRoleRegistry();
+               $registry->defineRole( 'main', function ( $role ) {
+                       return new MainSlotRoleHandler( [] );
+               } );
+               $registry->defineRoleWithModel( 'foo', CONTENT_MODEL_TEXT );
+
+               $title = $this->makeBlankTitleObject();
+               $this->assertEquals( [ 'main', 'foo' ], $registry->getAllowedRoles( $title ) );
+       }
+
+       /**
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::getKnownRoles()
+        * @covers \MediaWiki\Revision\SlotRoleRegistry::isKnownRole()
+        */
+       public function testGetKnownRoles() {
+               $registry = $this->newSlotRoleRegistry(
+                       $this->makeNameTableStore( [ 1 => 'foo' ] )
+               );
+               $registry->defineRoleWithModel( 'bar', CONTENT_MODEL_TEXT );
+
+               $this->assertTrue( $registry->isKnownRole( 'foo' ) );
+               $this->assertTrue( $registry->isKnownRole( 'bar' ) );
+               $this->assertFalse( $registry->isKnownRole( 'xyzzy' ) );
+
+               $title = $this->makeBlankTitleObject();
+               $this->assertArrayEquals( [ 'foo', 'bar' ], $registry->getKnownRoles( $title ) );
+       }
+
+}
index e5e5551..32c9e5a 100644 (file)
@@ -458,6 +458,7 @@ abstract class RevisionDbTestBase extends MediaWikiTestCase {
                        $services->getCommentStore(),
                        $services->getContentModelStore(),
                        $services->getSlotRoleStore(),
+                       $services->getSlotRoleRegistry(),
                        $this->getMcrMigrationStage(),
                        $services->getActorMigration()
                );
index c053104..d6c33f0 100644 (file)
@@ -474,6 +474,7 @@ class RevisionTest extends MediaWikiTestCase {
                        MediaWikiServices::getInstance()->getCommentStore(),
                        MediaWikiServices::getInstance()->getContentModelStore(),
                        MediaWikiServices::getInstance()->getSlotRoleStore(),
+                       MediaWikiServices::getInstance()->getSlotRoleRegistry(),
                        MIGRATION_OLD,
                        MediaWikiServices::getInstance()->getActorMigration()
                );
index c175e2f..8b7e7a8 100644 (file)
@@ -102,6 +102,9 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                }
 
                $rev = $updater->saveRevision( $comment );
+               if ( !$updater->wasSuccessful() ) {
+                       $this->fail( $updater->getStatus()->getWikiText() );
+               }
 
                $this->getDerivedPageDataUpdater( $page ); // flush cached instance after.
                return $rev;
@@ -186,6 +189,11 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
         * @covers \MediaWiki\Storage\DerivedPageDataUpdater::getCanonicalParserOutput()
         */
        public function testPrepareContent() {
+               MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
+                       'aux',
+                       CONTENT_MODEL_WIKITEXT
+               );
+
                $sysop = $this->getTestUser( [ 'sysop' ] )->getUser();
                $updater = $this->getDerivedPageDataUpdater( __METHOD__ );
 
@@ -584,9 +592,7 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
        }
 
        public function testGetSecondaryDataUpdatesWithSlotRemoval() {
-               global $wgMultiContentRevisionSchemaMigrationStage;
-
-               if ( ! ( $wgMultiContentRevisionSchemaMigrationStage & SCHEMA_COMPAT_READ_NEW ) ) {
+               if ( !$this->hasMultiSlotSupport() ) {
                        $this->markTestSkipped( 'Slot removal cannot happen with MCR being enabled' );
                }
 
@@ -594,6 +600,11 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
                $a1 = $this->defineMockContentModelForUpdateTesting( 'A1' );
                $m2 = $this->defineMockContentModelForUpdateTesting( 'M2' );
 
+               MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
+                       'aux',
+                       $a1->getModelID()
+               );
+
                $mainContent1 = $this->createMockContent( $m1, 'main 1' );
                $auxContent1 = $this->createMockContent( $a1, 'aux 1' );
                $mainContent2 = $this->createMockContent( $m2, 'main 2' );
@@ -876,6 +887,11 @@ class DerivedPageDataUpdaterTest extends MediaWikiTestCase {
 
                if ( $this->hasMultiSlotSupport() ) {
                        $content['aux'] = new WikitextContent( 'Aux [[Nix]]' );
+
+                       MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
+                               'aux',
+                               CONTENT_MODEL_WIKITEXT
+                       );
                }
 
                $rev = $this->createRevision( $page, 'first', $content );
index 3ba9032..4e09077 100644 (file)
@@ -22,6 +22,15 @@ use WikiPage;
  */
 class PageUpdaterTest extends MediaWikiTestCase {
 
+       public function setUp() {
+               parent::setUp();
+
+               MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
+                       'aux',
+                       CONTENT_MODEL_WIKITEXT
+               );
+       }
+
        private function getDummyTitle( $method ) {
                return Title::newFromText( $method, $this->getDefaultWikitextNS() );
        }
@@ -337,6 +346,34 @@ class PageUpdaterTest extends MediaWikiTestCase {
                $this->assertTrue( $status->hasMessage( 'edit-already-exists' ), 'edit-already-exists' );
        }
 
+       /**
+        * @covers \MediaWiki\Storage\PageUpdater::saveRevision()
+        */
+       public function testFailureOnBadContentModel() {
+               $user = $this->getTestUser()->getUser();
+               $title = $this->getDummyTitle( __METHOD__ );
+
+               // start editing non-existing page
+               $page = WikiPage::factory( $title );
+               $updater = $page->newPageUpdater( $user );
+
+               // plain text content should fail in aux slot (the main slot doesn't care)
+               $updater->setContent( 'main', new TextContent( 'Main Content' ) );
+               $updater->setContent( 'aux', new TextContent( 'Aux Content' ) );
+
+               $summary = CommentStoreComment::newUnsavedComment( 'udpate?!' );
+               $updater->saveRevision( $summary, EDIT_UPDATE );
+               $status = $updater->getStatus();
+
+               $this->assertFalse( $updater->wasSuccessful(), 'wasSuccessful()' );
+               $this->assertNull( $updater->getNewRevision(), 'getNewRevision()' );
+               $this->assertFalse( $status->isOK(), 'getStatus()->isOK()' );
+               $this->assertTrue(
+                       $status->hasMessage( 'content-not-allowed-here' ),
+                       'content-not-allowed-here'
+               );
+       }
+
        public function provideSetRcPatrolStatus( $patrolled ) {
                yield [ RecentChange::PRC_UNPATROLLED ];
                yield [ RecentChange::PRC_AUTOPATROLLED ];
index 7665b78..215177d 100644 (file)
@@ -48,6 +48,11 @@ class RefreshLinksJobTest extends MediaWikiTestCase {
        // TODO: test partition
 
        public function testRunForSinglePage() {
+               MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
+                       'aux',
+                       CONTENT_MODEL_WIKITEXT
+               );
+
                $mainContent = new WikitextContent( 'MAIN [[Kittens]]' );
                $auxContent = new WikitextContent( 'AUX [[Category:Goats]]' );
                $page = $this->createPage( __METHOD__, [ 'main' => $mainContent, 'aux' => $auxContent ] );
index fee4583..ef7c4bd 100644 (file)
@@ -135,6 +135,9 @@ abstract class WikiPageDbTestBase extends MediaWikiLangTestCase {
                }
 
                $updater->saveRevision( CommentStoreComment::newUnsavedComment( "testing" ) );
+               if ( !$updater->wasSuccessful() ) {
+                       $this->fail( $updater->getStatus()->getWikiText() );
+               }
 
                return $page;
        }
index 69d12e3..78c0ac3 100644 (file)
@@ -1,4 +1,6 @@
 <?php
+
+use MediaWiki\MediaWikiServices;
 use MediaWiki\Tests\Revision\McrReadNewSchemaOverride;
 
 /**
@@ -24,6 +26,11 @@ class WikiPageMcrReadNewDbTest extends WikiPageDbTestBase {
                $m1 = $this->defineMockContentModelForUpdateTesting( 'M1' );
                $a1 = $this->defineMockContentModelForUpdateTesting( 'A1' );
 
+               MediaWikiServices::getInstance()->getSlotRoleRegistry()->defineRoleWithModel(
+                       'aux',
+                       $a1->getModelID()
+               );
+
                $mainContent1 = $this->createMockContent( $m1, 'main 1' );
                $auxContent1 = $this->createMockContent( $a1, 'aux 1' );