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 MediaWiki\Maintenance\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    /** @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
218The format of the replacements file is tab separated with three fields.
219Any line that does not have a tab is ignored and can be considered a comment.
220
221Fields 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
228Example:
229
230This is a comment
231TARGET    REPLACE
232regex(p*)    Count the Ps; \\1    true
233
234
235EOF;
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;
474require_once RUN_MAINTENANCE_IF_MAIN;