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', '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 ); 119 if ( !$steal && $user && $user->isLoggedIn() ) { 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_MASTER : 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 function ( $ns ) use ( $nsinfo ) { 236 return $nsinfo->isMovable( $ns ) && $nsinfo->isCapitalized( $ns ); 237 } 238 ); 239 usort( $this->namespaces, 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 $mp = new MovePage( $oldTitle, $newTitle ); 309 $status = $mp->isValidMove(); 310 if ( !$status->isOK() && $status->hasMessage( 'articleexists' ) ) { 311 $munge = 'Target title exists'; 312 } 313 } 314 if ( !$munge ) { 315 return true; 316 } 317 318 if ( $this->prefix !== null ) { 319 $newTitle = Title::makeTitle( 320 $this->prefixNs, 321 $this->prefix . $oldTitle->getPrefixedText() . ( $this->suffix ?? '' ) 322 ); 323 } elseif ( $this->suffix !== null ) { 324 $dbkey = $newTitle->getText(); 325 $i = $newTitle->getNamespace() === NS_FILE ? strrpos( $dbkey, '.' ) : false; 326 if ( $i !== false ) { 327 $newTitle = Title::makeTitle( 328 $newTitle->getNamespace(), 329 substr( $dbkey, 0, $i ) . $this->suffix . substr( $dbkey, $i ) 330 ); 331 } else { 332 $newTitle = Title::makeTitle( $newTitle->getNamespace(), $dbkey . $this->suffix ); 333 } 334 } else { 335 $this->error( 336 "Cannot move {$oldTitle->getPrefixedText()} → $nt: " 337 . "$munge and no --prefix or --suffix was given" 338 ); 339 return false; 340 } 341 342 if ( !$newTitle->canExist() ) { 343 $this->error( 344 "Cannot move {$oldTitle->getPrefixedText()} → $nt: " 345 . "$munge and munged title '{$newTitle->getPrefixedText()}' is not valid" 346 ); 347 return false; 348 } 349 if ( $newTitle->exists() ) { 350 $this->error( 351 "Cannot move {$oldTitle->getPrefixedText()} → $nt: " 352 . "$munge and munged title '{$newTitle->getPrefixedText()}' also exists" 353 ); 354 return false; 355 } 356 357 return true; 358 } 359 360 /** 361 * Use MovePage to move a title 362 * @param IDatabase $db Database handle 363 * @param int $ns 364 * @param string $title 365 * @return bool|null True on success, false on error, null if skipped 366 */ 367 private function doMove( IDatabase $db, $ns, $title ) { 368 $char = mb_substr( $title, 0, 1 ); 369 if ( !array_key_exists( $char, $this->charmap ) ) { 370 $this->error( 371 "Query returned NS$ns $title, which does not begin with a character in the charmap." 372 ); 373 return false; 374 } 375 376 if ( $this->isUserPage( $db, $ns, $title ) ) { 377 $this->output( "... Skipping user page NS$ns $title\n" ); 378 return null; 379 } 380 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 ) ) { 385 return false; 386 } 387 388 $mp = new MovePage( $oldTitle, $newTitle ); 389 $status = $mp->isValidMove(); 390 if ( !$status->isOK() ) { 391 $this->error( 392 "Invalid move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}: " 393 . $status->getMessage( false, false, 'en' )->useDatabase( false )->plain() 394 ); 395 return false; 396 } 397 398 if ( !$this->run ) { 399 $this->output( 400 "Would rename {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n" 401 ); 402 if ( $deletionReason ) { 403 $this->output( 404 "Would then delete {$newTitle->getPrefixedText()}: $deletionReason\n" 405 ); 406 } 407 return true; 408 } 409 410 $status = $mp->move( $this->user, $this->reason, false, $this->tags ); 411 if ( !$status->isOK() ) { 412 $this->error( 413 "Move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()} failed: " 414 . $status->getMessage( false, false, 'en' )->useDatabase( false )->plain() 415 ); 416 } 417 $this->output( "Renamed {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n" ); 418 419 // The move created a log entry under the old invalid title. Fix it. 420 $db->update( 421 'logging', 422 [ 423 'log_title' => $this->charmap[$char] . mb_substr( $title, 1 ), 424 ], 425 [ 426 'log_namespace' => $oldTitle->getNamespace(), 427 'log_title' => $oldTitle->getDBkey(), 428 'log_page' => $newTitle->getArticleID(), 429 ], 430 __METHOD__ 431 ); 432 433 if ( $deletionReason !== null ) { 434 $page = WikiPage::factory( $newTitle ); 435 $error = ''; 436 $status = $page->doDeleteArticleReal( 437 $deletionReason, 438 $this->user, 439 false, // don't suppress 440 null, // unused 441 $error, 442 null, // unused 443 [], // tags 444 'delete', 445 true // immediate 446 ); 447 if ( !$status->isOK() ) { 448 $this->error( 449 "Deletion of {$newTitle->getPrefixedText()} failed: " 450 . $status->getMessage( false, false, 'en' )->useDatabase( false )->plain() 451 ); 452 return false; 453 } 454 $this->output( "Deleted {$newTitle->getPrefixedText()}\n" ); 455 } 456 457 return true; 458 } 459 460 /** 461 * Determine whether the old title should be deleted 462 * 463 * If it's already a redirect to the new title, or the old and new titles 464 * are redirects to the same place, there's no point in keeping it. 465 * 466 * Note the caller will still rename it before deleting it, so the archive 467 * and logging rows wind up in a sane place. 468 * 469 * @param IDatabase $db 470 * @param Title $oldTitle 471 * @param Title $newTitle 472 * @return string|null Deletion reason, or null if it shouldn't be deleted 473 */ 474 private function shouldDelete( IDatabase $db, Title $oldTitle, Title $newTitle ) { 475 $oldRow = $db->selectRow( 476 [ 'page', 'redirect' ], 477 [ 'ns' => 'rd_namespace', 'title' => 'rd_title' ], 478 [ 'page_namespace' => $oldTitle->getNamespace(), 'page_title' => $oldTitle->getDBkey() ], 479 __METHOD__, 480 [], 481 [ 'redirect' => [ 'JOIN', 'rd_from = page_id' ] ] 482 ); 483 if ( !$oldRow ) { 484 // Not a redirect 485 return null; 486 } 487 488 if ( (int)$oldRow->ns === $newTitle->getNamespace() && 489 $oldRow->title === $newTitle->getDBkey() 490 ) { 491 return $this->reason . ", and found that [[{$oldTitle->getPrefixedText()}]] is " 492 . "already a redirect to [[{$newTitle->getPrefixedText()}]]"; 493 } else { 494 $newRow = $db->selectRow( 495 [ 'page', 'redirect' ], 496 [ 'ns' => 'rd_namespace', 'title' => 'rd_title' ], 497 [ 'page_namespace' => $newTitle->getNamespace(), 'page_title' => $newTitle->getDBkey() ], 498 __METHOD__, 499 [], 500 [ 'redirect' => [ 'JOIN', 'rd_from = page_id' ] ] 501 ); 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()}]]."; 506 } 507 } 508 509 return null; 510 } 511 512 /** 513 * Directly update a database row 514 * @param IDatabase $db Database handle 515 * @param int $op Operation to perform 516 * - self::INPLACE_MOVE: Directly update the database table to move the page 517 * - self::UPPERCASE: Rewrite the table to point to the new uppercase title 518 * @param string $table 519 * @param string|int $nsField 520 * @param string $titleField 521 * @param stdClass $row 522 * @return bool|null True on success, false on error, null if skipped 523 */ 524 private function doUpdate( IDatabase $db, $op, $table, $nsField, $titleField, $row ) { 525 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField; 526 $title = $row->$titleField; 527 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 ); 531 $this->error( 532 "Query returned $r, but title does not begin with a character in the charmap." 533 ); 534 return false; 535 } 536 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 ) ) { 540 return false; 541 } 542 543 if ( $this->run ) { 544 $db->update( 545 $table, 546 array_merge( 547 is_int( $nsField ) ? [] : [ $nsField => $newTitle->getNamespace() ], 548 [ $titleField => $newTitle->getDBkey() ] 549 ), 550 (array)$row, 551 __METHOD__ 552 ); 553 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); 554 $this->output( "Set $r to {$newTitle->getPrefixedText()}\n" ); 555 } else { 556 $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); 557 $this->output( "Would set $r to {$newTitle->getPrefixedText()}\n" ); 558 } 559 560 return true; 561 } 562 563 /** 564 * Rename entries in other tables 565 * @param IDatabase $db Database handle 566 * @param int $op Operation to perform 567 * - self::MOVE: Use MovePage to move the page 568 * - self::INPLACE_MOVE: Directly update the database table to move the page 569 * - self::UPPERCASE: Rewrite the table to point to the new uppercase title 570 * @param string $table 571 * @param string|int $nsField 572 * @param string $titleField 573 * @param string[] $pkFields Additional fields to match a unique index 574 * starting with $nsField and $titleField. 575 */ 576 private function processTable( IDatabase $db, $op, $table, $nsField, $titleField, $pkFields ) { 577 if ( $this->tables !== null && !in_array( $table, $this->tables, true ) ) { 578 $this->output( "Skipping table `$table`, not in --tables.\n" ); 579 return; 580 } 581 582 $batchSize = $this->getBatchSize(); 583 $namespaces = $this->getNamespaces(); 584 $likes = $this->getLikeBatches( $db, $titleField ); 585 $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); 586 587 if ( is_int( $nsField ) ) { 588 $namespaces = array_intersect( $namespaces, [ $nsField ] ); 589 } 590 591 if ( !$namespaces ) { 592 $this->output( "Skipping table `$table`, no valid namespaces.\n" ); 593 return; 594 } 595 596 $this->output( "Processing table `$table`...\n" ); 597 598 $selectFields = array_merge( 599 is_int( $nsField ) ? [] : [ $nsField ], 600 [ $titleField ], 601 $pkFields 602 ); 603 $contFields = array_reverse( array_merge( [ $titleField ], $pkFields ) ); 604 605 $lastReplicationWait = 0.0; 606 $count = 0; 607 $errors = 0; 608 foreach ( $namespaces as $ns ) { 609 foreach ( $likes as $like ) { 610 $cont = []; 611 do { 612 $res = $db->select( 613 $table, 614 $selectFields, 615 array_merge( [ "$nsField = $ns", $like ], $cont ), 616 __METHOD__, 617 [ 'ORDER BY' => array_merge( [ $titleField ], $pkFields ), 'LIMIT' => $batchSize ] 618 ); 619 $cont = []; 620 foreach ( $res as $row ) { 621 $cont = ''; 622 foreach ( $contFields as $field ) { 623 $v = $db->addQuotes( $row->$field ); 624 if ( $cont === '' ) { 625 $cont = "$field > $v"; 626 } else { 627 $cont = "$field > $v OR $field = $v AND ($cont)"; 628 } 629 } 630 $cont = [ $cont ]; 631 632 if ( $op === self::MOVE ) { 633 $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField; 634 $ret = $this->doMove( $db, $ns, $row->$titleField ); 635 } else { 636 $ret = $this->doUpdate( $db, $op, $table, $nsField, $titleField, $row ); 637 } 638 if ( $ret === true ) { 639 $count++; 640 } elseif ( $ret === false ) { 641 $errors++; 642 } 643 } 644 645 if ( $this->run ) { 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( 649 [ 'timeout' => 30, 'ifWritesSince' => $lastReplicationWait ] 650 ); 651 $lastReplicationWait = microtime( true ); 652 } 653 } while ( $cont ); 654 } 655 } 656 657 $this->output( "Done processing table `$table`.\n" ); 658 } 659 660 /** 661 * List users needing renaming 662 * @param IDatabase $db Database handle 663 */ 664 private function processUsers( IDatabase $db ) { 665 $userlistFile = $this->getOption( 'userlist' ); 666 if ( $userlistFile === null ) { 667 $this->output( "Not generating user list, --userlist was not specified.\n" ); 668 return; 669 } 670 671 $fh = fopen( $userlistFile, 'wb' ); 672 if ( !$fh ) { 673 $this->error( "Could not open user list file $userlistFile" ); 674 return; 675 } 676 677 $this->output( "Generating user list...\n" ); 678 $count = 0; 679 $batchSize = $this->getBatchSize(); 680 foreach ( $this->getLikeBatches( $db, 'user_name' ) as $like ) { 681 $cont = []; 682 while ( true ) { 683 $names = $db->selectFieldValues( 684 'user', 685 'user_name', 686 array_merge( [ $like ], $cont ), 687 __METHOD__, 688 [ 'ORDER BY' => 'user_name', 'LIMIT' => $batchSize ] 689 ); 690 if ( !$names ) { 691 break; 692 } 693 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 ) ) { 699 $this->error( 700 "Query returned $name, but user name does not begin with a character in the charmap." 701 ); 702 continue; 703 } 704 $newName = $this->charmap[$char] . mb_substr( $name, 1 ); 705 fprintf( $fh, "%s\t%s\n", $name, $newName ); 706 $count++; 707 } 708 $this->output( "... at $last, $count names so far\n" ); 709 } 710 } 711 712 if ( !fclose( $fh ) ) { 713 $this->error( "fclose on $userlistFile failed" ); 714 } 715 $this->output( "User list output to $userlistFile, $count users need renaming.\n" ); 716 } 717} 718 719$maintClass = UppercaseTitlesForUnicodeTransition::class; 720require_once RUN_MAINTENANCE_IF_MAIN; 721