Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 251 |
|
0.00% |
0 / 20 |
CRAP | |
0.00% |
0 / 1 |
ReplaceAll | |
0.00% |
0 / 245 |
|
0.00% |
0 / 20 |
4556 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
2 | |||
getUser | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
getTarget | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getReplacement | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getReplacements | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
42 | |||
shouldContinueByDefault | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
getSummary | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
listNamespaces | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
showFileFormat | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getNamespaces | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
56 | |||
getCategory | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPrefix | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getPageLimit | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
useRegex | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getRename | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
listTitles | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
42 | |||
replaceTitles | |
0.00% |
0 / 25 |
|
0.00% |
0 / 1 |
20 | |||
getReply | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
localSetup | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
execute | |
0.00% |
0 / 57 |
|
0.00% |
0 / 1 |
210 |
1 | <?php |
2 | /** |
3 | * Replace text in pages or page titles. |
4 | * |
5 | * Copyright © 2014 NicheWork, LLC |
6 | * |
7 | * This program is free software; you can redistribute it and/or modify |
8 | * it under the terms of the GNU General Public License as published by |
9 | * the Free Software Foundation; either version 2 of the License, or |
10 | * (at your option) any later version. |
11 | * |
12 | * This program is distributed in the hope that it will be useful, |
13 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
14 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
15 | * GNU General Public License for more details. |
16 | * |
17 | * You should have received a copy of the GNU General Public License along |
18 | * with this program; if not, write to the Free Software Foundation, Inc., |
19 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
20 | * https://www.gnu.org/copyleft/gpl.html |
21 | * |
22 | * @file |
23 | * @category Maintenance |
24 | * @package ReplaceText |
25 | * @author Mark A. Hershberger <mah@nichework.com> |
26 | * @license GPL-2.0-or-later |
27 | * @link https://www.mediawiki.org/wiki/Extension:Replace_Text |
28 | * |
29 | */ |
30 | namespace MediaWiki\Extension\ReplaceText; |
31 | |
32 | use MediaWiki\Maintenance\Maintenance; |
33 | use MediaWiki\User\User; |
34 | use MWException; |
35 | |
36 | $IP = getenv( 'MW_INSTALL_PATH' ) ?: __DIR__ . '/../../..'; |
37 | if ( !is_readable( "$IP/maintenance/Maintenance.php" ) ) { |
38 | die( "MW_INSTALL_PATH needs to be set to your MediaWiki installation.\n" ); |
39 | } |
40 | require_once "$IP/maintenance/Maintenance.php"; |
41 | |
42 | /** |
43 | * Maintenance script that replaces text in pages |
44 | * |
45 | * @ingroup Maintenance |
46 | * @SuppressWarnings(StaticAccess) |
47 | * @SuppressWarnings(LongVariable) |
48 | */ |
49 | class ReplaceAll extends Maintenance { |
50 | /** @var User */ |
51 | private $user; |
52 | /** @var string[] */ |
53 | private $target; |
54 | /** @var string[] */ |
55 | private $replacement; |
56 | /** @var int[] */ |
57 | private $namespaces; |
58 | /** @var string */ |
59 | private $category; |
60 | /** @var string */ |
61 | private $prefix; |
62 | /** @var int */ |
63 | private $pageLimit; |
64 | /** @var bool[] */ |
65 | private $useRegex; |
66 | /** @var bool */ |
67 | private $defaultContinue; |
68 | /** @var bool */ |
69 | private $botEdit; |
70 | /** @var bool */ |
71 | private $rename; |
72 | |
73 | public function __construct() { |
74 | parent::__construct(); |
75 | $this->addDescription( 'CLI utility to replace text wherever it is ' . |
76 | 'found in the wiki.' ); |
77 | |
78 | $this->addArg( 'target', 'Target text to find.', false ); |
79 | $this->addArg( 'replace', 'Text to replace.', false ); |
80 | |
81 | $this->addOption( 'dry-run', 'Only find the texts, don\'t replace.', |
82 | false, false, 'n' ); |
83 | $this->addOption( 'regex', 'This is a regex (false).', |
84 | false, false, 'r' ); |
85 | $this->addOption( 'user', 'The user to attribute this to (uid 1).', |
86 | false, true, 'u' ); |
87 | $this->addOption( 'yes', 'Skip all prompts with an assumed \'yes\'.', |
88 | false, false, 'y' ); |
89 | $this->addOption( 'summary', 'Alternate edit summary. (%r is where to ' . |
90 | ' place the replacement text, %f the text to look for.)', |
91 | false, true, 's' ); |
92 | $this->addOption( 'nsall', 'Search all canonical namespaces (false). ' . |
93 | 'If true, this option overrides the ns option.', false, false, 'a' ); |
94 | $this->addOption( 'ns', 'Comma separated namespaces to search in ' . |
95 | '(Main) .', false, true ); |
96 | $this->addOption( 'category', 'Search only pages within this category.', |
97 | false, true, 'c' ); |
98 | $this->addOption( 'prefix', 'Search only pages whose names start with this string.', |
99 | false, true, 'p' ); |
100 | $this->addOption( 'pageLimit', 'Maximum number of pages to return from the search.', |
101 | false, true, 'p' ); |
102 | $this->addOption( 'replacements', 'File containing the list of ' . |
103 | 'replacements to be made. Fields in the file are tab-separated. ' . |
104 | 'See --show-file-format for more information.', false, true, 'f' ); |
105 | $this->addOption( 'show-file-format', 'Show a description of the ' . |
106 | 'file format to use with --replacements.', false, false ); |
107 | $this->addOption( 'bot-edit', 'Mark changes as bot edits.', |
108 | false, false, 'b' ); |
109 | $this->addOption( 'debug', 'Display replacements being made.', false, false ); |
110 | $this->addOption( 'listns', 'List out the namespaces on this wiki.', |
111 | false, false ); |
112 | $this->addOption( 'rename', 'Rename page titles instead of replacing contents.', |
113 | false, false ); |
114 | |
115 | $this->requireExtension( 'Replace Text' ); |
116 | } |
117 | |
118 | private function getUser() { |
119 | $userReplacing = $this->getOption( 'user', 1 ); |
120 | |
121 | $userFactory = $this->getServiceContainer()->getUserFactory(); |
122 | $user = is_numeric( $userReplacing ) ? |
123 | $userFactory->newFromId( $userReplacing ) : |
124 | $userFactory->newFromName( $userReplacing ); |
125 | |
126 | if ( !$user instanceof User ) { |
127 | $this->fatalError( |
128 | "Couldn't translate '$userReplacing' to a user." |
129 | ); |
130 | } |
131 | |
132 | return $user; |
133 | } |
134 | |
135 | private function getTarget() { |
136 | $ret = $this->getArg( 0 ); |
137 | if ( $ret === null ) { |
138 | $this->fatalError( 'You have to specify a target.' ); |
139 | } |
140 | return [ $ret ]; |
141 | } |
142 | |
143 | private function getReplacement() { |
144 | $ret = $this->getArg( 1 ); |
145 | if ( $ret === null ) { |
146 | $this->fatalError( 'You have to specify replacement text.' ); |
147 | } |
148 | return [ $ret ]; |
149 | } |
150 | |
151 | private function getReplacements() { |
152 | $file = $this->getOption( 'replacements' ); |
153 | if ( !$file ) { |
154 | return false; |
155 | } |
156 | |
157 | if ( !is_readable( $file ) ) { |
158 | throw new MWException( 'File does not exist or is not readable: ' |
159 | . "$file\n" ); |
160 | } |
161 | |
162 | $handle = fopen( $file, 'r' ); |
163 | if ( $handle === false ) { |
164 | throw new MWException( "Trouble opening file: $file\n" ); |
165 | } |
166 | |
167 | $this->defaultContinue = true; |
168 | // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition |
169 | while ( ( $line = fgets( $handle ) ) !== false ) { |
170 | $field = explode( "\t", substr( $line, 0, -1 ) ); |
171 | if ( !isset( $field[1] ) ) { |
172 | continue; |
173 | } |
174 | |
175 | $this->target[] = $field[0]; |
176 | $this->replacement[] = $field[1]; |
177 | $this->useRegex[] = isset( $field[2] ); |
178 | } |
179 | return true; |
180 | } |
181 | |
182 | private function shouldContinueByDefault() { |
183 | if ( !is_bool( $this->defaultContinue ) ) { |
184 | $this->defaultContinue = |
185 | $this->getOption( 'yes' ) ? |
186 | true : |
187 | false; |
188 | } |
189 | return $this->defaultContinue; |
190 | } |
191 | |
192 | private function getSummary( $target, $replacement ) { |
193 | $msg = wfMessage( 'replacetext_editsummary', $target, $replacement )-> |
194 | plain(); |
195 | if ( $this->getOption( 'summary' ) !== null ) { |
196 | $msg = str_replace( [ '%f', '%r' ], |
197 | [ $target, $replacement ], |
198 | $this->getOption( 'summary' ) ); |
199 | } |
200 | return $msg; |
201 | } |
202 | |
203 | private function listNamespaces() { |
204 | $this->output( "Index\tNamespace\n" ); |
205 | $nsList = $this->getServiceContainer()->getNamespaceInfo()->getCanonicalNamespaces(); |
206 | ksort( $nsList ); |
207 | foreach ( $nsList as $int => $val ) { |
208 | if ( $val == '' ) { |
209 | $val = '(main)'; |
210 | } |
211 | $this->output( " $int\t$val\n" ); |
212 | } |
213 | } |
214 | |
215 | private function showFileFormat() { |
216 | $text = <<<EOF |
217 | |
218 | The format of the replacements file is tab separated with three fields. |
219 | Any line that does not have a tab is ignored and can be considered a comment. |
220 | |
221 | Fields are: |
222 | |
223 | 1. String to search for. |
224 | 2. String to replace found text with. |
225 | 3. (optional) The presence of this field indicates that the previous two |
226 | are considered a regular expression. |
227 | |
228 | Example: |
229 | |
230 | This is a comment |
231 | TARGET REPLACE |
232 | regex(p*) Count the Ps; \\1 true |
233 | |
234 | |
235 | EOF; |
236 | $this->output( $text ); |
237 | } |
238 | |
239 | private function getNamespaces() { |
240 | $nsall = $this->getOption( 'nsall' ); |
241 | $ns = $this->getOption( 'ns' ); |
242 | if ( !$nsall && !$ns ) { |
243 | $namespaces = [ NS_MAIN ]; |
244 | } else { |
245 | $canonical = $this->getServiceContainer()->getNamespaceInfo()->getCanonicalNamespaces(); |
246 | $canonical[NS_MAIN] = '_'; |
247 | $namespaces = array_flip( $canonical ); |
248 | if ( !$nsall ) { |
249 | $namespaces = array_map( |
250 | static function ( $n ) use ( $canonical, $namespaces ) { |
251 | if ( is_numeric( $n ) ) { |
252 | if ( isset( $canonical[ $n ] ) ) { |
253 | return intval( $n ); |
254 | } |
255 | } else { |
256 | if ( isset( $namespaces[ $n ] ) ) { |
257 | return $namespaces[ $n ]; |
258 | } |
259 | } |
260 | return null; |
261 | }, explode( ',', $ns ) ); |
262 | $namespaces = array_filter( |
263 | $namespaces, |
264 | static function ( $val ) { |
265 | return $val !== null; |
266 | } ); |
267 | } |
268 | } |
269 | return $namespaces; |
270 | } |
271 | |
272 | private function getCategory() { |
273 | return $this->getOption( 'category' ); |
274 | } |
275 | |
276 | private function getPrefix() { |
277 | return $this->getOption( 'prefix' ); |
278 | } |
279 | |
280 | private function getPageLimit() { |
281 | return $this->getOption( 'pageLimit' ); |
282 | } |
283 | |
284 | private function useRegex() { |
285 | return [ $this->getOption( 'regex' ) ]; |
286 | } |
287 | |
288 | private function getRename() { |
289 | return $this->hasOption( 'rename' ); |
290 | } |
291 | |
292 | private function listTitles( $titles, $target, $replacement, $regex, $rename ) { |
293 | $skippedTitles = []; |
294 | foreach ( $titles as $prefixedText => $title ) { |
295 | if ( $title === null ) { |
296 | $skippedTitles[] = $prefixedText; |
297 | continue; |
298 | } |
299 | |
300 | if ( $rename ) { |
301 | $newTitle = Search::getReplacedTitle( $title, $target, $replacement, $regex ); |
302 | // Implicit conversion of objects to strings |
303 | $this->output( "$title\t->\t$newTitle\n" ); |
304 | } else { |
305 | $this->output( "$title\n" ); |
306 | } |
307 | } |
308 | |
309 | if ( $skippedTitles ) { |
310 | $this->output( "\nExtension hook filtered out the following titles from being moved:\n" ); |
311 | foreach ( $skippedTitles as $prefixedTitle ) { |
312 | $this->output( "$prefixedTitle\n" ); |
313 | } |
314 | } |
315 | } |
316 | |
317 | private function replaceTitles( $titles, $target, $replacement, $useRegex, $rename ) { |
318 | foreach ( $titles as $title ) { |
319 | $params = [ |
320 | 'target_str' => $target, |
321 | 'replacement_str' => $replacement, |
322 | 'use_regex' => $useRegex, |
323 | 'user_id' => $this->user->getId(), |
324 | 'edit_summary' => $this->getSummary( $target, $replacement ), |
325 | 'botEdit' => $this->botEdit |
326 | ]; |
327 | |
328 | if ( $rename ) { |
329 | $params[ 'move_page' ] = true; |
330 | $params[ 'create_redirect' ] = false; |
331 | $params[ 'watch_page' ] = false; |
332 | } |
333 | |
334 | $this->output( "Replacing on $title... " ); |
335 | $services = $this->getServiceContainer(); |
336 | $job = new Job( $title, $params, |
337 | $services->getMovePageFactory(), |
338 | $services->getPermissionManager(), |
339 | $services->getUserFactory(), |
340 | $services->getWatchlistManager(), |
341 | $services->getWikiPageFactory() |
342 | ); |
343 | if ( $job->run() !== true ) { |
344 | $this->error( "Trouble on the page '$title'." ); |
345 | } |
346 | $this->output( "done.\n" ); |
347 | } |
348 | } |
349 | |
350 | private function getReply( $question ) { |
351 | $reply = ''; |
352 | if ( $this->shouldContinueByDefault() ) { |
353 | return true; |
354 | } |
355 | while ( $reply !== 'y' && $reply !== 'n' ) { |
356 | $reply = $this->readconsole( "$question (Y/N) " ); |
357 | $reply = substr( strtolower( $reply ), 0, 1 ); |
358 | } |
359 | return $reply === 'y'; |
360 | } |
361 | |
362 | private function localSetup() { |
363 | if ( $this->getOption( 'listns' ) ) { |
364 | $this->listNamespaces(); |
365 | return false; |
366 | } |
367 | if ( $this->getOption( 'show-file-format' ) ) { |
368 | $this->showFileFormat(); |
369 | return false; |
370 | } |
371 | $this->user = $this->getUser(); |
372 | if ( !$this->getReplacements() ) { |
373 | $this->target = $this->getTarget(); |
374 | $this->replacement = $this->getReplacement(); |
375 | $this->useRegex = $this->useRegex(); |
376 | } |
377 | $this->namespaces = $this->getNamespaces(); |
378 | $this->category = $this->getCategory(); |
379 | $this->prefix = $this->getPrefix(); |
380 | $this->pageLimit = $this->getPageLimit(); |
381 | $this->rename = $this->getRename(); |
382 | |
383 | return true; |
384 | } |
385 | |
386 | /** |
387 | * @inheritDoc |
388 | */ |
389 | public function execute() { |
390 | $this->botEdit = false; |
391 | if ( !$this->localSetup() ) { |
392 | return; |
393 | } |
394 | |
395 | if ( $this->namespaces === [] ) { |
396 | $this->fatalError( 'No matching namespaces.' ); |
397 | } |
398 | |
399 | $services = $this->getServiceContainer(); |
400 | $hookHelper = new HookHelper( $services->getHookContainer() ); |
401 | $search = new Search( |
402 | $services->getMainConfig(), |
403 | $services->getDBLoadBalancerFactory() |
404 | ); |
405 | foreach ( $this->target as $index => $target ) { |
406 | $replacement = $this->replacement[$index]; |
407 | $useRegex = $this->useRegex[$index]; |
408 | |
409 | if ( $this->getOption( 'debug' ) ) { |
410 | $this->output( "Replacing '$target' with '$replacement'" ); |
411 | if ( $useRegex ) { |
412 | $this->output( ' as regular expression' ); |
413 | } |
414 | $this->output( ".\n" ); |
415 | } |
416 | |
417 | if ( $this->rename ) { |
418 | $res = $search->getMatchingTitles( |
419 | $target, |
420 | $this->namespaces, |
421 | $this->category, |
422 | $this->prefix, |
423 | $this->pageLimit, |
424 | $useRegex |
425 | ); |
426 | $titlesToProcess = $hookHelper->filterPageTitlesForRename( $res ); |
427 | } else { |
428 | $res = $search->doSearchQuery( |
429 | $target, |
430 | $this->namespaces, |
431 | $this->category, |
432 | $this->prefix, |
433 | $this->pageLimit, |
434 | $useRegex |
435 | ); |
436 | $titlesToProcess = $hookHelper->filterPageTitlesForEdit( $res ); |
437 | } |
438 | |
439 | if ( count( $titlesToProcess ) === 0 ) { |
440 | $this->fatalError( 'No targets found to replace.' ); |
441 | } |
442 | |
443 | if ( $this->getOption( 'dry-run' ) ) { |
444 | $this->listTitles( $titlesToProcess, $target, $replacement, $useRegex, $this->rename ); |
445 | continue; |
446 | } |
447 | |
448 | if ( !$this->shouldContinueByDefault() ) { |
449 | $this->listTitles( $titlesToProcess, $target, $replacement, $useRegex, $this->rename ); |
450 | if ( !$this->getReply( 'Replace instances on these pages?' ) ) { |
451 | return; |
452 | } |
453 | } |
454 | |
455 | $comment = ''; |
456 | if ( $this->getOption( 'user', null ) === null ) { |
457 | $comment = ' (Use --user to override)'; |
458 | } |
459 | if ( $this->getOption( 'bot-edit', false ) ) { |
460 | $this->botEdit = true; |
461 | } |
462 | if ( !$this->getReply( |
463 | "Attribute changes to the user '{$this->user->getName()}'?$comment" |
464 | ) ) { |
465 | return; |
466 | } |
467 | |
468 | $this->replaceTitles( $titlesToProcess, $target, $replacement, $useRegex, $this->rename ); |
469 | } |
470 | } |
471 | } |
472 | |
473 | $maintClass = ReplaceAll::class; |
474 | require_once RUN_MAINTENANCE_IF_MAIN; |