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 );
114 $username = $this->
getOption(
'user',
'Maintenance script' );
115 $steal = $this->
getOption(
'steal',
false );
116 $this->user = User::newSystemUser( $username, [
'steal' => $steal ] );
117 if ( !$this->user ) {
118 $user = User::newFromName( $username );
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 function ( $ns ) use ( $nsinfo ) {
236 return $nsinfo->isMovable( $ns ) && $nsinfo->isCapitalized( $ns );
239 usort( $this->namespaces,
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 $mp =
new MovePage( $oldTitle, $newTitle );
309 $status = $mp->isValidMove();
310 if ( !$status->isOK() && $status->hasMessage(
'articleexists' ) ) {
311 $munge =
'Target title exists';
318 if ( $this->prefix !==
null ) {
319 $newTitle = Title::makeTitle(
323 } elseif ( $this->suffix !==
null ) {
326 if ( $i !==
false ) {
327 $newTitle = Title::makeTitle(
329 substr( $dbkey, 0, $i ) . $this->suffix . substr( $dbkey, $i )
332 $newTitle = Title::makeTitle( $newTitle->
getNamespace(), $dbkey . $this->suffix );
336 "Cannot move {$oldTitle->getPrefixedText()} → $nt: "
337 .
"$munge and no --prefix or --suffix was given"
344 "Cannot move {$oldTitle->getPrefixedText()} → $nt: "
345 .
"$munge and munged title '{$newTitle->getPrefixedText()}' is not valid"
349 if ( $newTitle->
exists() ) {
351 "Cannot move {$oldTitle->getPrefixedText()} → $nt: "
352 .
"$munge and munged title '{$newTitle->getPrefixedText()}' also exists"
368 $char = mb_substr(
$title, 0, 1 );
369 if ( !array_key_exists( $char, $this->charmap ) ) {
371 "Query returned NS$ns $title, which does not begin with a character in the charmap."
377 $this->
output(
"... Skipping user page NS$ns $title\n" );
381 $oldTitle = Title::makeTitle( $ns,
$title );
382 $newTitle = Title::makeTitle( $ns, $this->charmap[$char] . mb_substr(
$title, 1 ) );
383 $deletionReason = $this->
shouldDelete( $db, $oldTitle, $newTitle );
384 if ( !$this->
mungeTitle( $db, $oldTitle, $newTitle ) ) {
388 $mp =
new MovePage( $oldTitle, $newTitle );
389 $status = $mp->isValidMove();
390 if ( !$status->isOK() ) {
392 "Invalid move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}: "
393 . $status->getMessage(
false,
false,
'en' )->useDatabase(
false )->plain()
400 "Would rename {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n"
402 if ( $deletionReason ) {
404 "Would then delete {$newTitle->getPrefixedText()}: $deletionReason\n"
410 $status = $mp->move( $this->user, $this->reason,
false, $this->tags );
411 if ( !$status->isOK() ) {
413 "Move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()} failed: "
414 . $status->getMessage(
false,
false,
'en' )->useDatabase(
false )->plain()
417 $this->
output(
"Renamed {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n" );
423 'log_title' => $this->charmap[$char] . mb_substr(
$title, 1 ),
426 'log_namespace' => $oldTitle->getNamespace(),
427 'log_title' => $oldTitle->getDBkey(),
428 'log_page' => $newTitle->getArticleID(),
433 if ( $deletionReason !==
null ) {
434 $page = WikiPage::factory( $newTitle );
436 $status = $page->doDeleteArticleReal(
447 if ( !$status->isOK() ) {
449 "Deletion of {$newTitle->getPrefixedText()} failed: "
450 . $status->getMessage(
false,
false,
'en' )->useDatabase(
false )->plain()
454 $this->
output(
"Deleted {$newTitle->getPrefixedText()}\n" );
476 [
'page',
'redirect' ],
477 [
'ns' =>
'rd_namespace',
'title' =>
'rd_title' ],
481 [
'redirect' => [
'JOIN',
'rd_from = page_id' ] ]
489 $oldRow->title === $newTitle->
getDBkey()
491 return $this->reason .
", and found that [[{$oldTitle->getPrefixedText()}]] is "
492 .
"already a redirect to [[{$newTitle->getPrefixedText()}]]";
495 [
'page',
'redirect' ],
496 [
'ns' =>
'rd_namespace',
'title' =>
'rd_title' ],
500 [
'redirect' => [
'JOIN',
'rd_from = page_id' ] ]
502 if ( $newRow && $oldRow->ns === $newRow->ns && $oldRow->title === $newRow->title ) {
503 $nt = Title::makeTitle( $newRow->ns, $newRow->title );
504 return $this->reason .
", and found that [[{$oldTitle->getPrefixedText()}]] and "
505 .
"[[{$newTitle->getPrefixedText()}]] both redirect to [[{$nt->getPrefixedText()}]].";
525 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField;
526 $title = $row->$titleField;
528 $char = mb_substr(
$title, 0, 1 );
529 if ( !array_key_exists( $char, $this->charmap ) ) {
530 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
532 "Query returned $r, but title does not begin with a character in the charmap."
537 $oldTitle = Title::makeTitle( $ns,
$title );
538 $newTitle = Title::makeTitle( $ns, $this->charmap[$char] . mb_substr(
$title, 1 ) );
539 if ( $op !== self::UPPERCASE && !$this->
mungeTitle( $db, $oldTitle, $newTitle ) ) {
547 is_int( $nsField ) ? [] : [ $nsField => $newTitle->getNamespace() ],
548 [ $titleField => $newTitle->getDBkey() ]
553 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
554 $this->
output(
"Set $r to {$newTitle->getPrefixedText()}\n" );
556 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
557 $this->
output(
"Would set $r to {$newTitle->getPrefixedText()}\n" );
577 if ( $this->tables !==
null && !in_array( $table, $this->tables,
true ) ) {
578 $this->
output(
"Skipping table `$table`, not in --tables.\n" );
585 $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
587 if ( is_int( $nsField ) ) {
592 $this->
output(
"Skipping table `$table`, no valid namespaces.\n" );
596 $this->
output(
"Processing table `$table`...\n" );
598 $selectFields = array_merge(
599 is_int( $nsField ) ? [] : [ $nsField ],
603 $contFields = array_reverse( array_merge( [ $titleField ], $pkFields ) );
609 foreach ( $likes as $like ) {
615 array_merge( [
"$nsField = $ns", $like ], $cont ),
617 [
'ORDER BY' => array_merge( [ $titleField ], $pkFields ),
'LIMIT' => $batchSize ]
620 foreach (
$res as $row ) {
622 foreach ( $contFields as $field ) {
624 if ( $cont ===
'' ) {
625 $cont =
"$field > $v";
627 $cont =
"$field > $v OR $field = $v AND ($cont)";
632 if ( $op === self::MOVE ) {
633 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField;
634 $ret = $this->
doMove( $db, $ns, $row->$titleField );
636 $ret = $this->
doUpdate( $db, $op, $table, $nsField, $titleField, $row );
638 if ( $ret ===
true ) {
640 } elseif ( $ret ===
false ) {
646 $r = $cont ? json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) :
'<end>';
647 $this->
output(
"... $table: $count renames, $errors errors at $r\n" );
648 $lbFactory->waitForReplication(
657 $this->
output(
"Done processing table `$table`.\n" );
665 $userlistFile = $this->
getOption(
'userlist' );
666 if ( $userlistFile ===
null ) {
667 $this->
output(
"Not generating user list, --userlist was not specified.\n" );
671 $fh = fopen( $userlistFile,
'wb' );
673 $this->
error(
"Could not open user list file $userlistFile" );
677 $this->
output(
"Generating user list...\n" );
686 array_merge( [ $like ], $cont ),
688 [
'ORDER BY' =>
'user_name',
'LIMIT' => $batchSize ]
694 $last = end( $names );
695 $cont = [
'user_name > ' . $db->
addQuotes( $last ) ];
696 foreach ( $names as $name ) {
697 $char = mb_substr( $name, 0, 1 );
698 if ( !array_key_exists( $char, $this->charmap ) ) {
700 "Query returned $name, but user name does not begin with a character in the charmap."
704 $newName = $this->charmap[$char] . mb_substr( $name, 1 );
705 fprintf( $fh,
"%s\t%s\n", $name, $newName );
708 $this->
output(
"... at $last, $count names so far\n" );
712 if ( !fclose( $fh ) ) {
713 $this->
error(
"fclose on $userlistFile failed" );
715 $this->
output(
"User list output to $userlistFile, $count users need renaming.\n" );
const 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.
setBatchSize( $s=0)
Set the batch size.
fatalError( $msg, $exitCode=1)
Output a message and terminate the current script.
Handles the backend logic of moving a page from one title to another.
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,...
isLoggedIn()
Get whether the user is registered.