Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 403 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
UppercaseTitlesForUnicodeTransition | |
0.00% |
0 / 400 |
|
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 / 66 |
|
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 / 50 |
|
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\Title\Title; |
26 | use MediaWiki\User\User; |
27 | use MediaWiki\WikiMap\WikiMap; |
28 | use Wikimedia\Rdbms\IDatabase; |
29 | use Wikimedia\Rdbms\IExpression; |
30 | use Wikimedia\Rdbms\IReadableDatabase; |
31 | use Wikimedia\Rdbms\LikeValue; |
32 | use Wikimedia\Rdbms\OrExpressionGroup; |
33 | |
34 | require_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 | */ |
42 | class 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; |
717 | require_once RUN_MAINTENANCE_IF_MAIN; |