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 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21use MediaWiki\MediaWikiServices;
22
23class SpecialReplaceText extends SpecialPage {
24	private $target;
25	private $replacement;
26	private $use_regex;
27	private $category;
28	private $prefix;
29	private $edit_pages;
30	private $move_pages;
31	private $selected_namespaces;
32	private $doAnnounce;
33
34	public function __construct() {
35		parent::__construct( 'ReplaceText', 'replacetext' );
36	}
37
38	/**
39	 * @inheritDoc
40	 */
41	public function doesWrites() {
42		return true;
43	}
44
45	/**
46	 * @param null|string $query
47	 */
48	function execute( $query ) {
49		global $wgCompressRevisions, $wgExternalStores;
50
51		if ( !$this->getUser()->isAllowed( 'replacetext' ) ) {
52			throw new PermissionsError( 'replacetext' );
53		}
54
55		$out = $this->getOutput();
56		// Replace Text can't be run with certain settings, due to the
57		// changes they make to the DB storage setup.
58		if ( $wgCompressRevisions ) {
59			$errorMsg = "Error: text replacements cannot be run if \$wgCompressRevisions is set to true.";
60			$out->addWikiTextAsContent( "<div class=\"errorbox\">$errorMsg</div>" );
61			return;
62		}
63		if ( !empty( $wgExternalStores ) ) {
64			$errorMsg = "Error: text replacements cannot be run if \$wgExternalStores is non-empty.";
65			$out->addWikiTextAsContent( "<div class=\"errorbox\">$errorMsg</div>" );
66			return;
67		}
68
69		$this->setHeaders();
70		if ( $out->getResourceLoader()->getModule( 'mediawiki.special' ) !== null ) {
71			$out->addModuleStyles( 'mediawiki.special' );
72		}
73		$this->doSpecialReplaceText();
74	}
75
76	/**
77	 * @return array namespaces selected for search
78	 */
79	function getSelectedNamespaces() {
80		$all_namespaces = MediaWikiServices::getInstance()->getSearchEngineConfig()
81			->searchableNamespaces();
82		$selected_namespaces = [];
83		foreach ( $all_namespaces as $ns => $name ) {
84			if ( $this->getRequest()->getCheck( 'ns' . $ns ) ) {
85				$selected_namespaces[] = $ns;
86			}
87		}
88		return $selected_namespaces;
89	}
90
91	/**
92	 * Do the actual display and logic of Special:ReplaceText.
93	 */
94	function doSpecialReplaceText() {
95		$out = $this->getOutput();
96		$request = $this->getRequest();
97
98		$this->target = $request->getText( 'target' );
99		$this->replacement = $request->getText( 'replacement' );
100		$this->use_regex = $request->getBool( 'use_regex' );
101		$this->category = $request->getText( 'category' );
102		$this->prefix = $request->getText( 'prefix' );
103		$this->edit_pages = $request->getBool( 'edit_pages' );
104		$this->move_pages = $request->getBool( 'move_pages' );
105		$this->doAnnounce = $request->getBool( 'doAnnounce' );
106		$this->selected_namespaces = $this->getSelectedNamespaces();
107
108		$linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
109
110		if ( $request->getCheck( 'continue' ) && $this->target === '' ) {
111			$this->showForm( 'replacetext_givetarget' );
112			return;
113		}
114
115		if ( $request->getCheck( 'replace' ) ) {
116
117			// check for CSRF
118			$user = $this->getUser();
119			if ( !$user->matchEditToken( $request->getVal( 'token' ) ) ) {
120				$out->addWikiMsg( 'sessionfailure' );
121				return;
122			}
123
124			$jobs = $this->createJobsForTextReplacements();
125			JobQueueGroup::singleton()->push( $jobs );
126
127			$count = $this->getLanguage()->formatNum( count( $jobs ) );
128			$out->addWikiMsg(
129				'replacetext_success',
130				"<code><nowiki>{$this->target}</nowiki></code>",
131				"<code><nowiki>{$this->replacement}</nowiki></code>",
132				$count
133			);
134			// Link back
135			$out->addHTML(
136				$linkRenderer->makeLink(
137					$this->getPageTitle(),
138					$this->msg( 'replacetext_return' )->text()
139				)
140			);
141			return;
142		}
143
144		if ( $request->getCheck( 'target' ) ) {
145			// check for CSRF
146			$user = $this->getUser();
147			if ( !$user->matchEditToken( $request->getVal( 'token' ) ) ) {
148				$out->addWikiMsg( 'sessionfailure' );
149				return;
150			}
151
152			// first, check that at least one namespace has been
153			// picked, and that either editing or moving pages
154			// has been selected
155			if ( count( $this->selected_namespaces ) == 0 ) {
156				$this->showForm( 'replacetext_nonamespace' );
157				return;
158			}
159			if ( !$this->edit_pages && !$this->move_pages ) {
160				$this->showForm( 'replacetext_editormove' );
161				return;
162			}
163
164			// If user is replacing text within pages...
165			$titles_for_edit = $titles_for_move = $unmoveable_titles = [];
166			if ( $this->edit_pages ) {
167				$titles_for_edit = $this->getTitlesForEditingWithContext();
168			}
169			if ( $this->move_pages ) {
170				list( $titles_for_move, $unmoveable_titles ) = $this->getTitlesForMoveAndUnmoveableTitles();
171			}
172
173			// If no results were found, check to see if a bad
174			// category name was entered.
175			if ( count( $titles_for_edit ) == 0 && count( $titles_for_move ) == 0 ) {
176				$category_title = null;
177
178				if ( !empty( $this->category ) ) {
179					$category_title = Title::makeTitleSafe( NS_CATEGORY, $this->category );
180					if ( !$category_title->exists() ) {
181						$category_title = null;
182					}
183				}
184
185				if ( $category_title !== null ) {
186					$link = $linkRenderer->makeLink(
187						$category_title,
188						ucfirst( $this->category )
189					);
190					$out->addHTML(
191						$this->msg( 'replacetext_nosuchcategory' )->rawParams( $link )->escaped()
192					);
193				} else {
194					if ( $this->edit_pages ) {
195						$out->addWikiMsg(
196							'replacetext_noreplacement', "<code><nowiki>{$this->target}</nowiki></code>"
197						);
198					}
199
200					if ( $this->move_pages ) {
201						$out->addWikiMsg( 'replacetext_nomove', "<code><nowiki>{$this->target}</nowiki></code>" );
202					}
203				}
204				// link back to starting form
205				$out->addHTML(
206					'<p>' .
207					$linkRenderer->makeLink(
208						$this->getPageTitle(),
209						$this->msg( 'replacetext_return' )->text()
210					)
211					. '</p>'
212				);
213			} else {
214				$warning_msg = $this->getAnyWarningMessageBeforeReplace( $titles_for_edit, $titles_for_move );
215				if ( $warning_msg !== null ) {
216					$out->addWikiTextAsContent(
217						"<div class=\"errorbox\">$warning_msg</div><br clear=\"both\" />"
218					);
219				}
220
221				$this->pageListForm( $titles_for_edit, $titles_for_move, $unmoveable_titles );
222			}
223			return;
224		}
225
226		// If we're still here, show the starting form.
227		$this->showForm();
228	}
229
230	/**
231	 * Returns the set of MediaWiki jobs that will do all the actual replacements.
232	 *
233	 * @return array jobs
234	 */
235	function createJobsForTextReplacements() {
236		global $wgReplaceTextUser;
237
238		$replacement_params = [];
239		if ( $wgReplaceTextUser != null ) {
240			$user = User::newFromName( $wgReplaceTextUser );
241		} else {
242			$user = $this->getUser();
243		}
244
245		$replacement_params['user_id'] = $user->getId();
246		$replacement_params['target_str'] = $this->target;
247		$replacement_params['replacement_str'] = $this->replacement;
248		$replacement_params['use_regex'] = $this->use_regex;
249		$replacement_params['edit_summary'] = $this->msg(
250			'replacetext_editsummary',
251			$this->target, $this->replacement
252		)->inContentLanguage()->plain();
253		$replacement_params['create_redirect'] = false;
254		$replacement_params['watch_page'] = false;
255		$replacement_params['doAnnounce'] = $this->doAnnounce;
256
257		$request = $this->getRequest();
258		foreach ( $request->getValues() as $key => $value ) {
259			if ( $key == 'create-redirect' && $value == '1' ) {
260				$replacement_params['create_redirect'] = true;
261			} elseif ( $key == 'watch-pages' && $value == '1' ) {
262				$replacement_params['watch_page'] = true;
263			}
264		}
265
266		$jobs = [];
267		foreach ( $request->getValues() as $key => $value ) {
268			if ( $value == '1' && $key !== 'replace' && $key !== 'use_regex' ) {
269				if ( strpos( $key, 'move-' ) !== false ) {
270					$title = Title::newFromID( (int)substr( $key, 5 ) );
271					$replacement_params['move_page'] = true;
272				} else {
273					$title = Title::newFromID( (int)$key );
274				}
275				if ( $title !== null ) {
276					$jobs[] = new ReplaceTextJob( $title, $replacement_params );
277				}
278			}
279		}
280
281		return $jobs;
282	}
283
284	/**
285	 * Returns the set of Titles whose contents would be modified by this
286	 * replacement, along with the "search context" string for each one.
287	 *
288	 * @return array The set of Titles and their search context strings
289	 */
290	function getTitlesForEditingWithContext() {
291		$titles_for_edit = [];
292
293		$res = ReplaceTextSearch::doSearchQuery(
294			$this->target,
295			$this->selected_namespaces,
296			$this->category,
297			$this->prefix,
298			$this->use_regex
299		);
300
301		foreach ( $res as $row ) {
302			$title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
303			if ( $title == null ) {
304				continue;
305			}
306			// @phan-suppress-next-line SecurityCheck-ReDoS target could be a regex from user
307			$context = $this->extractContext( $row->old_text, $this->target, $this->use_regex );
308			$titles_for_edit[] = [ $title, $context ];
309		}
310
311		return $titles_for_edit;
312	}
313
314	/**
315	 * Returns two lists: the set of titles that would be moved/renamed by
316	 * the current text replacement, and the set of titles that would
317	 * ordinarily be moved but are not moveable, due to permissions or any
318	 * other reason.
319	 *
320	 * @return array
321	 */
322	function getTitlesForMoveAndUnmoveableTitles() {
323		$titles_for_move = [];
324		$unmoveable_titles = [];
325
326		$res = ReplaceTextSearch::getMatchingTitles(
327			$this->target,
328			$this->selected_namespaces,
329			$this->category,
330			$this->prefix,
331			$this->use_regex
332		);
333
334		foreach ( $res as $row ) {
335			$title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
336			if ( $title == null ) {
337				continue;
338			}
339
340			$new_title = ReplaceTextSearch::getReplacedTitle(
341				$title,
342				$this->target,
343				$this->replacement,
344				$this->use_regex
345			);
346
347			$mvPage = new MovePage( $title, $new_title );
348			$moveStatus = $mvPage->isValidMove();
349			$permissionStatus = $mvPage->checkPermissions( $this->getUser(), null );
350
351			if ( $permissionStatus->isOK() && $moveStatus->isOK() ) {
352				$titles_for_move[] = $title;
353			} else {
354				$unmoveable_titles[] = $title;
355			}
356		}
357
358		return [ $titles_for_move, $unmoveable_titles ];
359	}
360
361	/**
362	 * Get the warning message if the replacement string is either blank
363	 * or found elsewhere on the wiki (since undoing the replacement
364	 * would be difficult in either case).
365	 *
366	 * @param array $titles_for_edit
367	 * @param array $titles_for_move
368	 * @return string|null Warning message, if any
369	 */
370	function getAnyWarningMessageBeforeReplace( $titles_for_edit, $titles_for_move ) {
371		if ( $this->replacement === '' ) {
372			return $this->msg( 'replacetext_blankwarning' )->text();
373		} elseif ( $this->use_regex ) {
374			// If it's a regex, don't bother checking for existing
375			// pages - if the replacement string includes wildcards,
376			// it's a meaningless check.
377			return null;
378		} elseif ( count( $titles_for_edit ) > 0 ) {
379			$res = ReplaceTextSearch::doSearchQuery(
380				$this->replacement,
381				$this->selected_namespaces,
382				$this->category,
383				$this->prefix,
384				$this->use_regex
385			);
386			$count = $res->numRows();
387			if ( $count > 0 ) {
388				return $this->msg( 'replacetext_warning' )->numParams( $count )
389					->params( "<code><nowiki>{$this->replacement}</nowiki></code>" )->text();
390			}
391		} elseif ( count( $titles_for_move ) > 0 ) {
392			$res = ReplaceTextSearch::getMatchingTitles(
393				$this->replacement,
394				$this->selected_namespaces,
395				$this->category,
396				$this->prefix,
397				$this->use_regex
398			);
399			$count = $res->numRows();
400			if ( $count > 0 ) {
401				return $this->msg( 'replacetext_warning' )->numParams( $count )
402					->params( $this->replacement )->text();
403			}
404		}
405
406		return null;
407	}
408
409	/**
410	 * @param string|null $warning_msg Message to be shown at top of form
411	 */
412	function showForm( $warning_msg = null ) {
413		$out = $this->getOutput();
414
415		$out->addHTML(
416			Xml::openElement(
417				'form',
418				[
419					'id' => 'powersearch',
420					'action' => $this->getPageTitle()->getFullURL(),
421					'method' => 'post'
422				]
423			) . "\n" .
424			Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
425			Html::hidden( 'continue', 1 ) .
426			Html::hidden( 'token', $out->getUser()->getEditToken() )
427		);
428		if ( $warning_msg === null ) {
429			$out->addWikiMsg( 'replacetext_docu' );
430		} else {
431			$out->wrapWikiMsg(
432				"<div class=\"errorbox\">\n$1\n</div><br clear=\"both\" />",
433				$warning_msg
434			);
435		}
436
437		$out->addHTML( '<table><tr><td style="vertical-align: top;">' );
438		$out->addWikiMsg( 'replacetext_originaltext' );
439		$out->addHTML( '</td><td>' );
440		// 'width: auto' style is needed to override MediaWiki's
441		// normal 'width: 100%', which causes the textarea to get
442		// zero width in IE
443		$out->addHTML(
444			Xml::textarea( 'target', $this->target, 100, 5, [ 'style' => 'width: auto;' ] )
445		);
446		$out->addHTML( '</td></tr><tr><td style="vertical-align: top;">' );
447		$out->addWikiMsg( 'replacetext_replacementtext' );
448		$out->addHTML( '</td><td>' );
449		$out->addHTML(
450			Xml::textarea( 'replacement', $this->replacement, 100, 5, [ 'style' => 'width: auto;' ] )
451		);
452		$out->addHTML( '</td></tr></table>' );
453
454		// MSSQL/SQLServer and SQLite unfortunately lack a REGEXP
455		// function or operator by default, so disable regex(p)
456		// searches for both these DB types.
457		$dbr = wfGetDB( DB_REPLICA );
458		if ( $dbr->getType() != 'sqlite' && $dbr->getType() != 'mssql' ) {
459			$out->addHTML( Xml::tags( 'p', null,
460					Xml::checkLabel(
461						$this->msg( 'replacetext_useregex' )->text(),
462						'use_regex', 'use_regex'
463					)
464				) . "\n" .
465				Xml::element( 'p',
466					[ 'style' => 'font-style: italic' ],
467					$this->msg( 'replacetext_regexdocu' )->text()
468				)
469			);
470		}
471
472		// The interface is heavily based on the one in Special:Search.
473		$namespaces = MediaWikiServices::getInstance()->getSearchEngineConfig()
474			->searchableNamespaces();
475		$tables = $this->namespaceTables( $namespaces );
476		$out->addHTML(
477			"<div class=\"mw-search-formheader\"></div>\n" .
478			"<fieldset id=\"mw-searchoptions\">\n" .
479			Xml::tags( 'h4', null, $this->msg( 'powersearch-ns' )->parse() )
480		);
481		// The ability to select/unselect groups of namespaces in the
482		// search interface exists only in some skins, like Vector -
483		// check for the presence of the 'powersearch-togglelabel'
484		// message to see if we can use this functionality here.
485		if ( $this->msg( 'powersearch-togglelabel' )->isDisabled() ) {
486			// do nothing
487		} else {
488			$out->addHTML(
489				Html::element(
490					'div',
491					[ 'id' => 'mw-search-togglebox' ]
492				)
493			);
494		}
495		$out->addHTML(
496			Xml::element( 'div', [ 'class' => 'divider' ], '', false ) .
497			"$tables\n</fieldset>"
498		);
499		// @todo FIXME: raw html messages
500		$category_search_label = $this->msg( 'replacetext_categorysearch' )->escaped();
501		$prefix_search_label = $this->msg( 'replacetext_prefixsearch' )->escaped();
502		$rcPage = SpecialPage::getTitleFor( 'Recentchanges' );
503		$rcPageName = $rcPage->getPrefixedText();
504		$out->addHTML(
505			"<fieldset id=\"mw-searchoptions\">\n" .
506			Xml::tags( 'h4', null, $this->msg( 'replacetext_optionalfilters' )->parse() ) .
507			Xml::element( 'div', [ 'class' => 'divider' ], '', false ) .
508			"<p>$category_search_label\n" .
509			Xml::input( 'category', 20, $this->category, [ 'type' => 'text' ] ) . '</p>' .
510			"<p>$prefix_search_label\n" .
511			Xml::input( 'prefix', 20, $this->prefix, [ 'type' => 'text' ] ) . '</p>' .
512			"</fieldset>\n" .
513			"<p>\n" .
514			Xml::checkLabel(
515				$this->msg( 'replacetext_editpages' )->text(), 'edit_pages', 'edit_pages', true
516			) . '<br />' .
517			Xml::checkLabel(
518				$this->msg( 'replacetext_movepages' )->text(), 'move_pages', 'move_pages'
519			) . '<br />' .
520			Xml::checkLabel(
521				$this->msg( 'replacetext_announce', $rcPageName )->text(), 'doAnnounce', 'doAnnounce', true
522			) .
523			"</p>\n" .
524			Xml::submitButton( $this->msg( 'replacetext_continue' )->text() ) .
525			Xml::closeElement( 'form' )
526		);
527		$out->addModules( 'ext.ReplaceText' );
528	}
529
530	/**
531	 * Copied almost exactly from MediaWiki's SpecialSearch class, i.e.
532	 * the search page
533	 * @param string[] $namespaces
534	 * @param int $rowsPerTable
535	 * @return string HTML
536	 */
537	function namespaceTables( $namespaces, $rowsPerTable = 3 ) {
538		// Group namespaces into rows according to subject.
539		// Try not to make too many assumptions about namespace numbering.
540		$rows = [];
541		$tables = "";
542		foreach ( $namespaces as $ns => $name ) {
543			$subj = MWNamespace::getSubject( $ns );
544			if ( !array_key_exists( $subj, $rows ) ) {
545				$rows[$subj] = "";
546			}
547			$name = str_replace( '_', ' ', $name );
548			if ( $name == '' ) {
549				$name = $this->msg( 'blanknamespace' )->text();
550			}
551			$rows[$subj] .= Xml::openElement( 'td', [ 'style' => 'white-space: nowrap' ] ) .
552				Xml::checkLabel( $name, "ns{$ns}", "mw-search-ns{$ns}", in_array( $ns, $namespaces ) ) .
553				Xml::closeElement( 'td' ) . "\n";
554		}
555		$rows = array_values( $rows );
556		$numRows = count( $rows );
557		// Lay out namespaces in multiple floating two-column tables so they'll
558		// be arranged nicely while still accommodating different screen widths
559		// Float to the right on RTL wikis
560		$tableStyle = MediaWikiServices::getInstance()->getContentLanguage()->isRTL() ?
561			'float: right; margin: 0 0 0em 1em' : 'float: left; margin: 0 1em 0em 0';
562		// Build the final HTML table...
563		for ( $i = 0; $i < $numRows; $i += $rowsPerTable ) {
564			$tables .= Xml::openElement( 'table', [ 'style' => $tableStyle ] );
565			for ( $j = $i; $j < $i + $rowsPerTable && $j < $numRows; $j++ ) {
566				$tables .= "<tr>\n" . $rows[$j] . "</tr>";
567			}
568			$tables .= Xml::closeElement( 'table' ) . "\n";
569		}
570		return $tables;
571	}
572
573	/**
574	 * @param array $titles_for_edit
575	 * @param array $titles_for_move
576	 * @param array $unmoveable_titles
577	 */
578	function pageListForm( $titles_for_edit, $titles_for_move, $unmoveable_titles ) {
579		global $wgLang;
580
581		$out = $this->getOutput();
582
583		$formOpts = [
584			'id' => 'choose_pages',
585			'method' => 'post',
586			'action' => $this->getPageTitle()->getFullUrl()
587		];
588		$out->addHTML(
589			Xml::openElement( 'form', $formOpts ) . "\n" .
590			Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
591			Html::hidden( 'target', $this->target ) .
592			Html::hidden( 'replacement', $this->replacement ) .
593			Html::hidden( 'use_regex', $this->use_regex ) .
594			Html::hidden( 'move_pages', $this->move_pages ) .
595			Html::hidden( 'edit_pages', $this->edit_pages ) .
596			Html::hidden( 'doAnnounce', $this->doAnnounce ) .
597			Html::hidden( 'replace', 1 ) .
598			Html::hidden( 'token', $out->getUser()->getEditToken() )
599		);
600
601		foreach ( $this->selected_namespaces as $ns ) {
602			$out->addHTML( Html::hidden( 'ns' . $ns, 1 ) );
603		}
604
605		$out->addModules( "ext.ReplaceText" );
606		$out->addModuleStyles( "ext.ReplaceTextStyles" );
607		// Needed for bolding of search term.
608		$out->addModuleStyles( "mediawiki.special.search.styles" );
609
610		$linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
611
612		if ( count( $titles_for_edit ) > 0 ) {
613			$out->addWikiMsg(
614				'replacetext_choosepagesforedit',
615				"<code><nowiki>{$this->target}</nowiki></code>",
616				"<code><nowiki>{$this->replacement}</nowiki></code>",
617				$wgLang->formatNum( count( $titles_for_edit ) )
618			);
619
620			foreach ( $titles_for_edit as $title_and_context ) {
621				/**
622				 * @var $title Title
623				 */
624				list( $title, $context ) = $title_and_context;
625				$out->addHTML(
626					Xml::check( $title->getArticleID(), true ) .
627					$linkRenderer->makeLink( $title, null ) .
628					" - <small>$context</small><br />\n"
629				);
630			}
631			$out->addHTML( '<br />' );
632		}
633
634		if ( count( $titles_for_move ) > 0 ) {
635			$out->addWikiMsg(
636				'replacetext_choosepagesformove',
637				$this->target, $this->replacement, $wgLang->formatNum( count( $titles_for_move ) )
638			);
639			foreach ( $titles_for_move as $title ) {
640				$out->addHTML(
641					Xml::check( 'move-' . $title->getArticleID(), true ) .
642					$linkRenderer->makeLink( $title, null ) . "<br />\n"
643				);
644			}
645			$out->addHTML( '<br />' );
646			$out->addWikiMsg( 'replacetext_formovedpages' );
647			$rcPage = SpecialPage::getTitleFor( 'Recentchanges' );
648			$rcPageName = $rcPage->getPrefixedText();
649			$out->addHTML(
650				Xml::checkLabel(
651					$this->msg( 'replacetext_savemovedpages' )->text(),
652						'create-redirect', 'create-redirect', true ) . "<br />\n" .
653				Xml::checkLabel(
654					$this->msg( 'replacetext_watchmovedpages' )->text(),
655					'watch-pages', 'watch-pages', false ) . '<br />'
656			);
657			$out->addHTML( '<br />' );
658		}
659
660		$out->addHTML(
661			"<br />\n" .
662			Xml::submitButton( $this->msg( 'replacetext_replace' )->text() ) . "\n"
663		);
664
665		// Only show "invert selections" link if there are more than
666		// five pages.
667		if ( count( $titles_for_edit ) + count( $titles_for_move ) > 5 ) {
668			$buttonOpts = [
669				'type' => 'button',
670				'value' => $this->msg( 'replacetext_invertselections' )->text(),
671				'disabled' => true,
672				'id' => 'replacetext-invert',
673				'class' => 'mw-replacetext-invert'
674			];
675
676			$out->addHTML(
677				Xml::element( 'input', $buttonOpts )
678			);
679		}
680
681		$out->addHTML( '</form>' );
682
683		if ( count( $unmoveable_titles ) > 0 ) {
684			$out->addWikiMsg( 'replacetext_cannotmove', $wgLang->formatNum( count( $unmoveable_titles ) ) );
685			$text = "<ul>\n";
686			foreach ( $unmoveable_titles as $title ) {
687				$text .= "<li>" . $linkRenderer->makeLink( $title, null ) . "<br />\n";
688			}
689			$text .= "</ul>\n";
690			$out->addHTML( $text );
691		}
692	}
693
694	/**
695	 * Extract context and highlights search text
696	 *
697	 * @todo The bolding needs to be fixed for regular expressions.
698	 * @param string $text
699	 * @param string $target
700	 * @param bool $use_regex
701	 * @return string
702	 */
703	function extractContext( $text, $target, $use_regex = false ) {
704		global $wgLang;
705
706		$cw = $this->getUser()->getOption( 'contextchars', 40 );
707
708		// Get all indexes
709		if ( $use_regex ) {
710			preg_match_all( "/$target/Uu", $text, $matches, PREG_OFFSET_CAPTURE );
711		} else {
712			$targetq = preg_quote( $target, '/' );
713			preg_match_all( "/$targetq/", $text, $matches, PREG_OFFSET_CAPTURE );
714		}
715
716		$poss = [];
717		$match = $matches[0] ?? [];
718		foreach ( $match as $_ ) {
719			$poss[] = $_[1];
720		}
721
722		$cuts = [];
723		for ( $i = 0; $i < count( $poss ); $i++ ) {
724			$index = $poss[$i];
725			$len = strlen( $target );
726
727			// Merge to the next if possible
728			while ( isset( $poss[$i + 1] ) ) {
729				if ( $poss[$i + 1] < $index + $len + $cw * 2 ) {
730					$len += $poss[$i + 1] - $poss[$i];
731					$i++;
732				} else {
733					// Can't merge, exit the inner loop
734					break;
735				}
736			}
737			$cuts[] = [ $index, $len ];
738		}
739
740		$context = '';
741		foreach ( $cuts as $_ ) {
742			list( $index, $len, ) = $_;
743			$contextBefore = substr( $text, 0, $index );
744			$contextAfter = substr( $text, $index + $len );
745			if ( !is_callable( [ $wgLang, 'truncateForDatabase' ] ) ) {
746				// Backwards compatibility code; remove once MW 1.30 is
747				// no longer supported.
748				$contextBefore =
749					// @phan-suppress-next-line PhanUndeclaredMethod
750					$wgLang->truncate( $contextBefore, -$cw, '...', false );
751				$contextAfter =
752					// @phan-suppress-next-line PhanUndeclaredMethod
753					$wgLang->truncate( $contextAfter, $cw, '...', false );
754			} else {
755				$contextBefore =
756					$wgLang->truncateForDatabase( $contextBefore, -$cw, '...', false );
757				$contextAfter =
758					$wgLang->truncateForDatabase( $contextAfter, $cw, '...', false );
759			}
760
761			$context .= $this->convertWhiteSpaceToHTML( $contextBefore );
762			$snippet = $this->convertWhiteSpaceToHTML( substr( $text, $index, $len ) );
763			if ( $use_regex ) {
764				$targetStr = "/$target/Uu";
765			} else {
766				$targetq = preg_quote( $this->convertWhiteSpaceToHTML( $target ), '/' );
767				$targetStr = "/$targetq/i";
768			}
769			$context .= preg_replace( $targetStr, '<span class="searchmatch">\0</span>', $snippet );
770
771			$context .= $this->convertWhiteSpaceToHTML( $contextAfter );
772		}
773		return $context;
774	}
775
776	private function convertWhiteSpaceToHTML( $message ) {
777		$msg = htmlspecialchars( $message );
778		$msg = preg_replace( '/^ /m', '&#160; ', $msg );
779		$msg = preg_replace( '/ $/m', ' &#160;', $msg );
780		$msg = preg_replace( '/  /', '&#160; ', $msg );
781		# $msg = str_replace( "\n", '<br />', $msg );
782		return $msg;
783	}
784
785	/**
786	 * @inheritDoc
787	 */
788	protected function getGroupName() {
789		return 'wiki';
790	}
791}
792