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