getUser()->isAllowed( 'replacetext' ) ) {
throw new PermissionsError( 'replacetext' );
}
$out = $this->getOutput();
// Replace Text can't be run with certain settings, due to the
// changes they make to the DB storage setup.
if ( $wgCompressRevisions ) {
$errorMsg = "Error: text replacements cannot be run if \$wgCompressRevisions is set to true.";
$out->addWikiTextAsContent( "
$errorMsg
" );
return;
}
if ( !empty( $wgExternalStores ) ) {
$errorMsg = "Error: text replacements cannot be run if \$wgExternalStores is non-empty.";
$out->addWikiTextAsContent( "$errorMsg
" );
return;
}
$this->setHeaders();
if ( $out->getResourceLoader()->getModule( 'mediawiki.special' ) !== null ) {
$out->addModuleStyles( 'mediawiki.special' );
}
$this->doSpecialReplaceText();
}
/**
* @return array namespaces selected for search
*/
function getSelectedNamespaces() {
if ( class_exists( MediaWikiServices::class ) ) {
// MW 1.27+
$all_namespaces = MediaWikiServices::getInstance()->getSearchEngineConfig()
->searchableNamespaces();
} else {
/** @phan-suppress-next-line PhanUndeclaredStaticMethod */
$all_namespaces = SearchEngine::searchableNamespaces();
}
$selected_namespaces = [];
foreach ( $all_namespaces as $ns => $name ) {
if ( $this->getRequest()->getCheck( 'ns' . $ns ) ) {
$selected_namespaces[] = $ns;
}
}
return $selected_namespaces;
}
/**
* Do the actual display and logic of Special:ReplaceText.
*/
function doSpecialReplaceText() {
$out = $this->getOutput();
$request = $this->getRequest();
$this->target = $request->getText( 'target' );
$this->replacement = $request->getText( 'replacement' );
$this->use_regex = $request->getBool( 'use_regex' );
$this->category = $request->getText( 'category' );
$this->prefix = $request->getText( 'prefix' );
$this->edit_pages = $request->getBool( 'edit_pages' );
$this->move_pages = $request->getBool( 'move_pages' );
$this->doAnnounce = $request->getBool( 'doAnnounce' );
$this->selected_namespaces = $this->getSelectedNamespaces();
if ( $request->getCheck( 'continue' ) && $this->target === '' ) {
$this->showForm( 'replacetext_givetarget' );
return;
}
if ( $request->getCheck( 'replace' ) ) {
// check for CSRF
$user = $this->getUser();
if ( !$user->matchEditToken( $request->getVal( 'token' ) ) ) {
$out->addWikiMsg( 'sessionfailure' );
return;
}
$jobs = $this->createJobsForTextReplacements();
JobQueueGroup::singleton()->push( $jobs );
$count = $this->getLanguage()->formatNum( count( $jobs ) );
$out->addWikiMsg(
'replacetext_success',
"{$this->target}
",
"{$this->replacement}
",
$count
);
// Link back
$out->addHTML(
ReplaceTextUtils::link(
$this->getPageTitle(),
$this->msg( 'replacetext_return' )->text()
)
);
return;
}
if ( $request->getCheck( 'target' ) ) {
// check for CSRF
$user = $this->getUser();
if ( !$user->matchEditToken( $request->getVal( 'token' ) ) ) {
$out->addWikiMsg( 'sessionfailure' );
return;
}
// first, check that at least one namespace has been
// picked, and that either editing or moving pages
// has been selected
if ( count( $this->selected_namespaces ) == 0 ) {
$this->showForm( 'replacetext_nonamespace' );
return;
}
if ( !$this->edit_pages && !$this->move_pages ) {
$this->showForm( 'replacetext_editormove' );
return;
}
// If user is replacing text within pages...
$titles_for_edit = $titles_for_move = $unmoveable_titles = [];
if ( $this->edit_pages ) {
$titles_for_edit = $this->getTitlesForEditingWithContext();
}
if ( $this->move_pages ) {
list( $titles_for_move, $unmoveable_titles ) = $this->getTitlesForMoveAndUnmoveableTitles();
}
// If no results were found, check to see if a bad
// category name was entered.
if ( count( $titles_for_edit ) == 0 && count( $titles_for_move ) == 0 ) {
$category_title = null;
if ( !empty( $this->category ) ) {
$category_title = Title::makeTitleSafe( NS_CATEGORY, $this->category );
if ( !$category_title->exists() ) {
$category_title = null;
}
}
if ( $category_title !== null ) {
$link = ReplaceTextUtils::link(
$category_title,
ucfirst( $this->category )
);
$out->addHTML(
$this->msg( 'replacetext_nosuchcategory' )->rawParams( $link )->escaped()
);
} else {
if ( $this->edit_pages ) {
$out->addWikiMsg(
'replacetext_noreplacement', "{$this->target}
"
);
}
if ( $this->move_pages ) {
$out->addWikiMsg( 'replacetext_nomove', "{$this->target}
" );
}
}
// link back to starting form
$out->addHTML(
'' .
ReplaceTextUtils::link(
$this->getPageTitle(),
$this->msg( 'replacetext_return' )->text()
)
. '
'
);
} else {
$warning_msg = $this->getAnyWarningMessageBeforeReplace( $titles_for_edit, $titles_for_move );
if ( $warning_msg !== null ) {
$out->addWikiTextAsContent(
"$warning_msg
"
);
}
$this->pageListForm( $titles_for_edit, $titles_for_move, $unmoveable_titles );
}
return;
}
// If we're still here, show the starting form.
$this->showForm();
}
/**
* Returns the set of MediaWiki jobs that will do all the actual replacements.
*
* @return array jobs
*/
function createJobsForTextReplacements() {
global $wgReplaceTextUser;
$replacement_params = [];
if ( $wgReplaceTextUser != null ) {
$user = User::newFromName( $wgReplaceTextUser );
} else {
$user = $this->getUser();
}
$replacement_params['user_id'] = $user->getId();
$replacement_params['target_str'] = $this->target;
$replacement_params['replacement_str'] = $this->replacement;
$replacement_params['use_regex'] = $this->use_regex;
$replacement_params['edit_summary'] = $this->msg(
'replacetext_editsummary',
$this->target, $this->replacement
)->inContentLanguage()->plain();
$replacement_params['create_redirect'] = false;
$replacement_params['watch_page'] = false;
$replacement_params['doAnnounce'] = $this->doAnnounce;
$request = $this->getRequest();
foreach ( $request->getValues() as $key => $value ) {
if ( $key == 'create-redirect' && $value == '1' ) {
$replacement_params['create_redirect'] = true;
} elseif ( $key == 'watch-pages' && $value == '1' ) {
$replacement_params['watch_page'] = true;
}
}
$jobs = [];
foreach ( $request->getValues() as $key => $value ) {
if ( $value == '1' && $key !== 'replace' && $key !== 'use_regex' ) {
if ( strpos( $key, 'move-' ) !== false ) {
$title = Title::newFromID( (int)substr( $key, 5 ) );
$replacement_params['move_page'] = true;
} else {
$title = Title::newFromID( (int)$key );
}
if ( $title !== null ) {
$jobs[] = new ReplaceTextJob( $title, $replacement_params );
}
}
}
return $jobs;
}
/**
* Returns the set of Titles whose contents would be modified by this
* replacement, along with the "search context" string for each one.
*
* @return array The set of Titles and their search context strings
*/
function getTitlesForEditingWithContext() {
$titles_for_edit = [];
$res = ReplaceTextSearch::doSearchQuery(
$this->target,
$this->selected_namespaces,
$this->category,
$this->prefix,
$this->use_regex
);
foreach ( $res as $row ) {
$title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
if ( $title == null ) {
continue;
}
$context = $this->extractContext( $row->old_text, $this->target, $this->use_regex );
$titles_for_edit[] = [ $title, $context ];
}
return $titles_for_edit;
}
/**
* Returns two lists: the set of titles that would be moved/renamed by
* the current text replacement, and the set of titles that would
* ordinarily be moved but are not moveable, due to permissions or any
* other reason.
*
* @return array
*/
function getTitlesForMoveAndUnmoveableTitles() {
$titles_for_move = [];
$unmoveable_titles = [];
$res = ReplaceTextSearch::getMatchingTitles(
$this->target,
$this->selected_namespaces,
$this->category,
$this->prefix,
$this->use_regex
);
foreach ( $res as $row ) {
$title = Title::makeTitleSafe( $row->page_namespace, $row->page_title );
if ( $title == null ) {
continue;
}
$new_title = ReplaceTextSearch::getReplacedTitle(
$title,
$this->target,
$this->replacement,
$this->use_regex
);
$mvPage = new MovePage( $title, $new_title );
$moveStatus = $mvPage->isValidMove();
$permissionStatus = $mvPage->checkPermissions( $this->getUser(), null );
if ( $permissionStatus->isOK() && $moveStatus->isOK() ) {
$titles_for_move[] = $title;
} else {
$unmoveable_titles[] = $title;
}
}
return [ $titles_for_move, $unmoveable_titles ];
}
/**
* Get the warning message if the replacement string is either blank
* or found elsewhere on the wiki (since undoing the replacement
* would be difficult in either case).
*
* @param array $titles_for_edit
* @param array $titles_for_move
* @return string|null Warning message, if any
*/
function getAnyWarningMessageBeforeReplace( $titles_for_edit, $titles_for_move ) {
if ( $this->replacement === '' ) {
return $this->msg( 'replacetext_blankwarning' )->text();
} elseif ( $this->use_regex ) {
// If it's a regex, don't bother checking for existing
// pages - if the replacement string includes wildcards,
// it's a meaningless check.
return null;
} elseif ( count( $titles_for_edit ) > 0 ) {
$res = ReplaceTextSearch::doSearchQuery(
$this->replacement,
$this->selected_namespaces,
$this->category,
$this->prefix,
$this->use_regex
);
$count = $res->numRows();
if ( $count > 0 ) {
return $this->msg( 'replacetext_warning' )->numParams( $count )
->params( "{$this->replacement}
" )->text();
}
} elseif ( count( $titles_for_move ) > 0 ) {
$res = ReplaceTextSearch::getMatchingTitles(
$this->replacement,
$this->selected_namespaces,
$this->category,
$this->prefix,
$this->use_regex
);
$count = $res->numRows();
if ( $count > 0 ) {
return $this->msg( 'replacetext_warning' )->numParams( $count )
->params( $this->replacement )->text();
}
}
return null;
}
/**
* @param string|null $warning_msg Message to be shown at top of form
*/
function showForm( $warning_msg = null ) {
$out = $this->getOutput();
$out->addHTML(
Xml::openElement(
'form',
[
'id' => 'powersearch',
'action' => $this->getPageTitle()->getFullURL(),
'method' => 'post'
]
) . "\n" .
Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
Html::hidden( 'continue', 1 ) .
Html::hidden( 'token', $out->getUser()->getEditToken() )
);
if ( $warning_msg === null ) {
$out->addWikiMsg( 'replacetext_docu' );
} else {
$out->wrapWikiMsg(
"\n$1\n
",
$warning_msg
);
}
$out->addHTML( '' );
$out->addWikiMsg( 'replacetext_originaltext' );
$out->addHTML( ' ' );
// 'width: auto' style is needed to override MediaWiki's
// normal 'width: 100%', which causes the textarea to get
// zero width in IE
$out->addHTML(
Xml::textarea( 'target', $this->target, 100, 5, [ 'style' => 'width: auto;' ] )
);
$out->addHTML( ' ' );
$out->addWikiMsg( 'replacetext_replacementtext' );
$out->addHTML( ' ' );
$out->addHTML(
Xml::textarea( 'replacement', $this->replacement, 100, 5, [ 'style' => 'width: auto;' ] )
);
$out->addHTML( '
' );
// MSSQL/SQLServer and SQLite unfortunately lack a REGEXP
// function or operator by default, so disable regex(p)
// searches for both these DB types.
$dbr = wfGetDB( DB_REPLICA );
if ( $dbr->getType() != 'sqlite' && $dbr->getType() != 'mssql' ) {
$out->addHTML( Xml::tags( 'p', null,
Xml::checkLabel(
$this->msg( 'replacetext_useregex' )->text(),
'use_regex', 'use_regex'
)
) . "\n" .
Xml::element( 'p',
[ 'style' => 'font-style: italic' ],
$this->msg( 'replacetext_regexdocu' )->text()
)
);
}
// The interface is heavily based on the one in Special:Search.
if ( class_exists( MediaWikiServices::class ) ) {
// MW 1.27+
$namespaces = MediaWikiServices::getInstance()->getSearchEngineConfig()
->searchableNamespaces();
} else {
/** @phan-suppress-next-line PhanUndeclaredStaticMethod */
$namespaces = SearchEngine::searchableNamespaces();
}
$tables = $this->namespaceTables( $namespaces );
$out->addHTML(
"\n" .
"\n" .
Xml::tags( 'h4', null, $this->msg( 'powersearch-ns' )->parse() )
);
// The ability to select/unselect groups of namespaces in the
// search interface exists only in some skins, like Vector -
// check for the presence of the 'powersearch-togglelabel'
// message to see if we can use this functionality here.
if ( $this->msg( 'powersearch-togglelabel' )->isDisabled() ) {
// do nothing
} else {
$out->addHTML(
Html::element(
'div',
[ 'id' => 'mw-search-togglebox' ]
)
);
}
$out->addHTML(
Xml::element( 'div', [ 'class' => 'divider' ], '', false ) .
"$tables\n "
);
// @todo FIXME: raw html messages
$category_search_label = $this->msg( 'replacetext_categorysearch' )->escaped();
$prefix_search_label = $this->msg( 'replacetext_prefixsearch' )->escaped();
$rcPage = SpecialPage::getTitleFor( 'Recentchanges' );
$rcPageName = $rcPage->getPrefixedText();
$out->addHTML(
"\n" .
Xml::tags( 'h4', null, $this->msg( 'replacetext_optionalfilters' )->parse() ) .
Xml::element( 'div', [ 'class' => 'divider' ], '', false ) .
"$category_search_label\n" .
Xml::input( 'category', 20, $this->category, [ 'type' => 'text' ] ) . '
' .
"$prefix_search_label\n" .
Xml::input( 'prefix', 20, $this->prefix, [ 'type' => 'text' ] ) . '
' .
" \n" .
"\n" .
Xml::checkLabel(
$this->msg( 'replacetext_editpages' )->text(), 'edit_pages', 'edit_pages', true
) . ' ' .
Xml::checkLabel(
$this->msg( 'replacetext_movepages' )->text(), 'move_pages', 'move_pages'
) . ' ' .
Xml::checkLabel(
$this->msg( 'replacetext_announce', $rcPageName )->text(), 'doAnnounce', 'doAnnounce', true
) .
"
\n" .
Xml::submitButton( $this->msg( 'replacetext_continue' )->text() ) .
Xml::closeElement( 'form' )
);
$out->addModules( 'ext.ReplaceText' );
}
/**
* Copied almost exactly from MediaWiki's SpecialSearch class, i.e.
* the search page
* @param string[] $namespaces
* @param int $rowsPerTable
* @return string HTML
*/
function namespaceTables( $namespaces, $rowsPerTable = 3 ) {
global $wgContLang;
// Group namespaces into rows according to subject.
// Try not to make too many assumptions about namespace numbering.
$rows = [];
$tables = "";
foreach ( $namespaces as $ns => $name ) {
$subj = MWNamespace::getSubject( $ns );
if ( !array_key_exists( $subj, $rows ) ) {
$rows[$subj] = "";
}
$name = str_replace( '_', ' ', $name );
if ( '' == $name ) {
$name = $this->msg( 'blanknamespace' )->text();
}
$rows[$subj] .= Xml::openElement( 'td', [ 'style' => 'white-space: nowrap' ] ) .
Xml::checkLabel( $name, "ns{$ns}", "mw-search-ns{$ns}", in_array( $ns, $namespaces ) ) .
Xml::closeElement( 'td' ) . "\n";
}
$rows = array_values( $rows );
$numRows = count( $rows );
// Lay out namespaces in multiple floating two-column tables so they'll
// be arranged nicely while still accommodating different screen widths
// Float to the right on RTL wikis
$tableStyle = $wgContLang->isRTL() ?
'float: right; margin: 0 0 0em 1em' : 'float: left; margin: 0 1em 0em 0';
// Build the final HTML table...
for ( $i = 0; $i < $numRows; $i += $rowsPerTable ) {
$tables .= Xml::openElement( 'table', [ 'style' => $tableStyle ] );
for ( $j = $i; $j < $i + $rowsPerTable && $j < $numRows; $j++ ) {
$tables .= "\n" . $rows[$j] . " ";
}
$tables .= Xml::closeElement( 'table' ) . "\n";
}
return $tables;
}
/**
* @param array $titles_for_edit
* @param array $titles_for_move
* @param array $unmoveable_titles
*/
function pageListForm( $titles_for_edit, $titles_for_move, $unmoveable_titles ) {
global $wgLang;
$out = $this->getOutput();
$formOpts = [
'id' => 'choose_pages',
'method' => 'post',
'action' => $this->getPageTitle()->getFullUrl()
];
$out->addHTML(
Xml::openElement( 'form', $formOpts ) . "\n" .
Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) .
Html::hidden( 'target', $this->target ) .
Html::hidden( 'replacement', $this->replacement ) .
Html::hidden( 'use_regex', $this->use_regex ) .
Html::hidden( 'move_pages', $this->move_pages ) .
Html::hidden( 'edit_pages', $this->edit_pages ) .
Html::hidden( 'doAnnounce', $this->doAnnounce ) .
Html::hidden( 'replace', 1 ) .
Html::hidden( 'token', $out->getUser()->getEditToken() )
);
foreach ( $this->selected_namespaces as $ns ) {
$out->addHTML( Html::hidden( 'ns' . $ns, 1 ) );
}
$out->addModules( "ext.ReplaceText" );
$out->addModuleStyles( "ext.ReplaceTextStyles" );
// Needed for bolding of search term.
$out->addModuleStyles( "mediawiki.special.search.styles" );
if ( count( $titles_for_edit ) > 0 ) {
$out->addWikiMsg(
'replacetext_choosepagesforedit',
"{$this->target}
",
"{$this->replacement}
",
$wgLang->formatNum( count( $titles_for_edit ) )
);
foreach ( $titles_for_edit as $title_and_context ) {
/**
* @var $title Title
*/
list( $title, $context ) = $title_and_context;
$out->addHTML(
Xml::check( $title->getArticleID(), true ) .
ReplaceTextUtils::link( $title ) .
" - $context \n"
);
}
$out->addHTML( ' ' );
}
if ( count( $titles_for_move ) > 0 ) {
$out->addWikiMsg(
'replacetext_choosepagesformove',
$this->target, $this->replacement, $wgLang->formatNum( count( $titles_for_move ) )
);
foreach ( $titles_for_move as $title ) {
$out->addHTML(
Xml::check( 'move-' . $title->getArticleID(), true ) .
ReplaceTextUtils::link( $title ) . " \n"
);
}
$out->addHTML( ' ' );
$out->addWikiMsg( 'replacetext_formovedpages' );
$rcPage = SpecialPage::getTitleFor( 'Recentchanges' );
$rcPageName = $rcPage->getPrefixedText();
$out->addHTML(
Xml::checkLabel(
$this->msg( 'replacetext_savemovedpages' )->text(),
'create-redirect', 'create-redirect', true ) . " \n" .
Xml::checkLabel(
$this->msg( 'replacetext_watchmovedpages' )->text(),
'watch-pages', 'watch-pages', false ) . ' '
);
$out->addHTML( ' ' );
}
$out->addHTML(
" \n" .
Xml::submitButton( $this->msg( 'replacetext_replace' )->text() ) . "\n"
);
// Only show "invert selections" link if there are more than
// five pages.
if ( count( $titles_for_edit ) + count( $titles_for_move ) > 5 ) {
$buttonOpts = [
'type' => 'button',
'value' => $this->msg( 'replacetext_invertselections' )->text(),
'disabled' => true,
'id' => 'replacetext-invert',
'class' => 'mw-replacetext-invert'
];
$out->addHTML(
Xml::element( 'input', $buttonOpts )
);
}
$out->addHTML( '' );
if ( count( $unmoveable_titles ) > 0 ) {
$out->addWikiMsg( 'replacetext_cannotmove', $wgLang->formatNum( count( $unmoveable_titles ) ) );
$text = "\n";
foreach ( $unmoveable_titles as $title ) {
$text .= "" . ReplaceTextUtils::link( $title ) . " \n";
}
$text .= " \n";
$out->addHTML( $text );
}
}
/**
* Extract context and highlights search text
*
* @todo The bolding needs to be fixed for regular expressions.
* @param string $text
* @param string $target
* @param bool $use_regex
* @return string
*/
function extractContext( $text, $target, $use_regex = false ) {
global $wgLang;
$cw = $this->getUser()->getOption( 'contextchars', 40 );
// Get all indexes
if ( $use_regex ) {
preg_match_all( "/$target/Uu", $text, $matches, PREG_OFFSET_CAPTURE );
} else {
$targetq = preg_quote( $target, '/' );
preg_match_all( "/$targetq/", $text, $matches, PREG_OFFSET_CAPTURE );
}
$poss = [];
foreach ( $matches[0] as $_ ) {
$poss[] = $_[1];
}
$cuts = [];
// @codingStandardsIgnoreStart
for ( $i = 0; $i < count( $poss ); $i++ ) {
// @codingStandardsIgnoreEnd
$index = $poss[$i];
$len = strlen( $target );
// Merge to the next if possible
while ( isset( $poss[$i + 1] ) ) {
if ( $poss[$i + 1] < $index + $len + $cw * 2 ) {
$len += $poss[$i + 1] - $poss[$i];
$i++;
} else {
// Can't merge, exit the inner loop
break;
}
}
$cuts[] = [ $index, $len ];
}
$context = '';
foreach ( $cuts as $_ ) {
list( $index, $len, ) = $_;
$contextBefore = substr( $text, 0, $index );
$contextAfter = substr( $text, $index + $len );
if ( !is_callable( [ $wgLang, 'truncateForDatabase' ] ) ) {
// Backwards compatibility code; remove once MW 1.30 is
// no longer supported.
$contextBefore =
// @phan-suppress-next-line PhanUndeclaredMethod
$wgLang->truncate( $contextBefore, - $cw, '...', false );
$contextAfter =
// @phan-suppress-next-line PhanUndeclaredMethod
$wgLang->truncate( $contextAfter, $cw, '...', false );
} else {
$contextBefore =
$wgLang->truncateForDatabase( $contextBefore, - $cw, '...', false );
$contextAfter =
$wgLang->truncateForDatabase( $contextAfter, $cw, '...', false );
}
// @phan-suppress-next-line SecurityCheck-DoubleEscaped
$context .= $this->convertWhiteSpaceToHTML( $contextBefore );
$snippet = $this->convertWhiteSpaceToHTML( substr( $text, $index, $len ) );
if ( $use_regex ) {
$targetStr = "/$target/Uu";
} else {
$targetq = preg_quote( $this->convertWhiteSpaceToHTML( $target ), '/' );
$targetStr = "/$targetq/i";
}
$context .= preg_replace( $targetStr, '\0 ', $snippet );
// @phan-suppress-next-line SecurityCheck-DoubleEscaped
$context .= $this->convertWhiteSpaceToHTML( $contextAfter );
}
return $context;
}
private function convertWhiteSpaceToHTML( $message ) {
$msg = htmlspecialchars( $message );
$msg = preg_replace( '/^ /m', ' ', $msg );
$msg = preg_replace( '/ $/m', ' ', $msg );
$msg = preg_replace( '/ /', ' ', $msg );
# $msg = str_replace( "\n", ' ', $msg );
return $msg;
}
/**
* @inheritDoc
*/
protected function getGroupName() {
return 'wiki';
}
}