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