wikimedia/mediawiki-extensions-TwnMainPage

View on GitHub
includes/specials/SpecialTwnMainPage.php

Summary

Maintainability
F
5 days
Test Coverage
<?php

use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
use MediaWiki\Extension\Translate\Services;
use MediaWiki\Extension\Translate\Statistics\MessageGroupStats;
use MediaWiki\Extension\Translate\TranslatorSandbox\TranslateSandbox;
use MediaWiki\Extension\Translate\TranslatorSandbox\TranslationStashReader;
use MediaWiki\Extension\Translate\Utilities\Utilities;
use MediaWiki\MainConfigNames;
use MediaWiki\MediaWikiServices;

/**
 * Provides the main page with stats and stuff.
 * @ingroup SpecialPage
 * @author Niklas Laxström
 * @author Santhosh Thottingal
 * @license GPL-2.0-or-later
 */
class SpecialTwnMainPage extends SpecialPage {
    protected int $maxProjectTiles = 8;
    private TranslationStashReader $translationStashReader;
    private ?ProjectHandler $projectHandler;

    public function __construct() {
        parent::__construct( 'TwnMainPage' );
        $this->translationStashReader = Services::getInstance()->getTranslationStashReader();
    }

    public function getDescription() {
        return $this->msg( 'twnmp-mainpage' );
    }

    private function getProjectHandler(): ProjectHandler {
        if ( !isset( $this->projectHandler ) ) {
            $this->projectHandler = new ProjectHandler();
        }

        return $this->projectHandler;
    }

    private function getStatsTiles( $stats ): array {
        return [
            // Rows X cols
            [
                [
                    'name' => 'twnmp-s-projects',
                    'stats' => $stats['projects'],
                    'url' => Title::makeTitle( NS_CATEGORY, 'Supported projects' )->getLocalURL(),
                ],
                [
                    'name' => 'twnmp-s-translators',
                    'stats' => $stats['translators'],
                    'url' => SpecialPage::getTitleFor( 'Activeusers' )->getLocalURL(),
                ],
                [
                    'name' => 'twnmp-s-messages',
                    'stats' => $stats['messages'],
                    'url' => SpecialPage::getTitleFor( 'Translate' )->getLocalURL(),
                ],
            ],
            [
                null,
                null,
                [
                    'name' => 'twnmp-s-languages',
                    'stats' => $stats['languages'],
                    'url' => SpecialPage::getTitleFor( 'SupportedLanguages' )->getLocalURL(),
                ],
            ],
        ];
    }

    /** @inheritDoc */
    public function getRobotPolicy() {
        // We very much do want this page to be indexed even though special pages normally aren't
        return $this->getConfig()->get( MainConfigNames::DefaultRobotPolicy );
    }

    public function execute( $parameters ) {
        $out = $this->getOutput();
        $skin = $this->getSkin();

        if ( !$this->getConfig()->get( 'TranslateUseSandbox' ) ) {
            $out->showFatalError( 'TwnMainPage requires $wgTranslateUseSandbox to be enabled.' );
            return;
        }

        $this->setHeaders();
        $out->setArticleBodyOnly( true );
        $out->loadSkinModules( $skin );

        // Enable this if you need useful debugging information
        // $out->addHtml( MWDebug::getDebugHTML( $this->getContext() ) );
        $this->getHookContainer()->run( 'BeforePageDisplay', [ &$out, &$skin ] );
        $out->addModuleStyles( 'jquery.uls.grid' );
        $out->addModuleStyles( 'ext.translate.mainpage.styles' );
        $out->addModuleStyles( 'mediawiki.ui.button' );
        $out->addModuleStyles( 'ext.translate.mainpage.icons' );
        $out->addModules( 'ext.translate.mainpage' );
        // Forcing wgULSPosition to personal to mimic that behavior regardless
        // of the position of the uls trigger in other pages.
        $out->addJsConfigVars( 'wgULSPosition', 'personal' );
        $out->addJsConfigVars( 'maxProjectTiles', $this->maxProjectTiles );
        $out->addMeta( 'viewport', 'width=device-width, initial-scale=0.5' );

        // These add modules so this has to be called before headElement
        $output = $this->makeContent();

        $out->addHTML(
            $out->headElement( $skin ) .
            $output .
            $out->getBottomScripts() .
             '</body></html>'
        );
    }

    private function makeContent(): string {
        $output = Html::openElement( 'div', [ 'class' => 'grid twn-mainpage' ] );
        $output .= $this->header();
        $output .= Html::openElement( 'main' );
        $output .= $this->banner();
        $output .= $this->searchBar();
        $output .= $this->projectSelector();
        $output .= $this->newProject();
        $output .= Html::closeElement( 'main' );
        $output .= $this->footer();
        $output .= Html::closeElement( 'div' );
        return $output;
    }

    private function header(): string {
        global $wgSitename;

        $siteNameEsc = htmlspecialchars( $wgSitename );
        $siteMottoEsc = $this->msg( 'twnmp-brand-motto' )->escaped();

        $code = $this->getLanguage()->getCode();
        $languageName = Utilities::getLanguageName( $code, $code );
        $params = [
            'href' => '#',
            'class' => 'uls-trigger',
            'tabindex' => 0,
            'role' => 'button',
            'aria-haspopup' => 'true'
        ];
        $uls = Html::element( 'a', $params, $languageName );

        $userLink = '';

        $user = $this->getUser();
        if ( $user->isRegistered() ) {
            $params = [
                'class' => 'login username text-right',
                'href' => $user->getUserPage()->getLocalURL(),
            ];
            $userLink = Html::element( 'a', $params, $user->getName() );

            $logout = SpecialPage::getTitleFor( 'Userlogout' );
            $params = [
                'class' => 'logout text-right',
                'href' => $logout->getLocalURL( [ 'returnto' => 'Special:MainPage' ] ),
            ];
            $logInOut = Html::element( 'a', $params, $this->msg( 'twnmp-logout' )->text() );
        } else {
            $login = SpecialPage::getTitleFor( 'Userlogin' );
            $params = [
                'class' => 'login text-right',
                'href' => $login->getLocalURL( [ 'returnto' => 'Special:MainPage' ] ),
            ];
            $logInOut = Html::element( 'a', $params, $this->msg( 'twnmp-login' )->text() );
        }

        return <<<HTML
<header class="row twn-mainpage-header">
    <div class="seven columns twn-mainpage-title">
        <div class="twn-brand-name">$siteNameEsc</div>
        <div class="twn-brand-motto">$siteMottoEsc</div>
    </div>
    <div class="five columns twn-mainpage-personal-actions">
        $uls
        $userLink
        $logInOut
    </div>
</header>
HTML;
    }

    private function searchBar(): string {
        $out = Html::openElement( 'form',
            [
                'class' => 'row twn-mainpage-search',
                'action' => SpecialPage::getTitleFor( 'SearchTranslations' )->getLocalURL(),
            ] );

        $out .= Html::element( 'input',
            [
                'class' => 'ten columns searchbox',
                'id' => 'twnmp-search-field',
                // @todo move to JS, placeholders are not supported in IE
                'placeholder' => $this->msg( 'twnmp-search-placeholder' )->text(),
                'type' => 'search',
                'name' => 'query',
                'dir' => $this->getLanguage()->getDir(),
            ] );

        $out .= Html::element( 'input',
            [
                'name' => 'language',
                'value' => $this->getLanguage()->getCode(),
                'type' => 'hidden',
            ] );

        $out .= Html::element( 'button',
            [
                'class' => 'mw-ui-button mw-ui-progressive',
                'type' => 'submit',
                'id' => 'twnmp-search-button',
            ],
            $this->msg( 'twnmp-search-button' )->text() );
        $out .= Html::closeElement( 'form' );

        return $out;
    }

    private function projectSelector(): string {
        $out = Html::element( 'div', [ 'class' => 'row twn-mainpage-project-selector-title' ],
            $this->msg( 'twnmp-search-choose-project' )->text() );

        $out .= Html::openElement(
            'div',
            [
                'class' => 'row twn-mainpage-project-tiles',
            ]
        );

        $handler = $this->getProjectHandler();
        $projects = $handler->getProjects();
        $language = $this->getLanguage()->getCode();
        $stats = MessageGroupStats::forLanguage( $language, MessageGroupStats::FLAG_CACHE_ONLY );
        $handler->sortByPriority( $projects, $language, $stats );

        $tiles = [];

        foreach ( $projects as $group ) {
            $tiles[] = $this->makeGroupTile( $group, $stats[$group->getId()] );
            if ( count( $tiles ) === $this->maxProjectTiles ) {
                break;
            }
        }

        $out .= implode( "\n\n", $tiles );
        $out .= Html::closeElement( 'div' );

        return $out;
    }

    private function newProject(): string {
        $add = Title::newFromText( 'Special:MyLanguage/Translating:New_project' )
            ->getFullURL();

        return Html::element(
            'a',
            [
                'class' => 'row twn-mainpage-add-project',
                'href' => $add
            ],
            $this->msg( 'twnmp-add-project' )->text()
        );
    }

    private function makeGroupTile( MessageGroup $group, array $stats ): string {
        $id = $group->getId();
        $uiLanguage = $this->getLanguage()->getCode();
        $groupLanguage = $group->getSourceLanguage();
        $statsHtml = '';

        // If this is the source language, show the number of messages.
        // Else we load stats and statsbar with JavaScript on the client.
        if (
            $uiLanguage === $groupLanguage &&
            // This can be null if we don't have stats available. And numParams
            // throws ugly notice if we pass null to it.
            $stats[MessageGroupStats::TOTAL] !== null
        ) {
            $statsHtml = $this->msg( 'twn-mainpage-total-messages-in-language' )
                ->numParams( $stats[MessageGroupStats::TOTAL] )
                ->escaped();
        }

        // Approximate project page links while we don't have config value for them
        $projectPage = Title::newFromText( "Translating:{$group->getLabel()}" );
        $dataUrl = $linked = '';
        if ( $projectPage->exists() ) {
            $dataUrl = str_replace(
                'X',
                htmlspecialchars( $projectPage->getLocalURL() ),
                'data-url="X"'
            );
            $linked = 'linked';
        }

        $escLang = htmlspecialchars( $groupLanguage );

        $class = 'project-icon-' . Sanitizer::escapeClass( $id );
        $image = Html::element( 'div', [ 'class' => $class ] );
        $label = htmlspecialchars( $group->getLabel( $this->getContext() ) );

        $messageGroupId = htmlspecialchars( $id );
        return <<<HTML
<div class="three columns twn-mainpage-project-tile">
    <div class="project-tile $linked" $dataUrl data-lang="$escLang" data-msggroupid="$messageGroupId"
        tabindex="0">
        <div class="row project-top">
            <div class="project-icon four columns">$image</div>
            <div class="project-content eight columns">
                <div class="row project-name" dir="auto">$label</div>
                <div class="project-stats">
                    <div class="row project-statsbar"></div>
                    <div class="row project-statstext">$statsHtml</div>
                </div>
            </div>
        </div>
        <div class="row project-actions">
            {$this->getProjectActions( $id )}
        </div>
    </div>
</div>
HTML;
    }

    /**
     * @param string $id Message group id
     * @return string HTML
     */
    private function getProjectActions( string $id ): string {
        $user = $this->getUser();
        $title = SpecialPage::getTitleFor( 'Translate' );

        $view = Html::element( 'a', [
            'class' => 'translate',
            'href' => $title->getLocalURL( [ 'group' => $id ] )
        ], $this->msg( 'twnmp-view-link' )->text() );

        $translate = Html::element( 'a', [
            'class' => 'translate',
            'href' => $title->getLocalURL( [ 'group' => $id ] )
        ], $this->msg( 'twnmp-translate-link' )->text() );

        $proofread = Html::element( 'a', [
            'class' => 'proofread',
            'href' => $title->getLocalURL( [ 'group' => $id, 'action' => 'proofread' ] )
        ], $this->msg( 'twnmp-proofread-link' )->text() );

        if ( $user->isAnon() || TranslateSandbox::isSandboxed( $user ) ) {
            return <<<HTML
<div class="twelve columns action">$view</div>

HTML;
        } else {
            return <<<HTML
<div class="six columns action">$translate</div>
<div class="six columns action">$proofread</div>

HTML;
        }
    }

    private function banner(): string {
        global $wgMainPageImages;

        $image = [];
        $images = array_values( $wgMainPageImages );
        $imageIndex = date( 'z' ) % count( $images );
        if ( isset( $images[$imageIndex] ) ) {
            $image = $images[$imageIndex];
        }

        $bannerAttribs = [ 'class' => 'row twn-mainpage-banner' ];
        if ( isset( $image['url'] ) ) {
            $url = $image['url'];
            $bannerAttribs['style'] = "background-image: url($url);";
        }

        $out = Html::openElement( 'div', $bannerAttribs );
        $out .= $this->twnStats();

        if ( isset( $image['attribution'] ) ) {
            $out .= Html::rawElement( 'div', [ 'class' => 'banner-attribution' ],
                $this->msg( 'twnmp-bannerwho' )->rawParams( $image['attribution'] )->escaped()
            );
        }

        $out .= $this->userWidget();

        $out .= Html::closeElement( 'div' );

        return $out;
    }

    private function footer(): string {
        $out = Html::openElement( 'footer' );
        $out .= Html::openElement( 'div', [ 'class' => 'row twn-mainpage-footer' ] );
        $out .= Html::element( 'a', [
            'class' => 'three column',
            'href' => Title::newFromText( 'Special:MyLanguage/Project:About' )->getLocalURL(),
        ], $this->msg( 'twnmp-bottom-about' )->text() );
        $out .= Html::element( 'a', [
            'class' => 'three column',
            'href' => SpecialPage::getTitleFor( 'SupportedLanguages' )->getLocalURL(),
        ], $this->msg( 'twnmp-bottom-languages-supported' )->text() );
        $out .= Html::element( 'a', [
            'class' => 'three column',
            'href' => Title::newFromText( 'Support' )->getLocalURL(),
        ], $this->msg( 'twnmp-bottom-support' )->text() );
        $out .= Html::element( 'a', [
            'class' => 'three column',
            'href' => Title::newFromText( 'Translating:Index' )->getLocalURL(),
        ], $this->msg( 'twnmp-bottom-help' )->text() );
        $out .= Html::closeElement( 'div' );

        global $wgFooterIcons, $wgExternalLinkTarget;
        foreach ( $wgFooterIcons['poweredby'] as $icon ) {
            $out .= Html::openElement( 'div', [ 'class' => 'row twn-mainpage-poweredby' ] );
            $out .= Html::element(
                'a',
                [
                    'href' => $icon['url'],
                    'target' => $wgExternalLinkTarget
                ],
                htmlspecialchars( $icon['alt'] )
            );
            $out .= Html::closeElement( 'div' );
        }

        $out .= Html::closeElement( 'footer' );

        return $out;
    }

    private static function numberOfLanguages( int $period ): int {
        global $wgTranslateMessageNamespaces;

        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
        $tables = [ 'recentchanges' ];
        $fields = [ 'substring_index(rc_title, \'/\', -1) as lang, count(rc_id) as count' ];
        $conditions = [
            'rc_title' . $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() ),
            'rc_namespace' => $wgTranslateMessageNamespaces,
            'rc_timestamp > ' . $dbr->timestamp( wfTimestamp( TS_UNIX ) - 60 * 60 * 24 * $period ),
            'rc_bot' => 0,
        ];
        $options = [ 'GROUP BY' => 'lang', 'HAVING' => 'count > 20' ];

        $res = $dbr->select( $tables, $fields, $conditions, __METHOD__, $options );

        $count = 0;
        $languageNameUtils = MediaWikiServices::getInstance()->getLanguageNameUtils();
        foreach ( $res as $row ) {
            // @todo FIXME: This has awful performance
            if ( $languageNameUtils->isKnownLanguageTag( $row->lang ) ) {
                $count++;
            }
        }

        return $count;
    }

    /**
     * Callback for CachedStat
     * @param ProjectHandler $handler
     * @return array
     */
    public static function getTwnStats( ProjectHandler $handler ): array {
        $projects = count( $handler->getProjects() );
        $translators = SiteStats::numberingroup( 'translator' );
        $messages = count( Services::getInstance()->getMessageIndex()->getKeys() );
        $languages = self::numberOfLanguages( 180 );

        return [
            'projects' => $projects,
            'translators' => $translators,
            'messages' => $messages,
            'languages' => $languages,
        ];
    }

    /** Callback for CachedStat */
    public static function getUserStats( string $code, int $period ): array {
        return [
            'translators' => TwnUserStats::getTranslationRankings( $code, $period ),
            'proofreaders' => TwnUserStats::getProofreadRankings( $code, $period ),
        ];
    }

    private function twnStats(): string {
        $stale = 60 * 60 * 6;
        $expired = 60 * 60 * 24;
        $handler = $this->getProjectHandler();

        $statesCache = new CachedStat( 'twnstats', $stale, $expired,
            [ 'SpecialTwnMainPage::getTwnStats', $handler ], 'allow miss' );
        $stats = $statesCache->get();

        $data = [
            [ null, null, null ],
            [ null, null, null ],
        ];

        if ( is_array( $stats ) ) {
            $data = $this->getStatsTiles( $stats );
        }

        $out = '<div class="six columns twn-mainpage-stats-tiles">';

        $lang = $this->getLanguage();

        foreach ( $data as $rows ) {
            $out .= '<div class="row stats-tile-row">';
            foreach ( $rows as $column ) {
                if ( $column === null ) {
                    $out .= <<<HTML
<div class="four columns">
    <div class="stats-tile unused"></div>
</div>
HTML;
                    continue;
                }
                $name = $column['name'];
                $value = $column['stats'];
                $url = htmlspecialchars( $column['url'] );

                if ( $value > 1000 ) {
                    $digits = 3 - ceil( log( $value, 100 ) );
                    $fmtValue = number_format( $value / 1000, $digits );
                    $fmtValue = $this->msg( 'twnmp-stats-number-k' )->numParams( $fmtValue )->escaped();
                } else {
                    $fmtValue = htmlspecialchars( $lang->formatNum( $value ) );
                }

                $text = $this->msg( $name )->numParams( $value )->escaped();

                $out .= <<<HTML
<div class="four columns">
        <div class="stats-tile" id="$name">
            <a href="$url"></a>
            <div class="stats-number">$fmtValue</div>
            <div class="stats-text">$text</div>
        </div>
</div>
HTML;
            }

            $out .= '</div>';
        }

        $out .= '</div>';

        return $out;
    }

    private function userWidget(): string {
        if ( $this->getUser()->isRegistered() ) {
            return $this->loggedInWidget();
        } else {
            return $this->loginForm();
        }
    }

    /** Form that allows users to signup via sandbox. */
    private function loginForm(): string {
        $this->getOutput()->addModules( 'ext.translate.mainpage.signup' );

        $languageCode = $this->getLanguage()->getCode();
        $languageName = Utilities::getLanguageName( $languageCode, $languageCode );

        $defaultLanguage = Html::rawElement( 'label', [],
            Html::element( 'input', [
            'type' => 'checkbox',
            'name' => 'signuplanguage',
            'value' => $languageCode,
            'checked' => true,
        ] ) . ' ' . htmlspecialchars( $languageName ) );
        $username = Html::element( 'input', [
            'class' => 'twelve columns required',
            'name' => 'wpName',
            'autocomplete' => 'off',
            'required',
            'placeholder' => $this->msg( 'twnmp-signup-username-placeholder' )->text(),
        ] );
        $password = Html::element( 'input', [
            'class' => 'twelve columns required',
            'name' => 'wpPassword',
            'autocomplete' => 'off',
            'type' => 'password',
            'required',
            'placeholder' => $this->msg( 'twnmp-signup-password-placeholder' )->text(),
        ] );
        $email = Html::element( 'input', [
            'class' => 'twelve columns required',
            'name' => 'wpEmail',
            'autocomplete' => 'off',
            'type' => 'email',
            'required',
            'placeholder' => $this->msg( 'twnmp-signup-email-placeholder' )->text(),
        ] );
        $reasonInput = Html::element( 'textarea', [
            'class' => 'twelve columns required',
            'name' => 'reason',
            'rows' => '4',
        ] );

        $contents = <<<HTML
    <h1 class="row only-dev hide">
        {$this->msg( 'twnmp-join-community' )->escaped()}
    </h1>
    <div class="row only-dev hide label">
        {$this->msg( 'twnmp-join-community-desc' )->escaped()}
    </div>
    <h1 class="row only-nondev">
        {$this->msg( 'twnmp-become-translator' )->escaped()}
    </h1>
    <h2 class="row only-nondev">
        {$this->msg( 'twnmp-choose-languages-you-know' )->escaped()}
    </h2>
    <ul class="row signup-languages only-nondev autonym">
        <li>
            $defaultLanguage
        </li>
    </ul>
    <div class="row only-nondev">
        <button class="signup-language-selector mw-ui-button" type="button">
            {$this->msg( 'twnmp-choose-another-language' )->escaped()}
        </button>
    </div>
    <h2 class="row">
        {$this->msg( 'twnmp-choose-fill-account-details' )->escaped()}
    </h2>
    <div class="row">$username</div>
    <div class="js-signup-err twnmp-signup-error hide"></div>
    <div class="row">$password</div>
    <div class="js-signup-err twnmp-signup-error hide"></div>
    <div class="row">$email</div>
    <div class="js-signup-err twnmp-signup-error hide"></div>
    <div class="row label only-dev hide">
        {$this->msg( 'twnmp-join-community-reason' )->escaped()}
    </div>
    <div class="row only-dev hide">$reasonInput</div>
    <div class="row">
        <button class="mw-ui-button mw-ui-progressive mw-ui-big" type="submit" id="twnmp-create-account">
            {$this->msg( 'twnmp-create-account-button' )->escaped()}
        </button>
        <button class="mw-ui-button mw-ui-big mw-ui-quiet only-dev cancel hide">
            {$this->msg( 'twnmp-create-account-cancel' )->escaped()}
        </button>
        <span class="twn-mainpage-loading-indicator hide"></span>
    </div>
    <div class="js-signup-err twnmp-signup-generic-error hide"></div>
    <div class="row dev-signup only-nondev">
        <a>{$this->msg( 'twnmp-join-community-info' )->escaped()}</a>
    </div>
HTML;

        $action = SpecialPage::getTitleFor( 'Userlogin' )->getLocalURL(
            [
                'returnto' => 'Special:MainPage',
                'type' => 'signup'
            ]
        );

        $out = Html::rawElement( 'form',
            [ 'class' => 'five columns main-widget login-widget',
                'method' => 'post',
                'action' => $action,
            ],
            "\n$contents\n"
        );

        return "\n$out\n";
    }

    private function loggedInWidget(): string {
        $languageCode = $this->getLanguage()->getCode();
        $languageName = Utilities::getLanguageName( $languageCode, $languageCode );

        $groupsSourceLanguage = MessageGroups::haveSingleSourceLanguage(
            MessageGroups::getAllGroups()
        );

        $link = Html::element( 'a', [
            'href' => SpecialPage::getTitleFor( 'LanguageStats' )->getLocalURL(),
        ], $this->msg( 'twnmp-your-view-language-stats' )->text() );

        if ( TranslateSandbox::isSandboxed( $this->getUser() ) ) {
            $subtitleClass = 'for-sandbox';
            $subtitle = '';
            $rows = $this->getSandboxRows();
        } elseif ( $groupsSourceLanguage === $languageCode ) {
            $subtitleClass = 'for-all-languages';
            $subtitle = $this->msg( 'twnmp-your-translations-stats-all-languages' )->escaped();
            $rows = $this->getTranslationStatsRows( '' );
        } else {
            $subtitleClass = Sanitizer::escapeClass( "for-language-$languageCode" );
            $subtitle = htmlspecialchars( $languageName );
            $rows = $this->getTranslationStatsRows( $languageCode );
        }

        $email = $this->getUser()->getEmail();
        $avatar = 'https://secure.gravatar.com/avatar/' . md5( strtolower( $email ) );
        $background = "background-image: url('$avatar?d=mm');";
        $background = htmlspecialchars( $background );
        $background = "style=\"$background\"";

        return <<<HTML

<div class="five columns main-widget stats-widget">
    <div class="row user-stats-title" $background>
        <h2>
            {$this->msg( 'twnmp-your-translations-stats' )->escaped()}
        </h2>
        <div class="subtitle $subtitleClass">$subtitle</div>
    </div>
    $rows
    <div class="row langstats-link">$link</div>
</div>

HTML;
    }

    /**
     * Gets data and formats language stats row. Use empty string to
     * get stats for all languages.
     * @param string $languageForStats Language code or empty string.
     * @return string HTML
     */
    private function getTranslationStatsRows( string $languageForStats ): string {
        $stale = 60 * 5;
        $expired = 60 * 60 * 12;
        $statsCache = new CachedStat( "userstats-$languageForStats", $stale, $expired,
            [
                'SpecialTwnMainPage::getUserStats',
                $languageForStats,
                30
            ],
            'allow miss'
        );
        $statsArray = $statsCache->get();
        if ( $statsArray === null ) {
            $statsArray = [
                'translators' => [],
                'proofreaders' => [],
            ];
        }

        if ( $languageForStats === '' ) {
            $translationStatsRankingMsg = 'twnmp-translations-translator-ranking-source';
            $languageName = '';
        } else {
            $translationStatsRankingMsg = 'twnmp-translations-translator-ranking';
            $languageName = Utilities::getLanguageName( $languageForStats, $languageForStats );
        }

        $currentUser = $this->getUser()->getName();

        $out = Html::openElement( 'form', [
            'class' => 'row ranking',
            'action' => SpecialPage::getTitleFor( 'LanguageStats' )->getLocalURL(),
        ] );
        $out .= Html::openElement( 'div', [ 'class' => 'row eight columns' ] );
        $stats = $statsArray['translators'];
        $i = 1;
        $translators = count( $stats );
        foreach ( $stats as $user => $count ) {
            if ( $user === $currentUser ) {
                $out .= Html::element(
                    'div',
                    [ 'class' => 'count' ],
                    $this->getLanguage()->formatNum( $count )
                );
                $out .= Html::element(
                    'div',
                    [ 'class' => 'count-description' ],
                    $this->msg( 'twnmp-translations-per-month' )->numParams( $count )->text()
                );

                // @todo When refactoring, $languageName should not be used
                // when using the message for the source page
                $msg = $this->msg( $translationStatsRankingMsg, $currentUser )
                    ->numParams( $i, $translators )
                    ->params( $languageName )
                    ->plain();
                $wrap = new RawMessage( "<div class='rank-description'>$msg</div>" );
                $out .= $wrap->parse();

                break;
            }
            $i++;
        }
        $out .= Html::closeElement( 'div' );
        $out .= Html::openElement( 'div', [ 'class' => 'four columns' ] );
        $out .= Html::element( 'button', [
            'id' => 'twnmp-translate',
            'type' => 'submit',
            'class' => 'mw-ui-button mw-ui-progressive'
        ], $this->msg( 'twnmp-translate-button' )->text() );
        $out .= Html::closeElement( 'div' );
        $out .= Html::closeElement( 'form' );

        // Proofreading row
        $out .= Html::openElement( 'form', [
            'class' => 'row ranking',
            'action' => SpecialPage::getTitleFor( 'Translate' )->getLocalURL(),
        ] );
        $out .= Html::hidden( 'action', 'proofread' );
        $out .= Html::hidden( 'group', '!recent' );
        $out .= Html::openElement( 'div', [ 'class' => 'row eight columns' ] );
        $stats = $statsArray['proofreaders'];
        $i = 1;
        $translators = count( $stats );
        foreach ( $stats as $user => $count ) {
            if ( $user === $currentUser ) {
                $out .= Html::element(
                    'div',
                    [ 'class' => 'count' ],
                    $this->getLanguage()->formatNum( $count )
                );
                $out .= Html::element(
                    'div',
                    [ 'class' => 'count-description' ],
                    $this->msg( 'twnmp-reviews-per-month' )->numParams( $count )->text()
                );

                // @todo When refactoring, $languageName should not be used
                // when using the message for the source page
                $msg = $this->msg( $translationStatsRankingMsg, $currentUser )
                    ->numParams( $i, $translators )
                    ->params( $languageName )
                    ->plain();
                $wrap = new RawMessage( "<div class='rank-description'>$msg</div>" );
                $out .= $wrap->parse();

                break;
            }
            $i++;
        }
        $out .= Html::closeElement( 'div' );
        $out .= Html::openElement( 'div', [ 'class' => 'four columns' ] );
        $out .= Html::element( 'button', [
            'id' => 'twnmp-proofread',
            'type' => 'submit',
            'class' => 'mw-ui-button mw-ui-progressive'
        ], $this->msg( 'twnmp-proofread-button' )->text() );
        $out .= Html::closeElement( 'div' );
        $out .= Html::closeElement( 'form' );

        return $out;
    }

    private function getSandboxRows(): string {
        global $wgTranslateSandboxLimit;

        $count = count( $this->translationStashReader->getTranslations( $this->getUser() ) );

        if ( $count < $wgTranslateSandboxLimit ) {
            $button = $this->msg( 'twnmp-translate-button' )->escaped();
            $message = $this->msg( 'twnmp-sandboxed' )->numParams( $count )->escaped();
        } else {
            $button = $this->msg( 'twnmp-view-button' )->escaped();
            $message = $this->msg( 'twnmp-sandboxed-limit' )->escaped();
        }

        $count = $this->getLanguage()->formatNum( $count );
        $count = htmlspecialchars( $count );

        $action = SpecialPage::getTitleFor( 'TranslationStash' )->getLocalURL();
        $action = htmlspecialchars( $action );

        return <<<HTML

<form class="row ranking" action="$action">
    <div class="eight columns">
        <div class="count">$count</div>
        <div class="count-description">
            {$this->msg( 'twnmp-translations-in-sandbox' )->numParams( $count )->escaped()}
        </div>
    </div>
    <div class="four columns">
        <button type="submit" class="mw-ui-button mw-ui-progressive">$button</button>
    </div>
</form>
<div class="row sandbox-message">$message</div>

HTML;
    }

    protected function getGroupName(): string {
        return 'wiki';
    }
}