Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 589
0.00% covered (danger)
0.00%
0 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialReplaceText
0.00% covered (danger)
0.00%
0 / 589
0.00% covered (danger)
0.00%
0 / 21
11342
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 20
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
 execute
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 getSelectedNamespaces
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 doSpecialReplaceText
0.00% covered (danger)
0.00%
0 / 95
0.00% covered (danger)
0.00%
0 / 1
552
 createJobsForTextReplacements
0.00% covered (danger)
0.00%
0 / 52
0.00% covered (danger)
0.00%
0 / 1
210
 getTitlesForEditingWithContext
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 getTitlesForMoveAndUnmoveableTitles
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
56
 getAnyWarningMessageBeforeReplace
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
56
 showForm
0.00% covered (danger)
0.00%
0 / 123
0.00% covered (danger)
0.00%
0 / 1
42
 checkLabel
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 namespaceTables
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
56
 pageListForm
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 1
110
 extractContext
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
72
 extractRole
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 convertWhiteSpaceToHTML
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getReplaceTextUser
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 displayTitles
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 checkToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * https://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20namespace MediaWiki\Extension\ReplaceText;
21
22use ErrorPageError;
23use JobQueueGroup;
24use MediaWiki\HookContainer\HookContainer;
25use MediaWiki\Html\Html;
26use MediaWiki\Language\Language;
27use MediaWiki\Linker\LinkRenderer;
28use MediaWiki\Page\MovePageFactory;
29use MediaWiki\Page\WikiPageFactory;
30use MediaWiki\Permissions\PermissionManager;
31use MediaWiki\Revision\SlotRecord;
32use MediaWiki\SpecialPage\SpecialPage;
33use MediaWiki\Storage\NameTableStore;
34use MediaWiki\Title\NamespaceInfo;
35use MediaWiki\Title\Title;
36use MediaWiki\User\Options\UserOptionsLookup;
37use MediaWiki\User\UserFactory;
38use MediaWiki\Watchlist\WatchlistManager;
39use OOUI;
40use PermissionsError;
41use SearchEngineConfig;
42use Wikimedia\Rdbms\IConnectionProvider;
43use Wikimedia\Rdbms\ReadOnlyMode;
44
45class SpecialReplaceText extends SpecialPage {
46    /** @var string */
47    private $target;
48    /** @var string */
49    private $targetString;
50    /** @var string */
51    private $replacement;
52    /** @var bool */
53    private $use_regex;
54    /** @var string */
55    private $category;
56    /** @var string */
57    private $prefix;
58    /** @var string|int */
59    private $pageLimit;
60    /** @var bool */
61    private $edit_pages;
62    /** @var bool */
63    private $move_pages;
64    /** @var int[] */
65    private $selected_namespaces;
66    /** @var bool */
67    private $botEdit;
68
69    /** @var HookHelper */
70    private $hookHelper;
71
72    /** @var IConnectionProvider */
73    private $dbProvider;
74
75    /** @var Language */
76    private $contentLanguage;
77
78    /** @var JobQueueGroup */
79    private $jobQueueGroup;
80
81    /** @var LinkRenderer */
82    private $linkRenderer;
83
84    /** @var MovePageFactory */
85    private $movePageFactory;
86
87    /** @var NamespaceInfo */
88    private $namespaceInfo;
89
90    /** @var PermissionManager */
91    private $permissionManager;
92
93    /** @var ReadOnlyMode */
94    private $readOnlyMode;
95
96    /** @var SearchEngineConfig */
97    private $searchEngineConfig;
98
99    /** @var NameTableStore */
100    private $slotRoleStore;
101
102    /** @var UserFactory */
103    private $userFactory;
104
105    /** @var UserOptionsLookup */
106    private $userOptionsLookup;
107
108    private WatchlistManager $watchlistManager;
109    private WikiPageFactory $wikiPageFactory;
110
111    /** @var Search */
112    private $search;
113
114    /**
115     * @param HookContainer $hookContainer
116     * @param IConnectionProvider $dbProvider
117     * @param Language $contentLanguage
118     * @param JobQueueGroup $jobQueueGroup
119     * @param LinkRenderer $linkRenderer
120     * @param MovePageFactory $movePageFactory
121     * @param NamespaceInfo $namespaceInfo
122     * @param PermissionManager $permissionManager
123     * @param ReadOnlyMode $readOnlyMode
124     * @param SearchEngineConfig $searchEngineConfig
125     * @param NameTableStore $slotRoleStore
126     * @param UserFactory $userFactory
127     * @param UserOptionsLookup $userOptionsLookup
128     * @param WatchlistManager $watchlistManager
129     * @param WikiPageFactory $wikiPageFactory
130     */
131    public function __construct(
132        HookContainer $hookContainer,
133        IConnectionProvider $dbProvider,
134        Language $contentLanguage,
135        JobQueueGroup $jobQueueGroup,
136        LinkRenderer $linkRenderer,
137        MovePageFactory $movePageFactory,
138        NamespaceInfo $namespaceInfo,
139        PermissionManager $permissionManager,
140        ReadOnlyMode $readOnlyMode,
141        SearchEngineConfig $searchEngineConfig,
142        NameTableStore $slotRoleStore,
143        UserFactory $userFactory,
144        UserOptionsLookup $userOptionsLookup,
145        WatchlistManager $watchlistManager,
146        WikiPageFactory $wikiPageFactory
147    ) {
148        parent::__construct( 'ReplaceText', 'replacetext' );
149        $this->hookHelper = new HookHelper( $hookContainer );
150        $this->dbProvider = $dbProvider;
151        $this->contentLanguage = $contentLanguage;
152        $this->jobQueueGroup = $jobQueueGroup;
153        $this->linkRenderer = $linkRenderer;
154        $this->movePageFactory = $movePageFactory;
155        $this->namespaceInfo = $namespaceInfo;
156        $this->permissionManager = $permissionManager;
157        $this->readOnlyMode = $readOnlyMode;
158        $this->searchEngineConfig = $searchEngineConfig;
159        $this->slotRoleStore = $slotRoleStore;
160        $this->userFactory = $userFactory;
161        $this->userOptionsLookup = $userOptionsLookup;
162        $this->watchlistManager = $watchlistManager;
163        $this->wikiPageFactory = $wikiPageFactory;
164        $this->search = new Search(
165            $this->getConfig(),
166            $dbProvider
167        );
168    }
169
170    /**
171     * @inheritDoc
172     */
173    public function doesWrites() {
174        return true;
175    }
176
177    /**
178     * @param null|string $query
179     */
180    function execute( $query ) {
181        if ( !$this->getUser()->isAllowed( 'replacetext' ) ) {
182            throw new PermissionsError( 'replacetext' );
183        }
184
185        // Replace Text can't be run with certain settings, due to the
186        // changes they make to the DB storage setup.
187        if ( $this->getConfig()->get( 'CompressRevisions' ) ) {
188            throw new ErrorPageError( 'replacetext_cfg_error', 'replacetext_no_compress' );
189        }
190        if ( $this->getConfig()->get( 'ExternalStores' ) ) {
191            throw new ErrorPageError( 'replacetext_cfg_error', 'replacetext_no_external_stores' );
192        }
193
194        $out = $this->getOutput();
195
196        if ( $this->readOnlyMode->isReadOnly() ) {
197            $permissionErrors = [ [ 'readonlytext', [ $this->readOnlyMode->getReason() ] ] ];
198            $out->setPageTitleMsg( $this->msg( 'badaccess' ) );
199            $out->addWikiTextAsInterface( $out->formatPermissionsErrorMessage( $permissionErrors, 'replacetext' ) );
200            return;
201        }
202
203        $out->enableOOUI();
204        $this->setHeaders();
205        $this->doSpecialReplaceText();
206    }
207
208    /**
209     * @return array namespaces selected for search
210     */
211    function getSelectedNamespaces() {
212        $all_namespaces = $this->searchEngineConfig->searchableNamespaces();
213        $selected_namespaces = [];
214        foreach ( $all_namespaces as $ns => $name ) {
215            if ( $this->getRequest()->getCheck( 'ns' . $ns ) ) {
216                $selected_namespaces[] = $ns;
217            }
218        }
219        return $selected_namespaces;
220    }
221
222    /**
223     * Do the actual display and logic of Special:ReplaceText.
224     */
225    function doSpecialReplaceText() {
226        $out = $this->getOutput();
227        $request = $this->getRequest();
228
229        $this->target = $request->getText( 'target' );
230        $this->targetString = str_replace( "\n", "\u{21B5}", $this->target );
231        $this->replacement = $request->getText( 'replacement' );
232        $this->use_regex = $request->getBool( 'use_regex' );
233        $this->category = $request->getText( 'category' );
234        $this->prefix = $request->getText( 'prefix' );
235        $this->pageLimit = $request->getText( 'pageLimit' );
236        $this->edit_pages = $request->getBool( 'edit_pages' );
237        $this->move_pages = $request->getBool( 'move_pages' );
238        $this->botEdit = $request->getBool( 'botEdit' );
239        $this->selected_namespaces = $this->getSelectedNamespaces();
240
241        if ( $request->getCheck( 'continue' ) && $this->target === '' ) {
242            $this->showForm( 'replacetext_givetarget' );
243            return;
244        }
245
246        if ( $request->getCheck( 'continue' ) && $this->pageLimit === '' ) {
247            $this->pageLimit = $this->getConfig()->get( 'ReplaceTextResultsLimit' );
248        } else {
249            $this->pageLimit = (int)$this->pageLimit;
250        }
251
252        if ( $request->getCheck( 'replace' ) ) {
253
254            // check for CSRF
255            if ( !$this->checkToken() ) {
256                $out->addWikiMsg( 'sessionfailure' );
257                return;
258            }
259
260            $jobs = $this->createJobsForTextReplacements();
261            $this->jobQueueGroup->push( $jobs );
262
263            $count = $this->getLanguage()->formatNum( count( $jobs ) );
264            $out->addWikiMsg(
265                'replacetext_success',
266                "<code><nowiki>{$this->targetString}</nowiki></code>",
267                "<code><nowiki>{$this->replacement}</nowiki></code>",
268                $count
269            );
270            // Link back
271            $out->addHTML(
272                $this->linkRenderer->makeLink(
273                    $this->getPageTitle(),
274                    $this->msg( 'replacetext_return' )->text()
275                )
276            );
277            return;
278        }
279
280        if ( $request->getCheck( 'target' ) ) {
281            // check for CSRF
282            if ( !$this->checkToken() ) {
283                $out->addWikiMsg( 'sessionfailure' );
284                return;
285            }
286
287            // first, check that at least one namespace has been
288            // picked, and that either editing or moving pages
289            // has been selected
290            if ( count( $this->selected_namespaces ) == 0 ) {
291                $this->showForm( 'replacetext_nonamespace' );
292                return;
293            }
294            if ( !$this->edit_pages && !$this->move_pages ) {
295                $this->showForm( 'replacetext_editormove' );
296                return;
297            }
298
299            // If user is replacing text within pages...
300            $titles_for_edit = $titles_for_move = $unmoveable_titles = $uneditable_titles = [];
301            if ( $this->edit_pages ) {
302                [ $titles_for_edit, $uneditable_titles ] = $this->getTitlesForEditingWithContext();
303            }
304            if ( $this->move_pages ) {
305                [ $titles_for_move, $unmoveable_titles ] = $this->getTitlesForMoveAndUnmoveableTitles();
306            }
307
308            // If no results were found, check to see if a bad
309            // category name was entered.
310            if ( count( $titles_for_edit ) == 0 && count( $titles_for_move ) == 0 ) {
311                $category_title_exists = true;
312
313                if ( $this->category ) {
314                    $category_title = Title::makeTitleSafe( NS_CATEGORY, $this->category );
315                    if ( !$category_title->exists() ) {
316                        $category_title_exists = false;
317                        $link = $this->linkRenderer->makeLink(
318                            $category_title,
319                            ucfirst( $this->category )
320                        );
321                        $out->addHTML(
322                            $this->msg( 'replacetext_nosuchcategory' )->rawParams( $link )->escaped()
323                        );
324                    }
325                }
326
327                if ( $this->edit_pages && $category_title_exists ) {
328                    $out->addWikiMsg(
329                        'replacetext_noreplacement',
330                        "<code><nowiki>{$this->targetString}</nowiki></code>"
331                    );
332                }
333
334                if ( $this->move_pages && $category_title_exists ) {
335                    $out->addWikiMsg( 'replacetext_nomove', "<code><nowiki>{$this->targetString}</nowiki></code>" );
336                }
337                // link back to starting form
338                $out->addHTML(
339                    '<p>' .
340                    $this->linkRenderer->makeLink(
341                        $this->getPageTitle(),
342                        $this->msg( 'replacetext_return' )->text()
343                    )
344                    . '</p>'
345                );
346            } else {
347                $warning_msg = $this->getAnyWarningMessageBeforeReplace( $titles_for_edit, $titles_for_move );
348                if ( $warning_msg !== null ) {
349                    $warningLabel = new OOUI\LabelWidget( [
350                        'label' => new OOUI\HtmlSnippet( $warning_msg )
351                    ] );
352                    $warning = new OOUI\MessageWidget( [
353                        'type' => 'warning',
354                        'label' => $warningLabel
355                    ] );
356                    $out->addHTML( $warning );
357                }
358
359                $this->pageListForm( $titles_for_edit, $titles_for_move, $uneditable_titles, $unmoveable_titles );
360            }
361            return;
362        }
363
364        // If we're still here, show the starting form.
365        $this->showForm();
366    }
367
368    /**
369     * Returns the set of MediaWiki jobs that will do all the actual replacements.
370     *
371     * @return array jobs
372     */
373    function createJobsForTextReplacements() {
374        $replacement_params = [
375            'user_id' => $this->getReplaceTextUser()->getId(),
376            'target_str' => $this->target,
377            'replacement_str' => $this->replacement,
378            'use_regex' => $this->use_regex,
379            'create_redirect' => false,
380            'watch_page' => false,
381            'botEdit' => $this->botEdit
382        ];
383        $replacement_params['edit_summary'] = $this->msg(
384            'replacetext_editsummary',
385            $this->targetString, $this->replacement
386        )->inContentLanguage()->plain();
387
388        $request = $this->getRequest();
389        foreach ( $request->getValues() as $key => $value ) {
390            if ( $key == 'create-redirect' && $value == '1' ) {
391                $replacement_params['create_redirect'] = true;
392            } elseif ( $key == 'watch-pages' && $value == '1' ) {
393                $replacement_params['watch_page'] = true;
394            }
395        }
396
397        $jobs = [];
398        $pages_to_edit = [];
399        // These are OOUI checkboxes - we don't determine whether they
400        // were checked by their value (which will be null), but rather
401        // by whether they were submitted at all.
402        foreach ( $request->getValues() as $key => $value ) {
403            if ( $key === 'replace' || $key === 'use_regex' ) {
404                continue;
405            }
406            if ( strpos( $key, 'move-' ) !== false ) {
407                $title = Title::newFromID( (int)substr( $key, 5 ) );
408                $replacement_params['move_page'] = true;
409                if ( $title !== null ) {
410                    $jobs[] = new Job( $title, $replacement_params,
411                        $this->movePageFactory,
412                        $this->permissionManager,
413                        $this->userFactory,
414                        $this->watchlistManager,
415                        $this->wikiPageFactory
416                    );
417                }
418                unset( $replacement_params['move_page'] );
419            } elseif ( strpos( $key, '|' ) !== false ) {
420                // Bundle multiple edits to the same page for a different slot into one job
421                [ $page_id, $role ] = explode( '|', $key, 2 );
422                $pages_to_edit[$page_id][] = $role;
423            }
424        }
425        // Create jobs for the bundled page edits
426        foreach ( $pages_to_edit as $page_id => $roles ) {
427            $title = Title::newFromID( (int)$page_id );
428            $replacement_params['roles'] = $roles;
429            if ( $title !== null ) {
430                $jobs[] = new Job( $title, $replacement_params,
431                    $this->movePageFactory,
432                    $this->permissionManager,
433                    $this->userFactory,
434                    $this->watchlistManager,
435                    $this->wikiPageFactory
436                );
437            }
438            unset( $replacement_params['roles'] );
439        }
440
441        return $jobs;
442    }
443
444    /**
445     * Returns the set of Titles whose contents would be modified by this
446     * replacement, along with the "search context" string for each one.
447     *
448     * @return array The set of Titles and their search context strings
449     */
450    function getTitlesForEditingWithContext() {
451        $titles_for_edit = [];
452
453        $res = $this->search->doSearchQuery(
454            $this->target,
455            $this->selected_namespaces,
456            $this->category,
457            $this->prefix,
458            $this->pageLimit,
459            $this->use_regex
460        );
461
462        $titles_to_process = $this->hookHelper->filterPageTitlesForEdit( $res );
463        $titles_to_skip = [];
464
465        foreach ( $res as $row ) {
466            $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
467            if ( $title == null ) {
468                continue;
469            }
470
471            if ( !isset( $titles_to_process[ $title->getPrefixedText() ] ) ) {
472                // Title has been filtered out by the hook: ReplaceTextFilterPageTitlesForEdit
473                $titles_to_skip[] = $title;
474                continue;
475            }
476
477            // @phan-suppress-next-line SecurityCheck-ReDoS target could be a regex from user
478            $context = $this->extractContext( $row->old_text, $this->target, $this->use_regex );
479            $role = $this->extractRole( (int)$row->slot_role_id );
480            $titles_for_edit[] = [ $title, $context, $role ];
481        }
482
483        return [ $titles_for_edit, $titles_to_skip ];
484    }
485
486    /**
487     * Returns two lists: the set of titles that would be moved/renamed by
488     * the current text replacement, and the set of titles that would
489     * ordinarily be moved but are not moveable, due to permissions or any
490     * other reason.
491     *
492     * @return array
493     */
494    function getTitlesForMoveAndUnmoveableTitles() {
495        $titles_for_move = [];
496        $unmoveable_titles = [];
497
498        $res = $this->search->getMatchingTitles(
499            $this->target,
500            $this->selected_namespaces,
501            $this->category,
502            $this->prefix,
503            $this->pageLimit,
504            $this->use_regex
505        );
506
507        $titles_to_process = $this->hookHelper->filterPageTitlesForRename( $res );
508
509        foreach ( $res as $row ) {
510            $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
511            if ( !$title ) {
512                continue;
513            }
514
515            if ( !isset( $titles_to_process[ $title->getPrefixedText() ] ) ) {
516                $unmoveable_titles[] = $title;
517                continue;
518            }
519
520            $new_title = Search::getReplacedTitle(
521                $title,
522                $this->target,
523                $this->replacement,
524                $this->use_regex
525            );
526            if ( !$new_title ) {
527                // New title is not valid because it contains invalid characters.
528                $unmoveable_titles[] = $title;
529                continue;
530            }
531
532            $mvPage = $this->movePageFactory->newMovePage( $title, $new_title );
533            $moveStatus = $mvPage->isValidMove();
534            $permissionStatus = $mvPage->checkPermissions( $this->getUser(), null );
535
536            if ( $permissionStatus->isOK() && $moveStatus->isOK() ) {
537                $titles_for_move[] = $title;
538            } else {
539                $unmoveable_titles[] = $title;
540            }
541        }
542
543        return [ $titles_for_move, $unmoveable_titles ];
544    }
545
546    /**
547     * Get the warning message if the replacement string is either blank
548     * or found elsewhere on the wiki (since undoing the replacement
549     * would be difficult in either case).
550     *
551     * @param array $titles_for_edit
552     * @param array $titles_for_move
553     * @return string|null Warning message, if any
554     */
555    function getAnyWarningMessageBeforeReplace( $titles_for_edit, $titles_for_move ) {
556        if ( $this->replacement === '' ) {
557            return $this->msg( 'replacetext_blankwarning' )->parse();
558        } elseif ( $this->use_regex ) {
559            // If it's a regex, don't bother checking for existing
560            // pages - if the replacement string includes wildcards,
561            // it's a meaningless check.
562            return null;
563        } elseif ( count( $titles_for_edit ) > 0 ) {
564            $res = $this->search->doSearchQuery(
565                $this->replacement,
566                $this->selected_namespaces,
567                $this->category,
568                $this->prefix,
569                $this->pageLimit,
570                $this->use_regex
571            );
572            $titles = $this->hookHelper->filterPageTitlesForEdit( $res );
573            $count = count( $titles );
574            if ( $count > 0 ) {
575                return $this->msg( 'replacetext_warning' )->numParams( $count )
576                    ->params( "<code><nowiki>{$this->replacement}</nowiki></code>" )->parse();
577            }
578        } elseif ( count( $titles_for_move ) > 0 ) {
579            $res = $this->search->getMatchingTitles(
580                $this->replacement,
581                $this->selected_namespaces,
582                $this->category,
583                $this->prefix,
584                $this->pageLimit,
585                $this->use_regex
586            );
587            $titles = $this->hookHelper->filterPageTitlesForRename( $res );
588            $count = count( $titles );
589            if ( $count > 0 ) {
590                return $this->msg( 'replacetext_warning' )->numParams( $count )
591                    ->params( $this->replacement )->parse();
592            }
593        }
594
595        return null;
596    }
597
598    /**
599     * @param string|null $warning_msg Message to be shown at top of form
600     */
601    function showForm( $warning_msg = null ) {
602        $out = $this->getOutput();
603
604        $out->addHTML(
605            Html::openElement(
606                'form',
607                [
608                    'id' => 'powersearch',
609                    'action' => $this->getPageTitle()->getLocalURL(),
610                    'method' => 'post'
611                ]
612            ) . "\n" .
613            Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
614            Html::hidden( 'continue', 1 ) .
615            Html::hidden( 'token', $this->getToken() )
616        );
617        if ( $warning_msg === null ) {
618            $out->addWikiMsg( 'replacetext_docu' );
619        } else {
620            $out->wrapWikiMsg(
621                "<div class=\"errorbox\">\n$1\n</div><br clear=\"both\" />",
622                $warning_msg
623            );
624        }
625
626        $out->addHTML( '<table><tr><td style="vertical-align: top;">' );
627        $out->addWikiMsg( 'replacetext_originaltext' );
628        $out->addHTML( '</td><td>' );
629        // 'width: auto' style is needed to override MediaWiki's
630        // normal 'width: 100%', which causes the textarea to get
631        // zero width in IE
632        $out->addHTML( Html::textarea( 'target', $this->target,
633            [ 'cols' => 100, 'rows' => 5, 'style' => 'width: auto;' ]
634        ) );
635        $out->addHTML( '</td></tr><tr><td style="vertical-align: top;">' );
636        $out->addWikiMsg( 'replacetext_replacementtext' );
637        $out->addHTML( '</td><td>' );
638        $out->addHTML( Html::textarea( 'replacement', $this->replacement,
639            [ 'cols' => 100, 'rows' => 5, 'style' => 'width: auto;' ]
640        ) );
641        $out->addHTML( '</td></tr></table>' );
642
643        // SQLite unfortunately lack a REGEXP
644        // function or operator by default, so disable regex(p)
645        // searches that DB type.
646        $dbr = $this->dbProvider->getReplicaDatabase();
647        if ( $dbr->getType() !== 'sqlite' ) {
648            $out->addHTML( Html::rawElement( 'p', [],
649                    Html::rawElement( 'label', [],
650                        Html::input( 'use_regex', '1', 'checkbox' )
651                        . ' ' . $this->msg( 'replacetext_useregex' )->escaped(),
652                    )
653                ) . "\n" .
654                Html::element( 'p',
655                    [ 'style' => 'font-style: italic' ],
656                    $this->msg( 'replacetext_regexdocu' )->text()
657                )
658            );
659        }
660
661        // The interface is heavily based on the one in Special:Search.
662        $namespaces = $this->searchEngineConfig->searchableNamespaces();
663        $tables = $this->namespaceTables( $namespaces );
664        $out->addHTML(
665            "<div class=\"mw-search-formheader\"></div>\n" .
666            "<fieldset class=\"ext-replacetext-searchoptions\">\n" .
667            Html::element( 'h4', [], $this->msg( 'powersearch-ns' )->text() )
668        );
669        // The ability to select/unselect groups of namespaces in the
670        // search interface exists only in some skins, like Vector -
671        // check for the presence of the 'powersearch-togglelabel'
672        // message to see if we can use this functionality here.
673        if ( $this->msg( 'powersearch-togglelabel' )->isDisabled() ) {
674            // do nothing
675        } else {
676            $out->addHTML(
677                Html::rawElement(
678                    'div',
679                    [ 'class' => 'ext-replacetext-search-togglebox' ],
680                    Html::element( 'label', [],
681                        $this->msg( 'powersearch-togglelabel' )->text()
682                    ) .
683                    Html::element( 'input', [
684                        'id' => 'mw-search-toggleall',
685                        'type' => 'button',
686                        'value' => $this->msg( 'powersearch-toggleall' )->text(),
687                    ] ) .
688                    Html::element( 'input', [
689                        'id' => 'mw-search-togglenone',
690                        'type' => 'button',
691                        'value' => $this->msg( 'powersearch-togglenone' )->text()
692                    ] )
693                )
694            );
695        }
696        $out->addHTML(
697            Html::element( 'div', [ 'class' => 'ext-replacetext-divider' ] ) .
698            "$tables\n</fieldset>"
699        );
700        $category_search_label = $this->msg( 'replacetext_categorysearch' )->escaped();
701        $prefix_search_label = $this->msg( 'replacetext_prefixsearch' )->escaped();
702        $page_limit_label = $this->msg( 'replacetext_pagelimit' )->escaped();
703        $this->pageLimit = $this->pageLimit === 0
704            ? $this->getConfig()->get( 'ReplaceTextResultsLimit' )
705            : $this->pageLimit;
706        $out->addHTML(
707            "<fieldset class=\"ext-replacetext-searchoptions\">\n" .
708            Html::element( 'h4', [], $this->msg( 'replacetext_optionalfilters' )->text() ) .
709            Html::element( 'div', [ 'class' => 'ext-replacetext-divider' ] ) .
710            "<p>$category_search_label\n" .
711            Html::element( 'input', [ 'name' => 'category', 'size' => 20, 'value' => $this->category ] ) . '</p>' .
712            "<p>$prefix_search_label\n" .
713            Html::element( 'input', [ 'name' => 'prefix', 'size' => 20, 'value' => $this->prefix ] ) . '</p>' .
714            "<p>$page_limit_label\n" .
715            Html::element( 'input', [ 'name' => 'pageLimit', 'size' => 20, 'value' => (string)$this->pageLimit,
716                'type' => 'number', 'min' => 0 ] ) . "</p></fieldset>\n" .
717            "<p>\n" .
718            Html::rawElement( 'label', [],
719                Html::input( 'edit_pages', '1', 'checkbox', [ 'checked' => true ] )
720                . ' ' . $this->msg( 'replacetext_editpages' )->escaped()
721            ) . '<br />' .
722            Html::rawElement( 'label', [],
723                Html::input( 'move_pages', '1', 'checkbox' )
724                . ' ' . $this->msg( 'replacetext_movepages' )->escaped()
725            )
726        );
727
728        // If the user is a bot, don't even show the "Mark changes as bot edits" checkbox -
729        // presumably a bot user should never be allowed to make non-bot edits.
730        if ( !$this->permissionManager->userHasRight( $this->getReplaceTextUser(), 'bot' ) ) {
731            $out->addHTML(
732                '<br />' .
733                Html::rawElement( 'label', [],
734                    Html::input( 'botEdit', '1', 'checkbox' ) . ' ' . $this->msg( 'replacetext_botedit' )->escaped()
735                )
736            );
737        }
738        $continueButton = new OOUI\ButtonInputWidget( [
739            'type' => 'submit',
740            'label' => $this->msg( 'replacetext_continue' )->text(),
741            'flags' => [ 'primary', 'progressive' ]
742        ] );
743        $out->addHTML(
744            "</p>\n" .
745            $continueButton .
746            Html::closeElement( 'form' )
747        );
748        $out->addModuleStyles( 'ext.ReplaceTextStyles' );
749        $out->addModules( 'ext.ReplaceText' );
750    }
751
752    /**
753     * This function is not currently used, but it may get used in the
754     * future if the "1st screen" interface changes to use OOUI.
755     *
756     * @param string $label
757     * @param string $name
758     * @param bool $selected
759     * @return string HTML
760     */
761    function checkLabel( $label, $name, $selected = false ) {
762        $checkbox = new OOUI\CheckboxInputWidget( [
763            'name' => $name,
764            'value' => 1,
765            'selected' => $selected
766        ] );
767        $layout = new OOUI\FieldLayout( $checkbox, [
768            'align' => 'inline',
769            'label' => $label
770        ] );
771        return $layout;
772    }
773
774    /**
775     * Copied almost exactly from MediaWiki's SpecialSearch class, i.e.
776     * the search page
777     * @param string[] $namespaces
778     * @param int $rowsPerTable
779     * @return string HTML
780     */
781    function namespaceTables( $namespaces, $rowsPerTable = 3 ) {
782        // Group namespaces into rows according to subject.
783        // Try not to make too many assumptions about namespace numbering.
784        $rows = [];
785        $tables = '';
786        foreach ( $namespaces as $ns => $name ) {
787            $subj = $this->namespaceInfo->getSubject( $ns );
788            if ( !array_key_exists( $subj, $rows ) ) {
789                $rows[$subj] = '';
790            }
791            $name = str_replace( '_', ' ', $name );
792            if ( $name == '' ) {
793                $name = $this->msg( 'blanknamespace' )->text();
794            }
795            $id = "mw-search-ns{$ns}";
796            $rows[$subj] .= Html::openElement( 'td' ) .
797                Html::input( "ns{$ns}", '1', 'checkbox', [ 'id' => $id, 'checked' => ( $ns == 0 ) ] ) .
798                ' ' . Html::label( $name, $id ) .
799                Html::closeElement( 'td' ) . "\n";
800        }
801        $rows = array_values( $rows );
802        $numRows = count( $rows );
803        // Lay out namespaces in multiple floating two-column tables so they'll
804        // be arranged nicely while still accommodating different screen widths
805        // Build the final HTML table...
806        for ( $i = 0; $i < $numRows; $i += $rowsPerTable ) {
807            $tables .= Html::openElement( 'table' );
808            for ( $j = $i; $j < $i + $rowsPerTable && $j < $numRows; $j++ ) {
809                $tables .= "<tr>\n" . $rows[$j] . "</tr>";
810            }
811            $tables .= Html::closeElement( 'table' ) . "\n";
812        }
813        return $tables;
814    }
815
816    /**
817     * @param array $titles_for_edit
818     * @param array $titles_for_move
819     * @param array $uneditable_titles
820     * @param array $unmoveable_titles
821     */
822    function pageListForm( $titles_for_edit, $titles_for_move, $uneditable_titles, $unmoveable_titles ) {
823        $out = $this->getOutput();
824
825        $formOpts = [
826            'id' => 'choose_pages',
827            'method' => 'post',
828            'action' => $this->getPageTitle()->getLocalURL()
829        ];
830        $out->addHTML(
831            Html::openElement( 'form', $formOpts ) . "\n" .
832            Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
833            Html::hidden( 'target', $this->target ) .
834            Html::hidden( 'replacement', $this->replacement ) .
835            Html::hidden( 'use_regex', $this->use_regex ) .
836            Html::hidden( 'move_pages', $this->move_pages ) .
837            Html::hidden( 'edit_pages', $this->edit_pages ) .
838            Html::hidden( 'botEdit', $this->botEdit ) .
839            Html::hidden( 'replace', 1 ) .
840            Html::hidden( 'token', $this->getToken() )
841        );
842
843        foreach ( $this->selected_namespaces as $ns ) {
844            $out->addHTML( Html::hidden( 'ns' . $ns, 1 ) );
845        }
846
847        $out->addModules( 'ext.ReplaceText' );
848        $out->addModuleStyles( 'ext.ReplaceTextStyles' );
849
850        // Only show "invert selections" link if there are more than
851        // five pages.
852        if ( count( $titles_for_edit ) + count( $titles_for_move ) > 5 ) {
853            $invertButton = new OOUI\ButtonWidget( [
854                'label' => $this->msg( 'replacetext_invertselections' )->text(),
855                'classes' => [ 'ext-replacetext-invert' ]
856            ] );
857            $out->addHTML( $invertButton );
858        }
859
860        if ( count( $titles_for_edit ) > 0 ) {
861            $out->addWikiMsg(
862                'replacetext_choosepagesforedit',
863                "<code><nowiki>{$this->targetString}</nowiki></code>",
864                "<code><nowiki>{$this->replacement}</nowiki></code>",
865                $this->getLanguage()->formatNum( count( $titles_for_edit ) )
866            );
867
868            foreach ( $titles_for_edit as $title_and_context ) {
869                /**
870                 * @var $title Title
871                 */
872                [ $title, $context, $role ] = $title_and_context;
873                $checkbox = new OOUI\CheckboxInputWidget( [
874                    'name' => $title->getArticleID() . '|' . $role,
875                    'selected' => true
876                ] );
877                if ( $role === SlotRecord::MAIN ) {
878                    $labelText = $this->linkRenderer->makeLink( $title ) .
879                        "<br /><small>$context</small>";
880                } else {
881                    $labelText = $this->linkRenderer->makeLink( $title ) .
882                        " ($role) <br /><small>$context</small>";
883                }
884                $checkboxLabel = new OOUI\LabelWidget( [
885                    'label' => new OOUI\HtmlSnippet( $labelText )
886                ] );
887                $layout = new OOUI\FieldLayout( $checkbox, [
888                    'align' => 'inline',
889                    'label' => $checkboxLabel
890                ] );
891                $out->addHTML( $layout );
892            }
893            $out->addHTML( '<br />' );
894        }
895
896        if ( count( $titles_for_move ) > 0 ) {
897            $out->addWikiMsg(
898                'replacetext_choosepagesformove',
899                $this->targetString,
900                $this->replacement,
901                $this->getLanguage()->formatNum( count( $titles_for_move ) )
902            );
903            foreach ( $titles_for_move as $title ) {
904                $out->addHTML(
905                    Html::check( 'move-' . $title->getArticleID(), true ) . "\u{00A0}" .
906                    $this->linkRenderer->makeLink( $title ) . "<br />\n"
907                );
908            }
909            $out->addHTML( '<br />' );
910            $out->addWikiMsg( 'replacetext_formovedpages' );
911            $out->addHTML(
912                Html::rawElement( 'label', [],
913                    Html::input( 'create-redirect', '1', 'checkbox', [ 'checked' => true ] )
914                    . ' ' . $this->msg( 'replacetext_savemovedpages' )->escaped()
915                ) . "<br />\n" .
916                Html::rawElement( 'label', [],
917                    Html::input( 'watch-pages', '1', 'checkbox' )
918                    . ' ' . $this->msg( 'replacetext_watchmovedpages' )->escaped()
919                ) . '<br />'
920            );
921            $out->addHTML( '<br />' );
922        }
923
924        $submitButton = new OOUI\ButtonInputWidget( [
925            'type' => 'submit',
926            'flags' => [ 'primary', 'progressive' ],
927            'label' => $this->msg( 'replacetext_replace' )->text()
928        ] );
929        $out->addHTML( $submitButton );
930
931        $out->addHTML( '</form>' );
932
933        if ( count( $uneditable_titles ) ) {
934            $out->addWikiMsg(
935                'replacetext_cannotedit',
936                $this->getLanguage()->formatNum( count( $uneditable_titles ) )
937            );
938            $out->addHTML( $this->displayTitles( $uneditable_titles ) );
939        }
940
941        if ( count( $unmoveable_titles ) ) {
942            $out->addWikiMsg(
943                'replacetext_cannotmove',
944                $this->getLanguage()->formatNum( count( $unmoveable_titles ) )
945            );
946            $out->addHTML( $this->displayTitles( $unmoveable_titles ) );
947        }
948    }
949
950    /**
951     * Extract context and highlights search text
952     *
953     * @todo The bolding needs to be fixed for regular expressions.
954     * @param string $text
955     * @param string $target
956     * @param bool $use_regex
957     * @return string
958     */
959    function extractContext( $text, $target, $use_regex = false ) {
960        $cw = $this->userOptionsLookup->getOption( $this->getUser(), 'contextchars', 40, true );
961
962        // Get all indexes
963        if ( $use_regex ) {
964            $targetq = str_replace( "/", "\\/", $target );
965            preg_match_all( "/$targetq/Uu", $text, $matches, PREG_OFFSET_CAPTURE );
966        } else {
967            $targetq = preg_quote( $target, '/' );
968            preg_match_all( "/$targetq/", $text, $matches, PREG_OFFSET_CAPTURE );
969        }
970
971        $strLengths = [];
972        $poss = [];
973        $match = $matches[0] ?? [];
974        foreach ( $match as $_ ) {
975            $strLengths[] = strlen( $_[0] );
976            $poss[] = $_[1];
977        }
978
979        $cuts = [];
980        for ( $i = 0; $i < count( $poss ); $i++ ) {
981            $index = $poss[$i];
982            $len = $strLengths[$i];
983
984            // Merge to the next if possible
985            while ( isset( $poss[$i + 1] ) ) {
986                if ( $poss[$i + 1] < $index + $len + $cw * 2 ) {
987                    $len += $poss[$i + 1] - $poss[$i];
988                    $i++;
989                } else {
990                    // Can't merge, exit the inner loop
991                    break;
992                }
993            }
994            $cuts[] = [ $index, $len ];
995        }
996
997        if ( $use_regex ) {
998            $targetStr = "/$target/Uu";
999        } else {
1000            $targetq = preg_quote( $this->convertWhiteSpaceToHTML( $target ), '/' );
1001            $targetStr = "/$targetq/i";
1002        }
1003
1004        $context = '';
1005        foreach ( $cuts as $_ ) {
1006            [ $index, $len, ] = $_;
1007            $contextBefore = substr( $text, 0, $index );
1008            $contextAfter = substr( $text, $index + $len );
1009
1010            $contextBefore = $this->getLanguage()->truncateForDatabase( $contextBefore, -$cw, '...', false );
1011            $contextAfter = $this->getLanguage()->truncateForDatabase( $contextAfter, $cw, '...', false );
1012
1013            $context .= $this->convertWhiteSpaceToHTML( $contextBefore );
1014            $snippet = $this->convertWhiteSpaceToHTML( substr( $text, $index, $len ) );
1015            $context .= preg_replace( $targetStr, '<span class="ext-replacetext-searchmatch">\0</span>', $snippet );
1016
1017            $context .= $this->convertWhiteSpaceToHTML( $contextAfter );
1018        }
1019
1020        // Display newlines as "line break" characters.
1021        $context = str_replace( "\n", "\u{21B5}", $context );
1022        return $context;
1023    }
1024
1025    /**
1026     * Extracts the role name
1027     *
1028     * @param int $role_id
1029     * @return string
1030     */
1031    private function extractRole( $role_id ) {
1032        return $this->slotRoleStore->getName( $role_id );
1033    }
1034
1035    private function convertWhiteSpaceToHTML( $message ) {
1036        $msg = htmlspecialchars( $message );
1037        $msg = preg_replace( '/^ /m', "\u{00A0} ", $msg );
1038        $msg = preg_replace( '/ $/m', " \u{00A0}", $msg );
1039        $msg = str_replace( '  ', "\u{00A0} ", $msg );
1040        # $msg = str_replace( "\n", '<br />', $msg );
1041        return $msg;
1042    }
1043
1044    private function getReplaceTextUser() {
1045        $replaceTextUser = $this->getConfig()->get( 'ReplaceTextUser' );
1046        if ( $replaceTextUser !== null ) {
1047            return $this->userFactory->newFromName( $replaceTextUser );
1048        }
1049
1050        return $this->getUser();
1051    }
1052
1053    /**
1054     * @inheritDoc
1055     */
1056    protected function getGroupName() {
1057        return 'wiki';
1058    }
1059
1060    private function displayTitles( array $titlesToDisplay ): string {
1061        $text = "<ul>\n";
1062        foreach ( $titlesToDisplay as $title ) {
1063            $text .= "<li>" . $this->linkRenderer->makeLink( $title ) . "</li>\n";
1064        }
1065        $text .= "</ul>\n";
1066        return $text;
1067    }
1068
1069    private function getToken(): string {
1070        return $this->getContext()->getCsrfTokenSet()->getToken();
1071    }
1072
1073    private function checkToken(): bool {
1074        return $this->getContext()->getCsrfTokenSet()->matchTokenField( 'token' );
1075    }
1076}