wikimedia/mediawiki-extensions-Translate

View on GitHub
src/TranslatorInterface/TranslateSpecialPage.php

Summary

Maintainability
D
2 days
Test Coverage
<?php
declare( strict_types = 1 );

namespace MediaWiki\Extension\Translate\TranslatorInterface;

use AggregateMessageGroup;
use Language;
use MediaWiki\Config\Config;
use MediaWiki\Extension\Translate\HookRunner;
use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
use MediaWiki\Extension\Translate\Utilities\Utilities;
use MediaWiki\Html\Html;
use MediaWiki\Languages\LanguageFactory;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\Logger\LoggerFactory;
use MediaWiki\MediaWikiServices;
use MediaWiki\SpecialPage\SpecialPage;
use MessageGroup;
use Psr\Log\LoggerInterface;
use Skin;

/**
 * Implements the core of Translate extension - a special page which shows
 * a list of messages in a format defined by Tasks.
 *
 * @author Niklas Laxström
 * @author Siebrand Mazeland
 * @license GPL-2.0-or-later
 * @ingroup SpecialPage TranslateSpecialPage
 */
class TranslateSpecialPage extends SpecialPage {
    private ?MessageGroup $group = null;
    private array $options = [];
    private Language $contentLanguage;
    private LanguageFactory $languageFactory;
    private LanguageNameUtils $languageNameUtils;
    private HookRunner $hookRunner;
    private LoggerInterface $logger;
    private bool $isMessageGroupSubscriptionEnabled;

    public function __construct(
        Language $contentLanguage,
        LanguageFactory $languageFactory,
        LanguageNameUtils $languageNameUtils,
        HookRunner $hookRunner,
        Config $config
    ) {
        parent::__construct( 'Translate' );
        $this->contentLanguage = $contentLanguage;
        $this->languageFactory = $languageFactory;
        $this->languageNameUtils = $languageNameUtils;
        $this->hookRunner = $hookRunner;
        $this->logger = LoggerFactory::getInstance( 'Translate' );
        $this->isMessageGroupSubscriptionEnabled = $config->get( 'TranslateEnableMessageGroupSubscription' );
    }

    public function doesWrites() {
        return true;
    }

    protected function getGroupName() {
        return 'translation';
    }

    /** @inheritDoc */
    public function execute( $parameters ) {
        $out = $this->getOutput();
        $out->addModuleStyles( [
            'ext.translate.special.translate.styles',
            'jquery.uls.grid',
            'mediawiki.ui.button'
        ] );

        $this->setHeaders();

        $this->setup( $parameters );

        // Redirect old export URLs to Special:ExportTranslations
        if ( $this->getRequest()->getText( 'taction' ) === 'export' ) {
            $exportPage = SpecialPage::getTitleFor( 'ExportTranslations' );
            $out->redirect( $exportPage->getLocalURL( $this->options ) );
        }

        $out->addModules( 'ext.translate.special.translate' );
        $out->addJsConfigVars( [
            'wgTranslateLanguages' => Utilities::getLanguageNames( LanguageNameUtils::AUTONYMS ),
            'wgTranslateEnableMessageGroupSubscription' => $this->isMessageGroupSubscriptionEnabled
        ] );

        $out->addHTML( Html::openElement( 'div', [
            // FIXME: Temporary hack. Add better support for dark mode.
            'class' => 'grid ext-translate-container notheme skin-invert',
        ] ) );

        $out->addHTML( $this->tuxSettingsForm() );
        $out->addHTML( $this->messageSelector() );

        $table = new MessageTable( $this->getContext(), $this->group, $this->options['language'] );
        $output = $table->fullTable();

        $out->addHTML( $output );
        $out->addHTML( Html::closeElement( 'div' ) );
    }

    private function setup( ?string $parameters ): void {
        $request = $this->getRequest();

        $defaults = [
            'language' => $this->getLanguage()->getCode(),
            'group' => '!additions',
        ];

        // Dump everything here
        $nonDefaults = [];
        $parameters = array_map( 'trim', explode( ';', (string)$parameters ) );

        foreach ( $parameters as $_ ) {
            if ( $_ === '' ) {
                continue;
            }

            if ( str_contains( $_, '=' ) ) {
                [ $key, $value ] = array_map( 'trim', explode( '=', $_, 2 ) );
            } else {
                $key = 'group';
                $value = $_;
            }

            if ( isset( $defaults[$key] ) ) {
                $nonDefaults[$key] = $value;
            }
        }

        foreach ( array_keys( $defaults ) as $key ) {
            $value = $request->getVal( $key );
            if ( is_string( $value ) ) {
                $nonDefaults[$key] = $value;
            }
        }

        $this->hookRunner->onTranslateGetSpecialTranslateOptions( $defaults, $nonDefaults );

        $this->options = $nonDefaults + $defaults;
        $this->group = MessageGroups::getGroup( $this->options['group'] );
        if ( $this->group ) {
            $this->options['group'] = $this->group->getId();
        } else {
            $this->group = MessageGroups::getGroup( $defaults['group'] );
            if (
                isset( $nonDefaults['group'] ) &&
                str_starts_with( $nonDefaults['group'], 'page-' ) &&
                !str_contains( $nonDefaults['group'], '+' )
            ) {
                // https://phabricator.wikimedia.org/T320220
                $this->logger->debug(
                    "[Special:Translate] Requested group {groupId} doesn't exist.",
                    [ 'groupId' => $nonDefaults['group'] ]
                );
            }
        }

        if ( !$this->languageNameUtils->isKnownLanguageTag( $this->options['language'] ) ) {
            $this->options['language'] = $defaults['language'];
        }

        if ( MessageGroups::isDynamic( $this->group ) ) {
            // @phan-suppress-next-line PhanUndeclaredMethod
            $this->group->setLanguage( $this->options['language'] );
        }
    }

    private function tuxSettingsForm(): string {
        $noJs = Html::errorBox(
            $this->msg( 'tux-nojs' )->escaped(),
            '',
            'tux-nojs'
        );

        $attrs = [ 'class' => 'row tux-editor-header' ];
        $selectors = $this->tuxGroupSelector() .
            $this->tuxLanguageSelector() .
            $this->tuxGroupSubscription() .
            $this->tuxGroupDescription() .
            $this->tuxWorkflowSelector() .
            $this->tuxGroupWarning();

        return Html::rawElement( 'div', $attrs, $selectors ) . $noJs;
    }

    private function messageSelector(): string {
        $output = Html::openElement( 'div', [ 'class' => 'row tux-messagetable-header hide' ] );
        $output .= Html::openElement( 'div', [ 'class' => 'nine columns' ] );
        $output .= Html::openElement( 'ul', [ 'class' => 'row tux-message-selector' ] );
        $userId = $this->getUser()->getId();
        $tabs = [
            'all' => '',
            'untranslated' => '!translated',
            'outdated' => 'fuzzy',
            'translated' => 'translated',
            'unproofread' => "translated|!reviewer:$userId|!last-translator:$userId",
        ];

        foreach ( $tabs as $tab => $filter ) {
            // Possible classes and messages, for grepping:
            // tux-tab-all
            // tux-tab-untranslated
            // tux-tab-outdated
            // tux-tab-translated
            // tux-tab-unproofread
            $tabClass = "tux-tab-$tab";
            $link = Html::element( 'a', [ 'href' => '#' ], $this->msg( $tabClass )->text() );
            $output .= Html::rawElement( 'li', [
                'class' => 'column ' . $tabClass,
                'data-filter' => $filter,
                'data-title' => $tab,
            ], $link );
        }

        // Check boxes for the "more" tab.
        $container = Html::openElement( 'ul', [ 'class' => 'column tux-message-selector' ] );
        $container .= Html::rawElement( 'li',
            [ 'class' => 'column' ],
            Html::element( 'input', [
                'type' => 'checkbox', 'name' => 'optional', 'value' => '1',
                'checked' => false,
                'id' => 'tux-option-optional',
                'data-filter' => 'optional'
            ] ) . "\u{00A0}" . Html::label(
                $this->msg( 'tux-message-filter-optional-messages-label' )->text(),
                'tux-option-optional'
            )
        );

        $container .= Html::closeElement( 'ul' );
        $output .= Html::openElement( 'li', [ 'class' => 'column more' ] ) .
            $this->msg( 'ellipsis' )->escaped() .
            $container .
            Html::closeElement( 'li' );

        $output .= Html::closeElement( 'ul' );
        $output .= Html::closeElement( 'div' ); // close nine columns
        $output .= Html::openElement( 'div', [ 'class' => 'three columns' ] );
        $output .= Html::rawElement(
            'div',
            [ 'class' => 'tux-message-filter-wrapper' ],
            Html::element( 'input', [
                'class' => 'tux-message-filter-box',
                'type' => 'search',
                'placeholder' => $this->msg( 'tux-message-filter-placeholder' )->text()
            ] )
        );

        // close three columns and the row
        $output .= Html::closeElement( 'div' ) . Html::closeElement( 'div' );

        return $output;
    }

    private function tuxGroupSelector(): string {
        $groupClass = [ 'grouptitle', 'grouplink' ];
        $subGroupCount = null;
        if ( $this->group instanceof AggregateMessageGroup ) {
            $groupClass[] = 'tux-breadcrumb__item--aggregate';
            $subGroupCount = count( $this->group->getGroups() );
        }

        // @todo FIXME The selector should have expanded parent-child lists
        return Html::openElement( 'div', [
            'class' => 'eight columns tux-breadcrumb',
            'data-language' => $this->options['language'],
        ] ) .
            Html::element( 'span',
                [ 'class' => 'grouptitle grouplink tux-breadcrumb__item--aggregate' ],
                $this->msg( 'translate-msggroupselector-search-all' )->text()
            ) .
            Html::element( 'span',
                [
                    'class' => $groupClass,
                    'data-msggroupid' => $this->group->getId(),
                    'data-msggroup-subgroup-count' => $subGroupCount
                ],
                $this->group->getLabel( $this->getContext() )
            ) .
            Html::closeElement( 'div' );
    }

    private function tuxLanguageSelector(): string {
        if ( $this->options['language'] === $this->getConfig()->get( 'TranslateDocumentationLanguageCode' ) ) {
            $targetLangName = $this->msg( 'translate-documentation-language' )->text();
            $targetLanguage = $this->contentLanguage;
        } else {
            $targetLangName = $this->languageNameUtils->getLanguageName( $this->options['language'] );
            $targetLanguage = $this->languageFactory->getLanguage( $this->options['language'] );
        }

        $label = Html::element( 'span', [], $this->msg( 'tux-languageselector' )->text() );

        $languageIcon = Html::element(
            'span',
            [ 'class' => 'ext-translate-language-icon' ]
        );

        $targetLanguageName = Html::element(
            'span',
            [
                'class' => 'ext-translate-target-language',
                'dir' => $targetLanguage->getDir(),
                'lang' => $targetLanguage->getHtmlCode()
            ],
            $targetLangName
        );

        $expandIcon = Html::element(
            'span',
            [ 'class' => 'ext-translate-language-selector-expand' ]
        );

        $value = Html::rawElement(
            'span',
            [
                'class' => 'uls mw-ui-button',
                'tabindex' => 0,
                'title' => $this->msg( 'tux-select-target-language' )->text()
            ],
            $languageIcon . $targetLanguageName . $expandIcon
        );

        return Html::rawElement(
            'div',
            [ 'class' => 'four columns ext-translate-language-selector' ],
            "$label $value"
        );
    }

    private function tuxGroupSubscription(): string {
        return Html::rawElement(
            'div',
            [ 'class' => 'twelve columns tux-watch-group' ]
        );
    }

    private function tuxGroupDescription(): string {
        // Initialize an empty warning box to be filled client-side.
        return Html::rawElement(
            'div',
            [ 'class' => 'twelve columns description' ],
            $this->getGroupDescription( $this->group )
        );
    }

    private function getGroupDescription( MessageGroup $group ): string {
        $description = $group->getDescription( $this->getContext() );
        return $description === null ?
            '' : $this->getOutput()->parseAsInterface( $description );
    }

    private function tuxGroupWarning(): string {
        if ( $this->options['group'] === '' ) {
            return Html::warningBox(
                $this->msg( 'tux-translate-page-no-such-group' )->parse(),
                'tux-group-warning twelve column'
            );
        }

        return '';
    }

    private function tuxWorkflowSelector(): string {
        return Html::element( 'div', [ 'class' => 'tux-workflow twelve columns' ] );
    }

    /**
     * Adds the task-based tabs on Special:Translate and few other special pages.
     * Hook: SkinTemplateNavigation::Universal
     */
    public static function tabify( Skin $skin, array &$tabs ): bool {
        $title = $skin->getTitle();
        if ( !$title->isSpecialPage() ) {
            return true;
        }
        [ $alias, $sub ] = MediaWikiServices::getInstance()
            ->getSpecialPageFactory()->resolveAlias( $title->getText() );

        $pagesInGroup = [ 'Translate', 'LanguageStats', 'MessageGroupStats', 'ExportTranslations' ];
        if ( !in_array( $alias, $pagesInGroup, true ) ) {
            return true;
        }

        // Extract subpage syntax, otherwise the values are not passed forward
        $params = [];
        if ( $sub !== null && trim( $sub ) !== '' ) {
            if ( $alias === 'Translate' || $alias === 'MessageGroupStats' ) {
                $params['group'] = $sub;
            } elseif ( $alias === 'LanguageStats' ) {
                // Breaks if additional parameters besides language are code provided
                $params['language'] = $sub;
            }
        }

        $request = $skin->getRequest();
        // However, query string params take precedence
        $params['language'] = $request->getRawVal( 'language' ) ?? '';
        $params['group'] = $request->getRawVal( 'group' ) ?? '';

        // Remove empty values from params
        $params = array_filter( $params, static function ( string $param ) {
            return $param !== '';
        } );

        $translate = SpecialPage::getTitleFor( 'Translate' );
        $languageStatistics = SpecialPage::getTitleFor( 'LanguageStats' );
        $messageGroupStatistics = SpecialPage::getTitleFor( 'MessageGroupStats' );

        // Clear the special page tab that might be there already
        $tabs['namespaces'] = [];

        $tabs['namespaces']['translate'] = [
            'text' => wfMessage( 'translate-taction-translate' )->text(),
            'href' => $translate->getLocalURL( $params ),
            'class' => 'tux-tab',
        ];

        if ( $alias === 'Translate' ) {
            $tabs['namespaces']['translate']['class'] .= ' selected';
        }

        $tabs['views']['lstats'] = [
            'text' => wfMessage( 'translate-taction-lstats' )->text(),
            'href' => $languageStatistics->getLocalURL( $params ),
            'class' => 'tux-tab',
        ];
        if ( $alias === 'LanguageStats' ) {
            $tabs['views']['lstats']['class'] .= ' selected';
        }

        $tabs['views']['mstats'] = [
            'text' => wfMessage( 'translate-taction-mstats' )->text(),
            'href' => $messageGroupStatistics->getLocalURL( $params ),
            'class' => 'tux-tab',
        ];

        if ( $alias === 'MessageGroupStats' ) {
            $tabs['views']['mstats']['class'] .= ' selected';
        }

        $tabs['views']['export'] = [
            'text' => wfMessage( 'translate-taction-export' )->text(),
            'href' => SpecialPage::getTitleFor( 'ExportTranslations' )->getLocalURL( $params ),
            'class' => 'tux-tab',
        ];

        return true;
    }
}