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