Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 251
0.00% covered (danger)
0.00%
0 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
ReplaceAll
0.00% covered (danger)
0.00%
0 / 245
0.00% covered (danger)
0.00%
0 / 20
4556
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
2
 getUser
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getTarget
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getReplacement
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getReplacements
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 shouldContinueByDefault
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getSummary
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 listNamespaces
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 showFileFormat
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getNamespaces
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
56
 getCategory
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPrefix
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPageLimit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 useRegex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRename
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listTitles
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 replaceTitles
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 getReply
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 localSetup
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
20
 execute
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
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 */
30namespace MediaWiki\Extension\ReplaceText;
31
32use Maintenance;
33use MediaWiki\User\User;
34use MWException;
35
36$IP = getenv( 'MW_INSTALL_PATH' ) ?: __DIR__ . '/../../..';
37if ( !is_readable( "$IP/maintenance/Maintenance.php" ) ) {
38    die( "MW_INSTALL_PATH needs to be set to your MediaWiki installation.\n" );
39}
40require_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 */
49class 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
208The format of the replacements file is tab separated with three fields.
209Any line that does not have a tab is ignored and can be considered a comment.
210
211Fields 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
218Example:
219
220This is a comment
221TARGET    REPLACE
222regex(p*)    Count the Ps; \\1    true
223
224
225EOF;
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;
464require_once RUN_MAINTENANCE_IF_MAIN;