Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 147
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialChangeContentModel
0.00% covered (danger)
0.00%
0 / 146
0.00% covered (danger)
0.00%
0 / 15
1560
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setParameter
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 postHtml
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getDisplayFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 alterForm
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 checkPermissions
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 validateTitle
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 preHtml
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 getFormFields
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
42
 getOptionsForTitle
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 onSubmit
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 onSuccess
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Specials;
4
5use MediaWiki\Collation\CollationFactory;
6use MediaWiki\CommentStore\CommentStore;
7use MediaWiki\Content\ContentHandler;
8use MediaWiki\Content\IContentHandlerFactory;
9use MediaWiki\EditPage\SpamChecker;
10use MediaWiki\Exception\ErrorPageError;
11use MediaWiki\Exception\PermissionsError;
12use MediaWiki\Html\Html;
13use MediaWiki\HTMLForm\HTMLForm;
14use MediaWiki\Language\RawMessage;
15use MediaWiki\Logging\LogEventsList;
16use MediaWiki\Logging\LogPage;
17use MediaWiki\Page\ContentModelChangeFactory;
18use MediaWiki\Page\WikiPageFactory;
19use MediaWiki\Permissions\PermissionManager;
20use MediaWiki\Revision\RevisionLookup;
21use MediaWiki\Revision\RevisionRecord;
22use MediaWiki\Revision\SlotRecord;
23use MediaWiki\Search\SearchEngineFactory;
24use MediaWiki\SpecialPage\FormSpecialPage;
25use MediaWiki\Status\Status;
26use MediaWiki\Title\Title;
27
28/**
29 * @ingroup SpecialPage
30 */
31class SpecialChangeContentModel extends FormSpecialPage {
32
33    public function __construct(
34        private readonly IContentHandlerFactory $contentHandlerFactory,
35        private readonly ContentModelChangeFactory $contentModelChangeFactory,
36        private readonly SpamChecker $spamChecker,
37        private readonly RevisionLookup $revisionLookup,
38        private readonly WikiPageFactory $wikiPageFactory,
39        private readonly SearchEngineFactory $searchEngineFactory,
40        private readonly CollationFactory $collationFactory,
41        private readonly PermissionManager $permissionManager,
42    ) {
43        parent::__construct( 'ChangeContentModel' );
44    }
45
46    /** @inheritDoc */
47    public function doesWrites() {
48        return true;
49    }
50
51    /**
52     * @var Title|null
53     */
54    private $title;
55
56    /**
57     * @var RevisionRecord|bool|null
58     *
59     * A RevisionRecord object, false if no revision exists, null if not loaded yet
60     */
61    private $oldRevision;
62
63    private string $oldContentModel;
64
65    private bool $titleExisted;
66
67    private string $newContentModel;
68
69    /** @inheritDoc */
70    protected function setParameter( $par ) {
71        $par = $this->getRequest()->getVal( 'pagetitle', $par );
72        $title = Title::newFromText( $par );
73        if ( $title ) {
74            $this->title = $title;
75            $this->par = $title->getPrefixedText();
76        } else {
77            $this->par = '';
78        }
79    }
80
81    /** @inheritDoc */
82    protected function postHtml() {
83        $text = '';
84        if ( $this->title ) {
85            $contentModelLogPage = new LogPage( 'contentmodel' );
86            $text = Html::element( 'h2', [], $contentModelLogPage->getName()->text() );
87            $out = '';
88            LogEventsList::showLogExtract( $out, 'contentmodel', $this->title );
89            $text .= $out;
90        }
91        return $text;
92    }
93
94    /** @inheritDoc */
95    protected function getDisplayFormat() {
96        return 'ooui';
97    }
98
99    protected function alterForm( HTMLForm $form ) {
100        $this->addHelpLink( 'Help:ChangeContentModel' );
101
102        if ( $this->title ) {
103            $form->setFormIdentifier( 'modelform' );
104            if ( $this->title->exists() ) {
105                // T120576
106                $form->setSubmitTextMsg( 'changecontentmodel-submit' );
107            } else {
108                $form->setSubmitTextMsg( 'changecontentmodel-create-submit' );
109            }
110            $this->getOutput()->addBacklinkSubtitle( $this->title );
111        } else {
112            $form->setFormIdentifier( 'titleform' );
113            // T120576
114            $form->setSubmitTextMsg( 'changecontentmodel-submit' );
115        }
116    }
117
118    /** @inheritDoc */
119    public function checkPermissions() {
120        $user = $this->getUser();
121        if ( $this->title ) {
122            $perm = $this->title->exists() ? 'editcontentmodel' : 'createwithcontentmodel';
123            $this->permissionManager->throwPermissionErrors( $perm, $user, $this->title );
124        } elseif ( !$this->permissionManager->userHasAnyRight( $user, 'editcontentmodel', 'createwithcontentmodel' ) ) {
125            // The intended use case of this special page is to change the content model of an existing page
126            // nothing stops you from creating a new page with it but that's a hack so display the permission error
127            // for editing an existing page's content model if you can't do either
128            throw new PermissionsError( 'editcontentmodel' );
129        }
130    }
131
132    /**
133     * @param string $title
134     * @return string|bool
135     */
136    private function validateTitle( $title ) {
137        // Already validated by HTMLForm, but if not, throw
138        // an exception instead of a fatal
139        $titleObj = Title::newFromTextThrow( $title );
140
141        $this->oldRevision = $this->revisionLookup->getRevisionByTitle( $titleObj ) ?: false;
142
143        if ( $this->oldRevision ) {
144            $oldContent = $this->oldRevision->getContent( SlotRecord::MAIN );
145            if ( !$oldContent->getContentHandler()->supportsDirectEditing() ) {
146                return $this->msg( 'changecontentmodel-nodirectediting' )
147                    ->params( ContentHandler::getLocalizedName( $oldContent->getModel() ) )
148                    ->escaped();
149            }
150        }
151
152        return true;
153    }
154
155    /** @inheritDoc */
156    protected function preHtml() {
157        if ( $this->title ) {
158            // Checking permissions is handled by checkPermissions above
159            if ( $this->title->exists() ) {
160                $msg = $this->msg( 'changecontentmodel-editing', $this->title->getPrefixedText() );
161            } else {
162                $msg = $this->msg( 'changecontentmodel-create', $this->title->getPrefixedText() );
163            }
164        } elseif ( !$this->permissionManager->userHasRight( $this->getUser(), 'editcontentmodel' ) ) {
165            $msg = $this->msg( 'changecontentmodel-create-only' );
166        } else {
167            $msg = $this->msg( 'changecontentmodel-edit' );
168        }
169        return $msg->parseAsBlock();
170    }
171
172    /** @inheritDoc */
173    protected function getFormFields() {
174        $fields = [
175            'pagetitle' => [
176                'type' => 'title',
177                'creatable' => true,
178                'name' => 'pagetitle',
179                'default' => $this->par,
180                'label-message' => 'changecontentmodel-title-label',
181                'validation-callback' => $this->validateTitle( ... ),
182                // If you need to enter a non-existing page then don't show autocomplete for existing ones ...
183                'suggestions' => $this->permissionManager->userHasRight( $this->getUser(), 'editcontentmodel' ),
184            ],
185        ];
186        if ( $this->title ) {
187            $options = $this->getOptionsForTitle( $this->title );
188            if ( !$options ) {
189                throw new ErrorPageError(
190                    'changecontentmodel-emptymodels-title',
191                    'changecontentmodel-emptymodels-text',
192                    [ $this->title->getPrefixedText() ]
193                );
194            }
195            $fields['pagetitle']['readonly'] = true;
196            $fields += [
197                'model' => [
198                    'type' => 'select',
199                    'name' => 'model',
200                    'default' => $this->title->getContentModel(),
201                    'options' => $options,
202                    'label-message' => 'changecontentmodel-model-label'
203                ],
204                'reason' => [
205                    'type' => 'text',
206                    'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
207                    'name' => 'reason',
208                    'validation-callback' => function ( $reason ) {
209                        if ( $reason === null || $reason === '' ) {
210                            // Null on form display, or no reason given
211                            return true;
212                        }
213
214                        $match = $this->spamChecker->checkSummary( $reason );
215
216                        if ( $match ) {
217                            return $this->msg( 'spamprotectionmatch', $match )->parse();
218                        }
219
220                        return true;
221                    },
222                    'label-message' => 'changecontentmodel-reason-label',
223                ],
224            ];
225        }
226
227        return $fields;
228    }
229
230    /**
231     * @return array $options An array of data for an OOUI drop-down list. The array keys
232     * correspond to the human readable text in the drop-down list. The array values
233     * correspond to the <option value="">.
234     */
235    private function getOptionsForTitle( ?Title $title = null ) {
236        $models = $this->contentHandlerFactory->getContentModels();
237        $options = [];
238        foreach ( $models as $model ) {
239            $handler = $this->contentHandlerFactory->getContentHandler( $model );
240            if ( !$handler->supportsDirectEditing() ) {
241                continue;
242            }
243            if ( $title ) {
244                if ( !$handler->canBeUsedOn( $title ) ) {
245                    continue;
246                }
247            }
248            $options[ContentHandler::getLocalizedName( $model )] = $model;
249        }
250
251        // Put the options in the drop-down list in alphabetical order.
252        // Sort by array key, case insensitive.
253        $collation = $this->collationFactory->getCategoryCollation();
254        uksort( $options, static function ( $a, $b ) use ( $collation ) {
255            $a = $collation->getSortKey( $a );
256            $b = $collation->getSortKey( $b );
257            return strcmp( $a, $b );
258        } );
259
260        return $options;
261    }
262
263    /** @inheritDoc */
264    public function onSubmit( array $data ) {
265        $this->title = Title::newFromText( $data['pagetitle'] );
266        $this->titleExisted = $this->title->exists();
267        $this->oldContentModel = $this->title->getContentModel();
268        $this->newContentModel = $data['model'];
269        $page = $this->wikiPageFactory->newFromTitle( $this->title );
270
271        $changer = $this->contentModelChangeFactory->newContentModelChange(
272                $this->getContext()->getAuthority(),
273                $page,
274                $data['model']
275            );
276
277        $permissionStatus = $changer->authorizeChange();
278        if ( !$permissionStatus->isGood() ) {
279            $out = $this->getOutput();
280            $wikitext = $out->formatPermissionStatus( $permissionStatus );
281            // Hack to get our wikitext parsed
282            return Status::newFatal( new RawMessage( '$1', [ $wikitext ] ) );
283        }
284
285        $status = $changer->doContentModelChange(
286            $this->getContext(),
287            $data['reason'],
288            true
289        );
290
291        return $status;
292    }
293
294    public function onSuccess() {
295        $out = $this->getOutput();
296        if ( $this->titleExisted ) {
297            $out->setPageTitleMsg( $this->msg( 'changecontentmodel-success-title' ) );
298            $out->addWikiMsg( 'changecontentmodel-success-text',
299                $this->title->getPrefixedText(),
300                ContentHandler::getLocalizedName( $this->oldContentModel, $this->getLanguage() ),
301                ContentHandler::getLocalizedName( $this->newContentModel, $this->getLanguage() )
302            );
303        } else {
304            $out->setPageTitleMsg( $this->msg( 'changecontentmodel-create-success-title' ) );
305            $out->addWikiMsg( 'changecontentmodel-create-success-text',
306                $this->title->getPrefixedText(),
307                ContentHandler::getLocalizedName( $this->newContentModel, $this->getLanguage() )
308            );
309        }
310    }
311
312    /**
313     * Return an array of subpages beginning with $search that this special page will accept.
314     *
315     * @param string $search Prefix to search for
316     * @param int $limit Maximum number of results to return (usually 10)
317     * @param int $offset Number of results to skip (usually 0)
318     * @return string[] Matching subpages
319     */
320    public function prefixSearchSubpages( $search, $limit, $offset ) {
321        return $this->prefixSearchString( $search, $limit, $offset, $this->searchEngineFactory );
322    }
323
324    /** @inheritDoc */
325    protected function getGroupName() {
326        return 'pagetools';
327    }
328}
329
330/** @deprecated class alias since 1.41 */
331class_alias( SpecialChangeContentModel::class, 'SpecialChangeContentModel' );