src/TranslatorInterface/TranslateSpecialPage.php
<?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;
}
}