Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 397
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
UppercaseTitlesForUnicodeTransition
0.00% covered (danger)
0.00%
0 / 394
0.00% covered (danger)
0.00%
0 / 11
9900
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
420
 getLikeBatches
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getNamespaces
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
72
 isUserPage
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 mungeTitle
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
156
 doMove
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
110
 shouldDelete
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 doUpdate
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 processTable
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
306
 processUsers
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
90
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\Title\Title;
26use MediaWiki\User\User;
27use MediaWiki\WikiMap\WikiMap;
28use Wikimedia\Rdbms\IDatabase;
29use Wikimedia\Rdbms\IExpression;
30use Wikimedia\Rdbms\IReadableDatabase;
31use Wikimedia\Rdbms\LikeValue;
32
33require_once __DIR__ . '/Maintenance.php';
34
35/**
36 * Maintenance script to rename titles affected by changes to Unicode (or
37 * otherwise to Language::ucfirst).
38 *
39 * @ingroup Maintenance
40 */
41class UppercaseTitlesForUnicodeTransition extends Maintenance {
42
43    private const MOVE = 0;
44    private const INPLACE_MOVE = 1;
45    private const UPPERCASE = 2;
46
47    /** @var bool */
48    private $run = false;
49
50    /** @var array */
51    private $charmap = [];
52
53    /** @var User */
54    private $user;
55
56    /** @var string */
57    private $reason = 'Uppercasing title for Unicode upgrade';
58
59    /** @var string[] */
60    private $tags = [];
61
62    /** @var array */
63    private $seenUsers = [];
64
65    /** @var array|null */
66    private $namespaces = null;
67
68    private ?string $prefix = null;
69    private ?string $suffix = null;
70
71    /** @var int|null */
72    private $prefixNs = null;
73
74    /** @var string[]|null */
75    private $tables = null;
76
77    public function __construct() {
78        parent::__construct();
79        $this->addDescription(
80            "Rename titles when changing behavior of Language::ucfirst().\n"
81            . "\n"
82            . "This script skips User and User_talk pages for registered users, as renaming of users "
83            . "is too complex to try to implement here. Use something like Extension:Renameuser to "
84            . "clean those up; this script can provide a list of user names affected."
85        );
86        $this->addOption(
87            'charmap', 'Character map generated by maintenance/language/generateUcfirstOverrides.php',
88            true, true
89        );
90        $this->addOption(
91            'user', 'System user to use to do the renames. Default is "Maintenance script".', false, true
92        );
93        $this->addOption(
94            'steal',
95            'If the username specified by --user exists, specify this to force conversion to a system user.'
96        );
97        $this->addOption(
98            'run', 'If not specified, the script will not actually perform any moves (i.e. it will dry-run).'
99        );
100        $this->addOption(
101            'prefix', 'When the new title already exists, add this prefix.', false, true
102        );
103        $this->addOption(
104            'suffix', 'When the new title already exists, add this suffix.', false, true
105        );
106        $this->addOption( 'reason', 'Reason to use when moving pages.', false, true );
107        $this->addOption( 'tag', 'Change tag to apply when moving pages.', false, true );
108        $this->addOption( 'tables', 'Comma-separated list of database tables to process.', false, true );
109        $this->addOption(
110            'userlist', 'Filename to which to output usernames needing rename. ' .
111            'This file can then be used directly by renameInvalidUsernames.php maintenance script',
112            false,
113            true
114        );
115        $this->setBatchSize( 1000 );
116    }
117
118    public function execute() {
119        $this->run = $this->getOption( 'run', false );
120
121        if ( $this->run ) {
122            $username = $this->getOption( 'user', User::MAINTENANCE_SCRIPT_USER );
123            $steal = $this->getOption( 'steal', false );
124            $this->user = User::newSystemUser( $username, [ 'steal' => $steal ] );
125            if ( !$this->user ) {
126                $user = User::newFromName( $username );
127                if ( !$steal && $user && $user->isRegistered() ) {
128                    $this->fatalError( "User $username already exists.\n"
129                        . "Use --steal if you really want to steal it from the human who currently owns it."
130                    );
131                }
132                $this->fatalError( "Could not obtain system user $username." );
133            }
134        }
135
136        $tables = $this->getOption( 'tables' );
137        if ( $tables !== null ) {
138            $this->tables = explode( ',', $tables );
139        }
140
141        $prefix = $this->getOption( 'prefix' );
142        if ( $prefix !== null ) {
143            $title = Title::newFromText( $prefix . 'X' );
144            if ( !$title || substr( $title->getDBkey(), -1 ) !== 'X' ) {
145                $this->fatalError( 'Invalid --prefix.' );
146            }
147            if ( $title->getNamespace() <= NS_MAIN || $title->isExternal() ) {
148                $this->fatalError( 'Invalid --prefix. It must not be in namespace 0 and must not be external' );
149            }
150            $this->prefixNs = $title->getNamespace();
151            $this->prefix = substr( $title->getText(), 0, -1 );
152        }
153        $this->suffix = $this->getOption( 'suffix' );
154
155        $this->reason = $this->getOption( 'reason' ) ?: $this->reason;
156        $this->tags = (array)$this->getOption( 'tag', null );
157
158        $charmapFile = $this->getOption( 'charmap' );
159        if ( !file_exists( $charmapFile ) ) {
160            $this->fatalError( "Charmap file $charmapFile does not exist." );
161        }
162        if ( !is_file( $charmapFile ) || !is_readable( $charmapFile ) ) {
163            $this->fatalError( "Charmap file $charmapFile is not readable." );
164        }
165        $this->charmap = require $charmapFile;
166        if ( !is_array( $this->charmap ) ) {
167            $this->fatalError( "Charmap file $charmapFile did not return a PHP array." );
168        }
169        $this->charmap = array_filter(
170            $this->charmap,
171            function ( $v, $k ) {
172                if ( mb_strlen( $k ) !== 1 ) {
173                    $this->error( "Ignoring mapping from multi-character key '$k' to '$v'" );
174                    return false;
175                }
176                return $k !== $v;
177            },
178            ARRAY_FILTER_USE_BOTH
179        );
180        if ( !$this->charmap ) {
181            $this->fatalError( "Charmap file $charmapFile did not contain any usable character mappings." );
182        }
183
184        $db = $this->run ? $this->getPrimaryDB() : $this->getReplicaDB();
185
186        // Process inplace moves first, before actual moves, so mungeTitle() doesn't get confused
187        $this->processTable(
188            $db, self::INPLACE_MOVE, 'archive', 'ar_namespace', 'ar_title', [ 'ar_timestamp', 'ar_id' ]
189        );
190        $this->processTable(
191            $db, self::INPLACE_MOVE, 'filearchive', NS_FILE, 'fa_name', [ 'fa_timestamp', 'fa_id' ]
192        );
193        $this->processTable(
194            $db, self::INPLACE_MOVE, 'logging', 'log_namespace', 'log_title', [ 'log_id' ]
195        );
196        $this->processTable(
197            $db, self::INPLACE_MOVE, 'protected_titles', 'pt_namespace', 'pt_title', []
198        );
199        $this->processTable( $db, self::MOVE, 'page', 'page_namespace', 'page_title', [ 'page_id' ] );
200        $this->processTable( $db, self::MOVE, 'image', NS_FILE, 'img_name', [] );
201        $this->processTable(
202            $db, self::UPPERCASE, 'redirect', 'rd_namespace', 'rd_title', [ 'rd_from' ]
203        );
204        $this->processUsers( $db );
205    }
206
207    /**
208     * Get batched LIKE conditions from the charmap
209     * @param IReadableDatabase $db Database handle
210     * @param string $field Field name
211     * @param int $batchSize Size of the batches
212     * @return array
213     */
214    private function getLikeBatches( IReadableDatabase $db, $field, $batchSize = 100 ) {
215        $ret = [];
216        $likes = [];
217        foreach ( $this->charmap as $from => $to ) {
218            $likes[] = $db->expr(
219                $field,
220                IExpression::LIKE,
221                new LikeValue( $from, $db->anyString() )
222            );
223            if ( count( $likes ) >= $batchSize ) {
224                $ret[] = $db->orExpr( $likes );
225                $likes = [];
226            }
227        }
228        if ( $likes ) {
229            $ret[] = $db->orExpr( $likes );
230        }
231        return $ret;
232    }
233
234    /**
235     * Get the list of namespaces to operate on
236     *
237     * We only care about namespaces where we can move pages and titles are
238     * capitalized.
239     *
240     * @return int[]
241     */
242    private function getNamespaces() {
243        if ( $this->namespaces === null ) {
244            $nsinfo = $this->getServiceContainer()->getNamespaceInfo();
245            $this->namespaces = array_filter(
246                array_keys( $nsinfo->getCanonicalNamespaces() ),
247                static function ( $ns ) use ( $nsinfo ) {
248                    return $nsinfo->isMovable( $ns ) && $nsinfo->isCapitalized( $ns );
249                }
250            );
251            usort( $this->namespaces, static function ( $ns1, $ns2 ) use ( $nsinfo ) {
252                if ( $ns1 === $ns2 ) {
253                    return 0;
254                }
255
256                $s1 = $nsinfo->getSubject( $ns1 );
257                $s2 = $nsinfo->getSubject( $ns2 );
258
259                // Order by subject namespace number first
260                if ( $s1 !== $s2 ) {
261                    return $s1 < $s2 ? -1 : 1;
262                }
263
264                // Second, put subject namespaces before non-subject namespaces
265                if ( $s1 === $ns1 ) {
266                    return -1;
267                }
268                if ( $s2 === $ns2 ) {
269                    return 1;
270                }
271
272                // Don't care about the relative order if there are somehow
273                // multiple non-subject namespaces for a namespace.
274                return 0;
275            } );
276        }
277
278        return $this->namespaces;
279    }
280
281    /**
282     * Check if a ns+title is a registered user's page
283     * @param IReadableDatabase $db Database handle
284     * @param int $ns
285     * @param string $title
286     * @return bool
287     */
288    private function isUserPage( IReadableDatabase $db, $ns, $title ) {
289        if ( $ns !== NS_USER && $ns !== NS_USER_TALK ) {
290            return false;
291        }
292
293        [ $base ] = explode( '/', $title, 2 );
294        if ( !isset( $this->seenUsers[$base] ) ) {
295            // Can't use User directly because it might uppercase the name
296            $this->seenUsers[$base] = (bool)$db->newSelectQueryBuilder()
297                ->select( 'user_id' )
298                ->from( 'user' )
299                ->where( [ 'user_name' => strtr( $base, '_', ' ' ) ] )
300                ->caller( __METHOD__ )->fetchField();
301        }
302        return $this->seenUsers[$base];
303    }
304
305    /**
306     * Munge a target title, if necessary
307     * @param IReadableDatabase $db Database handle
308     * @param Title $oldTitle
309     * @param Title &$newTitle
310     * @return bool If $newTitle is (now) ok
311     */
312    private function mungeTitle( IReadableDatabase $db, Title $oldTitle, Title &$newTitle ) {
313        $nt = $newTitle->getPrefixedText();
314
315        $munge = false;
316        if ( $this->isUserPage( $db, $newTitle->getNamespace(), $newTitle->getText() ) ) {
317            $munge = 'Target title\'s user exists';
318        } else {
319            $mpFactory = $this->getServiceContainer()->getMovePageFactory();
320            $status = $mpFactory->newMovePage( $oldTitle, $newTitle )->isValidMove();
321            if ( !$status->isOK() && (
322                $status->hasMessage( 'articleexists' ) || $status->hasMessage( 'redirectexists' ) ) ) {
323                $munge = 'Target title exists';
324            }
325        }
326        if ( !$munge ) {
327            return true;
328        }
329
330        if ( $this->prefix !== null ) {
331            $newTitle = Title::makeTitle(
332                $this->prefixNs,
333                $this->prefix . $oldTitle->getPrefixedText() . ( $this->suffix ?? '' )
334            );
335        } elseif ( $this->suffix !== null ) {
336            $dbkey = $newTitle->getText();
337            $i = $newTitle->getNamespace() === NS_FILE ? strrpos( $dbkey, '.' ) : false;
338            if ( $i !== false ) {
339                $newTitle = Title::makeTitle(
340                    $newTitle->getNamespace(),
341                    substr( $dbkey, 0, $i ) . $this->suffix . substr( $dbkey, $i )
342                );
343            } else {
344                $newTitle = Title::makeTitle( $newTitle->getNamespace(), $dbkey . $this->suffix );
345            }
346        } else {
347            $this->error(
348                "Cannot move {$oldTitle->getPrefixedText()} → $nt"
349                . "$munge and no --prefix or --suffix was given"
350            );
351            return false;
352        }
353
354        if ( !$newTitle->canExist() ) {
355            $this->error(
356                "Cannot move {$oldTitle->getPrefixedText()} → $nt"
357                . "$munge and munged title '{$newTitle->getPrefixedText()}' is not valid"
358            );
359            return false;
360        }
361        if ( $newTitle->exists() ) {
362            $this->error(
363                "Cannot move {$oldTitle->getPrefixedText()} → $nt"
364                . "$munge and munged title '{$newTitle->getPrefixedText()}' also exists"
365            );
366            return false;
367        }
368
369        return true;
370    }
371
372    /**
373     * Use MovePage to move a title
374     * @param IDatabase $db Database handle
375     * @param int $ns
376     * @param string $title
377     * @return bool|null True on success, false on error, null if skipped
378     */
379    private function doMove( IDatabase $db, $ns, $title ) {
380        $char = mb_substr( $title, 0, 1 );
381        if ( !array_key_exists( $char, $this->charmap ) ) {
382            $this->error(
383                "Query returned NS$ns $title, which does not begin with a character in the charmap."
384            );
385            return false;
386        }
387
388        if ( $this->isUserPage( $db, $ns, $title ) ) {
389            $this->output( "... Skipping user page NS$ns $title\n" );
390            return null;
391        }
392
393        $oldTitle = Title::makeTitle( $ns, $title );
394        $newTitle = Title::makeTitle( $ns, $this->charmap[$char] . mb_substr( $title, 1 ) );
395        $deletionReason = $this->shouldDelete( $db, $oldTitle, $newTitle );
396        if ( !$this->mungeTitle( $db, $oldTitle, $newTitle ) ) {
397            return false;
398        }
399
400        $services = $this->getServiceContainer();
401        $mpFactory = $services->getMovePageFactory();
402        $movePage = $mpFactory->newMovePage( $oldTitle, $newTitle );
403        $status = $movePage->isValidMove();
404        if ( !$status->isOK() ) {
405            $this->error( "Invalid move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}:" );
406            $this->error( $status );
407            return false;
408        }
409
410        if ( !$this->run ) {
411            $this->output(
412                "Would rename {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n"
413            );
414            if ( $deletionReason ) {
415                $this->output(
416                    "Would then delete {$newTitle->getPrefixedText()}$deletionReason\n"
417                );
418            }
419            return true;
420        }
421
422        $status = $movePage->move( $this->user, $this->reason, false, $this->tags );
423        if ( !$status->isOK() ) {
424            $this->error( "Move {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()} failed:" );
425            $this->error( $status );
426        }
427        $this->output( "Renamed {$oldTitle->getPrefixedText()} → {$newTitle->getPrefixedText()}\n" );
428
429        // The move created a log entry under the old invalid title. Fix it.
430        $db->newUpdateQueryBuilder()
431            ->update( 'logging' )
432            ->set( [
433                'log_title' => $this->charmap[$char] . mb_substr( $title, 1 ),
434            ] )
435            ->where( [
436                'log_namespace' => $oldTitle->getNamespace(),
437                'log_title' => $oldTitle->getDBkey(),
438                'log_page' => $newTitle->getArticleID(),
439            ] )
440            ->caller( __METHOD__ )
441            ->execute();
442
443        if ( $deletionReason !== null ) {
444            $page = $services->getWikiPageFactory()->newFromTitle( $newTitle );
445            $delPage = $services->getDeletePageFactory()->newDeletePage( $page, $this->user );
446            $status = $delPage
447                ->forceImmediate( true )
448                ->deleteUnsafe( $deletionReason );
449            if ( !$status->isOK() ) {
450                $this->error( "Deletion of {$newTitle->getPrefixedText()} failed:" );
451                $this->error( $status );
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 sensible place.
468     *
469     * @param IReadableDatabase $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( IReadableDatabase $db, Title $oldTitle, Title $newTitle ) {
475        $oldRow = $db->newSelectQueryBuilder()
476            ->select( [ 'ns' => 'rd_namespace', 'title' => 'rd_title' ] )
477            ->from( 'page' )
478            ->join( 'redirect', null, 'rd_from = page_id' )
479            ->where( [ 'page_namespace' => $oldTitle->getNamespace(), 'page_title' => $oldTitle->getDBkey() ] )
480            ->caller( __METHOD__ )->fetchRow();
481        if ( !$oldRow ) {
482            // Not a redirect
483            return null;
484        }
485
486        if ( (int)$oldRow->ns === $newTitle->getNamespace() &&
487            $oldRow->title === $newTitle->getDBkey()
488        ) {
489            return $this->reason . ", and found that [[{$oldTitle->getPrefixedText()}]] is "
490                . "already a redirect to [[{$newTitle->getPrefixedText()}]]";
491        } else {
492            $newRow = $db->newSelectQueryBuilder()
493                ->select( [ 'ns' => 'rd_namespace', 'title' => 'rd_title' ] )
494                ->from( 'page' )
495                ->join( 'redirect', null, 'rd_from = page_id' )
496                ->where( [ 'page_namespace' => $newTitle->getNamespace(), 'page_title' => $newTitle->getDBkey() ] )
497                ->caller( __METHOD__ )->fetchRow();
498            if ( $newRow && $oldRow->ns === $newRow->ns && $oldRow->title === $newRow->title ) {
499                $nt = Title::makeTitle( $newRow->ns, $newRow->title );
500                return $this->reason . ", and found that [[{$oldTitle->getPrefixedText()}]] and "
501                    . "[[{$newTitle->getPrefixedText()}]] both redirect to [[{$nt->getPrefixedText()}]].";
502            }
503        }
504
505        return null;
506    }
507
508    /**
509     * Directly update a database row
510     * @param IDatabase $db Database handle
511     * @param int $op Operation to perform
512     *  - self::INPLACE_MOVE: Directly update the database table to move the page
513     *  - self::UPPERCASE: Rewrite the table to point to the new uppercase title
514     * @param string $table
515     * @param string|int $nsField
516     * @param string $titleField
517     * @param stdClass $row
518     * @return bool|null True on success, false on error, null if skipped
519     */
520    private function doUpdate( IDatabase $db, $op, $table, $nsField, $titleField, $row ) {
521        $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField;
522        $title = $row->$titleField;
523
524        $char = mb_substr( $title, 0, 1 );
525        if ( !array_key_exists( $char, $this->charmap ) ) {
526            $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
527            $this->error(
528                "Query returned $r, but title does not begin with a character in the charmap."
529            );
530            return false;
531        }
532
533        $oldTitle = Title::makeTitle( $ns, $title );
534        $newTitle = Title::makeTitle( $ns, $this->charmap[$char] . mb_substr( $title, 1 ) );
535        if ( $op !== self::UPPERCASE && !$this->mungeTitle( $db, $oldTitle, $newTitle ) ) {
536            return false;
537        }
538
539        if ( $this->run ) {
540            $db->newUpdateQueryBuilder()
541                ->update( $table )
542                ->set( array_merge(
543                    is_int( $nsField ) ? [] : [ $nsField => $newTitle->getNamespace() ],
544                    [ $titleField => $newTitle->getDBkey() ]
545                ) )
546                ->where( (array)$row )
547                ->caller( __METHOD__ )
548                ->execute();
549            $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
550            $this->output( "Set $r to {$newTitle->getPrefixedText()}\n" );
551        } else {
552            $r = json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
553            $this->output( "Would set $r to {$newTitle->getPrefixedText()}\n" );
554        }
555
556        return true;
557    }
558
559    /**
560     * Rename entries in other tables
561     * @param IDatabase $db Database handle
562     * @param int $op Operation to perform
563     *  - self::MOVE: Use MovePage to move the page
564     *  - self::INPLACE_MOVE: Directly update the database table to move the page
565     *  - self::UPPERCASE: Rewrite the table to point to the new uppercase title
566     * @param string $table
567     * @param string|int $nsField
568     * @param string $titleField
569     * @param string[] $pkFields Additional fields to match a unique index
570     *  starting with $nsField and $titleField.
571     */
572    private function processTable( IDatabase $db, $op, $table, $nsField, $titleField, $pkFields ) {
573        if ( $this->tables !== null && !in_array( $table, $this->tables, true ) ) {
574            $this->output( "Skipping table `$table`, not in --tables.\n" );
575            return;
576        }
577
578        $batchSize = $this->getBatchSize();
579        $namespaces = $this->getNamespaces();
580        $likes = $this->getLikeBatches( $db, $titleField );
581
582        if ( is_int( $nsField ) ) {
583            $namespaces = array_intersect( $namespaces, [ $nsField ] );
584        }
585
586        if ( !$namespaces ) {
587            $this->output( "Skipping table `$table`, no valid namespaces.\n" );
588            return;
589        }
590
591        $this->output( "Processing table `$table`...\n" );
592
593        $selectFields = array_merge(
594            is_int( $nsField ) ? [] : [ $nsField ],
595            [ $titleField ],
596            $pkFields
597        );
598        $contFields = array_merge( [ $titleField ], $pkFields );
599
600        $lastReplicationWait = 0.0;
601        $count = 0;
602        $errors = 0;
603        foreach ( $namespaces as $ns ) {
604            foreach ( $likes as $like ) {
605                $cont = [];
606                do {
607                    $res = $db->newSelectQueryBuilder()
608                        ->select( $selectFields )
609                        ->from( $table )
610                        ->where( [ "$nsField = $ns", $like, $cont ? $db->buildComparison( '>', $cont ) : '1=1' ] )
611                        ->orderBy( array_merge( [ $titleField ], $pkFields ) )
612                        ->limit( $batchSize )
613                        ->caller( __METHOD__ )->fetchResultSet();
614                    $cont = [];
615                    foreach ( $res as $row ) {
616                        $cont = [];
617                        foreach ( $contFields as $field ) {
618                            $cont[ $field ] = $row->$field;
619                        }
620
621                        if ( $op === self::MOVE ) {
622                            $ns = is_int( $nsField ) ? $nsField : (int)$row->$nsField;
623                            $ret = $this->doMove( $db, $ns, $row->$titleField );
624                        } else {
625                            $ret = $this->doUpdate( $db, $op, $table, $nsField, $titleField, $row );
626                        }
627                        if ( $ret === true ) {
628                            $count++;
629                        } elseif ( $ret === false ) {
630                            $errors++;
631                        }
632                    }
633
634                    if ( $this->run ) {
635                        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable rows contains at least one item
636                        $r = $cont ? json_encode( $row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : '<end>';
637                        $this->output( "... $table$count renames, $errors errors at $r\n" );
638                        $this->waitForReplication();
639                    }
640                } while ( $cont );
641            }
642        }
643
644        $this->output( "Done processing table `$table`.\n" );
645    }
646
647    /**
648     * List users needing renaming
649     * @param IReadableDatabase $db Database handle
650     */
651    private function processUsers( IReadableDatabase $db ) {
652        $userlistFile = $this->getOption( 'userlist' );
653        if ( $userlistFile === null ) {
654            $this->output( "Not generating user list, --userlist was not specified.\n" );
655            return;
656        }
657
658        $fh = fopen( $userlistFile, 'ab' );
659        if ( !$fh ) {
660            $this->error( "Could not open user list file $userlistFile" );
661            return;
662        }
663
664        $this->output( "Generating user list...\n" );
665        $count = 0;
666        $batchSize = $this->getBatchSize();
667        foreach ( $this->getLikeBatches( $db, 'user_name' ) as $like ) {
668            $cont = [];
669            while ( true ) {
670                $rows = $db->newSelectQueryBuilder()
671                    ->select( [ 'user_id', 'user_name' ] )
672                    ->from( 'user' )
673                    ->where( $like )
674                    ->andWhere( $cont )
675                    ->orderBy( 'user_name' )
676                    ->limit( $batchSize )
677                    ->caller( __METHOD__ )->fetchResultSet();
678
679                if ( !$rows->numRows() ) {
680                    break;
681                }
682
683                foreach ( $rows as $row ) {
684                    $char = mb_substr( $row->user_name, 0, 1 );
685                    if ( !array_key_exists( $char, $this->charmap ) ) {
686                        $this->error(
687                            "Query returned $row->user_name, but user name does not " .
688                            "begin with a character in the charmap."
689                        );
690                        continue;
691                    }
692                    $newName = $this->charmap[$char] . mb_substr( $row->user_name, 1 );
693                    fprintf( $fh, "%s\t%s\t%s\n", WikiMap::getCurrentWikiId(), $row->user_id, $newName );
694                    $count++;
695                    $cont = [ $db->expr( 'user_name', '>', $row->user_name ) ];
696                }
697                // @phan-suppress-next-line PhanPossiblyUndeclaredVariable rows contains at least one item
698                $this->output( "... at $row->user_name$count names so far\n" );
699            }
700        }
701
702        if ( !fclose( $fh ) ) {
703            $this->error( "fclose on $userlistFile failed" );
704        }
705        $this->output( "User list output to $userlistFile$count users need renaming.\n" );
706    }
707}
708
709$maintClass = UppercaseTitlesForUnicodeTransition::class;
710require_once RUN_MAINTENANCE_IF_MAIN;