Merge "Type hint against LinkTarget in WatchedItemStore"
[lhc/web/wiklou.git] / includes / specials / SpecialTags.php
1 <?php
2 /**
3 * Implements Special:Tags
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup SpecialPage
22 */
23
24 /**
25 * A special page that lists tags for edits
26 *
27 * @ingroup SpecialPage
28 */
29 class SpecialTags extends SpecialPage {
30
31 /**
32 * @var array List of explicitly defined tags
33 */
34 protected $explicitlyDefinedTags;
35
36 /**
37 * @var array List of software defined tags
38 */
39 protected $softwareDefinedTags;
40
41 /**
42 * @var array List of software activated tags
43 */
44 protected $softwareActivatedTags;
45
46 function __construct() {
47 parent::__construct( 'Tags' );
48 }
49
50 function execute( $par ) {
51 $this->setHeaders();
52 $this->outputHeader();
53 $this->addHelpLink( 'Manual:Tags' );
54
55 $request = $this->getRequest();
56 switch ( $par ) {
57 case 'delete':
58 $this->showDeleteTagForm( $request->getVal( 'tag' ) );
59 break;
60 case 'activate':
61 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), true );
62 break;
63 case 'deactivate':
64 $this->showActivateDeactivateForm( $request->getVal( 'tag' ), false );
65 break;
66 case 'create':
67 // fall through, thanks to HTMLForm's logic
68 default:
69 $this->showTagList();
70 break;
71 }
72 }
73
74 function showTagList() {
75 $out = $this->getOutput();
76 $out->setPageTitle( $this->msg( 'tags-title' ) );
77 $out->wrapWikiMsg( "<div class='mw-tags-intro'>\n$1\n</div>", 'tags-intro' );
78
79 $user = $this->getUser();
80 $userCanManage = $user->isAllowed( 'managechangetags' );
81 $userCanDelete = $user->isAllowed( 'deletechangetags' );
82 $userCanEditInterface = $user->isAllowed( 'editinterface' );
83
84 // Show form to create a tag
85 if ( $userCanManage ) {
86 $fields = [
87 'Tag' => [
88 'type' => 'text',
89 'label' => $this->msg( 'tags-create-tag-name' )->plain(),
90 'required' => true,
91 ],
92 'Reason' => [
93 'type' => 'text',
94 'label' => $this->msg( 'tags-create-reason' )->plain(),
95 'size' => 50,
96 ],
97 'IgnoreWarnings' => [
98 'type' => 'hidden',
99 ],
100 ];
101
102 $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
103 $form->setAction( $this->getPageTitle( 'create' )->getLocalURL() );
104 $form->setWrapperLegendMsg( 'tags-create-heading' );
105 $form->setHeaderText( $this->msg( 'tags-create-explanation' )->parseAsBlock() );
106 $form->setSubmitCallback( [ $this, 'processCreateTagForm' ] );
107 $form->setSubmitTextMsg( 'tags-create-submit' );
108 $form->show();
109
110 // If processCreateTagForm generated a redirect, there's no point
111 // continuing with this, as the user is just going to end up getting sent
112 // somewhere else. Additionally, if we keep going here, we end up
113 // populating the memcache of tag data (see ChangeTags::listDefinedTags)
114 // with out-of-date data from the replica DB, because the replica DB hasn't caught
115 // up to the fact that a new tag has been created as part of an implicit,
116 // as yet uncommitted transaction on master.
117 if ( $out->getRedirect() !== '' ) {
118 return;
119 }
120 }
121
122 // Used to get hitcounts for #doTagRow()
123 $tagStats = ChangeTags::tagUsageStatistics();
124
125 // Used in #doTagRow()
126 $this->explicitlyDefinedTags = array_fill_keys(
127 ChangeTags::listExplicitlyDefinedTags(), true );
128 $this->softwareDefinedTags = array_fill_keys(
129 ChangeTags::listSoftwareDefinedTags(), true );
130
131 // List all defined tags, even if they were never applied
132 $definedTags = array_keys( $this->explicitlyDefinedTags + $this->softwareDefinedTags );
133
134 // Show header only if there exists atleast one tag
135 if ( !$tagStats && !$definedTags ) {
136 return;
137 }
138
139 // Write the headers
140 $thead = Xml::tags( 'tr', null, Xml::tags( 'th', null, $this->msg( 'tags-tag' )->parse() ) .
141 Xml::tags( 'th', null, $this->msg( 'tags-display-header' )->parse() ) .
142 Xml::tags( 'th', null, $this->msg( 'tags-description-header' )->parse() ) .
143 Xml::tags( 'th', null, $this->msg( 'tags-source-header' )->parse() ) .
144 Xml::tags( 'th', null, $this->msg( 'tags-active-header' )->parse() ) .
145 Xml::tags( 'th', null, $this->msg( 'tags-hitcount-header' )->parse() ) .
146 ( ( $userCanManage || $userCanDelete ) ?
147 Xml::tags( 'th', [ 'class' => 'unsortable' ],
148 $this->msg( 'tags-actions-header' )->parse() ) :
149 '' )
150 );
151
152 $tbody = '';
153 // Used in #doTagRow()
154 $this->softwareActivatedTags = array_fill_keys(
155 ChangeTags::listSoftwareActivatedTags(), true );
156
157 // Insert tags that have been applied at least once
158 foreach ( $tagStats as $tag => $hitcount ) {
159 $tbody .= $this->doTagRow( $tag, $hitcount, $userCanManage,
160 $userCanDelete, $userCanEditInterface );
161 }
162 // Insert tags defined somewhere but never applied
163 foreach ( $definedTags as $tag ) {
164 if ( !isset( $tagStats[$tag] ) ) {
165 $tbody .= $this->doTagRow( $tag, 0, $userCanManage, $userCanDelete, $userCanEditInterface );
166 }
167 }
168
169 $out->addModuleStyles( 'jquery.tablesorter.styles' );
170 $out->addModules( 'jquery.tablesorter' );
171 $out->addHTML( Xml::tags(
172 'table',
173 [ 'class' => 'mw-datatable sortable mw-tags-table' ],
174 Xml::tags( 'thead', null, $thead ) .
175 Xml::tags( 'tbody', null, $tbody )
176 ) );
177 }
178
179 function doTagRow( $tag, $hitcount, $showManageActions, $showDeleteActions, $showEditLinks ) {
180 $newRow = '';
181 $newRow .= Xml::tags( 'td', null, Xml::element( 'code', null, $tag ) );
182
183 $linkRenderer = $this->getLinkRenderer();
184 $disp = ChangeTags::tagDescription( $tag, $this->getContext() );
185 if ( $showEditLinks ) {
186 $disp .= ' ';
187 $editLink = $linkRenderer->makeLink(
188 $this->msg( "tag-$tag" )->inContentLanguage()->getTitle(),
189 $this->msg( 'tags-edit' )->text()
190 );
191 $disp .= $this->msg( 'parentheses' )->rawParams( $editLink )->escaped();
192 }
193 $newRow .= Xml::tags( 'td', null, $disp );
194
195 $msg = $this->msg( "tag-$tag-description" );
196 $desc = !$msg->exists() ? '' : $msg->parse();
197 if ( $showEditLinks ) {
198 $desc .= ' ';
199 $editDescLink = $linkRenderer->makeLink(
200 $this->msg( "tag-$tag-description" )->inContentLanguage()->getTitle(),
201 $this->msg( 'tags-edit' )->text()
202 );
203 $desc .= $this->msg( 'parentheses' )->rawParams( $editDescLink )->escaped();
204 }
205 $newRow .= Xml::tags( 'td', null, $desc );
206
207 $sourceMsgs = [];
208 $isSoftware = isset( $this->softwareDefinedTags[$tag] );
209 $isExplicit = isset( $this->explicitlyDefinedTags[$tag] );
210 if ( $isSoftware ) {
211 // TODO: Rename this message
212 $sourceMsgs[] = $this->msg( 'tags-source-extension' )->escaped();
213 }
214 if ( $isExplicit ) {
215 $sourceMsgs[] = $this->msg( 'tags-source-manual' )->escaped();
216 }
217 if ( !$sourceMsgs ) {
218 $sourceMsgs[] = $this->msg( 'tags-source-none' )->escaped();
219 }
220 $newRow .= Xml::tags( 'td', null, implode( Xml::element( 'br' ), $sourceMsgs ) );
221
222 $isActive = $isExplicit || isset( $this->softwareActivatedTags[$tag] );
223 $activeMsg = ( $isActive ? 'tags-active-yes' : 'tags-active-no' );
224 $newRow .= Xml::tags( 'td', null, $this->msg( $activeMsg )->escaped() );
225
226 $hitcountLabelMsg = $this->msg( 'tags-hitcount' )->numParams( $hitcount );
227 if ( $this->getConfig()->get( 'UseTagFilter' ) ) {
228 $hitcountLabel = $linkRenderer->makeLink(
229 SpecialPage::getTitleFor( 'Recentchanges' ),
230 $hitcountLabelMsg->text(),
231 [],
232 [ 'tagfilter' => $tag ]
233 );
234 } else {
235 $hitcountLabel = $hitcountLabelMsg->escaped();
236 }
237
238 // add raw $hitcount for sorting, because tags-hitcount contains numbers and letters
239 $newRow .= Xml::tags( 'td', [ 'data-sort-value' => $hitcount ], $hitcountLabel );
240
241 $actionLinks = [];
242
243 if ( $showDeleteActions && ChangeTags::canDeleteTag( $tag )->isOK() ) {
244 $actionLinks[] = $linkRenderer->makeKnownLink(
245 $this->getPageTitle( 'delete' ),
246 $this->msg( 'tags-delete' )->text(),
247 [],
248 [ 'tag' => $tag ] );
249 }
250
251 if ( $showManageActions ) { // we've already checked that the user had the requisite userright
252 if ( ChangeTags::canActivateTag( $tag )->isOK() ) {
253 $actionLinks[] = $linkRenderer->makeKnownLink(
254 $this->getPageTitle( 'activate' ),
255 $this->msg( 'tags-activate' )->text(),
256 [],
257 [ 'tag' => $tag ] );
258 }
259
260 if ( ChangeTags::canDeactivateTag( $tag )->isOK() ) {
261 $actionLinks[] = $linkRenderer->makeKnownLink(
262 $this->getPageTitle( 'deactivate' ),
263 $this->msg( 'tags-deactivate' )->text(),
264 [],
265 [ 'tag' => $tag ] );
266 }
267 }
268
269 if ( $showDeleteActions || $showManageActions ) {
270 $newRow .= Xml::tags( 'td', null, $this->getLanguage()->pipeList( $actionLinks ) );
271 }
272
273 return Xml::tags( 'tr', null, $newRow ) . "\n";
274 }
275
276 public function processCreateTagForm( array $data, HTMLForm $form ) {
277 $context = $form->getContext();
278 $out = $context->getOutput();
279
280 $tag = trim( strval( $data['Tag'] ) );
281 $ignoreWarnings = isset( $data['IgnoreWarnings'] ) && $data['IgnoreWarnings'] === '1';
282 $status = ChangeTags::createTagWithChecks( $tag, $data['Reason'],
283 $context->getUser(), $ignoreWarnings );
284
285 if ( $status->isGood() ) {
286 $out->redirect( $this->getPageTitle()->getLocalURL() );
287 return true;
288 } elseif ( $status->isOK() ) {
289 // we have some warnings, so we show a confirmation form
290 $fields = [
291 'Tag' => [
292 'type' => 'hidden',
293 'default' => $data['Tag'],
294 ],
295 'Reason' => [
296 'type' => 'hidden',
297 'default' => $data['Reason'],
298 ],
299 'IgnoreWarnings' => [
300 'type' => 'hidden',
301 'default' => '1',
302 ],
303 ];
304
305 // fool HTMLForm into thinking the form hasn't been submitted yet. Otherwise
306 // we get into an infinite loop!
307 $context->getRequest()->unsetVal( 'wpEditToken' );
308
309 $headerText = $this->msg( 'tags-create-warnings-above', $tag,
310 count( $status->getWarningsArray() ) )->parseAsBlock() .
311 $out->parseAsInterface( $status->getWikiText() ) .
312 $this->msg( 'tags-create-warnings-below' )->parseAsBlock();
313
314 $subform = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
315 $subform->setAction( $this->getPageTitle( 'create' )->getLocalURL() );
316 $subform->setWrapperLegendMsg( 'tags-create-heading' );
317 $subform->setHeaderText( $headerText );
318 $subform->setSubmitCallback( [ $this, 'processCreateTagForm' ] );
319 $subform->setSubmitTextMsg( 'htmlform-yes' );
320 $subform->show();
321
322 $out->addBacklinkSubtitle( $this->getPageTitle() );
323 return true;
324 } else {
325 $out->wrapWikiTextAsInterface( 'error', $status->getWikiText() );
326 return false;
327 }
328 }
329
330 protected function showDeleteTagForm( $tag ) {
331 $user = $this->getUser();
332 if ( !$user->isAllowed( 'deletechangetags' ) ) {
333 throw new PermissionsError( 'deletechangetags' );
334 }
335
336 $out = $this->getOutput();
337 $out->setPageTitle( $this->msg( 'tags-delete-title' ) );
338 $out->addBacklinkSubtitle( $this->getPageTitle() );
339
340 // is the tag actually able to be deleted?
341 $canDeleteResult = ChangeTags::canDeleteTag( $tag, $user );
342 if ( !$canDeleteResult->isGood() ) {
343 $out->wrapWikiTextAsInterface( 'error', $canDeleteResult->getWikiText() );
344 if ( !$canDeleteResult->isOK() ) {
345 return;
346 }
347 }
348
349 $preText = $this->msg( 'tags-delete-explanation-initial', $tag )->parseAsBlock();
350 $tagUsage = ChangeTags::tagUsageStatistics();
351 if ( isset( $tagUsage[$tag] ) && $tagUsage[$tag] > 0 ) {
352 $preText .= $this->msg( 'tags-delete-explanation-in-use', $tag,
353 $tagUsage[$tag] )->parseAsBlock();
354 }
355 $preText .= $this->msg( 'tags-delete-explanation-warning', $tag )->parseAsBlock();
356
357 // see if the tag is in use
358 $this->softwareActivatedTags = array_fill_keys(
359 ChangeTags::listSoftwareActivatedTags(), true );
360 if ( isset( $this->softwareActivatedTags[$tag] ) ) {
361 $preText .= $this->msg( 'tags-delete-explanation-active', $tag )->parseAsBlock();
362 }
363
364 $fields = [];
365 $fields['Reason'] = [
366 'type' => 'text',
367 'label' => $this->msg( 'tags-delete-reason' )->plain(),
368 'size' => 50,
369 ];
370 $fields['HiddenTag'] = [
371 'type' => 'hidden',
372 'name' => 'tag',
373 'default' => $tag,
374 'required' => true,
375 ];
376
377 $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
378 $form->setAction( $this->getPageTitle( 'delete' )->getLocalURL() );
379 $form->tagAction = 'delete'; // custom property on HTMLForm object
380 $form->setSubmitCallback( [ $this, 'processTagForm' ] );
381 $form->setSubmitTextMsg( 'tags-delete-submit' );
382 $form->setSubmitDestructive(); // nasty!
383 $form->addPreText( $preText );
384 $form->show();
385 }
386
387 protected function showActivateDeactivateForm( $tag, $activate ) {
388 $actionStr = $activate ? 'activate' : 'deactivate';
389
390 $user = $this->getUser();
391 if ( !$user->isAllowed( 'managechangetags' ) ) {
392 throw new PermissionsError( 'managechangetags' );
393 }
394
395 $out = $this->getOutput();
396 // tags-activate-title, tags-deactivate-title
397 $out->setPageTitle( $this->msg( "tags-$actionStr-title" ) );
398 $out->addBacklinkSubtitle( $this->getPageTitle() );
399
400 // is it possible to do this?
401 $func = $activate ? 'canActivateTag' : 'canDeactivateTag';
402 $result = ChangeTags::$func( $tag, $user );
403 if ( !$result->isGood() ) {
404 $out->wrapWikiTextAsInterface( 'error', $result->getWikiText() );
405 if ( !$result->isOK() ) {
406 return;
407 }
408 }
409
410 // tags-activate-question, tags-deactivate-question
411 $preText = $this->msg( "tags-$actionStr-question", $tag )->parseAsBlock();
412
413 $fields = [];
414 // tags-activate-reason, tags-deactivate-reason
415 $fields['Reason'] = [
416 'type' => 'text',
417 'label' => $this->msg( "tags-$actionStr-reason" )->plain(),
418 'size' => 50,
419 ];
420 $fields['HiddenTag'] = [
421 'type' => 'hidden',
422 'name' => 'tag',
423 'default' => $tag,
424 'required' => true,
425 ];
426
427 $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() );
428 $form->setAction( $this->getPageTitle( $actionStr )->getLocalURL() );
429 $form->tagAction = $actionStr;
430 $form->setSubmitCallback( [ $this, 'processTagForm' ] );
431 // tags-activate-submit, tags-deactivate-submit
432 $form->setSubmitTextMsg( "tags-$actionStr-submit" );
433 $form->addPreText( $preText );
434 $form->show();
435 }
436
437 public function processTagForm( array $data, HTMLForm $form ) {
438 $context = $form->getContext();
439 $out = $context->getOutput();
440
441 $tag = $data['HiddenTag'];
442 $status = call_user_func( [ ChangeTags::class, "{$form->tagAction}TagWithChecks" ],
443 $tag, $data['Reason'], $context->getUser(), true );
444
445 if ( $status->isGood() ) {
446 $out->redirect( $this->getPageTitle()->getLocalURL() );
447 return true;
448 } elseif ( $status->isOK() && $form->tagAction === 'delete' ) {
449 // deletion succeeded, but hooks raised a warning
450 $out->addWikiTextAsInterface( $this->msg( 'tags-delete-warnings-after-delete', $tag,
451 count( $status->getWarningsArray() ) )->text() . "\n" .
452 $status->getWikitext() );
453 $out->addReturnTo( $this->getPageTitle() );
454 return true;
455 } else {
456 $out->wrapWikiTextAsInterface( 'error', $status->getWikitext() );
457 return false;
458 }
459 }
460
461 /**
462 * Return an array of subpages that this special page will accept.
463 *
464 * @return string[] subpages
465 */
466 public function getSubpagesForPrefixSearch() {
467 // The subpages does not have an own form, so not listing it at the moment
468 return [
469 // 'delete',
470 // 'activate',
471 // 'deactivate',
472 // 'create',
473 ];
474 }
475
476 protected function getGroupName() {
477 return 'changes';
478 }
479 }