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