src/Statistics/MessageGroupStatsSpecialPage.php
<?php
declare( strict_types = 1 );
namespace MediaWiki\Extension\Translate\Statistics;
use JobQueueGroup;
use MediaWiki\Config\Config;
use MediaWiki\Config\ServiceOptions;
use MediaWiki\Deferred\DeferredUpdates;
use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
use MediaWiki\Extension\Translate\MessageProcessing\MessageGroupMetadata;
use MediaWiki\Extension\Translate\TranslatorInterface\EntitySearch;
use MediaWiki\Html\Html;
use MediaWiki\HTMLForm\HTMLForm;
use MediaWiki\Languages\LanguageNameUtils;
use MediaWiki\SpecialPage\SpecialPage;
use MessagePrefixMessageGroup;
/**
* Implements includable special page Special:MessageGroupStats which provides
* translation statistics for all languages for a group.
*
* @author Niklas Laxström
* @author Siebrand Mazeland
* @license GPL-2.0-or-later
* @ingroup SpecialPage TranslateSpecialPage Stats
*/
class MessageGroupStatsSpecialPage extends SpecialPage {
/** Whether to hide rows which are fully translated. */
private bool $noComplete = true;
/** Whether to hide rows which are fully untranslated. */
private bool $noEmpty = false;
/** The target of stats: group id or message prefix. */
private string $target;
/** The target type of stats requested: */
private ?string $targetType = null;
private ServiceOptions $options;
private JobQueueGroup $jobQueueGroup;
private MessageGroupStatsTableFactory $messageGroupStatsTableFactory;
private EntitySearch $entitySearch;
private MessagePrefixStats $messagePrefixStats;
private LanguageNameUtils $languageNameUtils;
private MessageGroupMetadata $messageGroupMetadata;
private const GROUPS = 'group';
private const MESSAGES = 'messages';
private const CONSTRUCTOR_OPTIONS = [
'TranslateMessagePrefixStatsLimit',
];
public function __construct(
Config $config,
JobQueueGroup $jobQueueGroup,
MessageGroupStatsTableFactory $messageGroupStatsTableFactory,
EntitySearch $entitySearch,
MessagePrefixStats $messagePrefixStats,
LanguageNameUtils $languageNameUtils,
MessageGroupMetadata $messageGroupMetadata
) {
parent::__construct( 'MessageGroupStats' );
$this->options = new ServiceOptions( self::CONSTRUCTOR_OPTIONS, $config );
$this->jobQueueGroup = $jobQueueGroup;
$this->messageGroupStatsTableFactory = $messageGroupStatsTableFactory;
$this->entitySearch = $entitySearch;
$this->messagePrefixStats = $messagePrefixStats;
$this->languageNameUtils = $languageNameUtils;
$this->messageGroupMetadata = $messageGroupMetadata;
}
public function getDescription() {
return $this->msg( 'translate-mgs-pagename' );
}
public function isIncludable() {
return true;
}
protected function getGroupName() {
return 'translation';
}
public function execute( $par ) {
$request = $this->getRequest();
$purge = $request->getVal( 'action' ) === 'purge';
if ( $purge && !$request->wasPosted() ) {
LanguageStatsSpecialPage::showPurgeForm( $this->getContext() );
return;
}
$this->setHeaders();
$this->outputHeader();
$out = $this->getOutput();
$out->addModules( 'ext.translate.special.languagestats' );
$out->addModuleStyles( 'ext.translate.statstable' );
$out->addModuleStyles( 'ext.translate.special.groupstats' );
$params = $par ? explode( '/', $par ) : [];
if ( isset( $params[0] ) && trim( $params[0] ) ) {
$this->target = $params[0];
}
if ( isset( $params[1] ) ) {
$this->noComplete = (bool)$params[1];
}
if ( isset( $params[2] ) ) {
$this->noEmpty = (bool)$params[2];
}
// Whether the form has been submitted, only relevant if not including
$submitted = !$this->including() && $request->getVal( 'x' ) === 'D';
$this->target = $request->getVal( self::GROUPS, $this->target ?? '' );
if ( $this->target !== '' ) {
$this->targetType = self::GROUPS;
} else {
$this->target = $request->getVal( self::MESSAGES, '' );
if ( $this->target !== '' ) {
$this->targetType = self::MESSAGES;
}
}
// Default booleans to false if the form was submitted
$this->noComplete = $request->getBool(
'suppresscomplete',
$this->noComplete && !$submitted
);
$this->noEmpty = $request->getBool( 'suppressempty', $this->noEmpty && !$submitted );
if ( !$this->including() ) {
$out->addHelpLink( 'Help:Extension:Translate/Statistics_and_reporting' );
$this->addForm();
}
$stats = $output = null;
if ( $this->targetType === self::GROUPS && $this->isValidGroup( $this->target ) ) {
$this->outputIntroduction();
$stats = $this->loadStatistics( $this->target, MessageGroupStats::FLAG_CACHE_ONLY );
$messageGroupStatsTable = $this->messageGroupStatsTableFactory->newFromContext( $this->getContext() );
$output = $messageGroupStatsTable->get(
$stats,
MessageGroups::getGroup( $this->target ),
$this->noComplete,
$this->noEmpty
);
$incomplete = $messageGroupStatsTable->areStatsIncomplete();
if ( $incomplete ) {
$out->wrapWikiMsg(
"<div class='error'>$1</div>",
'translate-langstats-incomplete'
);
}
if ( $incomplete || $purge ) {
DeferredUpdates::addCallableUpdate( function () use ( $purge ) {
// Attempt to recache on the fly the missing stats, unless a
// purge was requested, because that is likely to time out.
// Even though this is executed inside a deferred update, it
// counts towards the maximum execution time limit. If that is
// reached, or any other failure happens, no updates at all
// will be written into the database, as it does only single
// update at the end. Hence we always add a job too, so that
// even the slower updates will get done at some point. In
// regular case (no purge), the job sees that the stats are
// already updated, so it is not much of an overhead.
$jobParams = $this->getCacheRebuildJobParameters( $this->target );
$jobParams[ 'purge' ] = $purge;
$job = RebuildMessageGroupStatsJob::newJob( $jobParams );
$this->jobQueueGroup->push( $job );
// $purge is only true if request was posted
if ( !$purge ) {
$this->loadStatistics( $this->target );
}
} );
}
} elseif ( $this->targetType === self::MESSAGES ) {
$messagesWithPrefix = $this->entitySearch->matchMessages( $this->target );
if ( $messagesWithPrefix ) {
$messageWithPrefixLimit = $this->options->get( 'TranslateMessagePrefixStatsLimit' );
if ( count( $messagesWithPrefix ) > $messageWithPrefixLimit ) {
$out->addHTML(
Html::errorBox(
$this->msg( 'translate-mgs-message-prefix-limit' )
->params( $messageWithPrefixLimit )
->parse()
)
);
return;
}
$stats = $this->messagePrefixStats->forAll( ...$messagesWithPrefix );
$messageGroupStatsTable = $this->messageGroupStatsTableFactory
->newFromContext( $this->getContext() );
$output = $messageGroupStatsTable->get(
$stats,
new MessagePrefixMessageGroup(),
$this->noComplete,
$this->noEmpty
);
}
}
if ( $output ) {
// If output is present, put it on the page
$out->addHTML( $output );
} elseif ( $stats !== null ) {
// Output not present, but stats are present. Probably an issue?
$out->addHTML( Html::warningBox( $this->msg( 'translate-mgs-nothing' )->parse() ) );
} elseif ( $submitted ) {
$this->invalidTarget();
}
}
private function loadStatistics( string $target, int $flags = 0 ): array {
return MessageGroupStats::forGroup( $target, $flags );
}
private function getCacheRebuildJobParameters( string $target ): array {
return [ 'groupid' => $target ];
}
private function isValidGroup( ?string $value ): bool {
if ( $value === null ) {
return false;
}
$group = MessageGroups::getGroup( $value );
if ( $group ) {
if ( MessageGroups::isDynamic( $group ) ) {
/* Dynamic groups are not listed, but it is possible to end up
* on this page with a dynamic group by navigating from
* translation or proofreading activity or by giving group id
* of dynamic group explicitly. Ignore dynamic group to avoid
* throwing exceptions later. */
$group = false;
} else {
$this->target = $group->getId();
}
}
return (bool)$group;
}
private function invalidTarget(): void {
$this->getOutput()->wrapWikiMsg(
"<div class='error'>$1</div>",
[ 'translate-mgs-invalid-group', $this->target ]
);
}
private function outputIntroduction(): void {
$priorityLangs = $this->messageGroupMetadata->get( $this->target, 'prioritylangs' );
if ( $priorityLangs ) {
$languagesFormatted = $this->formatLanguageList( explode( ',', $priorityLangs ) );
$hasPriorityForce = $this->messageGroupMetadata->get( $this->target, 'priorityforce' ) === 'on';
if ( $hasPriorityForce ) {
$this->getOutput()->addWikiMsg( 'tpt-priority-languages-force', $languagesFormatted );
} else {
$this->getOutput()->addWikiMsg( 'tpt-priority-languages', $languagesFormatted );
}
}
}
private function formatLanguageList( array $codes ): string {
foreach ( $codes as &$value ) {
$value = $this->languageNameUtils->getLanguageName( $value, $this->getLanguage()->getCode() )
. $this->msg( 'word-separator' )->plain()
. $this->msg( 'parentheses', $value )->plain();
}
return $this->getLanguage()->listToText( $codes );
}
private function addForm(): void {
$formDescriptor = [
'select' => [
'type' => 'select',
'name' => self::GROUPS,
'id' => self::GROUPS,
'label' => $this->msg( 'translate-mgs-group' )->text(),
'options' => $this->getGroupOptions(),
'default' => $this->targetType === self::GROUPS ? $this->target : null,
'cssclass' => 'message-group-selector'
],
'input' => [
'type' => 'text',
'name' => self::MESSAGES,
'id' => self::MESSAGES,
'label' => $this->msg( 'translate-mgs-prefix' )->text(),
'default' => $this->targetType === self::MESSAGES ? $this->target : null,
'cssclass' => 'message-prefix-selector'
],
'nocomplete-check' => [
'type' => 'check',
'name' => 'suppresscomplete',
'id' => 'suppresscomplete',
'label' => $this->msg( 'translate-mgs-nocomplete' )->text(),
'default' => $this->noComplete,
],
'noempty-check' => [
'type' => 'check',
'name' => 'suppressempty',
'id' => 'suppressempty',
'label' => $this->msg( 'translate-mgs-noempty' )->text(),
'default' => $this->noEmpty,
]
];
$htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() );
/* Since these pages are in the tabgroup with Special:Translate,
* it makes sense to retain the selected group/language parameter
* on post requests even when not relevant to the current page. */
$val = $this->getRequest()->getVal( 'language' );
if ( $val !== null ) {
$htmlForm->addHiddenField( 'language', $val );
}
$htmlForm
->addHiddenField( 'x', 'D' ) // To detect submission
->setMethod( 'get' )
->setId( 'mw-message-group-stats-form' )
->setSubmitTextMsg( 'translate-mgs-submit' )
->setWrapperLegendMsg( 'translate-mgs-fieldset' )
->prepareForm()
->displayForm( false );
}
/** Creates a simple message group options. */
private function getGroupOptions(): array {
$options = [ '' => null ];
$groups = MessageGroups::getAllGroups();
foreach ( $groups as $id => $class ) {
if ( MessageGroups::getGroup( $id )->exists() ) {
$options[$class->getLabel()] = $id;
}
}
return $options;
}
}