38 private const MOVE = 0;
39 private const INPLACE_MOVE = 1;
40 private const UPPERCASE = 2;
46 private $charmap = [];
52 private $reason =
'Uppercasing title for Unicode upgrade';
58 private $seenUsers = [];
61 private $namespaces =
null;
64 private $prefix =
null, $suffix =
null;
67 private $prefixNs =
null;
70 private $tables =
null;
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. ' .
106 'This file can then be used directly by renameInvalidUsernames.php maintenance script',
114 $this->run = $this->
getOption(
'run',
false );
118 $steal = $this->
getOption(
'steal',
false );
120 if ( !$this->user ) {
123 $this->
fatalError(
"User $username already exists.\n"
124 .
"Use --steal if you really want to steal it from the human who currently owns it."
127 $this->
fatalError(
"Could not obtain system user $username." );
132 if ( $tables !==
null ) {
133 $this->tables = explode(
',', $tables );
137 if ( $prefix !==
null ) {
138 $title = Title::newFromText( $prefix .
'X' );
139 if ( !
$title || substr(
$title->getDBkey(), -1 ) !==
'X' ) {
143 $this->
fatalError(
'Invalid --prefix. It must not be in namespace 0 and must not be external' );
145 $this->prefixNs =
$title->getNamespace();
146 $this->prefix = substr(
$title->getText(), 0, -1 );
148 $this->suffix = $this->
getOption(
'suffix' );
150 $this->reason = $this->
getOption(
'reason' ) ?: $this->reason;
151 $this->tags = (array)$this->
getOption(
'tag',
null );
153 $charmapFile = $this->
getOption(
'charmap' );
154 if ( !file_exists( $charmapFile ) ) {
155 $this->
fatalError(
"Charmap file $charmapFile does not exist." );
157 if ( !is_file( $charmapFile ) || !is_readable( $charmapFile ) ) {
158 $this->
fatalError(
"Charmap file $charmapFile is not readable." );
160 $this->charmap = require $charmapFile;
161 if ( !is_array( $this->charmap ) ) {
162 $this->
fatalError(
"Charmap file $charmapFile did not return a PHP array." );
164 $this->charmap = array_filter(
166 function ( $v, $k ) {
167 if ( mb_strlen( $k ) !== 1 ) {
168 $this->
error(
"Ignoring mapping from multi-character key '$k' to '$v'" );
173 ARRAY_FILTER_USE_BOTH
175 if ( !$this->charmap ) {
176 $this->
fatalError(
"Charmap file $charmapFile did not contain any usable character mappings." );
183 $db, self::INPLACE_MOVE,
'archive',
'ar_namespace',
'ar_title', [
'ar_timestamp',
'ar_id' ]
186 $db, self::INPLACE_MOVE,
'filearchive',
NS_FILE,
'fa_name', [
'fa_timestamp',
'fa_id' ]
189 $db, self::INPLACE_MOVE,
'logging',
'log_namespace',
'log_title', [
'log_id' ]
192 $db, self::INPLACE_MOVE,
'protected_titles',
'pt_namespace',
'pt_title', []
194 $this->processTable( $db, self::MOVE,
'page',
'page_namespace',
'page_title', [
'page_id' ] );
195 $this->processTable( $db, self::MOVE,
'image',
NS_FILE,
'img_name', [] );
197 $db, self::UPPERCASE,
'redirect',
'rd_namespace',
'rd_title', [
'rd_from' ]
199 $this->processUsers( $db );
209 private function getLikeBatches(
IDatabase $db, $field, $batchSize = 100 ) {
212 foreach ( $this->charmap as $from => $to ) {
214 if ( count( $likes ) >= $batchSize ) {
215 $ret[] = $db->
makeList( $likes, $db::LIST_OR );
220 $ret[] = $db->
makeList( $likes, $db::LIST_OR );
233 private function getNamespaces() {
234 if ( $this->namespaces ===
null ) {
235 $nsinfo = MediaWikiServices::getInstance()->getNamespaceInfo();
236 $this->namespaces = array_filter(
237 array_keys( $nsinfo->getCanonicalNamespaces() ),
238 static function ( $ns ) use ( $nsinfo ) {
239 return $nsinfo->isMovable( $ns ) && $nsinfo->isCapitalized( $ns );
242 usort( $this->namespaces,
static function ( $ns1, $ns2 ) use ( $nsinfo ) {
243 if ( $ns1 === $ns2 ) {
247 $s1 = $nsinfo->getSubject( $ns1 );
248 $s2 = $nsinfo->getSubject( $ns2 );
252 return $s1 < $s2 ? -1 : 1;
256 if ( $s1 === $ns1 ) {
259 if ( $s2 === $ns2 ) {
269 return $this->namespaces;
285 if ( !isset( $this->seenUsers[
$base] ) ) {
290 [
'user_name' => strtr(
$base,
'_',
' ' ) ],
294 return $this->seenUsers[
$base];
309 $munge =
'Target title\'s user exists';
311 $mpFactory = MediaWikiServices::getInstance()->getMovePageFactory();
312 $status = $mpFactory->newMovePage( $oldTitle, $newTitle )->isValidMove();
313 if ( !$status->isOK() && (
314 $status->hasMessage(
'articleexists' ) || $status->hasMessage(
'redirectexists' ) ) ) {
315 $munge =
'Target title exists';
322 if ( $this->prefix !==
null ) {
327 } elseif ( $this->suffix !==
null ) {
330 if ( $i !==
false ) {
333 substr( $dbkey, 0, $i ) . $this->suffix . substr( $dbkey, $i )
340 "Cannot move {$oldTitle->getPrefixedText()} → $nt: "
341 .
"$munge and no --prefix or --suffix was given"
348 "Cannot move {$oldTitle->getPrefixedText()} → $nt: "
349 .
"$munge and munged title '{$newTitle->getPrefixedText()}' is not valid"
353 if ( $newTitle->
exists() ) {
355 "Cannot move {$oldTitle->getPrefixedText()} → $nt: "
356 .
"$munge and munged title '{$newTitle->getPrefixedText()}' also exists"
372 $char = mb_substr(
$title, 0, 1 );
373 if ( !array_key_exists( $char, $this->charmap ) ) {
375 "Query returned NS$ns $title, which does not begin with a character in the charmap."
380 if ( $this->isUserPage( $db, $ns,
$title ) ) {
381 $this->
output(
"... Skipping user page NS$ns $title\n" );
387 $deletionReason = $this->shouldDelete( $db, $oldTitle, $newTitle );
388 if ( !$this->mungeTitle( $db, $oldTitle, $newTitle ) ) {
392 $services = MediaWikiServices::getInstance();
393 $mpFactory = $services->getMovePageFactory();
394 $movePage = $mpFactory->newMovePage( $oldTitle, $newTitle );
395 $status = $movePage->isValidMove();
396 if ( !$status->isOK() ) {
398 "Invalid move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}: "
399 . $status->getMessage(
false,
false,
'en' )->useDatabase(
false )->plain()
406 "Would rename {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n"
408 if ( $deletionReason ) {
410 "Would then delete {$newTitle->getPrefixedText()}: $deletionReason\n"
416 $status = $movePage->move( $this->user, $this->reason,
false, $this->tags );
417 if ( !$status->isOK() ) {
419 "Move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()} failed: "
420 . $status->getMessage(
false,
false,
'en' )->useDatabase(
false )->plain()
423 $this->
output(
"Renamed {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n" );
429 'log_title' => $this->charmap[$char] . mb_substr(
$title, 1 ),
433 'log_title' => $oldTitle->
getDBkey(),
439 if ( $deletionReason !==
null ) {
440 $page = $services->getWikiPageFactory()->newFromTitle( $newTitle );
442 $status = $page->doDeleteArticleReal(
453 if ( !$status->isOK() ) {
455 "Deletion of {$newTitle->getPrefixedText()} failed: "
456 . $status->getMessage(
false,
false,
'en' )->useDatabase(
false )->plain()
460 $this->
output(
"Deleted {$newTitle->getPrefixedText()}\n" );
482 [
'page',
'redirect' ],
483 [
'ns' =>
'rd_namespace',
'title' =>
'rd_title' ],
487 [
'redirect' => [
'JOIN',
'rd_from = page_id' ] ]
495 $oldRow->title === $newTitle->
getDBkey()
497 return $this->reason .
", and found that [[{$oldTitle->getPrefixedText()}]] is "
498 .
"already a redirect to [[{$newTitle->getPrefixedText()}]]";
501 [
'page',
'redirect' ],
502 [
'ns' =>
'rd_namespace',
'title' =>
'rd_title' ],
506 [
'redirect' => [
'JOIN',
'rd_from = page_id' ] ]
508 if ( $newRow && $oldRow->ns === $newRow->ns && $oldRow->title === $newRow->title ) {
510 return $this->reason .
", and found that [[{$oldTitle->getPrefixedText()}]] and "
511 .
"[[{$newTitle->getPrefixedText()}]] both redirect to [[{$nt->getPrefixedText()}]].";
530 private function doUpdate(
IDatabase $db, $op, $table, $nsField, $titleField, $row ) {
531 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField;
532 $title = $row->$titleField;
534 $char = mb_substr(
$title, 0, 1 );
535 if ( !array_key_exists( $char, $this->charmap ) ) {
536 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
538 "Query returned $r, but title does not begin with a character in the charmap."
545 if ( $op !== self::UPPERCASE && !$this->mungeTitle( $db, $oldTitle, $newTitle ) ) {
553 is_int( $nsField ) ? [] : [ $nsField => $newTitle->getNamespace() ],
554 [ $titleField => $newTitle->getDBkey() ]
559 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
560 $this->
output(
"Set $r to {$newTitle->getPrefixedText()}\n" );
562 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
563 $this->
output(
"Would set $r to {$newTitle->getPrefixedText()}\n" );
582 private function processTable(
IDatabase $db, $op, $table, $nsField, $titleField, $pkFields ) {
583 if ( $this->tables !==
null && !in_array( $table, $this->tables,
true ) ) {
584 $this->
output(
"Skipping table `$table`, not in --tables.\n" );
589 $namespaces = $this->getNamespaces();
590 $likes = $this->getLikeBatches( $db, $titleField );
591 $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
593 if ( is_int( $nsField ) ) {
594 $namespaces = array_intersect( $namespaces, [ $nsField ] );
597 if ( !$namespaces ) {
598 $this->
output(
"Skipping table `$table`, no valid namespaces.\n" );
602 $this->
output(
"Processing table `$table`...\n" );
604 $selectFields = array_merge(
605 is_int( $nsField ) ? [] : [ $nsField ],
609 $contFields = array_reverse( array_merge( [ $titleField ], $pkFields ) );
611 $lastReplicationWait = 0.0;
614 foreach ( $namespaces as $ns ) {
615 foreach ( $likes as $like ) {
621 array_merge( [
"$nsField = $ns", $like ], $cont ),
623 [
'ORDER BY' => array_merge( [ $titleField ], $pkFields ),
'LIMIT' => $batchSize ]
626 foreach (
$res as $row ) {
628 foreach ( $contFields as $field ) {
630 if ( $cont ===
'' ) {
631 $cont =
"$field > $v";
633 $cont =
"$field > $v OR $field = $v AND ($cont)";
638 if ( $op === self::MOVE ) {
639 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField;
640 $ret = $this->doMove( $db, $ns, $row->$titleField );
642 $ret = $this->doUpdate( $db, $op, $table, $nsField, $titleField, $row );
644 if ( $ret ===
true ) {
646 } elseif ( $ret ===
false ) {
653 $r = $cont ? json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) :
'<end>';
654 $this->
output(
"... $table: $count renames, $errors errors at $r\n" );
655 $lbFactory->waitForReplication(
656 [
'timeout' => 30,
'ifWritesSince' => $lastReplicationWait ]
658 $lastReplicationWait = microtime(
true );
664 $this->
output(
"Done processing table `$table`.\n" );
671 private function processUsers(
IDatabase $db ) {
672 $userlistFile = $this->
getOption(
'userlist' );
673 if ( $userlistFile ===
null ) {
674 $this->
output(
"Not generating user list, --userlist was not specified.\n" );
678 $fh = fopen( $userlistFile,
'ab' );
680 $this->
error(
"Could not open user list file $userlistFile" );
684 $this->
output(
"Generating user list...\n" );
687 foreach ( $this->getLikeBatches( $db,
'user_name' ) as $like ) {
692 [
'user_id',
'user_name' ],
693 array_merge( [ $like ], $cont ),
695 [
'ORDER BY' =>
'user_name',
'LIMIT' => $batchSize ]
698 if ( !$rows->numRows() ) {
702 foreach ( $rows as $row ) {
703 $char = mb_substr( $row->user_name, 0, 1 );
704 if ( !array_key_exists( $char, $this->charmap ) ) {
706 "Query returned $row->user_name, but user name does not " .
707 "begin with a character in the charmap."
711 $newName = $this->charmap[$char] . mb_substr( $row->user_name, 1 );
712 fprintf( $fh,
"%s\t%s\t%s\n", WikiMap::getCurrentWikiId(), $row->user_id, $newName );
714 $cont = [
'user_name > ' . $db->
addQuotes( $row->user_name ) ];
717 $this->
output(
"... at $row->user_name, $count names so far\n" );
721 if ( !fclose( $fh ) ) {
722 $this->
error(
"fclose on $userlistFile failed" );
724 $this->
output(
"User list output to $userlistFile, $count users need renaming.\n" );
Abstract maintenance class for quickly writing and churning out maintenance scripts with minimal effo...
addOption( $name, $description, $required=false, $withArg=false, $shortName=false, $multiOccurrence=false)
Add a parameter to the script.