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