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