Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 398
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialInterwiki
0.00% covered (danger)
0.00%
0 / 398
0.00% covered (danger)
0.00%
0 / 12
6480
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getSubpagesForPrefixSearch
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 canModify
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 showForm
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 1
132
 onSubmit
0.00% covered (danger)
0.00%
0 / 89
0.00% covered (danger)
0.00%
0 / 1
342
 showList
0.00% covered (danger)
0.00%
0 / 110
0.00% covered (danger)
0.00%
0 / 1
650
 makeTable
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
110
 error
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Interwiki;
4
5use LogPage;
6use MediaWiki\Html\Html;
7use MediaWiki\HTMLForm\HTMLForm;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Message\Message;
10use MediaWiki\Output\OutputPage;
11use MediaWiki\SpecialPage\SpecialPage;
12use MediaWiki\Status\Status;
13use MediaWiki\Title\Title;
14use MediaWiki\WikiMap\WikiMap;
15use PermissionsError;
16use ReadOnlyError;
17
18/**
19 * Implements Special:Interwiki
20 * @ingroup SpecialPage
21 */
22class SpecialInterwiki extends SpecialPage {
23    /**
24     * Constructor - sets up the new special page
25     */
26    public function __construct() {
27        parent::__construct( 'Interwiki' );
28    }
29
30    public function doesWrites() {
31        return true;
32    }
33
34    /**
35     * Different description will be shown on Special:SpecialPage depending on
36     * whether the user can modify the data.
37     *
38     * @return Message
39     */
40    public function getDescription() {
41        return $this->msg( $this->canModify() ? 'interwiki' : 'interwiki-title-norights' );
42    }
43
44    public function getSubpagesForPrefixSearch() {
45        // delete, edit both require the prefix parameter.
46        return [ 'add' ];
47    }
48
49    /**
50     * Show the special page
51     *
52     * @param string|null $par parameter passed to the page or null
53     */
54    public function execute( $par ) {
55        $this->setHeaders();
56        $this->outputHeader();
57
58        $out = $this->getOutput();
59        $request = $this->getRequest();
60
61        $out->addModuleStyles( 'ext.interwiki.specialpage' );
62
63        $action = $par ?: $request->getVal( 'action', $par );
64
65        if ( !in_array( $action, [ 'add', 'edit', 'delete' ] ) || !$this->canModify( $out ) ) {
66            $this->showList();
67        } else {
68            $this->showForm( $action );
69        }
70    }
71
72    /**
73     * Returns boolean whether the user can modify the data.
74     * @param OutputPage|bool $out If $wgOut object given, it adds the respective error message.
75     * @return bool
76     * @throws PermissionsError|ReadOnlyError
77     */
78    public function canModify( $out = false ) {
79        if ( !$this->getUser()->isAllowed( 'interwiki' ) ) {
80            // Check permissions
81            if ( $out ) {
82                throw new PermissionsError( 'interwiki' );
83            }
84
85            return false;
86        } elseif ( $this->getConfig()->get( 'InterwikiCache' ) ) {
87            // Editing the interwiki cache is not supported
88            if ( $out ) {
89                $out->addWikiMsg( 'interwiki-cached' );
90            }
91
92            return false;
93        } else {
94            $this->checkReadOnly();
95        }
96
97        return true;
98    }
99
100    /**
101     * @param string $action The action of the form
102     */
103    protected function showForm( $action ) {
104        $formDescriptor = [];
105        $hiddenFields = [
106            'action' => $action,
107        ];
108
109        $status = Status::newGood();
110        $request = $this->getRequest();
111        $prefix = $request->getVal( 'prefix', $request->getVal( 'hiddenPrefix' ) );
112
113        switch ( $action ) {
114            case 'add':
115            case 'edit':
116                $formDescriptor = [
117                    'prefix' => [
118                        'type' => 'text',
119                        'label-message' => 'interwiki-prefix-label',
120                        'name' => 'prefix',
121                        'autofocus' => true,
122                    ],
123
124                    'local' => [
125                        'type' => 'check',
126                        'id' => 'mw-interwiki-local',
127                        'label-message' => 'interwiki-local-label',
128                        'name' => 'local',
129                    ],
130
131                    'trans' => [
132                        'type' => 'check',
133                        'id' => 'mw-interwiki-trans',
134                        'label-message' => 'interwiki-trans-label',
135                        'name' => 'trans',
136                    ],
137
138                    'url' => [
139                        'type' => 'url',
140                        'id' => 'mw-interwiki-url',
141                        'label-message' => 'interwiki-url-label',
142                        'maxlength' => 200,
143                        'name' => 'wpInterwikiURL',
144                        'size' => 60,
145                    ],
146
147                    'api' => [
148                        'type' => 'url',
149                        'id' => 'mw-interwiki-api',
150                        'label-message' => 'interwiki-api-label',
151                        'maxlength' => 200,
152                        'name' => 'wpInterwikiAPI',
153                        'size' => 60,
154                    ],
155
156                    'reason' => [
157                        'type' => 'text',
158                        'id' => "mw-interwiki-{$action}reason",
159                        'label-message' => 'interwiki_reasonfield',
160                        'maxlength' => 200,
161                        'name' => 'wpInterwikiReason',
162                        'size' => 60,
163                    ],
164                ];
165
166                break;
167            case 'delete':
168                $formDescriptor = [
169                    'prefix' => [
170                        'type' => 'hidden',
171                        'name' => 'prefix',
172                        'default' => $prefix,
173                    ],
174
175                    'reason' => [
176                        'type' => 'text',
177                        'name' => 'reason',
178                        'label-message' => 'interwiki_reasonfield',
179                    ],
180                ];
181
182                break;
183        }
184
185        $formDescriptor['hiddenPrefix'] = [
186            'type' => 'hidden',
187            'name' => 'hiddenPrefix',
188            'default' => $prefix,
189        ];
190
191        if ( $action === 'edit' ) {
192            $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
193            $row = $dbr->newSelectQueryBuilder()
194                ->select( '*' )
195                ->from( 'interwiki' )
196                ->where( [ 'iw_prefix' => $prefix ] )
197                ->caller( __METHOD__ )
198                ->fetchRow();
199
200            $formDescriptor['prefix']['disabled'] = true;
201            $formDescriptor['prefix']['default'] = $prefix;
202            $hiddenFields['prefix'] = $prefix;
203
204            if ( !$row ) {
205                $status->fatal( 'interwiki_editerror', $prefix );
206            } else {
207                $formDescriptor['url']['default'] = $row->iw_url;
208                $formDescriptor['api']['default'] = $row->iw_api;
209                $formDescriptor['trans']['default'] = $row->iw_trans;
210                $formDescriptor['local']['default'] = $row->iw_local;
211            }
212        }
213
214        if ( !$status->isOK() ) {
215            $formDescriptor = [];
216        }
217
218        $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
219        $htmlForm
220            ->addHiddenFields( $hiddenFields )
221            ->setSubmitCallback( [ $this, 'onSubmit' ] );
222
223        if ( $status->isOK() ) {
224            if ( $action === 'delete' ) {
225                $htmlForm->setSubmitDestructive();
226            }
227
228            $htmlForm->setSubmitTextMsg( $action !== 'add' ? $action : 'interwiki_addbutton' )
229                ->setPreHtml( $this->msg( $action !== 'delete' ? "interwiki_{$action}intro" :
230                    'interwiki_deleting', $prefix )->escaped() )
231                ->show();
232        } else {
233            $htmlForm->suppressDefaultSubmit()
234                ->prepareForm()
235                ->displayForm( $status );
236        }
237
238        $this->getOutput()->addBacklinkSubtitle( $this->getPageTitle() );
239    }
240
241    public function onSubmit( array $data ) {
242        $services = MediaWikiServices::getInstance();
243
244        $status = Status::newGood();
245        $request = $this->getRequest();
246        $config = $this->getConfig();
247        $prefix = $this->getRequest()->getVal( 'prefix' ) ?? '';
248        $do = $request->getVal( 'action' );
249        // Show an error if the prefix is invalid (only when adding one).
250        // Invalid characters for a title should also be invalid for a prefix.
251        // Whitespace, ':', '&' and '=' are invalid, too.
252        // (Bug 30599).
253        $validPrefixChars = preg_replace( '/[ :&=]/', '', Title::legalChars() );
254        if ( $do === 'add' && preg_match( "/\s|[^$validPrefixChars]/", $prefix ) ) {
255            $status->fatal( 'interwiki-badprefix', htmlspecialchars( $prefix ) );
256            return $status;
257        }
258        // Disallow adding local interlanguage definitions if using global
259        $interwikiCentralInterlanguageDB = $config->get( 'InterwikiCentralInterlanguageDB' );
260        if (
261            $do === 'add' && $services->getLanguageNameUtils()->getLanguageName( $prefix )
262            && $interwikiCentralInterlanguageDB !== WikiMap::getCurrentWikiId()
263            && $interwikiCentralInterlanguageDB !== null
264        ) {
265            $status->fatal( 'interwiki-cannotaddlocallanguage', htmlspecialchars( $prefix ) );
266            return $status;
267        }
268        $reason = $data['reason'];
269        $selfTitle = $this->getPageTitle();
270        $lookup = $services->getInterwikiLookup();
271        $dbw = $services->getConnectionProvider()->getPrimaryDatabase();
272        switch ( $do ) {
273            case 'delete':
274                $dbw->newDeleteQueryBuilder()
275                    ->deleteFrom( 'interwiki' )
276                    ->where( [ 'iw_prefix' => $prefix ] )
277                    ->caller( __METHOD__ )
278                    ->execute();
279
280                if ( $dbw->affectedRows() === 0 ) {
281                    $status->fatal( 'interwiki_delfailed', $prefix );
282                } else {
283                    $this->getOutput()->addWikiMsg( 'interwiki_deleted', $prefix );
284                    $log = new LogPage( 'interwiki' );
285                    $log->addEntry(
286                        'iw_delete',
287                        $selfTitle,
288                        $reason,
289                        [ $prefix ],
290                        $this->getUser()
291                    );
292                    $lookup->invalidateCache( $prefix );
293                }
294                break;
295            /** @noinspection PhpMissingBreakStatementInspection */
296            case 'add':
297                $contLang = $services->getContentLanguage();
298                $prefix = $contLang->lc( $prefix );
299                // Fall through
300            case 'edit':
301                // T374771: Trim the URL and API URLs to reduce confusion when
302                // the URLs are accidentally provided with extra whitespace
303                $theurl = trim( $data['url'] );
304                $api = trim( $data['api'] ?? '' );
305                $local = $data['local'] ? 1 : 0;
306                $trans = $data['trans'] ? 1 : 0;
307                $rows = [
308                    'iw_prefix' => $prefix,
309                    'iw_url' => $theurl,
310                    'iw_api' => $api,
311                    'iw_wikiid' => '',
312                    'iw_local' => $local,
313                    'iw_trans' => $trans
314                ];
315
316                if ( $prefix === '' || $theurl === '' ) {
317                    $status->fatal( 'interwiki-submit-empty' );
318                    break;
319                }
320
321                // Simple URL validation: check that the protocol is one of
322                // the supported protocols for this wiki.
323                // (T32600)
324                if ( !$services->getUrlUtils()->parse( $theurl ) ) {
325                    $status->fatal( 'interwiki-submit-invalidurl' );
326                    break;
327                }
328
329                if ( $do === 'add' ) {
330                    $dbw->newInsertQueryBuilder()
331                        ->insertInto( 'interwiki' )
332                        ->ignore()
333                        ->row( $rows )
334                        ->caller( __METHOD__ )
335                        ->execute();
336                } else { // $do === 'edit'
337                    $dbw->newUpdateQueryBuilder()
338                        ->update( 'interwiki' )
339                        ->ignore()
340                        ->set( $rows )
341                        ->where( [ 'iw_prefix' => $prefix ] )
342                        ->caller( __METHOD__ )
343                        ->execute();
344                }
345
346                // used here: interwiki_addfailed, interwiki_added, interwiki_edited
347                if ( $dbw->affectedRows() === 0 ) {
348                    $status->fatal( "interwiki_{$do}failed", $prefix );
349                } else {
350                    $this->getOutput()->addWikiMsg( "interwiki_{$do}ed", $prefix );
351                    $log = new LogPage( 'interwiki' );
352                    $log->addEntry(
353                        'iw_' . $do,
354                        $selfTitle,
355                        $reason,
356                        [ $prefix, $theurl, $trans, $local ],
357                        $this->getUser()
358                    );
359                    $lookup->invalidateCache( $prefix );
360                }
361                break;
362        }
363
364        return $status;
365    }
366
367    protected function showList() {
368        $canModify = $this->canModify();
369
370        // Build lists
371        $services = MediaWikiServices::getInstance();
372
373        $lookup = $services->getInterwikiLookup();
374        $iwPrefixes = $lookup->getAllPrefixes( null );
375        $iwGlobalPrefixes = [];
376        $iwGlobalLanguagePrefixes = [];
377
378        $config = $this->getConfig();
379        $interwikiCentralDB = $config->get( 'InterwikiCentralDB' );
380
381        $languageNameUtils = $services->getLanguageNameUtils();
382
383        $connectionProvider = $services->getConnectionProvider();
384
385        if ( $interwikiCentralDB !== null && $interwikiCentralDB !== WikiMap::getCurrentWikiId() ) {
386            // Fetch list from global table
387            $dbrCentralDB = $connectionProvider->getReplicaDatabase( $interwikiCentralDB );
388
389            $res = $dbrCentralDB->newSelectQueryBuilder()
390                ->select( '*' )
391                ->from( 'interwiki' )
392                ->caller( __METHOD__ )
393                ->fetchResultSet();
394            $retval = [];
395            foreach ( $res as $row ) {
396                $row = (array)$row;
397                if ( !$languageNameUtils->getLanguageName( $row['iw_prefix'] ) ) {
398                    $retval[] = $row;
399                }
400            }
401            $iwGlobalPrefixes = $retval;
402        }
403
404        // Almost the same loop as above, but for global inter*language* links, whereas the above is for
405        // global inter*wiki* links
406        $interwikiCentralInterlanguageDB = $config->get( 'InterwikiCentralInterlanguageDB' );
407        $usingGlobalInterlangLinks = ( $interwikiCentralInterlanguageDB !== null );
408        $isGlobalInterlanguageDB = ( $interwikiCentralInterlanguageDB === WikiMap::getCurrentWikiId() );
409        $usingGlobalLanguages = $usingGlobalInterlangLinks && !$isGlobalInterlanguageDB;
410        if ( $usingGlobalLanguages ) {
411            // Fetch list from global table
412            $dbrCentralLangDB = $connectionProvider->getReplicaDatabase( $interwikiCentralInterlanguageDB );
413
414            $res = $dbrCentralLangDB->newSelectQueryBuilder()
415                ->select( '*' )
416                ->from( 'interwiki' )
417                ->caller( __METHOD__ )
418                ->fetchResultSet();
419            $retval2 = [];
420            foreach ( $res as $row ) {
421                $row = (array)$row;
422                // Note that the above DB query explicitly *excludes* interlang ones
423                // (which makes sense), whereas here we _only_ care about interlang ones!
424                if ( $languageNameUtils->getLanguageName( $row['iw_prefix'] ) ) {
425                    $retval2[] = $row;
426                }
427            }
428            $iwGlobalLanguagePrefixes = $retval2;
429        }
430
431        // Split out language links
432        $iwLocalPrefixes = [];
433        $iwLanguagePrefixes = [];
434        foreach ( $iwPrefixes as $iwPrefix ) {
435            if ( $languageNameUtils->getLanguageName( $iwPrefix['iw_prefix'] ) ) {
436                $iwLanguagePrefixes[] = $iwPrefix;
437            } else {
438                $iwLocalPrefixes[] = $iwPrefix;
439            }
440        }
441
442        // If using global interlanguage links, just ditch the data coming from the
443        // local table and overwrite it with the global data
444        if ( $usingGlobalInterlangLinks ) {
445            unset( $iwLanguagePrefixes );
446            $iwLanguagePrefixes = $iwGlobalLanguagePrefixes;
447        }
448
449        // Page intro content
450        $this->getOutput()->addWikiMsg( 'interwiki_intro' );
451
452        // Add 'view log' link when possible
453        if ( !$config->get( 'InterwikiViewOnly' ) ) {
454            $logLink = $this->getLinkRenderer()->makeLink(
455                SpecialPage::getTitleFor( 'Log', 'interwiki' ),
456                $this->msg( 'interwiki-logtext' )->text()
457            );
458            $this->getOutput()->addHTML( '<p class="mw-interwiki-log">' . $logLink . '</p>' );
459        }
460
461        // Add 'add' link
462        if ( $canModify ) {
463            if ( count( $iwGlobalPrefixes ) !== 0 ) {
464                if ( $usingGlobalLanguages ) {
465                    $addtext = 'interwiki-addtext-local-nolang';
466                } else {
467                    $addtext = 'interwiki-addtext-local';
468                }
469            } else {
470                if ( $usingGlobalLanguages ) {
471                    $addtext = 'interwiki-addtext-nolang';
472                } else {
473                    $addtext = 'interwiki_addtext';
474                }
475            }
476            $addtext = $this->msg( $addtext )->text();
477            $addlink = $this->getLinkRenderer()->makeKnownLink(
478                $this->getPageTitle( 'add' ), $addtext );
479            $this->getOutput()->addHTML(
480                '<p class="mw-interwiki-addlink">' . $addlink . '</p>' );
481        }
482
483        $this->getOutput()->addWikiMsg( 'interwiki-legend' );
484
485        if ( $iwPrefixes === [] && $iwGlobalPrefixes === [] ) {
486            // If the interwiki table(s) are empty, display an error message
487            $this->error( 'interwiki_error' );
488            return;
489        }
490
491        // Add the global table
492        if ( count( $iwGlobalPrefixes ) !== 0 ) {
493            $this->getOutput()->addHTML(
494                '<h2 id="interwikitable-global">' .
495                $this->msg( 'interwiki-global-links' )->parse() .
496                '</h2>'
497            );
498            $this->getOutput()->addWikiMsg( 'interwiki-global-description' );
499
500            // $canModify is false here because this is just a display of remote data
501            $this->makeTable( false, $iwGlobalPrefixes );
502        }
503
504        // Add the local table
505        if ( count( $iwLocalPrefixes ) !== 0 ) {
506            if ( count( $iwGlobalPrefixes ) !== 0 ) {
507                $this->getOutput()->addHTML(
508                    '<h2 id="interwikitable-local">' .
509                    $this->msg( 'interwiki-local-links' )->parse() .
510                    '</h2>'
511                );
512                $this->getOutput()->addWikiMsg( 'interwiki-local-description' );
513            } else {
514                $this->getOutput()->addHTML(
515                    '<h2 id="interwikitable-local">' .
516                    $this->msg( 'interwiki-links' )->parse() .
517                    '</h2>'
518                );
519                $this->getOutput()->addWikiMsg( 'interwiki-description' );
520            }
521            $this->makeTable( $canModify, $iwLocalPrefixes );
522        }
523
524        // Add the language table
525        if ( count( $iwLanguagePrefixes ) !== 0 ) {
526            if ( $usingGlobalLanguages ) {
527                $header = 'interwiki-global-language-links';
528                $description = 'interwiki-global-language-description';
529            } else {
530                $header = 'interwiki-language-links';
531                $description = 'interwiki-language-description';
532            }
533
534            $this->getOutput()->addHTML(
535                '<h2 id="interwikitable-language">' .
536                $this->msg( $header )->parse() .
537                '</h2>'
538            );
539            $this->getOutput()->addWikiMsg( $description );
540
541            // When using global interlanguage links, don't allow them to be modified
542            // except on the source wiki
543            $canModify = ( $usingGlobalLanguages ? false : $canModify );
544            $this->makeTable( $canModify, $iwLanguagePrefixes );
545        }
546    }
547
548    protected function makeTable( $canModify, $iwPrefixes ) {
549        // Output the existing Interwiki prefixes table header
550        $out = Html::openElement(
551            'table',
552            [ 'class' => 'mw-interwikitable wikitable sortable body' ]
553        ) . "\n";
554        $out .= Html::openElement( 'thead' ) .
555            Html::openElement( 'tr', [ 'class' => 'interwikitable-header' ] ) .
556            Html::element( 'th', [], $this->msg( 'interwiki_prefix' )->text() ) .
557            Html::element( 'th', [], $this->msg( 'interwiki_url' )->text() ) .
558            Html::element( 'th', [], $this->msg( 'interwiki_local' )->text() ) .
559            Html::element( 'th', [], $this->msg( 'interwiki_trans' )->text() ) .
560            ( $canModify ?
561                Html::element(
562                    'th',
563                    [ 'class' => 'unsortable' ],
564                    $this->msg( 'interwiki_edit' )->text()
565                ) :
566                ''
567            );
568        $out .= Html::closeElement( 'tr' ) .
569            Html::closeElement( 'thead' ) . "\n" .
570            Html::openElement( 'tbody' );
571
572        $selfTitle = $this->getPageTitle();
573
574        // Output the existing Interwiki prefixes table rows
575        foreach ( $iwPrefixes as $iwPrefix ) {
576            $out .= Html::openElement( 'tr', [ 'class' => 'mw-interwikitable-row' ] );
577            $out .= Html::element( 'td', [ 'class' => 'mw-interwikitable-prefix' ],
578                $iwPrefix['iw_prefix'] );
579            $out .= Html::element(
580                'td',
581                [ 'class' => 'mw-interwikitable-url' ],
582                $iwPrefix['iw_url']
583            );
584            $attribs = [ 'class' => 'mw-interwikitable-local' ];
585            // Green background for cells with "yes".
586            if ( isset( $iwPrefix['iw_local'] ) && $iwPrefix['iw_local'] ) {
587                $attribs['class'] .= ' mw-interwikitable-local-yes';
588            }
589            // The messages interwiki_0 and interwiki_1 are used here.
590            $contents = isset( $iwPrefix['iw_local'] ) ?
591                $this->msg( 'interwiki_' . $iwPrefix['iw_local'] )->text() :
592                '-';
593            $out .= Html::element( 'td', $attribs, $contents );
594            $attribs = [ 'class' => 'mw-interwikitable-trans' ];
595            // Green background for cells with "yes".
596            if ( isset( $iwPrefix['iw_trans'] ) && $iwPrefix['iw_trans'] ) {
597                $attribs['class'] .= ' mw-interwikitable-trans-yes';
598            }
599            // The messages interwiki_0 and interwiki_1 are used here.
600            $contents = isset( $iwPrefix['iw_trans'] ) ?
601                $this->msg( 'interwiki_' . $iwPrefix['iw_trans'] )->text() :
602                '-';
603            $out .= Html::element( 'td', $attribs, $contents );
604
605            // Additional column when the interwiki table can be modified.
606            if ( $canModify ) {
607                $out .= Html::rawElement( 'td', [ 'class' => 'mw-interwikitable-modify' ],
608                    $this->getLinkRenderer()->makeKnownLink(
609                        $selfTitle,
610                        $this->msg( 'edit' )->text(),
611                        [],
612                        [ 'action' => 'edit', 'prefix' => $iwPrefix['iw_prefix'] ]
613                    ) .
614                    $this->msg( 'comma-separator' )->escaped() .
615                    $this->getLinkRenderer()->makeKnownLink(
616                        $selfTitle,
617                        $this->msg( 'delete' )->text(),
618                        [],
619                        [ 'action' => 'delete', 'prefix' => $iwPrefix['iw_prefix'] ]
620                    )
621                );
622            }
623            $out .= Html::closeElement( 'tr' ) . "\n";
624        }
625        $out .= Html::closeElement( 'tbody' ) .
626            Html::closeElement( 'table' );
627
628        $this->getOutput()->addHTML( $out );
629        $this->getOutput()->addModuleStyles( 'jquery.tablesorter.styles' );
630        $this->getOutput()->addModules( 'jquery.tablesorter' );
631    }
632
633    /**
634     * @param string ...$args
635     */
636    protected function error( ...$args ) {
637        $this->getOutput()->wrapWikiMsg( "<p class='error'>$1</p>", $args );
638    }
639
640    protected function getGroupName() {
641        return 'wiki';
642    }
643}