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