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