1<?php 2/** 3 * Obligatory redundant license notice. Exception to the GPL's "keep intact all 4 * the notices" clause with respect to this notice is hereby granted. 5 * 6 * This program is free software; you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation; either version 2 of the License, or 9 * (at your option) any later version. 10 * 11 * This program is distributed in the hope that it will be useful, 12 * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 * GNU General Public License for more details. 15 * 16 * You should have received a copy of the GNU General Public License along 17 * with this program; if not, write to the Free Software Foundation, Inc., 18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 19 * http://www.gnu.org/copyleft/gpl.html 20 * 21 * @file 22 * @ingroup Maintenance 23 */ 24 25use MediaWiki\MediaWikiServices; 26use Wikimedia\Rdbms\IDatabase; 27 28require_once __DIR__ . '/Maintenance.php'; 29 30/** 31 * Maintenance script to rename titles affected by changes to Unicode (or 32 * otherwise to Language::ucfirst). 33 * 34 * @ingroup Maintenance 35 */ 36class UppercaseTitlesForUnicodeTransition extends Maintenance { 37 38 private const MOVE = 0; 39 private const INPLACE_MOVE = 1; 40 private const UPPERCASE = 2; 41 42 /** @var bool */ 43 private $run = false; 44 45 /** @var array */ 46 private $charmap = []; 47 48 /** @var User */ 49 private $user; 50 51 /** @var string */ 52 private $reason = 'Uppercasing title for Unicode upgrade'; 53 54 /** @var string[] */ 55 private $tags = []; 56 57 /** @var array */ 58 private $seenUsers = []; 59 60 /** @var array|null */ 61 private $namespaces = null; 62 63 /** @var string|null */ 64 private $prefix = null, $suffix = null; 65 66 /** @var int|null */ 67 private $prefixNs = null; 68 69 /** @var string[]|null */ 70 private $tables = null; 71 72 public function __construct() { 73 parent::__construct(); 74 $this->addDescription( 75 "Rename titles when changing behavior of Language::ucfirst().\n" 76 . "\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." 80 ); 81 $this->addOption( 82 'charmap', 'Character map generated by maintenance/language/generateUcfirstOverrides.php', 83 true, true 84 ); 85 $this->addOption( 86 'user', 'System user to use to do the renames. Default is "Maintenance script".', false, true 87 ); 88 $this->addOption( 89 'steal', 90 'If the username specified by --user exists, specify this to force conversion to a system user.' 91 ); 92 $this->addOption( 93 'run', 'If not specified, the script will not actually perform any moves (i.e. it will dry-run).' 94 ); 95 $this->addOption( 96 'prefix', 'When the new title already exists, add this prefix.', false, true 97 ); 98 $this->addOption( 99 'suffix', 'When the new title already exists, add this suffix.', false, true 100 ); 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 ); 104 $this->addOption( 105 'userlist', 'Filename to which to output usernames needing rename.', false, true 106 ); 107 $this->setBatchSize( 1000 ); 108 } 109 110 public function execute() { 111 $this->run = $this->getOption( 'run', false ); 112 113 if ( $this->run ) { 114 $username = $this->getOption( 'user', User::MAINTENANCE_SCRIPT_USER ); 115 $steal = $this->getOption( 'steal', false ); 116 $this->user = User::newSystemUser( $username, [ 'steal' => $steal ] ); 117 if ( !$this->user ) { 118 $user = User::newFromName( $username ); 119 if ( !$steal && $user && $user->isRegistered() ) { 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." 122 ); 123 } 124 $this->fatalError( "Could not obtain system user $username." ); 125 } 126 } 127 128 $tables = $this->getOption( 'tables' ); 129 if ( $tables !== null ) { 130 $this->tables = explode( ',', $tables ); 131 } 132 133 $prefix = $this->getOption( 'prefix' ); 134 if ( $prefix !== null ) { 135 $title = Title::newFromText( $prefix . 'X' ); 136 if ( !$title || substr( $title->getDBkey(), -1 ) !== 'X' ) { 137 $this->fatalError( 'Invalid --prefix.' ); 138 } 139 if ( $title->getNamespace() <= NS_MAIN || $title->isExternal() ) { 140 $this->fatalError( 'Invalid --prefix. It must not be in namespace 0 and must not be external' ); 141 } 142 $this->prefixNs = $title->getNamespace(); 143 $this->prefix = substr( $title->getText(), 0, -1 ); 144 } 145 $this->suffix = $this->getOption( 'suffix' ); 146 147 $this->reason = $this->getOption( 'reason' ) ?: $this->reason; 148 $this->tags = (array)$this->getOption( 'tag', null ); 149 150 $charmapFile = $this->getOption( 'charmap' ); 151 if ( !file_exists( $charmapFile ) ) { 152 $this->fatalError( "Charmap file $charmapFile does not exist." ); 153 } 154 if ( !is_file( $charmapFile ) || !is_readable( $charmapFile ) ) { 155 $this->fatalError( "Charmap file $charmapFile is not readable." ); 156 } 157 $this->charmap = require $charmapFile; 158 if ( !is_array( $this->charmap ) ) { 159 $this->fatalError( "Charmap file $charmapFile did not return a PHP array." ); 160 } 161 $this->charmap = array_filter( 162 $this->charmap, 163 function ( $v, $k ) { 164 if ( mb_strlen( $k ) !== 1 ) { 165 $this->error( "Ignoring mapping from multi-character key '$k' to '$v'" ); 166 return false; 167 } 168 return $k !== $v; 169 }, 170 ARRAY_FILTER_USE_BOTH 171 ); 172 if ( !$this->charmap ) { 173 $this->fatalError( "Charmap file $charmapFile did not contain any usable character mappings." ); 174 } 175 176 $db = $this->getDB( $this->run ? DB_PRIMARY : DB_REPLICA ); 177 178 // Process inplace moves first, before actual moves, so mungeTitle() doesn't get confused 179 $this->processTable( 180 $db, self::INPLACE_MOVE, 'archive', 'ar_namespace', 'ar_title', [ 'ar_timestamp', 'ar_id' ] 181 ); 182 $this->processTable( 183 $db, self::INPLACE_MOVE, 'filearchive', NS_FILE, 'fa_name', [ 'fa_timestamp', 'fa_id' ] 184 ); 185 $this->processTable( 186 $db, self::INPLACE_MOVE, 'logging', 'log_namespace', 'log_title', [ 'log_id' ] 187 ); 188 $this->processTable( 189 $db, self::INPLACE_MOVE, 'protected_titles', 'pt_namespace', 'pt_title', [] 190 ); 191 $this->processTable( $db, self::MOVE, 'page', 'page_namespace', 'page_title', [ 'page_id' ] ); 192 $this->processTable( $db, self::MOVE, 'image', NS_FILE, 'img_name', [] ); 193 $this->processTable( 194 $db, self::UPPERCASE, 'redirect', 'rd_namespace', 'rd_title', [ 'rd_from' ] 195 ); 196 $this->processUsers( $db ); 197 } 198 199 /** 200 * Get batched LIKE conditions from the charmap 201 * @param IDatabase $db Database handle 202 * @param string $field Field name 203 * @param int $batchSize Size of the batches 204 * @return array 205 */ 206 private function getLikeBatches( IDatabase $db, $field, $batchSize = 100 ) { 207 $ret = []; 208 $likes = []; 209 foreach ( $this->charmap as $from => $to ) { 210 $likes[] = $field . $db->buildLike( $from, $db->anyString() ); 211 if ( count( $likes ) >= $batchSize ) { 212 $ret[] = $db->makeList( $likes, $db::LIST_OR ); 213 $likes = []; 214 } 215 } 216 if ( $likes ) { 217 $ret[] = $db->makeList( $likes, $db::LIST_OR ); 218 } 219 return $ret; 220 } 221 222 /** 223 * Get the list of namespaces to operate on 224 * 225 * We only care about namespaces where we can move pages and titles are 226 * capitalized. 227 * 228 * @return int[] 229 */ 230 private function getNamespaces() { 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 ); 237 } 238 ); 239 usort( $this->namespaces, static function ( $ns1, $ns2 ) use ( $nsinfo ) { 240 if ( $ns1 === $ns2 ) { 241 return 0; 242 } 243 244 $s1 = $nsinfo->getSubject( $ns1 ); 245 $s2 = $nsinfo->getSubject( $ns2 ); 246 247 // Order by subject namespace number first 248 if ( $s1 !== $s2 ) { 249 return $s1 < $s2 ? -1 : 1; 250 } 251 252 // Second, put subject namespaces before non-subject namespaces 253 if ( $s1 === $ns1 ) { 254 return -1; 255 } 256 if ( $s2 === $ns2 ) { 257 return 1; 258 } 259 260 // Don't care about the relative order if there are somehow 261 // multiple non-subject namespaces for a namespace. 262 return 0; 263 } ); 264 } 265 266 return $this->namespaces; 267 } 268 269 /** 270 * Check if a ns+title is a registered user's page 271 * @param IDatabase $db Database handle 272 * @param int $ns 273 * @param string $title 274 * @return bool 275 */ 276 private function isUserPage( IDatabase $db, $ns, $title ) { 277 if ( $ns !== NS_USER && $ns !== NS_USER_TALK ) { 278 return false; 279 } 280 281 list( $base ) = explode( '/', $title, 2 ); 282 if ( !isset( $this->seenUsers[$base] ) ) { 283 // Can't use User directly because it might uppercase the name 284 $this->seenUsers[$base] = (bool)$db->selectField( 285 'user', 286 'user_id', 287 [ 'user_name' => strtr( $base, '_', ' ' ) ], 288 __METHOD__ 289 ); 290 } 291 return $this->seenUsers[$base]; 292 } 293 294 /** 295 * Munge a target title, if necessary 296 * @param IDatabase $db Database handle 297 * @param Title $oldTitle 298 * @param Title &$newTitle 299 * @return bool If $newTitle is (now) ok 300 */ 301 private function mungeTitle( IDatabase $db, Title $oldTitle, Title &$newTitle ) { 302 $nt = $newTitle->getPrefixedText(); 303 304 $munge = false; 305 if ( $this->isUserPage( $db, $newTitle->getNamespace(), $newTitle->getText() ) ) { 306 $munge = 'Target title\'s user exists'; 307 } else { 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'; 313 } 314 } 315 if ( !$munge ) { 316 return true; 317 } 318 319 if ( $this->prefix !== null ) { 320 $newTitle = Title::makeTitle( 321 $this->prefixNs, 322 $this->prefix . $oldTitle->getPrefixedText() . ( $this->suffix ?? '' ) 323 ); 324 } elseif ( $this->suffix !== null ) { 325 $dbkey = $newTitle->getText(); 326 $i = $newTitle->getNamespace() === NS_FILE ? strrpos( $dbkey, '.' ) : false; 327 if ( $i !== false ) { 328 $newTitle = Title::makeTitle( 329 $newTitle->getNamespace(), 330 substr( $dbkey, 0, $i ) . $this->suffix . substr( $dbkey, $i ) 331 ); 332 } else { 333 $newTitle = Title::makeTitle( $newTitle->getNamespace(), $dbkey . $this->suffix ); 334 } 335 } else { 336 $this->error( 337 "Cannot move {$oldTitle->getPrefixedText()} → $nt: " 338 . "$munge and no --prefix or --suffix was given" 339 ); 340 return false; 341 } 342 343 if ( !$newTitle->canExist() ) { 344 $this->error( 345 "Cannot move {$oldTitle->getPrefixedText()} → $nt: " 346 . "$munge and munged title '{$newTitle->getPrefixedText()}' is not valid" 347 ); 348 return false; 349 } 350 if ( $newTitle->exists() ) { 351 $this->error( 352 "Cannot move {$oldTitle->getPrefixedText()} → $nt: " 353 . "$munge and munged title '{$newTitle->getPrefixedText()}' also exists" 354 ); 355 return false; 356 } 357 358 return true; 359 } 360 361 /** 362 * Use MovePage to move a title 363 * @param IDatabase $db Database handle 364 * @param int $ns 365 * @param string $title 366 * @return bool|null True on success, false on error, null if skipped 367 */ 368 private function doMove( IDatabase $db, $ns, $title ) { 369 $char = mb_substr( $title, 0, 1 ); 370 if ( !array_key_exists( $char, $this->charmap ) ) { 371 $this->error( 372 "Query returned NS$ns $title, which does not begin with a character in the charmap." 373 ); 374 return false; 375 } 376 377 if ( $this->isUserPage( $db, $ns, $title ) ) { 378 $this->output( "... Skipping user page NS$ns $title\n" ); 379 return null; 380 } 381 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 ) ) { 386 return false; 387 } 388 389 $services = MediaWikiServices::getInstance(); 390 $mpFactory = $services->getMovePageFactory(); 391 $movePage = $mpFactory->newMovePage( $oldTitle, $newTitle ); 392 $status = $movePage->isValidMove(); 393 if ( !$status->isOK() ) { 394 $this->error( 395 "Invalid move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}: " 396 . $status->getMessage( false, false, 'en' )->useDatabase( false )->plain() 397 ); 398 return false; 399 } 400 401 if ( !$this->run ) { 402 $this->output( 403 "Would rename {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n" 404 ); 405 if ( $deletionReason ) { 406 $this->output( 407 "Would then delete {$newTitle->getPrefixedText()}: $deletionReason\n" 408 ); 409 } 410 return true; 411 } 412 413 $status = $movePage->move( $this->user, $this->reason, false, $this->tags ); 414 if ( !$status->isOK() ) { 415 $this->error( 416 "Move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()} failed: " 417 . $status->getMessage( false, false, 'en' )->useDatabase( false )->plain() 418 ); 419 } 420 $this->output( "Renamed {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n" ); 421 422 // The move created a log entry under the old invalid title. Fix it. 423 $db->update( 424 'logging', 425 [ 426 'log_title' => $this->charmap[$char] . mb_substr( $title, 1 ), 427 ], 428 [ 429 'log_namespace' => $oldTitle->getNamespace(), 430 'log_title' => $oldTitle->getDBkey(), 431 'log_page' => $newTitle->getArticleID(), 432 ], 433 __METHOD__ 434 ); 435 436 if ( $deletionReason !== null ) { 437 $page = $services->getWikiPageFactory()->newFromTitle( $newTitle ); 438 $error = ''; 439 $status = $page->doDeleteArticleReal( 440 $deletionReason, 441 $this->user, 442 false, // don't suppress 443 null, // unused 444 $error, 445 null, // unused 446 [], // tags 447 'delete', 448 true // immediate 449 ); 450 if ( !$status->isOK() ) { 451 $this->error( 452 "Deletion of {$newTitle->getPrefixedText()} failed: " 453 . $status->getMessage( false, false, 'en' )->useDatabase( false )->plain() 454 ); 455 return false; 456 } 457 $this->output( "Deleted {$newTitle->getPrefixedText()}\n" ); 458 } 459 460 return true; 461 } 462 463 /** 464 * Determine whether the old title should be deleted 465 * 466 * If it's already a redirect to the new title, or the old and new titles 467 * are redirects to the same place, there's no point in keeping it. 468 * 469 * Note the caller will still rename it before deleting it, so the archive 470 * and logging rows wind up in a sane place. 471 * 472 * @param IDatabase $db 473 * @param Title $oldTitle 474 * @param Title $newTitle 475 * @return string|null Deletion reason, or null if it shouldn't be deleted 476 */ 477 private function shouldDelete( IDatabase $db, Title $oldTitle, Title $newTitle ) { 478 $oldRow = $db->selectRow( 479 [ 'page', 'redirect' ], 480 [ 'ns' => 'rd_namespace', 'title' => 'rd_title' ], 481 [ 'page_namespace' => $oldTitle->getNamespace(), 'page_title' => $oldTitle->getDBkey() ], 482 __METHOD__, 483 [], 484 [ 'redirect' => [ 'JOIN', 'rd_from = page_id' ] ] 485 ); 486 if ( !$oldRow ) { 487 // Not a redirect 488 return null; 489 } 490 491 if ( (int)$oldRow->ns === $newTitle->getNamespace() && 492 $oldRow->title === $newTitle->getDBkey() 493 ) { 494 return $this->reason . ", and found that [[{$oldTitle->getPrefixedText()}]] is " 495 . "already a redirect to [[{$newTitle->getPrefixedText()}]]"; 496 } else { 497 $newRow = $db->selectRow( 498 [ 'page', 'redirect' ], 499 [ 'ns' => 'rd_namespace', 'title' => 'rd_title' ], 500 [ 'page_namespace' => $newTitle->getNamespace(), 'page_title' => $newTitle->getDBkey() ], 501 __METHOD__, 502 [], 503 [ 'redirect' => [ 'JOIN', 'rd_from = page_id' ] ] 504 ); 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()}]]."; 509 } 510 } 511 512 return null; 513 } 514 515 /** 516 * Directly update a database row 517 * @param IDatabase $db Database handle 518 * @param int $op Operation to perform 519 * - self::INPLACE_MOVE: Directly update the database table to move the page 520 * - self::UPPERCASE: Rewrite the table to point to the new uppercase title 521 * @param string $table 522 * @param string|int $nsField 523 * @param string $titleField 524 * @param stdClass $row 525 * @return bool|null True on success, false on error, null if skipped 526 */ 527 private function doUpdate( IDatabase $db, $op, $table, $nsField, $titleField, $row ) { 528 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField; 529 $title = $row->$titleField; 530 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 ); 534 $this->error( 535 "Query returned $r, but title does not begin with a character in the charmap." 536 ); 537 return false; 538 } 539 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 ) ) { 543 return false; 544 } 545 546 if ( $this->run ) { 547 $db->update( 548 $table, 549 array_merge( 550 is_int( $nsField ) ? [] : [ $nsField => $newTitle->getNamespace() ], 551 [ $titleField => $newTitle->getDBkey() ] 552 ), 553 (array)$row, 554 __METHOD__ 555 ); 556 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); 557 $this->output( "Set $r to {$newTitle->getPrefixedText()}\n" ); 558 } else { 559 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); 560 $this->output( "Would set $r to {$newTitle->getPrefixedText()}\n" ); 561 } 562 563 return true; 564 } 565 566 /** 567 * Rename entries in other tables 568 * @param IDatabase $db Database handle 569 * @param int $op Operation to perform 570 * - self::MOVE: Use MovePage to move the page 571 * - self::INPLACE_MOVE: Directly update the database table to move the page 572 * - self::UPPERCASE: Rewrite the table to point to the new uppercase title 573 * @param string $table 574 * @param string|int $nsField 575 * @param string $titleField 576 * @param string[] $pkFields Additional fields to match a unique index 577 * starting with $nsField and $titleField. 578 */ 579 private function processTable( IDatabase $db, $op, $table, $nsField, $titleField, $pkFields ) { 580 if ( $this->tables !== null && !in_array( $table, $this->tables, true ) ) { 581 $this->output( "Skipping table `$table`, not in --tables.\n" ); 582 return; 583 } 584 585 $batchSize = $this->getBatchSize(); 586 $namespaces = $this->getNamespaces(); 587 $likes = $this->getLikeBatches( $db, $titleField ); 588 $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); 589 590 if ( is_int( $nsField ) ) { 591 $namespaces = array_intersect( $namespaces, [ $nsField ] ); 592 } 593 594 if ( !$namespaces ) { 595 $this->output( "Skipping table `$table`, no valid namespaces.\n" ); 596 return; 597 } 598 599 $this->output( "Processing table `$table`...\n" ); 600 601 $selectFields = array_merge( 602 is_int( $nsField ) ? [] : [ $nsField ], 603 [ $titleField ], 604 $pkFields 605 ); 606 $contFields = array_reverse( array_merge( [ $titleField ], $pkFields ) ); 607 608 $lastReplicationWait = 0.0; 609 $count = 0; 610 $errors = 0; 611 foreach ( $namespaces as $ns ) { 612 foreach ( $likes as $like ) { 613 $cont = []; 614 do { 615 $res = $db->select( 616 $table, 617 $selectFields, 618 array_merge( [ "$nsField = $ns", $like ], $cont ), 619 __METHOD__, 620 [ 'ORDER BY' => array_merge( [ $titleField ], $pkFields ), 'LIMIT' => $batchSize ] 621 ); 622 $cont = []; 623 foreach ( $res as $row ) { 624 $cont = ''; 625 foreach ( $contFields as $field ) { 626 $v = $db->addQuotes( $row->$field ); 627 if ( $cont === '' ) { 628 $cont = "$field > $v"; 629 } else { 630 $cont = "$field > $v OR $field = $v AND ($cont)"; 631 } 632 } 633 $cont = [ $cont ]; 634 635 if ( $op === self::MOVE ) { 636 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField; 637 $ret = $this->doMove( $db, $ns, $row->$titleField ); 638 } else { 639 $ret = $this->doUpdate( $db, $op, $table, $nsField, $titleField, $row ); 640 } 641 if ( $ret === true ) { 642 $count++; 643 } elseif ( $ret === false ) { 644 $errors++; 645 } 646 } 647 648 if ( $this->run ) { 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( 652 [ 'timeout' => 30, 'ifWritesSince' => $lastReplicationWait ] 653 ); 654 $lastReplicationWait = microtime( true ); 655 } 656 } while ( $cont ); 657 } 658 } 659 660 $this->output( "Done processing table `$table`.\n" ); 661 } 662 663 /** 664 * List users needing renaming 665 * @param IDatabase $db Database handle 666 */ 667 private function processUsers( IDatabase $db ) { 668 $userlistFile = $this->getOption( 'userlist' ); 669 if ( $userlistFile === null ) { 670 $this->output( "Not generating user list, --userlist was not specified.\n" ); 671 return; 672 } 673 674 $fh = fopen( $userlistFile, 'wb' ); 675 if ( !$fh ) { 676 $this->error( "Could not open user list file $userlistFile" ); 677 return; 678 } 679 680 $this->output( "Generating user list...\n" ); 681 $count = 0; 682 $batchSize = $this->getBatchSize(); 683 foreach ( $this->getLikeBatches( $db, 'user_name' ) as $like ) { 684 $cont = []; 685 while ( true ) { 686 $names = $db->selectFieldValues( 687 'user', 688 'user_name', 689 array_merge( [ $like ], $cont ), 690 __METHOD__, 691 [ 'ORDER BY' => 'user_name', 'LIMIT' => $batchSize ] 692 ); 693 if ( !$names ) { 694 break; 695 } 696 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 ) ) { 702 $this->error( 703 "Query returned $name, but user name does not begin with a character in the charmap." 704 ); 705 continue; 706 } 707 $newName = $this->charmap[$char] . mb_substr( $name, 1 ); 708 fprintf( $fh, "%s\t%s\n", $name, $newName ); 709 $count++; 710 } 711 $this->output( "... at $last, $count names so far\n" ); 712 } 713 } 714 715 if ( !fclose( $fh ) ) { 716 $this->error( "fclose on $userlistFile failed" ); 717 } 718 $this->output( "User list output to $userlistFile, $count users need renaming.\n" ); 719 } 720} 721 722$maintClass = UppercaseTitlesForUnicodeTransition::class; 723require_once RUN_MAINTENANCE_IF_MAIN; 724