28require_once __DIR__ .
'/Maintenance.php';
52 private $reason =
'Uppercasing title for Unicode upgrade';
73 parent::__construct();
75 "Rename titles when changing behavior of Language::ucfirst().\n"
77 .
"This script skips User and User_talk pages for registered users, as renaming of users "
78 .
"is too complex to try to implement here. Use something like Extension:Renameuser to "
79 .
"clean those up; this script can provide a list of user names affected."
82 'charmap',
'Character map generated by maintenance/language/generateUcfirstOverrides.php',
86 'user',
'System user to use to do the renames. Default is "Maintenance script".',
false,
true
90 'If the username specified by --user exists, specify this to force conversion to a system user.'
93 'run',
'If not specified, the script will not actually perform any moves (i.e. it will dry-run).'
96 'prefix',
'When the new title already exists, add this prefix.',
false,
true
99 'suffix',
'When the new title already exists, add this suffix.',
false,
true
101 $this->
addOption(
'reason',
'Reason to use when moving pages.',
false,
true );
102 $this->
addOption(
'tag',
'Change tag to apply when moving pages.',
false,
true );
103 $this->
addOption(
'tables',
'Comma-separated list of database tables to process.',
false,
true );
105 'userlist',
'Filename to which to output usernames needing rename.',
false,
true
111 $this->run = $this->
getOption(
'run',
false );
115 $steal = $this->
getOption(
'steal',
false );
117 if ( !$this->user ) {
120 $this->
fatalError(
"User $username already exists.\n"
121 .
"Use --steal if you really want to steal it from the human who currently owns it."
124 $this->
fatalError(
"Could not obtain system user $username." );
130 $this->tables = explode(
',',
$tables );
136 if ( !
$title || substr(
$title->getDBkey(), -1 ) !==
'X' ) {
140 $this->
fatalError(
'Invalid --prefix. It must not be in namespace 0 and must not be external' );
142 $this->prefixNs =
$title->getNamespace();
143 $this->prefix = substr(
$title->getText(), 0, -1 );
145 $this->suffix = $this->
getOption(
'suffix' );
148 $this->tags = (array)$this->
getOption(
'tag',
null );
150 $charmapFile = $this->
getOption(
'charmap' );
151 if ( !file_exists( $charmapFile ) ) {
152 $this->
fatalError(
"Charmap file $charmapFile does not exist." );
154 if ( !is_file( $charmapFile ) || !is_readable( $charmapFile ) ) {
155 $this->
fatalError(
"Charmap file $charmapFile is not readable." );
157 $this->charmap = require $charmapFile;
158 if ( !is_array( $this->charmap ) ) {
159 $this->
fatalError(
"Charmap file $charmapFile did not return a PHP array." );
161 $this->charmap = array_filter(
163 function ( $v, $k ) {
164 if ( mb_strlen( $k ) !== 1 ) {
165 $this->
error(
"Ignoring mapping from multi-character key '$k' to '$v'" );
170 ARRAY_FILTER_USE_BOTH
172 if ( !$this->charmap ) {
173 $this->
fatalError(
"Charmap file $charmapFile did not contain any usable character mappings." );
180 $db, self::INPLACE_MOVE,
'archive',
'ar_namespace',
'ar_title', [
'ar_timestamp',
'ar_id' ]
183 $db, self::INPLACE_MOVE,
'filearchive',
NS_FILE,
'fa_name', [
'fa_timestamp',
'fa_id' ]
186 $db, self::INPLACE_MOVE,
'logging',
'log_namespace',
'log_title', [
'log_id' ]
189 $db, self::INPLACE_MOVE,
'protected_titles',
'pt_namespace',
'pt_title', []
191 $this->
processTable( $db, self::MOVE,
'page',
'page_namespace',
'page_title', [
'page_id' ] );
194 $db, self::UPPERCASE,
'redirect',
'rd_namespace',
'rd_title', [
'rd_from' ]
209 foreach ( $this->charmap as $from => $to ) {
211 if ( count( $likes ) >= $batchSize ) {
212 $ret[] = $db->
makeList( $likes, $db::LIST_OR );
217 $ret[] = $db->
makeList( $likes, $db::LIST_OR );
231 if ( $this->namespaces ===
null ) {
232 $nsinfo = MediaWikiServices::getInstance()->getNamespaceInfo();
233 $this->namespaces = array_filter(
234 array_keys( $nsinfo->getCanonicalNamespaces() ),
235 static function ( $ns ) use ( $nsinfo ) {
236 return $nsinfo->isMovable( $ns ) && $nsinfo->isCapitalized( $ns );
239 usort( $this->namespaces,
static function ( $ns1, $ns2 ) use ( $nsinfo ) {
240 if ( $ns1 === $ns2 ) {
244 $s1 = $nsinfo->getSubject( $ns1 );
245 $s2 = $nsinfo->getSubject( $ns2 );
249 return $s1 < $s2 ? -1 : 1;
253 if ( $s1 === $ns1 ) {
256 if ( $s2 === $ns2 ) {
282 if ( !isset( $this->seenUsers[
$base] ) ) {
287 [
'user_name' => strtr(
$base,
'_',
' ' ) ],
291 return $this->seenUsers[
$base];
306 $munge =
'Target title\'s user exists';
308 $mpFactory = MediaWikiServices::getInstance()->getMovePageFactory();
309 $status = $mpFactory->newMovePage( $oldTitle, $newTitle )->isValidMove();
310 if ( !$status->isOK() && (
311 $status->hasMessage(
'articleexists' ) || $status->hasMessage(
'redirectexists' ) ) ) {
312 $munge =
'Target title exists';
319 if ( $this->prefix !==
null ) {
320 $newTitle = Title::makeTitle(
324 } elseif ( $this->suffix !==
null ) {
327 if ( $i !==
false ) {
328 $newTitle = Title::makeTitle(
330 substr( $dbkey, 0, $i ) . $this->suffix . substr( $dbkey, $i )
333 $newTitle = Title::makeTitle( $newTitle->
getNamespace(), $dbkey . $this->suffix );
337 "Cannot move {$oldTitle->getPrefixedText()} → $nt: "
338 .
"$munge and no --prefix or --suffix was given"
345 "Cannot move {$oldTitle->getPrefixedText()} → $nt: "
346 .
"$munge and munged title '{$newTitle->getPrefixedText()}' is not valid"
350 if ( $newTitle->
exists() ) {
352 "Cannot move {$oldTitle->getPrefixedText()} → $nt: "
353 .
"$munge and munged title '{$newTitle->getPrefixedText()}' also exists"
369 $char = mb_substr(
$title, 0, 1 );
370 if ( !array_key_exists( $char, $this->charmap ) ) {
372 "Query returned NS$ns $title, which does not begin with a character in the charmap."
378 $this->
output(
"... Skipping user page NS$ns $title\n" );
382 $oldTitle = Title::makeTitle( $ns,
$title );
383 $newTitle = Title::makeTitle( $ns, $this->charmap[$char] . mb_substr(
$title, 1 ) );
384 $deletionReason = $this->
shouldDelete( $db, $oldTitle, $newTitle );
385 if ( !$this->
mungeTitle( $db, $oldTitle, $newTitle ) ) {
389 $services = MediaWikiServices::getInstance();
390 $mpFactory = $services->getMovePageFactory();
391 $movePage = $mpFactory->newMovePage( $oldTitle, $newTitle );
392 $status = $movePage->isValidMove();
393 if ( !$status->isOK() ) {
395 "Invalid move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}: "
396 . $status->getMessage(
false,
false,
'en' )->useDatabase(
false )->plain()
403 "Would rename {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n"
405 if ( $deletionReason ) {
407 "Would then delete {$newTitle->getPrefixedText()}: $deletionReason\n"
413 $status = $movePage->move( $this->user, $this->reason,
false, $this->tags );
414 if ( !$status->isOK() ) {
416 "Move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()} failed: "
417 . $status->getMessage(
false,
false,
'en' )->useDatabase(
false )->plain()
420 $this->
output(
"Renamed {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n" );
426 'log_title' => $this->charmap[$char] . mb_substr(
$title, 1 ),
429 'log_namespace' => $oldTitle->getNamespace(),
430 'log_title' => $oldTitle->getDBkey(),
431 'log_page' => $newTitle->getArticleID(),
436 if ( $deletionReason !==
null ) {
437 $page = $services->getWikiPageFactory()->newFromTitle( $newTitle );
439 $status = $page->doDeleteArticleReal(
450 if ( !$status->isOK() ) {
452 "Deletion of {$newTitle->getPrefixedText()} failed: "
453 . $status->getMessage(
false,
false,
'en' )->useDatabase(
false )->plain()
457 $this->
output(
"Deleted {$newTitle->getPrefixedText()}\n" );
479 [
'page',
'redirect' ],
480 [
'ns' =>
'rd_namespace',
'title' =>
'rd_title' ],
484 [
'redirect' => [
'JOIN',
'rd_from = page_id' ] ]
492 $oldRow->title === $newTitle->
getDBkey()
494 return $this->reason .
", and found that [[{$oldTitle->getPrefixedText()}]] is "
495 .
"already a redirect to [[{$newTitle->getPrefixedText()}]]";
498 [
'page',
'redirect' ],
499 [
'ns' =>
'rd_namespace',
'title' =>
'rd_title' ],
503 [
'redirect' => [
'JOIN',
'rd_from = page_id' ] ]
505 if ( $newRow && $oldRow->ns === $newRow->ns && $oldRow->title === $newRow->title ) {
506 $nt = Title::makeTitle( $newRow->ns, $newRow->title );
507 return $this->reason .
", and found that [[{$oldTitle->getPrefixedText()}]] and "
508 .
"[[{$newTitle->getPrefixedText()}]] both redirect to [[{$nt->getPrefixedText()}]].";
528 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField;
529 $title = $row->$titleField;
531 $char = mb_substr(
$title, 0, 1 );
532 if ( !array_key_exists( $char, $this->charmap ) ) {
533 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
535 "Query returned $r, but title does not begin with a character in the charmap."
540 $oldTitle = Title::makeTitle( $ns,
$title );
541 $newTitle = Title::makeTitle( $ns, $this->charmap[$char] . mb_substr(
$title, 1 ) );
542 if ( $op !== self::UPPERCASE && !$this->
mungeTitle( $db, $oldTitle, $newTitle ) ) {
550 is_int( $nsField ) ? [] : [ $nsField => $newTitle->getNamespace() ],
551 [ $titleField => $newTitle->getDBkey() ]
556 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
557 $this->
output(
"Set $r to {$newTitle->getPrefixedText()}\n" );
559 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
560 $this->
output(
"Would set $r to {$newTitle->getPrefixedText()}\n" );
580 if ( $this->tables !==
null && !in_array( $table, $this->tables,
true ) ) {
581 $this->
output(
"Skipping table `$table`, not in --tables.\n" );
588 $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
590 if ( is_int( $nsField ) ) {
595 $this->
output(
"Skipping table `$table`, no valid namespaces.\n" );
599 $this->
output(
"Processing table `$table`...\n" );
601 $selectFields = array_merge(
602 is_int( $nsField ) ? [] : [ $nsField ],
606 $contFields = array_reverse( array_merge( [ $titleField ], $pkFields ) );
612 foreach ( $likes as $like ) {
618 array_merge( [
"$nsField = $ns", $like ], $cont ),
620 [
'ORDER BY' => array_merge( [ $titleField ], $pkFields ),
'LIMIT' => $batchSize ]
623 foreach (
$res as $row ) {
625 foreach ( $contFields as $field ) {
627 if ( $cont ===
'' ) {
628 $cont =
"$field > $v";
630 $cont =
"$field > $v OR $field = $v AND ($cont)";
635 if ( $op === self::MOVE ) {
636 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField;
637 $ret = $this->
doMove( $db, $ns, $row->$titleField );
639 $ret = $this->
doUpdate( $db, $op, $table, $nsField, $titleField, $row );
641 if ( $ret ===
true ) {
643 } elseif ( $ret ===
false ) {
649 $r = $cont ? json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) :
'<end>';
650 $this->
output(
"... $table: $count renames, $errors errors at $r\n" );
651 $lbFactory->waitForReplication(
660 $this->
output(
"Done processing table `$table`.\n" );
668 $userlistFile = $this->
getOption(
'userlist' );
669 if ( $userlistFile ===
null ) {
670 $this->
output(
"Not generating user list, --userlist was not specified.\n" );
674 $fh = fopen( $userlistFile,
'wb' );
676 $this->
error(
"Could not open user list file $userlistFile" );
680 $this->
output(
"Generating user list...\n" );
689 array_merge( [ $like ], $cont ),
691 [
'ORDER BY' =>
'user_name',
'LIMIT' => $batchSize ]
697 $last = end( $names );
698 $cont = [
'user_name > ' . $db->
addQuotes( $last ) ];
699 foreach ( $names as $name ) {
700 $char = mb_substr( $name, 0, 1 );
701 if ( !array_key_exists( $char, $this->charmap ) ) {
703 "Query returned $name, but user name does not begin with a character in the charmap."
707 $newName = $this->charmap[$char] . mb_substr( $name, 1 );
708 fprintf( $fh,
"%s\t%s\n", $name, $newName );
711 $this->
output(
"... at $last, $count names so far\n" );
715 if ( !fclose( $fh ) ) {
716 $this->
error(
"fclose on $userlistFile failed" );
718 $this->
output(
"User list output to $userlistFile, $count users need renaming.\n" );
723require_once RUN_MAINTENANCE_IF_MAIN;
Abstract maintenance class for quickly writing and churning out maintenance scripts with minimal effo...
error( $err, $die=0)
Throw an error to the user.
output( $out, $channel=null)
Throw some output to the user.
float $lastReplicationWait
UNIX timestamp.
getBatchSize()
Returns batch size.
addDescription( $text)
Set the description text.
addOption( $name, $description, $required=false, $withArg=false, $shortName=false, $multiOccurrence=false)
Add a parameter to the script.
getOption( $name, $default=null)
Get an option, or return the default.
fatalError( $msg, $exitCode=1)
Output a message and terminate the current script.
Represents a title within MediaWiki.
getNamespace()
Get the namespace index, i.e.
exists( $flags=0)
Check if page exists.
canExist()
Can this title represent a page in the wiki's database?
getDBkey()
Get the main part with underscores.
getText()
Get the text form (spaces not underscores) of the main part.
getPrefixedText()
Get the prefixed title with spaces.
Maintenance script to rename titles affected by changes to Unicode (or otherwise to Language::ucfirst...
processTable(IDatabase $db, $op, $table, $nsField, $titleField, $pkFields)
Rename entries in other tables.
doMove(IDatabase $db, $ns, $title)
Use MovePage to move a title.
shouldDelete(IDatabase $db, Title $oldTitle, Title $newTitle)
Determine whether the old title should be deleted.
isUserPage(IDatabase $db, $ns, $title)
Check if a ns+title is a registered user's page.
mungeTitle(IDatabase $db, Title $oldTitle, Title &$newTitle)
Munge a target title, if necessary.
getLikeBatches(IDatabase $db, $field, $batchSize=100)
Get batched LIKE conditions from the charmap.
doUpdate(IDatabase $db, $op, $table, $nsField, $titleField, $row)
Directly update a database row.
execute()
Do the actual work.
getNamespaces()
Get the list of namespaces to operate on.
processUsers(IDatabase $db)
List users needing renaming.
__construct()
Default constructor.
The User object encapsulates all of the user-specific settings (user_id, name, rights,...
static newFromName( $name, $validate='valid')
isRegistered()
Get whether the user is registered.
static newSystemUser( $name, $options=[])
Static factory method for creation of a "system" user from username.
const MAINTENANCE_SCRIPT_USER
Username used for various maintenance scripts.