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