Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 589 |
|
0.00% |
0 / 21 |
CRAP | |
0.00% |
0 / 1 |
SpecialReplaceText | |
0.00% |
0 / 589 |
|
0.00% |
0 / 21 |
11342 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
30 | |||
getSelectedNamespaces | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
doSpecialReplaceText | |
0.00% |
0 / 95 |
|
0.00% |
0 / 1 |
552 | |||
createJobsForTextReplacements | |
0.00% |
0 / 52 |
|
0.00% |
0 / 1 |
210 | |||
getTitlesForEditingWithContext | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
20 | |||
getTitlesForMoveAndUnmoveableTitles | |
0.00% |
0 / 34 |
|
0.00% |
0 / 1 |
56 | |||
getAnyWarningMessageBeforeReplace | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
56 | |||
showForm | |
0.00% |
0 / 123 |
|
0.00% |
0 / 1 |
42 | |||
checkLabel | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
namespaceTables | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
56 | |||
pageListForm | |
0.00% |
0 / 99 |
|
0.00% |
0 / 1 |
110 | |||
extractContext | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
72 | |||
extractRole | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
convertWhiteSpaceToHTML | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
getReplaceTextUser | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
getGroupName | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
displayTitles | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
getToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
checkToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * https://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | */ |
20 | namespace MediaWiki\Extension\ReplaceText; |
21 | |
22 | use ErrorPageError; |
23 | use JobQueueGroup; |
24 | use MediaWiki\HookContainer\HookContainer; |
25 | use MediaWiki\Html\Html; |
26 | use MediaWiki\Language\Language; |
27 | use MediaWiki\Linker\LinkRenderer; |
28 | use MediaWiki\Page\MovePageFactory; |
29 | use MediaWiki\Page\WikiPageFactory; |
30 | use MediaWiki\Permissions\PermissionManager; |
31 | use MediaWiki\Revision\SlotRecord; |
32 | use MediaWiki\SpecialPage\SpecialPage; |
33 | use MediaWiki\Storage\NameTableStore; |
34 | use MediaWiki\Title\NamespaceInfo; |
35 | use MediaWiki\Title\Title; |
36 | use MediaWiki\User\Options\UserOptionsLookup; |
37 | use MediaWiki\User\UserFactory; |
38 | use MediaWiki\Watchlist\WatchlistManager; |
39 | use OOUI; |
40 | use PermissionsError; |
41 | use SearchEngineConfig; |
42 | use Wikimedia\Rdbms\IConnectionProvider; |
43 | use Wikimedia\Rdbms\ReadOnlyMode; |
44 | |
45 | class SpecialReplaceText extends SpecialPage { |
46 | /** @var string */ |
47 | private $target; |
48 | /** @var string */ |
49 | private $targetString; |
50 | /** @var string */ |
51 | private $replacement; |
52 | /** @var bool */ |
53 | private $use_regex; |
54 | /** @var string */ |
55 | private $category; |
56 | /** @var string */ |
57 | private $prefix; |
58 | /** @var string|int */ |
59 | private $pageLimit; |
60 | /** @var bool */ |
61 | private $edit_pages; |
62 | /** @var bool */ |
63 | private $move_pages; |
64 | /** @var int[] */ |
65 | private $selected_namespaces; |
66 | /** @var bool */ |
67 | private $botEdit; |
68 | |
69 | /** @var HookHelper */ |
70 | private $hookHelper; |
71 | |
72 | /** @var IConnectionProvider */ |
73 | private $dbProvider; |
74 | |
75 | /** @var Language */ |
76 | private $contentLanguage; |
77 | |
78 | /** @var JobQueueGroup */ |
79 | private $jobQueueGroup; |
80 | |
81 | /** @var LinkRenderer */ |
82 | private $linkRenderer; |
83 | |
84 | /** @var MovePageFactory */ |
85 | private $movePageFactory; |
86 | |
87 | /** @var NamespaceInfo */ |
88 | private $namespaceInfo; |
89 | |
90 | /** @var PermissionManager */ |
91 | private $permissionManager; |
92 | |
93 | /** @var ReadOnlyMode */ |
94 | private $readOnlyMode; |
95 | |
96 | /** @var SearchEngineConfig */ |
97 | private $searchEngineConfig; |
98 | |
99 | /** @var NameTableStore */ |
100 | private $slotRoleStore; |
101 | |
102 | /** @var UserFactory */ |
103 | private $userFactory; |
104 | |
105 | /** @var UserOptionsLookup */ |
106 | private $userOptionsLookup; |
107 | |
108 | private WatchlistManager $watchlistManager; |
109 | private WikiPageFactory $wikiPageFactory; |
110 | |
111 | /** @var Search */ |
112 | private $search; |
113 | |
114 | /** |
115 | * @param HookContainer $hookContainer |
116 | * @param IConnectionProvider $dbProvider |
117 | * @param Language $contentLanguage |
118 | * @param JobQueueGroup $jobQueueGroup |
119 | * @param LinkRenderer $linkRenderer |
120 | * @param MovePageFactory $movePageFactory |
121 | * @param NamespaceInfo $namespaceInfo |
122 | * @param PermissionManager $permissionManager |
123 | * @param ReadOnlyMode $readOnlyMode |
124 | * @param SearchEngineConfig $searchEngineConfig |
125 | * @param NameTableStore $slotRoleStore |
126 | * @param UserFactory $userFactory |
127 | * @param UserOptionsLookup $userOptionsLookup |
128 | * @param WatchlistManager $watchlistManager |
129 | * @param WikiPageFactory $wikiPageFactory |
130 | */ |
131 | public function __construct( |
132 | HookContainer $hookContainer, |
133 | IConnectionProvider $dbProvider, |
134 | Language $contentLanguage, |
135 | JobQueueGroup $jobQueueGroup, |
136 | LinkRenderer $linkRenderer, |
137 | MovePageFactory $movePageFactory, |
138 | NamespaceInfo $namespaceInfo, |
139 | PermissionManager $permissionManager, |
140 | ReadOnlyMode $readOnlyMode, |
141 | SearchEngineConfig $searchEngineConfig, |
142 | NameTableStore $slotRoleStore, |
143 | UserFactory $userFactory, |
144 | UserOptionsLookup $userOptionsLookup, |
145 | WatchlistManager $watchlistManager, |
146 | WikiPageFactory $wikiPageFactory |
147 | ) { |
148 | parent::__construct( 'ReplaceText', 'replacetext' ); |
149 | $this->hookHelper = new HookHelper( $hookContainer ); |
150 | $this->dbProvider = $dbProvider; |
151 | $this->contentLanguage = $contentLanguage; |
152 | $this->jobQueueGroup = $jobQueueGroup; |
153 | $this->linkRenderer = $linkRenderer; |
154 | $this->movePageFactory = $movePageFactory; |
155 | $this->namespaceInfo = $namespaceInfo; |
156 | $this->permissionManager = $permissionManager; |
157 | $this->readOnlyMode = $readOnlyMode; |
158 | $this->searchEngineConfig = $searchEngineConfig; |
159 | $this->slotRoleStore = $slotRoleStore; |
160 | $this->userFactory = $userFactory; |
161 | $this->userOptionsLookup = $userOptionsLookup; |
162 | $this->watchlistManager = $watchlistManager; |
163 | $this->wikiPageFactory = $wikiPageFactory; |
164 | $this->search = new Search( |
165 | $this->getConfig(), |
166 | $dbProvider |
167 | ); |
168 | } |
169 | |
170 | /** |
171 | * @inheritDoc |
172 | */ |
173 | public function doesWrites() { |
174 | return true; |
175 | } |
176 | |
177 | /** |
178 | * @param null|string $query |
179 | */ |
180 | function execute( $query ) { |
181 | if ( !$this->getUser()->isAllowed( 'replacetext' ) ) { |
182 | throw new PermissionsError( 'replacetext' ); |
183 | } |
184 | |
185 | // Replace Text can't be run with certain settings, due to the |
186 | // changes they make to the DB storage setup. |
187 | if ( $this->getConfig()->get( 'CompressRevisions' ) ) { |
188 | throw new ErrorPageError( 'replacetext_cfg_error', 'replacetext_no_compress' ); |
189 | } |
190 | if ( $this->getConfig()->get( 'ExternalStores' ) ) { |
191 | throw new ErrorPageError( 'replacetext_cfg_error', 'replacetext_no_external_stores' ); |
192 | } |
193 | |
194 | $out = $this->getOutput(); |
195 | |
196 | if ( $this->readOnlyMode->isReadOnly() ) { |
197 | $permissionErrors = [ [ 'readonlytext', [ $this->readOnlyMode->getReason() ] ] ]; |
198 | $out->setPageTitleMsg( $this->msg( 'badaccess' ) ); |
199 | $out->addWikiTextAsInterface( $out->formatPermissionsErrorMessage( $permissionErrors, 'replacetext' ) ); |
200 | return; |
201 | } |
202 | |
203 | $out->enableOOUI(); |
204 | $this->setHeaders(); |
205 | $this->doSpecialReplaceText(); |
206 | } |
207 | |
208 | /** |
209 | * @return array namespaces selected for search |
210 | */ |
211 | function getSelectedNamespaces() { |
212 | $all_namespaces = $this->searchEngineConfig->searchableNamespaces(); |
213 | $selected_namespaces = []; |
214 | foreach ( $all_namespaces as $ns => $name ) { |
215 | if ( $this->getRequest()->getCheck( 'ns' . $ns ) ) { |
216 | $selected_namespaces[] = $ns; |
217 | } |
218 | } |
219 | return $selected_namespaces; |
220 | } |
221 | |
222 | /** |
223 | * Do the actual display and logic of Special:ReplaceText. |
224 | */ |
225 | function doSpecialReplaceText() { |
226 | $out = $this->getOutput(); |
227 | $request = $this->getRequest(); |
228 | |
229 | $this->target = $request->getText( 'target' ); |
230 | $this->targetString = str_replace( "\n", "\u{21B5}", $this->target ); |
231 | $this->replacement = $request->getText( 'replacement' ); |
232 | $this->use_regex = $request->getBool( 'use_regex' ); |
233 | $this->category = $request->getText( 'category' ); |
234 | $this->prefix = $request->getText( 'prefix' ); |
235 | $this->pageLimit = $request->getText( 'pageLimit' ); |
236 | $this->edit_pages = $request->getBool( 'edit_pages' ); |
237 | $this->move_pages = $request->getBool( 'move_pages' ); |
238 | $this->botEdit = $request->getBool( 'botEdit' ); |
239 | $this->selected_namespaces = $this->getSelectedNamespaces(); |
240 | |
241 | if ( $request->getCheck( 'continue' ) && $this->target === '' ) { |
242 | $this->showForm( 'replacetext_givetarget' ); |
243 | return; |
244 | } |
245 | |
246 | if ( $request->getCheck( 'continue' ) && $this->pageLimit === '' ) { |
247 | $this->pageLimit = $this->getConfig()->get( 'ReplaceTextResultsLimit' ); |
248 | } else { |
249 | $this->pageLimit = (int)$this->pageLimit; |
250 | } |
251 | |
252 | if ( $request->getCheck( 'replace' ) ) { |
253 | |
254 | // check for CSRF |
255 | if ( !$this->checkToken() ) { |
256 | $out->addWikiMsg( 'sessionfailure' ); |
257 | return; |
258 | } |
259 | |
260 | $jobs = $this->createJobsForTextReplacements(); |
261 | $this->jobQueueGroup->push( $jobs ); |
262 | |
263 | $count = $this->getLanguage()->formatNum( count( $jobs ) ); |
264 | $out->addWikiMsg( |
265 | 'replacetext_success', |
266 | "<code><nowiki>{$this->targetString}</nowiki></code>", |
267 | "<code><nowiki>{$this->replacement}</nowiki></code>", |
268 | $count |
269 | ); |
270 | // Link back |
271 | $out->addHTML( |
272 | $this->linkRenderer->makeLink( |
273 | $this->getPageTitle(), |
274 | $this->msg( 'replacetext_return' )->text() |
275 | ) |
276 | ); |
277 | return; |
278 | } |
279 | |
280 | if ( $request->getCheck( 'target' ) ) { |
281 | // check for CSRF |
282 | if ( !$this->checkToken() ) { |
283 | $out->addWikiMsg( 'sessionfailure' ); |
284 | return; |
285 | } |
286 | |
287 | // first, check that at least one namespace has been |
288 | // picked, and that either editing or moving pages |
289 | // has been selected |
290 | if ( count( $this->selected_namespaces ) == 0 ) { |
291 | $this->showForm( 'replacetext_nonamespace' ); |
292 | return; |
293 | } |
294 | if ( !$this->edit_pages && !$this->move_pages ) { |
295 | $this->showForm( 'replacetext_editormove' ); |
296 | return; |
297 | } |
298 | |
299 | // If user is replacing text within pages... |
300 | $titles_for_edit = $titles_for_move = $unmoveable_titles = $uneditable_titles = []; |
301 | if ( $this->edit_pages ) { |
302 | [ $titles_for_edit, $uneditable_titles ] = $this->getTitlesForEditingWithContext(); |
303 | } |
304 | if ( $this->move_pages ) { |
305 | [ $titles_for_move, $unmoveable_titles ] = $this->getTitlesForMoveAndUnmoveableTitles(); |
306 | } |
307 | |
308 | // If no results were found, check to see if a bad |
309 | // category name was entered. |
310 | if ( count( $titles_for_edit ) == 0 && count( $titles_for_move ) == 0 ) { |
311 | $category_title_exists = true; |
312 | |
313 | if ( $this->category ) { |
314 | $category_title = Title::makeTitleSafe( NS_CATEGORY, $this->category ); |
315 | if ( !$category_title->exists() ) { |
316 | $category_title_exists = false; |
317 | $link = $this->linkRenderer->makeLink( |
318 | $category_title, |
319 | ucfirst( $this->category ) |
320 | ); |
321 | $out->addHTML( |
322 | $this->msg( 'replacetext_nosuchcategory' )->rawParams( $link )->escaped() |
323 | ); |
324 | } |
325 | } |
326 | |
327 | if ( $this->edit_pages && $category_title_exists ) { |
328 | $out->addWikiMsg( |
329 | 'replacetext_noreplacement', |
330 | "<code><nowiki>{$this->targetString}</nowiki></code>" |
331 | ); |
332 | } |
333 | |
334 | if ( $this->move_pages && $category_title_exists ) { |
335 | $out->addWikiMsg( 'replacetext_nomove', "<code><nowiki>{$this->targetString}</nowiki></code>" ); |
336 | } |
337 | // link back to starting form |
338 | $out->addHTML( |
339 | '<p>' . |
340 | $this->linkRenderer->makeLink( |
341 | $this->getPageTitle(), |
342 | $this->msg( 'replacetext_return' )->text() |
343 | ) |
344 | . '</p>' |
345 | ); |
346 | } else { |
347 | $warning_msg = $this->getAnyWarningMessageBeforeReplace( $titles_for_edit, $titles_for_move ); |
348 | if ( $warning_msg !== null ) { |
349 | $warningLabel = new OOUI\LabelWidget( [ |
350 | 'label' => new OOUI\HtmlSnippet( $warning_msg ) |
351 | ] ); |
352 | $warning = new OOUI\MessageWidget( [ |
353 | 'type' => 'warning', |
354 | 'label' => $warningLabel |
355 | ] ); |
356 | $out->addHTML( $warning ); |
357 | } |
358 | |
359 | $this->pageListForm( $titles_for_edit, $titles_for_move, $uneditable_titles, $unmoveable_titles ); |
360 | } |
361 | return; |
362 | } |
363 | |
364 | // If we're still here, show the starting form. |
365 | $this->showForm(); |
366 | } |
367 | |
368 | /** |
369 | * Returns the set of MediaWiki jobs that will do all the actual replacements. |
370 | * |
371 | * @return array jobs |
372 | */ |
373 | function createJobsForTextReplacements() { |
374 | $replacement_params = [ |
375 | 'user_id' => $this->getReplaceTextUser()->getId(), |
376 | 'target_str' => $this->target, |
377 | 'replacement_str' => $this->replacement, |
378 | 'use_regex' => $this->use_regex, |
379 | 'create_redirect' => false, |
380 | 'watch_page' => false, |
381 | 'botEdit' => $this->botEdit |
382 | ]; |
383 | $replacement_params['edit_summary'] = $this->msg( |
384 | 'replacetext_editsummary', |
385 | $this->targetString, $this->replacement |
386 | )->inContentLanguage()->plain(); |
387 | |
388 | $request = $this->getRequest(); |
389 | foreach ( $request->getValues() as $key => $value ) { |
390 | if ( $key == 'create-redirect' && $value == '1' ) { |
391 | $replacement_params['create_redirect'] = true; |
392 | } elseif ( $key == 'watch-pages' && $value == '1' ) { |
393 | $replacement_params['watch_page'] = true; |
394 | } |
395 | } |
396 | |
397 | $jobs = []; |
398 | $pages_to_edit = []; |
399 | // These are OOUI checkboxes - we don't determine whether they |
400 | // were checked by their value (which will be null), but rather |
401 | // by whether they were submitted at all. |
402 | foreach ( $request->getValues() as $key => $value ) { |
403 | if ( $key === 'replace' || $key === 'use_regex' ) { |
404 | continue; |
405 | } |
406 | if ( strpos( $key, 'move-' ) !== false ) { |
407 | $title = Title::newFromID( (int)substr( $key, 5 ) ); |
408 | $replacement_params['move_page'] = true; |
409 | if ( $title !== null ) { |
410 | $jobs[] = new Job( $title, $replacement_params, |
411 | $this->movePageFactory, |
412 | $this->permissionManager, |
413 | $this->userFactory, |
414 | $this->watchlistManager, |
415 | $this->wikiPageFactory |
416 | ); |
417 | } |
418 | unset( $replacement_params['move_page'] ); |
419 | } elseif ( strpos( $key, '|' ) !== false ) { |
420 | // Bundle multiple edits to the same page for a different slot into one job |
421 | [ $page_id, $role ] = explode( '|', $key, 2 ); |
422 | $pages_to_edit[$page_id][] = $role; |
423 | } |
424 | } |
425 | // Create jobs for the bundled page edits |
426 | foreach ( $pages_to_edit as $page_id => $roles ) { |
427 | $title = Title::newFromID( (int)$page_id ); |
428 | $replacement_params['roles'] = $roles; |
429 | if ( $title !== null ) { |
430 | $jobs[] = new Job( $title, $replacement_params, |
431 | $this->movePageFactory, |
432 | $this->permissionManager, |
433 | $this->userFactory, |
434 | $this->watchlistManager, |
435 | $this->wikiPageFactory |
436 | ); |
437 | } |
438 | unset( $replacement_params['roles'] ); |
439 | } |
440 | |
441 | return $jobs; |
442 | } |
443 | |
444 | /** |
445 | * Returns the set of Titles whose contents would be modified by this |
446 | * replacement, along with the "search context" string for each one. |
447 | * |
448 | * @return array The set of Titles and their search context strings |
449 | */ |
450 | function getTitlesForEditingWithContext() { |
451 | $titles_for_edit = []; |
452 | |
453 | $res = $this->search->doSearchQuery( |
454 | $this->target, |
455 | $this->selected_namespaces, |
456 | $this->category, |
457 | $this->prefix, |
458 | $this->pageLimit, |
459 | $this->use_regex |
460 | ); |
461 | |
462 | $titles_to_process = $this->hookHelper->filterPageTitlesForEdit( $res ); |
463 | $titles_to_skip = []; |
464 | |
465 | foreach ( $res as $row ) { |
466 | $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title ); |
467 | if ( $title == null ) { |
468 | continue; |
469 | } |
470 | |
471 | if ( !isset( $titles_to_process[ $title->getPrefixedText() ] ) ) { |
472 | // Title has been filtered out by the hook: ReplaceTextFilterPageTitlesForEdit |
473 | $titles_to_skip[] = $title; |
474 | continue; |
475 | } |
476 | |
477 | // @phan-suppress-next-line SecurityCheck-ReDoS target could be a regex from user |
478 | $context = $this->extractContext( $row->old_text, $this->target, $this->use_regex ); |
479 | $role = $this->extractRole( (int)$row->slot_role_id ); |
480 | $titles_for_edit[] = [ $title, $context, $role ]; |
481 | } |
482 | |
483 | return [ $titles_for_edit, $titles_to_skip ]; |
484 | } |
485 | |
486 | /** |
487 | * Returns two lists: the set of titles that would be moved/renamed by |
488 | * the current text replacement, and the set of titles that would |
489 | * ordinarily be moved but are not moveable, due to permissions or any |
490 | * other reason. |
491 | * |
492 | * @return array |
493 | */ |
494 | function getTitlesForMoveAndUnmoveableTitles() { |
495 | $titles_for_move = []; |
496 | $unmoveable_titles = []; |
497 | |
498 | $res = $this->search->getMatchingTitles( |
499 | $this->target, |
500 | $this->selected_namespaces, |
501 | $this->category, |
502 | $this->prefix, |
503 | $this->pageLimit, |
504 | $this->use_regex |
505 | ); |
506 | |
507 | $titles_to_process = $this->hookHelper->filterPageTitlesForRename( $res ); |
508 | |
509 | foreach ( $res as $row ) { |
510 | $title = Title::makeTitleSafe( $row->page_namespace, $row->page_title ); |
511 | if ( !$title ) { |
512 | continue; |
513 | } |
514 | |
515 | if ( !isset( $titles_to_process[ $title->getPrefixedText() ] ) ) { |
516 | $unmoveable_titles[] = $title; |
517 | continue; |
518 | } |
519 | |
520 | $new_title = Search::getReplacedTitle( |
521 | $title, |
522 | $this->target, |
523 | $this->replacement, |
524 | $this->use_regex |
525 | ); |
526 | if ( !$new_title ) { |
527 | // New title is not valid because it contains invalid characters. |
528 | $unmoveable_titles[] = $title; |
529 | continue; |
530 | } |
531 | |
532 | $mvPage = $this->movePageFactory->newMovePage( $title, $new_title ); |
533 | $moveStatus = $mvPage->isValidMove(); |
534 | $permissionStatus = $mvPage->checkPermissions( $this->getUser(), null ); |
535 | |
536 | if ( $permissionStatus->isOK() && $moveStatus->isOK() ) { |
537 | $titles_for_move[] = $title; |
538 | } else { |
539 | $unmoveable_titles[] = $title; |
540 | } |
541 | } |
542 | |
543 | return [ $titles_for_move, $unmoveable_titles ]; |
544 | } |
545 | |
546 | /** |
547 | * Get the warning message if the replacement string is either blank |
548 | * or found elsewhere on the wiki (since undoing the replacement |
549 | * would be difficult in either case). |
550 | * |
551 | * @param array $titles_for_edit |
552 | * @param array $titles_for_move |
553 | * @return string|null Warning message, if any |
554 | */ |
555 | function getAnyWarningMessageBeforeReplace( $titles_for_edit, $titles_for_move ) { |
556 | if ( $this->replacement === '' ) { |
557 | return $this->msg( 'replacetext_blankwarning' )->parse(); |
558 | } elseif ( $this->use_regex ) { |
559 | // If it's a regex, don't bother checking for existing |
560 | // pages - if the replacement string includes wildcards, |
561 | // it's a meaningless check. |
562 | return null; |
563 | } elseif ( count( $titles_for_edit ) > 0 ) { |
564 | $res = $this->search->doSearchQuery( |
565 | $this->replacement, |
566 | $this->selected_namespaces, |
567 | $this->category, |
568 | $this->prefix, |
569 | $this->pageLimit, |
570 | $this->use_regex |
571 | ); |
572 | $titles = $this->hookHelper->filterPageTitlesForEdit( $res ); |
573 | $count = count( $titles ); |
574 | if ( $count > 0 ) { |
575 | return $this->msg( 'replacetext_warning' )->numParams( $count ) |
576 | ->params( "<code><nowiki>{$this->replacement}</nowiki></code>" )->parse(); |
577 | } |
578 | } elseif ( count( $titles_for_move ) > 0 ) { |
579 | $res = $this->search->getMatchingTitles( |
580 | $this->replacement, |
581 | $this->selected_namespaces, |
582 | $this->category, |
583 | $this->prefix, |
584 | $this->pageLimit, |
585 | $this->use_regex |
586 | ); |
587 | $titles = $this->hookHelper->filterPageTitlesForRename( $res ); |
588 | $count = count( $titles ); |
589 | if ( $count > 0 ) { |
590 | return $this->msg( 'replacetext_warning' )->numParams( $count ) |
591 | ->params( $this->replacement )->parse(); |
592 | } |
593 | } |
594 | |
595 | return null; |
596 | } |
597 | |
598 | /** |
599 | * @param string|null $warning_msg Message to be shown at top of form |
600 | */ |
601 | function showForm( $warning_msg = null ) { |
602 | $out = $this->getOutput(); |
603 | |
604 | $out->addHTML( |
605 | Html::openElement( |
606 | 'form', |
607 | [ |
608 | 'id' => 'powersearch', |
609 | 'action' => $this->getPageTitle()->getLocalURL(), |
610 | 'method' => 'post' |
611 | ] |
612 | ) . "\n" . |
613 | Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . |
614 | Html::hidden( 'continue', 1 ) . |
615 | Html::hidden( 'token', $this->getToken() ) |
616 | ); |
617 | if ( $warning_msg === null ) { |
618 | $out->addWikiMsg( 'replacetext_docu' ); |
619 | } else { |
620 | $out->wrapWikiMsg( |
621 | "<div class=\"errorbox\">\n$1\n</div><br clear=\"both\" />", |
622 | $warning_msg |
623 | ); |
624 | } |
625 | |
626 | $out->addHTML( '<table><tr><td style="vertical-align: top;">' ); |
627 | $out->addWikiMsg( 'replacetext_originaltext' ); |
628 | $out->addHTML( '</td><td>' ); |
629 | // 'width: auto' style is needed to override MediaWiki's |
630 | // normal 'width: 100%', which causes the textarea to get |
631 | // zero width in IE |
632 | $out->addHTML( Html::textarea( 'target', $this->target, |
633 | [ 'cols' => 100, 'rows' => 5, 'style' => 'width: auto;' ] |
634 | ) ); |
635 | $out->addHTML( '</td></tr><tr><td style="vertical-align: top;">' ); |
636 | $out->addWikiMsg( 'replacetext_replacementtext' ); |
637 | $out->addHTML( '</td><td>' ); |
638 | $out->addHTML( Html::textarea( 'replacement', $this->replacement, |
639 | [ 'cols' => 100, 'rows' => 5, 'style' => 'width: auto;' ] |
640 | ) ); |
641 | $out->addHTML( '</td></tr></table>' ); |
642 | |
643 | // SQLite unfortunately lack a REGEXP |
644 | // function or operator by default, so disable regex(p) |
645 | // searches that DB type. |
646 | $dbr = $this->dbProvider->getReplicaDatabase(); |
647 | if ( $dbr->getType() !== 'sqlite' ) { |
648 | $out->addHTML( Html::rawElement( 'p', [], |
649 | Html::rawElement( 'label', [], |
650 | Html::input( 'use_regex', '1', 'checkbox' ) |
651 | . ' ' . $this->msg( 'replacetext_useregex' )->escaped(), |
652 | ) |
653 | ) . "\n" . |
654 | Html::element( 'p', |
655 | [ 'style' => 'font-style: italic' ], |
656 | $this->msg( 'replacetext_regexdocu' )->text() |
657 | ) |
658 | ); |
659 | } |
660 | |
661 | // The interface is heavily based on the one in Special:Search. |
662 | $namespaces = $this->searchEngineConfig->searchableNamespaces(); |
663 | $tables = $this->namespaceTables( $namespaces ); |
664 | $out->addHTML( |
665 | "<div class=\"mw-search-formheader\"></div>\n" . |
666 | "<fieldset class=\"ext-replacetext-searchoptions\">\n" . |
667 | Html::element( 'h4', [], $this->msg( 'powersearch-ns' )->text() ) |
668 | ); |
669 | // The ability to select/unselect groups of namespaces in the |
670 | // search interface exists only in some skins, like Vector - |
671 | // check for the presence of the 'powersearch-togglelabel' |
672 | // message to see if we can use this functionality here. |
673 | if ( $this->msg( 'powersearch-togglelabel' )->isDisabled() ) { |
674 | // do nothing |
675 | } else { |
676 | $out->addHTML( |
677 | Html::rawElement( |
678 | 'div', |
679 | [ 'class' => 'ext-replacetext-search-togglebox' ], |
680 | Html::element( 'label', [], |
681 | $this->msg( 'powersearch-togglelabel' )->text() |
682 | ) . |
683 | Html::element( 'input', [ |
684 | 'id' => 'mw-search-toggleall', |
685 | 'type' => 'button', |
686 | 'value' => $this->msg( 'powersearch-toggleall' )->text(), |
687 | ] ) . |
688 | Html::element( 'input', [ |
689 | 'id' => 'mw-search-togglenone', |
690 | 'type' => 'button', |
691 | 'value' => $this->msg( 'powersearch-togglenone' )->text() |
692 | ] ) |
693 | ) |
694 | ); |
695 | } |
696 | $out->addHTML( |
697 | Html::element( 'div', [ 'class' => 'ext-replacetext-divider' ] ) . |
698 | "$tables\n</fieldset>" |
699 | ); |
700 | $category_search_label = $this->msg( 'replacetext_categorysearch' )->escaped(); |
701 | $prefix_search_label = $this->msg( 'replacetext_prefixsearch' )->escaped(); |
702 | $page_limit_label = $this->msg( 'replacetext_pagelimit' )->escaped(); |
703 | $this->pageLimit = $this->pageLimit === 0 |
704 | ? $this->getConfig()->get( 'ReplaceTextResultsLimit' ) |
705 | : $this->pageLimit; |
706 | $out->addHTML( |
707 | "<fieldset class=\"ext-replacetext-searchoptions\">\n" . |
708 | Html::element( 'h4', [], $this->msg( 'replacetext_optionalfilters' )->text() ) . |
709 | Html::element( 'div', [ 'class' => 'ext-replacetext-divider' ] ) . |
710 | "<p>$category_search_label\n" . |
711 | Html::element( 'input', [ 'name' => 'category', 'size' => 20, 'value' => $this->category ] ) . '</p>' . |
712 | "<p>$prefix_search_label\n" . |
713 | Html::element( 'input', [ 'name' => 'prefix', 'size' => 20, 'value' => $this->prefix ] ) . '</p>' . |
714 | "<p>$page_limit_label\n" . |
715 | Html::element( 'input', [ 'name' => 'pageLimit', 'size' => 20, 'value' => (string)$this->pageLimit, |
716 | 'type' => 'number', 'min' => 0 ] ) . "</p></fieldset>\n" . |
717 | "<p>\n" . |
718 | Html::rawElement( 'label', [], |
719 | Html::input( 'edit_pages', '1', 'checkbox', [ 'checked' => true ] ) |
720 | . ' ' . $this->msg( 'replacetext_editpages' )->escaped() |
721 | ) . '<br />' . |
722 | Html::rawElement( 'label', [], |
723 | Html::input( 'move_pages', '1', 'checkbox' ) |
724 | . ' ' . $this->msg( 'replacetext_movepages' )->escaped() |
725 | ) |
726 | ); |
727 | |
728 | // If the user is a bot, don't even show the "Mark changes as bot edits" checkbox - |
729 | // presumably a bot user should never be allowed to make non-bot edits. |
730 | if ( !$this->permissionManager->userHasRight( $this->getReplaceTextUser(), 'bot' ) ) { |
731 | $out->addHTML( |
732 | '<br />' . |
733 | Html::rawElement( 'label', [], |
734 | Html::input( 'botEdit', '1', 'checkbox' ) . ' ' . $this->msg( 'replacetext_botedit' )->escaped() |
735 | ) |
736 | ); |
737 | } |
738 | $continueButton = new OOUI\ButtonInputWidget( [ |
739 | 'type' => 'submit', |
740 | 'label' => $this->msg( 'replacetext_continue' )->text(), |
741 | 'flags' => [ 'primary', 'progressive' ] |
742 | ] ); |
743 | $out->addHTML( |
744 | "</p>\n" . |
745 | $continueButton . |
746 | Html::closeElement( 'form' ) |
747 | ); |
748 | $out->addModuleStyles( 'ext.ReplaceTextStyles' ); |
749 | $out->addModules( 'ext.ReplaceText' ); |
750 | } |
751 | |
752 | /** |
753 | * This function is not currently used, but it may get used in the |
754 | * future if the "1st screen" interface changes to use OOUI. |
755 | * |
756 | * @param string $label |
757 | * @param string $name |
758 | * @param bool $selected |
759 | * @return string HTML |
760 | */ |
761 | function checkLabel( $label, $name, $selected = false ) { |
762 | $checkbox = new OOUI\CheckboxInputWidget( [ |
763 | 'name' => $name, |
764 | 'value' => 1, |
765 | 'selected' => $selected |
766 | ] ); |
767 | $layout = new OOUI\FieldLayout( $checkbox, [ |
768 | 'align' => 'inline', |
769 | 'label' => $label |
770 | ] ); |
771 | return $layout; |
772 | } |
773 | |
774 | /** |
775 | * Copied almost exactly from MediaWiki's SpecialSearch class, i.e. |
776 | * the search page |
777 | * @param string[] $namespaces |
778 | * @param int $rowsPerTable |
779 | * @return string HTML |
780 | */ |
781 | function namespaceTables( $namespaces, $rowsPerTable = 3 ) { |
782 | // Group namespaces into rows according to subject. |
783 | // Try not to make too many assumptions about namespace numbering. |
784 | $rows = []; |
785 | $tables = ''; |
786 | foreach ( $namespaces as $ns => $name ) { |
787 | $subj = $this->namespaceInfo->getSubject( $ns ); |
788 | if ( !array_key_exists( $subj, $rows ) ) { |
789 | $rows[$subj] = ''; |
790 | } |
791 | $name = str_replace( '_', ' ', $name ); |
792 | if ( $name == '' ) { |
793 | $name = $this->msg( 'blanknamespace' )->text(); |
794 | } |
795 | $id = "mw-search-ns{$ns}"; |
796 | $rows[$subj] .= Html::openElement( 'td' ) . |
797 | Html::input( "ns{$ns}", '1', 'checkbox', [ 'id' => $id, 'checked' => ( $ns == 0 ) ] ) . |
798 | ' ' . Html::label( $name, $id ) . |
799 | Html::closeElement( 'td' ) . "\n"; |
800 | } |
801 | $rows = array_values( $rows ); |
802 | $numRows = count( $rows ); |
803 | // Lay out namespaces in multiple floating two-column tables so they'll |
804 | // be arranged nicely while still accommodating different screen widths |
805 | // Build the final HTML table... |
806 | for ( $i = 0; $i < $numRows; $i += $rowsPerTable ) { |
807 | $tables .= Html::openElement( 'table' ); |
808 | for ( $j = $i; $j < $i + $rowsPerTable && $j < $numRows; $j++ ) { |
809 | $tables .= "<tr>\n" . $rows[$j] . "</tr>"; |
810 | } |
811 | $tables .= Html::closeElement( 'table' ) . "\n"; |
812 | } |
813 | return $tables; |
814 | } |
815 | |
816 | /** |
817 | * @param array $titles_for_edit |
818 | * @param array $titles_for_move |
819 | * @param array $uneditable_titles |
820 | * @param array $unmoveable_titles |
821 | */ |
822 | function pageListForm( $titles_for_edit, $titles_for_move, $uneditable_titles, $unmoveable_titles ) { |
823 | $out = $this->getOutput(); |
824 | |
825 | $formOpts = [ |
826 | 'id' => 'choose_pages', |
827 | 'method' => 'post', |
828 | 'action' => $this->getPageTitle()->getLocalURL() |
829 | ]; |
830 | $out->addHTML( |
831 | Html::openElement( 'form', $formOpts ) . "\n" . |
832 | Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . |
833 | Html::hidden( 'target', $this->target ) . |
834 | Html::hidden( 'replacement', $this->replacement ) . |
835 | Html::hidden( 'use_regex', $this->use_regex ) . |
836 | Html::hidden( 'move_pages', $this->move_pages ) . |
837 | Html::hidden( 'edit_pages', $this->edit_pages ) . |
838 | Html::hidden( 'botEdit', $this->botEdit ) . |
839 | Html::hidden( 'replace', 1 ) . |
840 | Html::hidden( 'token', $this->getToken() ) |
841 | ); |
842 | |
843 | foreach ( $this->selected_namespaces as $ns ) { |
844 | $out->addHTML( Html::hidden( 'ns' . $ns, 1 ) ); |
845 | } |
846 | |
847 | $out->addModules( 'ext.ReplaceText' ); |
848 | $out->addModuleStyles( 'ext.ReplaceTextStyles' ); |
849 | |
850 | // Only show "invert selections" link if there are more than |
851 | // five pages. |
852 | if ( count( $titles_for_edit ) + count( $titles_for_move ) > 5 ) { |
853 | $invertButton = new OOUI\ButtonWidget( [ |
854 | 'label' => $this->msg( 'replacetext_invertselections' )->text(), |
855 | 'classes' => [ 'ext-replacetext-invert' ] |
856 | ] ); |
857 | $out->addHTML( $invertButton ); |
858 | } |
859 | |
860 | if ( count( $titles_for_edit ) > 0 ) { |
861 | $out->addWikiMsg( |
862 | 'replacetext_choosepagesforedit', |
863 | "<code><nowiki>{$this->targetString}</nowiki></code>", |
864 | "<code><nowiki>{$this->replacement}</nowiki></code>", |
865 | $this->getLanguage()->formatNum( count( $titles_for_edit ) ) |
866 | ); |
867 | |
868 | foreach ( $titles_for_edit as $title_and_context ) { |
869 | /** |
870 | * @var $title Title |
871 | */ |
872 | [ $title, $context, $role ] = $title_and_context; |
873 | $checkbox = new OOUI\CheckboxInputWidget( [ |
874 | 'name' => $title->getArticleID() . '|' . $role, |
875 | 'selected' => true |
876 | ] ); |
877 | if ( $role === SlotRecord::MAIN ) { |
878 | $labelText = $this->linkRenderer->makeLink( $title ) . |
879 | "<br /><small>$context</small>"; |
880 | } else { |
881 | $labelText = $this->linkRenderer->makeLink( $title ) . |
882 | " ($role) <br /><small>$context</small>"; |
883 | } |
884 | $checkboxLabel = new OOUI\LabelWidget( [ |
885 | 'label' => new OOUI\HtmlSnippet( $labelText ) |
886 | ] ); |
887 | $layout = new OOUI\FieldLayout( $checkbox, [ |
888 | 'align' => 'inline', |
889 | 'label' => $checkboxLabel |
890 | ] ); |
891 | $out->addHTML( $layout ); |
892 | } |
893 | $out->addHTML( '<br />' ); |
894 | } |
895 | |
896 | if ( count( $titles_for_move ) > 0 ) { |
897 | $out->addWikiMsg( |
898 | 'replacetext_choosepagesformove', |
899 | $this->targetString, |
900 | $this->replacement, |
901 | $this->getLanguage()->formatNum( count( $titles_for_move ) ) |
902 | ); |
903 | foreach ( $titles_for_move as $title ) { |
904 | $out->addHTML( |
905 | Html::check( 'move-' . $title->getArticleID(), true ) . "\u{00A0}" . |
906 | $this->linkRenderer->makeLink( $title ) . "<br />\n" |
907 | ); |
908 | } |
909 | $out->addHTML( '<br />' ); |
910 | $out->addWikiMsg( 'replacetext_formovedpages' ); |
911 | $out->addHTML( |
912 | Html::rawElement( 'label', [], |
913 | Html::input( 'create-redirect', '1', 'checkbox', [ 'checked' => true ] ) |
914 | . ' ' . $this->msg( 'replacetext_savemovedpages' )->escaped() |
915 | ) . "<br />\n" . |
916 | Html::rawElement( 'label', [], |
917 | Html::input( 'watch-pages', '1', 'checkbox' ) |
918 | . ' ' . $this->msg( 'replacetext_watchmovedpages' )->escaped() |
919 | ) . '<br />' |
920 | ); |
921 | $out->addHTML( '<br />' ); |
922 | } |
923 | |
924 | $submitButton = new OOUI\ButtonInputWidget( [ |
925 | 'type' => 'submit', |
926 | 'flags' => [ 'primary', 'progressive' ], |
927 | 'label' => $this->msg( 'replacetext_replace' )->text() |
928 | ] ); |
929 | $out->addHTML( $submitButton ); |
930 | |
931 | $out->addHTML( '</form>' ); |
932 | |
933 | if ( count( $uneditable_titles ) ) { |
934 | $out->addWikiMsg( |
935 | 'replacetext_cannotedit', |
936 | $this->getLanguage()->formatNum( count( $uneditable_titles ) ) |
937 | ); |
938 | $out->addHTML( $this->displayTitles( $uneditable_titles ) ); |
939 | } |
940 | |
941 | if ( count( $unmoveable_titles ) ) { |
942 | $out->addWikiMsg( |
943 | 'replacetext_cannotmove', |
944 | $this->getLanguage()->formatNum( count( $unmoveable_titles ) ) |
945 | ); |
946 | $out->addHTML( $this->displayTitles( $unmoveable_titles ) ); |
947 | } |
948 | } |
949 | |
950 | /** |
951 | * Extract context and highlights search text |
952 | * |
953 | * @todo The bolding needs to be fixed for regular expressions. |
954 | * @param string $text |
955 | * @param string $target |
956 | * @param bool $use_regex |
957 | * @return string |
958 | */ |
959 | function extractContext( $text, $target, $use_regex = false ) { |
960 | $cw = $this->userOptionsLookup->getOption( $this->getUser(), 'contextchars', 40, true ); |
961 | |
962 | // Get all indexes |
963 | if ( $use_regex ) { |
964 | $targetq = str_replace( "/", "\\/", $target ); |
965 | preg_match_all( "/$targetq/Uu", $text, $matches, PREG_OFFSET_CAPTURE ); |
966 | } else { |
967 | $targetq = preg_quote( $target, '/' ); |
968 | preg_match_all( "/$targetq/", $text, $matches, PREG_OFFSET_CAPTURE ); |
969 | } |
970 | |
971 | $strLengths = []; |
972 | $poss = []; |
973 | $match = $matches[0] ?? []; |
974 | foreach ( $match as $_ ) { |
975 | $strLengths[] = strlen( $_[0] ); |
976 | $poss[] = $_[1]; |
977 | } |
978 | |
979 | $cuts = []; |
980 | for ( $i = 0; $i < count( $poss ); $i++ ) { |
981 | $index = $poss[$i]; |
982 | $len = $strLengths[$i]; |
983 | |
984 | // Merge to the next if possible |
985 | while ( isset( $poss[$i + 1] ) ) { |
986 | if ( $poss[$i + 1] < $index + $len + $cw * 2 ) { |
987 | $len += $poss[$i + 1] - $poss[$i]; |
988 | $i++; |
989 | } else { |
990 | // Can't merge, exit the inner loop |
991 | break; |
992 | } |
993 | } |
994 | $cuts[] = [ $index, $len ]; |
995 | } |
996 | |
997 | if ( $use_regex ) { |
998 | $targetStr = "/$target/Uu"; |
999 | } else { |
1000 | $targetq = preg_quote( $this->convertWhiteSpaceToHTML( $target ), '/' ); |
1001 | $targetStr = "/$targetq/i"; |
1002 | } |
1003 | |
1004 | $context = ''; |
1005 | foreach ( $cuts as $_ ) { |
1006 | [ $index, $len, ] = $_; |
1007 | $contextBefore = substr( $text, 0, $index ); |
1008 | $contextAfter = substr( $text, $index + $len ); |
1009 | |
1010 | $contextBefore = $this->getLanguage()->truncateForDatabase( $contextBefore, -$cw, '...', false ); |
1011 | $contextAfter = $this->getLanguage()->truncateForDatabase( $contextAfter, $cw, '...', false ); |
1012 | |
1013 | $context .= $this->convertWhiteSpaceToHTML( $contextBefore ); |
1014 | $snippet = $this->convertWhiteSpaceToHTML( substr( $text, $index, $len ) ); |
1015 | $context .= preg_replace( $targetStr, '<span class="ext-replacetext-searchmatch">\0</span>', $snippet ); |
1016 | |
1017 | $context .= $this->convertWhiteSpaceToHTML( $contextAfter ); |
1018 | } |
1019 | |
1020 | // Display newlines as "line break" characters. |
1021 | $context = str_replace( "\n", "\u{21B5}", $context ); |
1022 | return $context; |
1023 | } |
1024 | |
1025 | /** |
1026 | * Extracts the role name |
1027 | * |
1028 | * @param int $role_id |
1029 | * @return string |
1030 | */ |
1031 | private function extractRole( $role_id ) { |
1032 | return $this->slotRoleStore->getName( $role_id ); |
1033 | } |
1034 | |
1035 | private function convertWhiteSpaceToHTML( $message ) { |
1036 | $msg = htmlspecialchars( $message ); |
1037 | $msg = preg_replace( '/^ /m', "\u{00A0} ", $msg ); |
1038 | $msg = preg_replace( '/ $/m', " \u{00A0}", $msg ); |
1039 | $msg = str_replace( ' ', "\u{00A0} ", $msg ); |
1040 | # $msg = str_replace( "\n", '<br />', $msg ); |
1041 | return $msg; |
1042 | } |
1043 | |
1044 | private function getReplaceTextUser() { |
1045 | $replaceTextUser = $this->getConfig()->get( 'ReplaceTextUser' ); |
1046 | if ( $replaceTextUser !== null ) { |
1047 | return $this->userFactory->newFromName( $replaceTextUser ); |
1048 | } |
1049 | |
1050 | return $this->getUser(); |
1051 | } |
1052 | |
1053 | /** |
1054 | * @inheritDoc |
1055 | */ |
1056 | protected function getGroupName() { |
1057 | return 'wiki'; |
1058 | } |
1059 | |
1060 | private function displayTitles( array $titlesToDisplay ): string { |
1061 | $text = "<ul>\n"; |
1062 | foreach ( $titlesToDisplay as $title ) { |
1063 | $text .= "<li>" . $this->linkRenderer->makeLink( $title ) . "</li>\n"; |
1064 | } |
1065 | $text .= "</ul>\n"; |
1066 | return $text; |
1067 | } |
1068 | |
1069 | private function getToken(): string { |
1070 | return $this->getContext()->getCsrfTokenSet()->getToken(); |
1071 | } |
1072 | |
1073 | private function checkToken(): bool { |
1074 | return $this->getContext()->getCsrfTokenSet()->matchTokenField( 'token' ); |
1075 | } |
1076 | } |