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