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', '  ', $msg ); 790 $msg = preg_replace( '/ $/m', '  ', $msg ); 791 $msg = preg_replace( '/ /', '  ', $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