diff options
author | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
---|---|---|
committer | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
commit | fc7369835258467bf97eb64f184b93691f9a9fd5 (patch) | |
tree | daabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/extensions/Translate/specials |
first commit
Diffstat (limited to 'www/wiki/extensions/Translate/specials')
16 files changed, 5871 insertions, 0 deletions
diff --git a/www/wiki/extensions/Translate/specials/SpecialAggregateGroups.php b/www/wiki/extensions/Translate/specials/SpecialAggregateGroups.php new file mode 100644 index 00000000..582a71a8 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialAggregateGroups.php @@ -0,0 +1,289 @@ +<?php +/** + * Contains logic for special page Special:AggregateGroups. + * + * @file + * @author Santhosh Thottingal + * @author Niklas Laxström + * @author Siebrand Mazeland + * @author Kunal Grover + * @license GPL-2.0-or-later + */ + +class SpecialAggregateGroups extends SpecialPage { + protected $hasPermission = false; + + public function __construct() { + parent::__construct( 'AggregateGroups', 'translate-manage' ); + } + + protected function getGroupName() { + return 'wiki'; + } + + public function execute( $parameters ) { + $this->setHeaders(); + + $out = $this->getOutput(); + $out->addModuleStyles( 'ext.translate.special.aggregategroups.styles' ); + + // Check permissions + if ( $this->getUser()->isAllowed( 'translate-manage' ) ) { + $this->hasPermission = true; + } + + $groupsPreload = array_merge( + MessageGroups::getGroupsByType( WikiPageMessageGroup::class ), + MessageGroups::getGroupsByType( AggregateMessageGroup::class ) + ); + TranslateMetadata::preloadGroups( array_keys( $groupsPreload ) ); + + $groups = MessageGroups::getAllGroups(); + uasort( $groups, [ 'MessageGroups', 'groupLabelSort' ] ); + $aggregates = []; + $pages = []; + foreach ( $groups as $group ) { + if ( $group instanceof WikiPageMessageGroup ) { + $pages[] = $group; + } elseif ( $group instanceof AggregateMessageGroup ) { + $subgroups = TranslateMetadata::getSubgroups( $group->getId() ); + if ( $subgroups !== false ) { + $aggregates[] = $group; + } + } + } + + if ( !count( $pages ) ) { + // @todo Use different message + $out->addWikiMsg( 'tpt-list-nopages' ); + + return; + } + + $this->showAggregateGroups( $aggregates ); + } + + /** + * @param AggregateMessageGroup $group + * @return string + */ + protected function showAggregateGroup( $group ) { + $out = ''; + $id = $group->getId(); + $label = $group->getLabel(); + $desc = $group->getDescription( $this->getContext() ); + + $div = Html::openElement( 'div', [ + 'class' => 'mw-tpa-group', + 'data-groupid' => $id, + 'data-id' => $this->htmlIdForGroup( $group ), + ] ); + + $out .= $div; + + $edit = ''; + $remove = ''; + $editGroup = ''; + $select = ''; + $addButton = ''; + + // Add divs for editing Aggregate Groups + if ( $this->hasPermission ) { + // Group edit and remove buttons + $edit = Html::element( 'span', [ 'class' => 'tp-aggregate-edit-ag-button' ] ); + $remove = Html::element( 'span', [ 'class' => 'tp-aggregate-remove-ag-button' ] ); + + // Edit group div + $editGroupNameLabel = $this->msg( 'tpt-aggregategroup-edit-name' )->escaped(); + $editGroupName = Html::input( + 'tp-agg-name', + $label, + 'text', + [ 'class' => 'tp-aggregategroup-edit-name', 'maxlength' => '200' ] + ); + $editGroupDescriptionLabel = $this->msg( 'tpt-aggregategroup-edit-description' )->escaped(); + $editGroupDescription = Html::input( + 'tp-agg-desc', + $desc, + 'text', + [ 'class' => 'tp-aggregategroup-edit-description' ] + ); + $saveButton = Xml::submitButton( + $this->msg( 'tpt-aggregategroup-update' )->text(), + [ 'class' => 'tp-aggregategroup-update' ] + ); + $cancelButton = Xml::submitButton( + $this->msg( 'tpt-aggregategroup-update-cancel' )->text(), + [ 'class' => 'tp-aggregategroup-update-cancel' ] + ); + $editGroup = Html::rawElement( + 'div', + [ + 'class' => 'tp-edit-group hidden' + ], + $editGroupNameLabel . + $editGroupName . '<br />' . + $editGroupDescriptionLabel . + $editGroupDescription . + $saveButton . + $cancelButton + ); + + // Subgroups selector + $select = Html::input( + 'tp-subgroups-input', + '', + 'text', + [ 'class' => 'tp-group-input' ] + ); + $addButton = Html::element( 'input', + [ 'type' => 'button', + 'value' => $this->msg( 'tpt-aggregategroup-add' )->text(), + 'class' => 'tp-aggregate-add-button' ] + ); + } + + // Aggregate Group info div + $groupName = Html::rawElement( 'h2', + [ 'class' => 'tp-name' ], + htmlspecialchars( $label ) . $edit . $remove + ); + $groupDesc = Html::element( 'p', + [ 'class' => 'tp-desc' ], + $desc + ); + $groupInfo = Html::rawElement( 'div', + [ 'class' => 'tp-display-group' ], + $groupName . + $groupDesc + ); + + $out .= $groupInfo; + $out .= $editGroup; + $out .= $this->listSubgroups( $group ); + $out .= $select . $addButton; + $out .= '</div>'; + + return $out; + } + + /** + * @param array $aggregates + */ + protected function showAggregateGroups( array $aggregates ) { + $out = $this->getOutput(); + $out->addModules( 'ext.translate.special.aggregategroups' ); + + $nojs = Html::element( + 'div', + [ 'class' => 'tux-nojs errorbox' ], + $this->msg( 'tux-nojs' )->plain() + ); + + $out->addHTML( $nojs ); + + /** + * @var AggregateMessageGroup $group + */ + foreach ( $aggregates as $group ) { + $out->addHTML( $this->showAggregateGroup( $group ) ); + } + + // Add new group if user has permissions + if ( $this->hasPermission ) { + $out->addHTML( "<br/><a class='tpt-add-new-group' href='#'>" . + $this->msg( 'tpt-aggregategroup-add-new' )->escaped() . + '</a>' ); + $newGroupNameLabel = $this->msg( 'tpt-aggregategroup-new-name' )->escaped(); + $newGroupName = Html::element( + 'input', + [ 'class' => 'tp-aggregategroup-add-name', 'maxlength' => '200' ] + ); + $newGroupDescriptionLabel = $this->msg( 'tpt-aggregategroup-new-description' )->escaped(); + $newGroupDescription = Html::element( 'input', + [ 'class' => 'tp-aggregategroup-add-description' ] + ); + $saveButton = Html::element( 'input', [ + 'type' => 'button', + 'value' => $this->msg( 'tpt-aggregategroup-save' )->text(), + 'id' => 'tpt-aggregategroups-save', + 'class' => 'tp-aggregate-save-button' + ] ); + $newGroupDiv = Html::rawElement( + 'div', + [ 'class' => 'tpt-add-new-group hidden' ], + "$newGroupNameLabel $newGroupName<br />" . + "$newGroupDescriptionLabel $newGroupDescription<br />$saveButton" + ); + $out->addHTML( $newGroupDiv ); + } + } + + /** + * @param AggregateMessageGroup $parent + * @return string + */ + protected function listSubgroups( AggregateMessageGroup $parent ) { + $id = $this->htmlIdForGroup( $parent, 'mw-tpa-grouplist-' ); + $out = Html::openElement( 'ol', [ 'id' => $id ] ); + + // Not calling $parent->getGroups() because it has done filtering already + $subgroupIds = TranslateMetadata::getSubgroups( $parent->getId() ); + + // Get the respective groups and sort them + $subgroups = MessageGroups::getGroupsById( $subgroupIds ); + uasort( $subgroups, [ 'MessageGroups', 'groupLabelSort' ] ); + + // Avoid potentially thousands of separate database queries + $lb = new LinkBatch(); + foreach ( $subgroups as $group ) { + $lb->addObj( $group->getTitle() ); + } + $lb->setCaller( __METHOD__ ); + $lb->execute(); + + // Add missing invalid group ids back, not returned by getGroupsById + foreach ( $subgroupIds as $id ) { + if ( !isset( $subgroups[$id] ) ) { + $subgroups[$id] = null; + } + } + + foreach ( $subgroups as $id => $group ) { + $remove = ''; + if ( $this->hasPermission ) { + $remove = Html::element( 'span', + [ + 'class' => 'tp-aggregate-remove-button', + 'data-groupid' => $id, + ] + ); + } + + if ( $group ) { + $text = $this->getLinkRenderer()->makeKnownLink( $group->getTitle() ); + $note = MessageGroups::getPriority( $id ); + } else { + $text = htmlspecialchars( $id ); + $note = $this->msg( 'tpt-aggregategroup-invalid-group' )->escaped(); + } + + $out .= Html::rawElement( 'li', [], "$text$remove $note" ); + } + $out .= Html::closeElement( 'ol' ); + + return $out; + } + + /** + * @param MessageGroup $group + * @param string $prefix + * @return string + */ + protected function htmlIdForGroup( MessageGroup $group, $prefix = '' ) { + $id = sha1( $group->getId() ); + $id = substr( $id, 5, 8 ); + + return $prefix . $id; + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialExportTranslations.php b/www/wiki/extensions/Translate/specials/SpecialExportTranslations.php new file mode 100644 index 00000000..8c7b3d2e --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialExportTranslations.php @@ -0,0 +1,265 @@ +<?php +/** + * @license GPL-2.0-or-later + * @ingroup SpecialPage TranslateSpecialPage + */ +class SpecialExportTranslations extends SpecialPage { + /** @var string */ + protected $language; + + /** @var string */ + protected $format; + + /** @var string */ + protected $groupId; + + /** @var string[] */ + public static $validFormats = [ 'export-as-po', 'export-to-file' ]; + + public function __construct() { + parent::__construct( 'ExportTranslations' ); + } + + /** + * @param null|string $par + */ + public function execute( $par ) { + $out = $this->getOutput(); + $request = $this->getRequest(); + $lang = $this->getLanguage(); + + $this->setHeaders(); + + $this->groupId = $request->getText( 'group', $par ); + $this->language = $request->getVal( 'language', $lang->getCode() ); + $this->format = $request->getText( 'format' ); + + $this->outputForm(); + + if ( $this->groupId ) { + $status = $this->checkInput(); + if ( !$status->isGood() ) { + TranslateUtils::wrapWikiTextAsInterface( + $out, 'error', + $status->getWikiText( false, false, $lang ) + ); + return; + } + + $this->doExport(); + } + } + + protected function outputForm() { + $fields = [ + 'group' => [ + 'type' => 'select', + 'name' => 'group', + 'id' => 'group', + 'label-message' => 'translate-page-group', + 'options' => $this->getGroupOptions(), + 'default' => $this->groupId, + ], + 'language' => [ + // @todo Apply ULS to this field + 'type' => 'select', + 'name' => 'language', + 'id' => 'language', + 'label-message' => 'translate-page-language', + 'options' => $this->getLanguageOptions(), + 'default' => $this->language, + ], + 'format' => [ + 'type' => 'radio', + 'name' => 'format', + 'id' => 'format', + 'label-message' => 'translate-export-form-format', + 'flatlist' => true, + 'options' => $this->getFormatOptions(), + 'default' => $this->format, + ], + ]; + $form = HTMLForm::factory( 'ooui', $fields, $this->getContext() ); + $form + ->setMethod( 'get' ) + ->setWrapperLegendMsg( 'translate-page-settings-legend' ) + ->setSubmitTextMsg( 'translate-submit' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * @return array + */ + protected function getGroupOptions() { + $selected = $this->groupId; + $groups = MessageGroups::getAllGroups(); + uasort( $groups, [ 'MessageGroups', 'groupLabelSort' ] ); + + $options = []; + foreach ( $groups as $id => $group ) { + if ( !$group->exists() + || ( MessageGroups::getPriority( $group ) === 'discouraged' && $id !== $selected ) + ) { + continue; + } + + $options[$group->getLabel()] = $id; + } + + return $options; + } + + /** + * @return array + */ + protected function getLanguageOptions() { + $languages = TranslateUtils::getLanguageNames( 'en' ); + $options = []; + foreach ( $languages as $code => $name ) { + $options["$code - $name"] = $code; + } + + return $options; + } + + /** + * @return array + */ + protected function getFormatOptions() { + $options = []; + foreach ( self::$validFormats as $format ) { + // translate-taskui-export-to-file, translate-taskui-export-as-po + $options[ $this->msg( "translate-taskui-$format" )->escaped() ] = $format; + } + return $options; + } + + /** + * @return Status + */ + protected function checkInput() { + $status = Status::newGood(); + + $msgGroup = MessageGroups::getGroup( $this->groupId ); + if ( $msgGroup === null ) { + $status->fatal( 'translate-page-no-such-group' ); + } elseif ( MessageGroups::isDynamic( $msgGroup ) ) { + $status->fatal( 'translate-export-not-supported' ); + } + + $langNames = TranslateUtils::getLanguageNames( 'en' ); + if ( !isset( $langNames[$this->language] ) ) { + $status->fatal( 'translate-page-no-such-language' ); + } + + // Do not show this error if no/invalid format is specified for translatable + // page groups as we can show a textarea box containing the translation page text + // (however it's not currently supported for other groups). + if ( !$msgGroup instanceof WikiPageMessageGroup + && !in_array( $this->format, self::$validFormats ) + ) { + $status->fatal( 'translate-export-invalid-format' ); + } + + if ( $this->format === 'export-to-file' + && !$msgGroup instanceof FileBasedMessageGroup + ) { + $status->fatal( 'translate-export-format-notsupported' ); + } + + return $status; + } + + protected function doExport() { + $out = $this->getOutput(); + $group = MessageGroups::getGroup( $this->groupId ); + $collection = $this->setupCollection( $group ); + + switch ( $this->format ) { + case 'export-as-po': + $out->disable(); + + $ffs = null; + if ( $group instanceof FileBasedMessageGroup ) { + $ffs = $group->getFFS(); + } + + if ( !$ffs instanceof GettextFFS ) { + $group = FileBasedMessageGroup::newFromMessageGroup( $group ); + $ffs = new GettextFFS( $group ); + } + + $ffs->setOfflineMode( true ); + + $filename = "{$group->getId()}_{$this->language}.po"; + $this->sendExportHeaders( $filename ); + + echo $ffs->writeIntoVariable( $collection ); + break; + + case 'export-to-file': + $out->disable(); + + $filename = basename( $group->getSourceFilePath( $collection->getLanguage() ) ); + $this->sendExportHeaders( $filename ); + + echo $group->getFFS()->writeIntoVariable( $collection ); + break; + + default: + // @todo Add web viewing for groups other than WikiPageMessageGroup + $pageTranslation = $this->getConfig()->get( 'EnablePageTranslation' ); + if ( $pageTranslation && $group instanceof WikiPageMessageGroup ) { + $collection->loadTranslations(); + $page = TranslatablePage::newFromTitle( $group->getTitle() ); + $text = $page->getParse()->getTranslationPageText( $collection ); + $displayTitle = $page->getPageDisplayTitle( $this->language ); + if ( $displayTitle ) { + $text = "{{DISPLAYTITLE:$displayTitle}}$text"; + } + $box = Html::element( + 'textarea', + [ 'id' => 'wpTextbox', 'rows' => 40, ], + $text + ); + $out->addHTML( $box ); + return; + } + + // This should have been prevented at validation. See checkInput(). + throw new Exception( 'Unexpected export format.' ); + } + } + + private function setupCollection( MessageGroup $group ) { + $collection = $group->initCollection( $this->language ); + + // Don't export ignored, unless it is the source language or message documentation + $translateDocCode = $this->getConfig()->get( 'TranslateDocumentationLanguageCode' ); + if ( $this->language !== $translateDocCode + && $this->language !== $group->getSourceLanguage() + ) { + $collection->filter( 'ignored' ); + } + + $collection->loadTranslations(); + + return $collection; + } + + /** + * Send the appropriate response headers for the export + * + * @param string $fileName + */ + protected function sendExportHeaders( $fileName ) { + $response = $this->getRequest()->response(); + $response->header( 'Content-Type: text/plain; charset=UTF-8' ); + $response->header( "Content-Disposition: attachment; filename=\"$fileName\"" ); + } + + protected function getGroupName() { + return 'wiki'; + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialImportTranslations.php b/www/wiki/extensions/Translate/specials/SpecialImportTranslations.php new file mode 100644 index 00000000..fcdd1ace --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialImportTranslations.php @@ -0,0 +1,244 @@ +<?php +/** + * Contains logic for special page Special:ImportTranslations. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ + +/** + * Special page to import Gettext (.po) files exported using Translate extension. + * Does not support generic Gettext files. + * + * @ingroup SpecialPage TranslateSpecialPage + */ +class SpecialImportTranslations extends SpecialPage { + /** + * Set up and fill some dependencies. + */ + public function __construct() { + parent::__construct( 'ImportTranslations', 'translate-import' ); + } + + public function doesWrites() { + return true; + } + + protected function getGroupName() { + return 'wiki'; + } + + /** + * Special page entry point. + * @param null|string $parameters + * @throws PermissionsError + */ + public function execute( $parameters ) { + $this->setHeaders(); + + // Security and validity checks + if ( !$this->userCanExecute( $this->getUser() ) ) { + $this->displayRestrictionError(); + + return; + } + + if ( !$this->getRequest()->wasPosted() ) { + $this->outputForm(); + + return; + } + + if ( !$this->getUser()->matchEditToken( $this->getRequest()->getVal( 'token' ) ) ) { + $this->getOutput()->addWikiMsg( 'session_fail_preview' ); + $this->outputForm(); + + return; + } + + if ( $this->getRequest()->getCheck( 'process' ) ) { + $data = $this->getCachedData(); + if ( !$data ) { + $this->getOutput()->addWikiMsg( 'session_fail_preview' ); + $this->outputForm(); + + return; + } + } else { + /** + * Proceed to loading and parsing if possible + * @todo: use a Status object instead? + */ + $file = null; + $msg = $this->loadFile( $file ); + if ( $this->checkError( $msg ) ) { + return; + } + + $msg = $this->parseFile( $file ); + if ( $this->checkError( $msg ) ) { + return; + } + + $data = $msg[1]; + $this->setCachedData( $data ); + } + + $messages = $data['MESSAGES']; + $group = $data['METADATA']['group']; + $code = $data['METADATA']['code']; + + if ( !MessageGroups::exists( $group ) ) { + $errorWrap = "<div class='error'>\n$1\n</div>"; + $this->getOutput()->wrapWikiMsg( $errorWrap, 'translate-import-err-stale-group' ); + + return; + } + + $importer = new MessageWebImporter( $this->getPageTitle(), $group, $code ); + $alldone = $importer->execute( $messages ); + + if ( $alldone ) { + $this->deleteCachedData(); + } + } + + /** + * Checks for error state from the return value of loadFile and parseFile + * functions. Prints the error and the form and returns true if there is an + * error. Returns false and does nothing if there is no error. + * @param array $msg + * @return bool + */ + protected function checkError( $msg ) { + // Give grep a chance to find the usages: + // translate-import-err-dl-failed, translate-import-err-ul-failed, + // translate-import-err-invalid-title, translate-import-err-no-such-file, + // translate-import-err-stale-group, translate-import-err-no-headers, + // translate-import-err-warnings + if ( $msg[0] !== 'ok' ) { + $errorWrap = "<div class='error'>\n$1\n</div>"; + $msg[0] = 'translate-import-err-' . $msg[0]; + $this->getOutput()->wrapWikiMsg( $errorWrap, $msg ); + $this->outputForm(); + + return true; + } + + return false; + } + + /** + * Constructs and outputs file input form with supported methods. + */ + protected function outputForm() { + $this->getOutput()->addModules( 'ext.translate.special.importtranslations' ); + $this->getOutput()->addHelpLink( 'Help:Extension:Translate/Off-line_translation' ); + /** + * Ugly but necessary form building ahead, ohoy + */ + $this->getOutput()->addHTML( + Xml::openElement( 'form', [ + 'action' => $this->getPageTitle()->getLocalURL(), + 'method' => 'post', + 'enctype' => 'multipart/form-data', + 'id' => 'mw-translate-import', + ] ) . + Html::hidden( 'token', $this->getUser()->getEditToken() ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Xml::inputLabel( + $this->msg( 'translate-import-from-local' )->text(), + 'upload-local', // name + 'mw-translate-up-local-input', // id + 50, // size + $this->getRequest()->getText( 'upload-local' ), + [ 'type' => 'file' ] + ) . + Xml::submitButton( $this->msg( 'translate-import-load' )->text() ) . + Xml::closeElement( 'form' ) + ); + } + + /** + * Try to get the file data from any of the supported methods. + * @param string &$filedata + * @return array + */ + protected function loadFile( &$filedata ) { + $filename = $this->getRequest()->getFileTempname( 'upload-local' ); + + if ( !is_uploaded_file( $filename ) ) { + return [ 'ul-failed' ]; + } + + $filedata = file_get_contents( $filename ); + + return [ 'ok' ]; + } + + /** + * Try parsing file. + * @param string $data + * @return array + */ + protected function parseFile( $data ) { + /** Construct a dummy group for us... + * @todo Time to rethink the interface again? + * @var FileBasedMessageGroup $group + */ + $group = MessageGroupBase::factory( [ + 'FILES' => [ + 'class' => 'GettextFFS', + 'CtxtAsKey' => true, + ], + 'BASIC' => [ + 'class' => 'FileBasedMessageGroup', + 'namespace' => -1, + ] + ] ); + + $ffs = new GettextFFS( $group ); + $data = $ffs->readFromVariable( $data ); + + /** + * Special data added by GettextFFS + */ + $metadata = $data['METADATA']; + + /** + * This should catch everything that is not a gettext file exported from us + */ + if ( !isset( $metadata['code'] ) || !isset( $metadata['group'] ) ) { + return [ 'no-headers' ]; + } + + /** + * And check for stupid editors that drop msgctxt which + * unfortunately breaks submission. + */ + if ( isset( $metadata['warnings'] ) ) { + return [ 'warnings', $this->getLanguage()->commaList( $metadata['warnings'] ) ]; + } + + return [ 'ok', $data ]; + } + + protected function setCachedData( $data ) { + $key = wfMemcKey( 'translate', 'webimport', $this->getUser()->getId() ); + wfGetCache( CACHE_DB )->set( $key, $data, 60 * 30 ); + } + + protected function getCachedData() { + $key = wfMemcKey( 'translate', 'webimport', $this->getUser()->getId() ); + + return wfGetCache( CACHE_DB )->get( $key ); + } + + protected function deleteCachedData() { + $key = wfMemcKey( 'translate', 'webimport', $this->getUser()->getId() ); + + return wfGetCache( CACHE_DB )->delete( $key ); + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialLanguageStats.php b/www/wiki/extensions/Translate/specials/SpecialLanguageStats.php new file mode 100644 index 00000000..94238370 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialLanguageStats.php @@ -0,0 +1,565 @@ +<?php +/** + * Contains logic for special page Special:LanguageStats. + * + * @file + * @author Siebrand Mazeland + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Implements includable special page Special:LanguageStats which provides + * translation statistics for all defined message groups. + * + * Loosely based on the statistics code in phase3/maintenance/language + * + * Use {{Special:LanguageStats/nl/1}} to show for 'nl' and suppress completely + * translated groups. + * + * @ingroup SpecialPage TranslateSpecialPage Stats + */ +class SpecialLanguageStats extends SpecialPage { + /** + * @var StatsTable + */ + protected $table; + + /** + * @var Array + */ + protected $targetValueName = [ 'code', 'language' ]; + + /** + * Most of the displayed numbers added together at the bottom of the table. + */ + protected $totals; + + /** + * Flag to set if nothing to show. + * @var bool + */ + protected $nothing = false; + + /** + * Flag to set if not all numbers are available. + * @var bool + */ + protected $incomplete = false; + + /** + * Whether to hide rows which are fully translated. + * @var bool + */ + protected $noComplete = true; + + /** + * Whether to hide rows which are fully untranslated. + * @var bool + */ + protected $noEmpty = false; + + /** + * The target of stats, language code or group id. + */ + protected $target; + + /** + * Whether to regenerate stats. Activated by action=purge in query params. + * @var bool + */ + protected $purge; + + /** + * Helper variable to avoid overcounting message groups that appear + * multiple times in the list with different parents. Aggregate message + * group stats are always excluded from totals. + * + * @var array + */ + protected $statsCounted = []; + + /** + * @var array + */ + protected $states; + + public function __construct() { + parent::__construct( 'LanguageStats' ); + + $this->target = $this->getLanguage()->getCode(); + $this->totals = MessageGroupStats::getEmptyStats(); + } + + public function isIncludable() { + return true; + } + + protected function getGroupName() { + return 'wiki'; + } + + public function execute( $par ) { + $request = $this->getRequest(); + + $this->purge = $request->getVal( 'action' ) === 'purge'; + if ( $this->purge && !$request->wasPosted() ) { + $this->showPurgeForm(); + return; + } + + $this->table = new StatsTable(); + + $this->setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + + $out->addModules( 'ext.translate.special.languagestats' ); + $out->addModuleStyles( 'ext.translate.statstable' ); + + $params = 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'; + + // Default booleans to false if the form was submitted + foreach ( $this->targetValueName as $key ) { + $this->target = $request->getVal( $key, $this->target ); + } + $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(); + } + + if ( $this->isValidValue( $this->target ) ) { + $this->outputIntroduction(); + + $stats = $this->loadStatistics( $this->target, MessageGroupStats::FLAG_CACHE_ONLY ); + $output = $this->getTable( $stats ); + if ( $this->incomplete ) { + $out->wrapWikiMsg( + "<div class='error'>$1</div>", + 'translate-langstats-incomplete' + ); + } + + if ( $this->incomplete || $this->purge ) { + DeferredUpdates::addCallableUpdate( function () { + // 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' ] = $this->purge; + $job = MessageGroupStatsRebuildJob::newJob( $jobParams ); + JobQueueGroup::singleton()->push( $job ); + + // $this->purge is only true if request was posted + if ( !$this->purge ) { + $this->loadStatistics( $this->target ); + } + } ); + } + if ( $this->nothing ) { + $out->wrapWikiMsg( "<div class='error'>$1</div>", 'translate-mgs-nothing' ); + } + $out->addHTML( $output ); + } elseif ( $submitted ) { + $this->invalidTarget(); + } + } + + /** + * Get stats. + * @param string $target For which target to get stats + * @param int $flags See MessageGroupStats for possible flags + * @return array[] + */ + protected function loadStatistics( $target, $flags = 0 ) { + return MessageGroupStats::forLanguage( $target, $flags ); + } + + protected function getCacheRebuildJobParameters( $target ) { + return [ 'languagecode' => $target ]; + } + + /** + * Return true if language exist in the list of allowed languages or false otherwise. + * @param string $value + * @return bool + */ + protected function isValidValue( $value ) { + $langs = Language::fetchLanguageNames(); + + return isset( $langs[$value] ); + } + + /** + * Called when the target is unknown. + */ + protected function invalidTarget() { + $this->getOutput()->wrapWikiMsg( + "<div class='error'>$1</div>", + 'translate-page-no-such-language' + ); + } + + protected function showPurgeForm() { + $formDescriptor[ 'intro' ] = [ + 'type' => 'info', + 'vertical-label' => true, + 'raw' => true, + 'default' => $this->msg( 'confirm-purge-top' )->parse() + ]; + + $context = new DerivativeContext( $this->getContext() ); + $requestValues = $this->getRequest()->getQueryValues(); + + HTMLForm::factory( 'ooui', $formDescriptor, $context ) + ->setWrapperLegendMsg( 'confirm-purge-title' ) + ->setSubmitTextMsg( 'confirm_purge_button' ) + ->addHiddenFields( $requestValues ) + ->show(); + } + + /** + * HTMLForm for the top form rendering. + */ + protected function addForm() { + $formDescriptor[ 'language' ] = [ + 'type' => 'text', + 'name' => 'language', + 'id' => 'language', + 'label' => $this->msg( 'translate-language-code-field-name' )->text(), + 'size' => 10, + 'default' => $this->target, + ]; + $formDescriptor[ 'suppresscomplete' ] = [ + 'type' => 'check', + 'label' => $this->msg( 'translate-suppress-complete' )->text(), + 'name' => 'suppresscomplete', + 'id' => 'suppresscomplete', + 'default' => $this->noComplete, + ]; + $formDescriptor[ 'suppressempty' ] = [ + 'type' => 'check', + 'label' => $this->msg( 'translate-ls-noempty' )->text(), + 'name' => 'suppressempty', + 'id' => 'suppressempty', + 'default' => $this->noEmpty, + ]; + + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $context ); + + /* 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( 'group' ); + if ( $val !== null ) { + $htmlForm->addHiddenField( 'group', $val ); + } + + $htmlForm + ->addHiddenField( 'x', 'D' ) // To detect submission + ->setMethod( 'get' ) + ->setSubmitTextMsg( 'translate-ls-submit' ) + ->setWrapperLegendMsg( 'translate-mgs-fieldset' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * Output something helpful to guide the confused user. + */ + protected function outputIntroduction() { + $languageName = TranslateUtils::getLanguageName( + $this->target, + $this->getLanguage()->getCode() + ); + + $rcInLangLink = $this->getLinkRenderer()->makeKnownLink( + SpecialPage::getTitleFor( 'Translate', '!recent' ), + $this->msg( 'languagestats-recenttranslations' )->text(), + [], + [ + 'action' => 'proofread', + 'language' => $this->target + ] + ); + + $out = $this->msg( 'languagestats-stats-for', $languageName )->rawParams( $rcInLangLink ) + ->parseAsBlock(); + $this->getOutput()->addHTML( $out ); + } + + /** + * If workflow states are configured, adds a workflow states column + */ + protected function addWorkflowStatesColumn() { + global $wgTranslateWorkflowStates; + + if ( $wgTranslateWorkflowStates ) { + $this->states = $this->getWorkflowStates(); + + // An array where keys are state names and values are numbers + $this->table->addExtraColumn( $this->msg( 'translate-stats-workflow' ) ); + } + } + + /** + * Returns the value of the workflow state for the given target. + * @param string $target Whose workflow state we want, either the language code or group id + * @return string Workflow state value + */ + protected function getWorkflowStateValue( $target ) { + return $this->states[$target] ?? ''; + } + + /** + * If workflow states are configured, adds a cell with the workflow state to the row, + * @param string $target Whose workflow state do we want, such as language code or group id. + * @param string $state The workflow state id + * @return string Html + */ + protected function getWorkflowStateCell( $target, $state ) { + // This will be set by addWorkflowStatesColumn if needed + if ( !isset( $this->states ) ) { + return ''; + } + + if ( $state === '' ) { + return "\n\t\t" . $this->table->element( '', '', -1 ); + } + + if ( $this instanceof SpecialMessageGroupStats ) { + // Same for every language + $group = MessageGroups::getGroup( $this->target ); + $stateConfig = $group->getMessageGroupStates()->getStates(); + $languageCode = $target; + } else { + // The message group for this row + $group = MessageGroups::getGroup( $target ); + $stateConfig = $group->getMessageGroupStates()->getStates(); + $languageCode = $this->target; + } + + if ( $group->getSourceLanguage() === $languageCode ) { + return "\n\t\t" . $this->table->element( '', '', -1 ); + } + + $sortValue = -1; + $stateColor = ''; + if ( isset( $stateConfig[$state] ) ) { + $sortIndex = array_flip( array_keys( $stateConfig ) ); + $sortValue = $sortIndex[$state] + 1; + + if ( is_string( $stateConfig[$state] ) ) { + // BC for old configuration format + $stateColor = $stateConfig[$state]; + } elseif ( isset( $stateConfig[$state]['color'] ) ) { + $stateColor = $stateConfig[$state]['color']; + } + } + + $stateMessage = $this->msg( "translate-workflow-state-$state" ); + $stateText = $stateMessage->isBlank() ? $state : $stateMessage->text(); + + return "\n\t\t" . $this->table->element( + $stateText, + $stateColor, + $sortValue + ); + } + + /** + * Returns the table itself. + * @param array $stats + * @return string HTML + */ + protected function getTable( $stats ) { + $table = $this->table; + + $this->addWorkflowStatesColumn(); + $out = ''; + + TranslateMetadata::preloadGroups( array_keys( MessageGroups::getAllGroups() ) ); + $structure = MessageGroups::getGroupStructure(); + foreach ( $structure as $item ) { + $out .= $this->makeGroupGroup( $item, $stats ); + } + + if ( $out ) { + $table->setMainColumnHeader( $this->msg( 'translate-ls-column-group' ) ); + $out = $table->createHeader() . "\n" . $out; + $out .= Html::closeElement( 'tbody' ); + + $out .= Html::openElement( 'tfoot' ); + $out .= $table->makeTotalRow( + $this->msg( 'translate-languagestats-overall' ), + $this->totals + ); + $out .= Html::closeElement( 'tfoot' ); + + $out .= Html::closeElement( 'table' ); + + return $out; + } else { + $this->nothing = true; + + return ''; + } + } + + /** + * Creates a html table row for given (top-level) message group. + * If $item is an array, meaning that the first group is an + * AggregateMessageGroup and the latter are its children, it will recurse + * and create rows for them too. + * @param MessageGroup|MessageGroup[] $item + * @param array $cache Cache as returned by MessageGroupStats::forLanguage + * @param MessageGroup|null $parent MessageGroup (do not use, used internally only) + * @return string + */ + protected function makeGroupGroup( $item, array $cache, MessageGroup $parent = null ) { + if ( !is_array( $item ) ) { + return $this->makeGroupRow( $item, $cache, $parent ); + } + + // The first group in the array is the parent AggregateMessageGroup + $out = ''; + $top = array_shift( $item ); + $out .= $this->makeGroupRow( $top, $cache, $parent ); + + // Rest are children + foreach ( $item as $subgroup ) { + $out .= $this->makeGroupGroup( $subgroup, $cache, $top ); + } + + return $out; + } + + /** + * Actually creates the table for single message group, unless it + * is blacklisted or hidden by filters. + * @param MessageGroup $group + * @param array $cache + * @param MessageGroup|null $parent + * @return string + */ + protected function makeGroupRow( MessageGroup $group, array $cache, + MessageGroup $parent = null + ) { + $groupId = $group->getId(); + + if ( $this->table->isBlacklisted( $groupId, $this->target ) !== null ) { + return ''; + } + + $stats = $cache[$groupId]; + $total = $stats[MessageGroupStats::TOTAL]; + $translated = $stats[MessageGroupStats::TRANSLATED]; + $fuzzy = $stats[MessageGroupStats::FUZZY]; + + // Quick checks to see whether filters apply + if ( $this->noComplete && $fuzzy === 0 && $translated === $total ) { + return ''; + } + if ( $this->noEmpty && $translated === 0 && $fuzzy === 0 ) { + return ''; + } + + if ( $total === null ) { + $this->incomplete = true; + } + + // Calculation of summary row values + if ( !$group instanceof AggregateMessageGroup && + !isset( $this->statsCounted[$groupId] ) + ) { + $this->totals = MessageGroupStats::multiAdd( $this->totals, $stats ); + $this->statsCounted[$groupId] = true; + } + + $state = $this->getWorkflowStateValue( $groupId ); + + // Place any state checks like $this->incomplete above this + $params = $stats; + $params[] = $state; + $params[] = md5( $groupId ); + $params[] = $this->getLanguage()->getCode(); + $params[] = md5( $this->target ); + $cachekey = wfMemcKey( __METHOD__ . '-v3', implode( '-', $params ) ); + $cacheval = wfGetCache( CACHE_ANYTHING )->get( $cachekey ); + if ( is_string( $cacheval ) ) { + return $cacheval; + } + + $extra = []; + if ( $translated === $total ) { + $extra = [ 'action' => 'proofread' ]; + } + + $rowParams = []; + $rowParams['data-groupid'] = $groupId; + $rowParams['class'] = get_class( $group ); + if ( $parent ) { + $rowParams['data-parentgroup'] = $parent->getId(); + } + + $out = "\t" . Html::openElement( 'tr', $rowParams ); + $out .= "\n\t\t" . Html::rawElement( 'td', [], + $this->table->makeGroupLink( $group, $this->target, $extra ) ); + $out .= $this->table->makeNumberColumns( $stats ); + $out .= $this->getWorkflowStateCell( $groupId, $state ); + $out .= "\n\t" . Html::closeElement( 'tr' ) . "\n"; + + wfGetCache( CACHE_ANYTHING )->set( $cachekey, $out, 3600 * 24 ); + + return $out; + } + + protected function getWorkflowStates( $field = 'tgr_group', $filter = 'tgr_lang' ) { + $db = wfGetDB( DB_REPLICA ); + $res = $db->select( + 'translate_groupreviews', + [ 'tgr_state', $field ], + [ $filter => $this->target ], + __METHOD__ + ); + + $states = []; + foreach ( $res as $row ) { + $states[$row->$field] = $row->tgr_state; + } + + return $states; + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialMagic.php b/www/wiki/extensions/Translate/specials/SpecialMagic.php new file mode 100644 index 00000000..f8200a7e --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialMagic.php @@ -0,0 +1,243 @@ +<?php +/** + * Contains logic for special page Special:AdvancedTranslate + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ + +/** + * This special page helps with the translations of %MediaWiki features that are + * not in the main messages array (special page aliases, magic words, namespace names). + * + * @ingroup SpecialPage TranslateSpecialPage + */ +class SpecialMagic extends SpecialPage { + const MODULE_MAGIC = 'words'; + const MODULE_SPECIAL = 'special'; + const MODULE_NAMESPACE = 'namespace'; + + /** + * List of supported modules + */ + private $aModules = [ + self::MODULE_SPECIAL, + self::MODULE_NAMESPACE, + self::MODULE_MAGIC + ]; + + /** + * Page options + */ + private $options = []; + private $defaults = []; + private $nondefaults = []; + + public function __construct() { + parent::__construct( 'Magic' ); + } + + public function doesWrites() { + return true; + } + + protected function getGroupName() { + return 'wiki'; + } + + /** + * @see SpecialPage::getDescription + * + * @return string + */ + public function getDescription() { + return $this->msg( 'translate-magic-pagename' )->text(); + } + + /** + * Returns HTML5 output of the form + * GLOBALS: $wgScript + * @return string + */ + protected function getForm() { + global $wgScript; + + $form = Xml::tags( 'form', + [ + 'action' => $wgScript, + 'method' => 'get' + ], + + '<table><tr><td>' . + $this->msg( 'translate-page-language' )->escaped() . + '</td><td>' . + TranslateUtils::languageSelector( + $this->getLanguage()->getCode(), + $this->options['language'] + ) . + '</td></tr><tr><td>' . + $this->msg( 'translate-magic-module' )->escaped() . + '</td><td>' . + $this->moduleSelector( $this->options['module'] ) . + '</td></tr><tr><td colspan="2">' . + Xml::submitButton( $this->msg( 'translate-magic-submit' )->text() ) . ' ' . + Xml::submitButton( + $this->msg( 'translate-magic-cm-export' )->text(), + [ 'name' => 'export' ] + ) . + '</td></tr></table>' . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) + ); + + return $form; + } + + /** + * Helper function get module selector. + * + * @param string $selectedId Which value should be selected by default + * @return string HTML5-compatible select-element. + */ + protected function moduleSelector( $selectedId ) { + // Give grep a chance to find the usages: + // translate-magic-words, translate-magic-special, translate-magic-namespace + $selector = new XmlSelect( 'module', 'module', $selectedId ); + foreach ( $this->aModules as $code ) { + $selector->addOption( $this->msg( 'translate-magic-' . $code )->text(), $code ); + } + + return $selector->getHTML(); + } + + protected function setup( $parameters ) { + $defaults = [ + /* str */'module' => '', + /* str */'language' => $this->getUser()->getOption( 'language' ), + /* bool */'export' => false, + /* bool */'savetodb' => false + ]; + + /** + * Place where all non default variables will end. + */ + $nondefaults = []; + + /** + * Temporary store possible values parsed from parameters. + */ + $options = $defaults; + $request = $this->getRequest(); + foreach ( $options as $v => $t ) { + if ( is_bool( $t ) ) { + $r = $request->getBool( $v, $options[$v] ); + } elseif ( is_int( $t ) ) { + $r = $request->getInt( $v, $options[$v] ); + } elseif ( is_string( $t ) ) { + $r = $request->getText( $v, $options[$v] ); + } + + if ( !isset( $r ) ) { + throw new MWException( '$r was not set' ); + } + + wfAppendToArrayIfNotDefault( $v, $r, $defaults, $nondefaults ); + } + + $this->defaults = $defaults; + $this->nondefaults = $nondefaults; + $this->options = $nondefaults + $defaults; + } + + /** + * The special page running code + * + * @param null|string $parameters + * @throws MWException|PermissionsError + */ + public function execute( $parameters ) { + $this->setup( $parameters ); + $this->setHeaders(); + + $out = $this->getOutput(); + $out->addHelpLink( '//translatewiki.net/wiki/FAQ#Special:AdvancedTranslate', true ); + + $out->addHTML( $this->getForm() ); + + if ( !$this->options['module'] ) { + return; + } + switch ( $this->options['module'] ) { + case 'alias': + case self::MODULE_SPECIAL: + $o = new SpecialPageAliasesCM( $this->options['language'] ); + break; + case self::MODULE_MAGIC: + $o = new MagicWordsCM( $this->options['language'] ); + break; + case self::MODULE_NAMESPACE: + $o = new NamespaceCM( $this->options['language'] ); + break; + default: + throw new MWException( "Unknown module {$this->options['module']}" ); + } + + $request = $this->getRequest(); + if ( $this->options['savetodb'] && $request->wasPosted() ) { + if ( !$this->getUser()->isAllowed( 'translate' ) ) { + throw new PermissionsError( 'translate' ); + } + + $errors = []; + $o->loadFromRequest( $request ); + $o->validate( $errors ); + if ( $errors ) { + $out->wrapWikiMsg( '<div class="error">$1</div>', + 'translate-magic-notsaved' ); + $this->outputErrors( $errors ); + $out->addHTML( $o->output() ); + + return; + } else { + $o->save( $request ); + $out->wrapWikiMsg( '<strong>$1</strong>', 'translate-magic-saved' ); + $out->addHTML( $o->output() ); + + return; + } + } + + if ( $this->options['export'] ) { + $output = $o->export(); + if ( $output === '' ) { + $out->addWikiMsg( 'translate-magic-nothing-to-export' ); + + return; + } + $result = Xml::element( 'textarea', [ 'rows' => '30' ], $output ); + $out->addHTML( $result ); + + return; + } + + $out->addWikiMsg( 'translate-magic-help' ); + $errors = []; + $o->validate( $errors ); + if ( $errors ) { + $this->outputErrors( $errors ); + } + $out->addHTML( $o->output() ); + } + + protected function outputErrors( $errors ) { + $count = $this->getLanguage()->formatNum( count( $errors ) ); + $out = $this->getOutput(); + $out->addWikiMsg( 'translate-magic-errors', $count ); + $out->addHTML( '<ol>' ); + foreach ( $errors as $error ) { + $out->addHTML( "<li>$error</li>" ); + } + $out->addHTML( '</ol>' ); + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialManageGroups.php b/www/wiki/extensions/Translate/specials/SpecialManageGroups.php new file mode 100644 index 00000000..6a0bc9d3 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialManageGroups.php @@ -0,0 +1,374 @@ +<?php +/** + * Implements special page for group management, where file based message + * groups are be managed. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ + +/** + * Class for special page Special:ManageMessageGroups. On this special page + * file based message groups can be managed (FileBasedMessageGroup). This page + * allows updating of the file cache, import and fuzzy for source language + * messages, as well as import/update of messages in other languages. + * + * @ingroup SpecialPage TranslateSpecialPage + * Rewritten in 2012-04-23 + */ +class SpecialManageGroups extends SpecialPage { + const RIGHT = 'translate-manage'; + + /** + * @var DifferenceEngine + */ + protected $diff; + + /** + * @var string Path to the change cdb file. + */ + protected $cdb; + + public function __construct() { + // Anyone is allowed to see, but actions are restricted + parent::__construct( 'ManageMessageGroups' ); + } + + public function doesWrites() { + return true; + } + + protected function getGroupName() { + return 'wiki'; + } + + public function getDescription() { + return $this->msg( 'managemessagegroups' )->text(); + } + + public function execute( $par ) { + $this->setHeaders(); + $out = $this->getOutput(); + $out->addModuleStyles( 'ext.translate.special.managegroups' ); + $out->addHelpLink( 'Help:Extension:Translate/Group_management' ); + + $name = $par ?: MessageChangeStorage::DEFAULT_NAME; + + $this->cdb = MessageChangeStorage::getCdbPath( $name ); + if ( !MessageChangeStorage::isValidCdbName( $name ) || !file_exists( $this->cdb ) ) { + // @todo Tell them when changes was last checked/process + // or how to initiate recheck. + $out->addWikiMsg( 'translate-smg-nochanges' ); + + return; + } + + $user = $this->getUser(); + $allowed = $user->isAllowed( self::RIGHT ); + + $req = $this->getRequest(); + if ( !$req->wasPosted() ) { + $this->showChanges( $allowed, $this->getLimit() ); + + return; + } + + $token = $req->getVal( 'token' ); + if ( !$allowed || !$user->matchEditToken( $token ) ) { + throw new PermissionsError( self::RIGHT ); + } + + $this->processSubmit(); + } + + /** + * How many changes can be shown per page. + * @return int + */ + protected function getLimit() { + $limits = [ + 1000, // Default max + ini_get( 'max_input_vars' ), + ini_get( 'suhosin.post.max_vars' ), + ini_get( 'suhosin.request.max_vars' ) + ]; + // Ignore things not set + $limits = array_filter( $limits ); + return min( $limits ); + } + + protected function getLegend() { + $text = $this->diff->addHeader( + '', + $this->msg( 'translate-smg-left' )->escaped(), + $this->msg( 'translate-smg-right' )->escaped() + ); + + return Html::rawElement( 'div', [ 'class' => 'mw-translate-smg-header' ], $text ); + } + + protected function showChanges( $allowed, $limit ) { + global $wgContLang; + + $diff = new DifferenceEngine( $this->getContext() ); + $diff->showDiffStyle(); + $diff->setReducedLineNumbers(); + $this->diff = $diff; + + $out = $this->getOutput(); + $out->addHTML( + '' . + Html::openElement( 'form', [ 'method' => 'post' ] ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::hidden( 'token', $this->getUser()->getEditToken() ) . + $this->getLegend() + ); + + // The above count as two + $limit = $limit - 2; + + $reader = \Cdb\Reader::open( $this->cdb ); + $groups = unserialize( $reader->get( '#keys' ) ); + foreach ( $groups as $id ) { + $group = MessageGroups::getGroup( $id ); + if ( !$group ) { + continue; + } + + $changes = unserialize( $reader->get( $id ) ); + $out->addHTML( Html::element( 'h2', [], $group->getLabel() ) ); + + // Reduce page existance queries to one per group + $lb = new LinkBatch(); + $ns = $group->getNamespace(); + $isCap = MWNamespace::isCapitalized( $ns ); + foreach ( $changes as $code => $subchanges ) { + foreach ( $subchanges as $messages ) { + foreach ( $messages as $params ) { + // Constructing title objects is way slower + $key = $params['key']; + if ( $isCap ) { + $key = $wgContLang->ucfirst( $key ); + } + $lb->add( $ns, "$key/$code" ); + } + } + } + $lb->execute(); + + foreach ( $changes as $code => $subchanges ) { + foreach ( $subchanges as $type => $messages ) { + foreach ( $messages as $params ) { + $change = $this->formatChange( $group, $code, $type, $params, $limit ); + $out->addHTML( $change ); + + if ( $limit <= 0 ) { + // We need to restrict the changes per page per form submission + // limitations as well as performance. + $out->wrapWikiMsg( "<div class=warning>\n$1\n</div>", 'translate-smg-more' ); + break 4; + } + } + } + } + } + + $attribs = [ 'type' => 'submit', 'class' => 'mw-translate-smg-submit' ]; + if ( !$allowed ) { + $attribs['disabled'] = 'disabled'; + $attribs['title'] = $this->msg( 'translate-smg-notallowed' )->text(); + } + $button = Html::element( 'button', $attribs, $this->msg( 'translate-smg-submit' )->text() ); + $out->addHTML( $button ); + $out->addHTML( Html::closeElement( 'form' ) ); + } + + /** + * @param MessageGroup $group + * @param string $code + * @param string $type + * @param array $params + * @param int &$limit + * @return string HTML + */ + protected function formatChange( MessageGroup $group, $code, $type, $params, &$limit ) { + $key = $params['key']; + $title = Title::makeTitleSafe( $group->getNamespace(), "$key/$code" ); + $id = self::changeId( $group->getId(), $code, $type, $key ); + + if ( $title && $type === 'addition' && $title->exists() ) { + // The message has for some reason dropped out from cache + // or perhaps it is being reused. In any case treat it + // as a change for display, so the admin can see if + // action is needed and let the message be processed. + // Otherwise it will end up in the postponed category + // forever and will prevent rebuilding the cache, which + // leads to many other annoying problems. + $type = 'change'; + } elseif ( $title && ( $type === 'deletion' || $type === 'change' ) && !$title->exists() ) { + return ''; + } + + $text = ''; + $titleLink = $this->getLinkRenderer()->makeLink( $title ); + + if ( $type === 'deletion' ) { + $wiki = ContentHandler::getContentText( Revision::newFromTitle( $title )->getContent() ); + $oldContent = ContentHandler::makeContent( $wiki, $title ); + $newContent = ContentHandler::makeContent( '', $title ); + + $this->diff->setContent( $oldContent, $newContent ); + + $text = $this->diff->getDiff( $titleLink, '' ); + } elseif ( $type === 'addition' ) { + $oldContent = ContentHandler::makeContent( '', $title ); + $newContent = ContentHandler::makeContent( $params['content'], $title ); + + $this->diff->setContent( $oldContent, $newContent ); + + $text = $this->diff->getDiff( '', $titleLink ); + } elseif ( $type === 'change' ) { + $wiki = ContentHandler::getContentText( Revision::newFromTitle( $title )->getContent() ); + + $handle = new MessageHandle( $title ); + if ( $handle->isFuzzy() ) { + $wiki = '!!FUZZY!!' . str_replace( TRANSLATE_FUZZY, '', $wiki ); + } + + $label = $this->msg( 'translate-manage-action-ignore' )->text(); + $actions = Xml::checkLabel( $label, "i/$id", "i/$id" ); + $limit--; + + if ( $group->getSourceLanguage() === $code ) { + $label = $this->msg( 'translate-manage-action-fuzzy' )->text(); + $actions .= ' ' . Xml::checkLabel( $label, "f/$id", "f/$id", true ); + $limit--; + } + + $oldContent = ContentHandler::makeContent( $wiki, $title ); + $newContent = ContentHandler::makeContent( $params['content'], $title ); + + $this->diff->setContent( $oldContent, $newContent ); + $text .= $this->diff->getDiff( $titleLink, $actions ); + } + + $hidden = Html::hidden( $id, 1 ); + $limit--; + $text .= $hidden; + $classes = "mw-translate-smg-change smg-change-$type"; + + if ( $limit < 0 ) { + // Don't add if one of the fields might get dropped of at submission + return ''; + } + + return Html::rawElement( 'div', [ 'class' => $classes ], $text ); + } + + protected function processSubmit() { + $req = $this->getRequest(); + $out = $this->getOutput(); + + $jobs = []; + $jobs[] = MessageIndexRebuildJob::newJob(); + + $reader = \Cdb\Reader::open( $this->cdb ); + $groups = unserialize( $reader->get( '#keys' ) ); + + $postponed = []; + + foreach ( $groups as $groupId ) { + $group = MessageGroups::getGroup( $groupId ); + $changes = unserialize( $reader->get( $groupId ) ); + + foreach ( $changes as $code => $subchanges ) { + foreach ( $subchanges as $type => $messages ) { + foreach ( $messages as $index => $params ) { + $id = self::changeId( $groupId, $code, $type, $params['key'] ); + if ( $req->getVal( $id ) === null ) { + // We probably hit the limit with number of post parameters. + $postponed[$groupId][$code][$type][$index] = $params; + continue; + } + + if ( $type === 'deletion' || $req->getCheck( "i/$id" ) ) { + continue; + } + + $fuzzy = $req->getCheck( "f/$id" ) ? 'fuzzy' : false; + $key = $params['key']; + $title = Title::makeTitleSafe( $group->getNamespace(), "$key/$code" ); + $jobs[] = MessageUpdateJob::newJob( $title, $params['content'], $fuzzy ); + } + } + + if ( !isset( $postponed[$groupId][$code] ) ) { + $cache = new MessageGroupCache( $groupId, $code ); + $cache->create(); + } + } + } + + JobQueueGroup::singleton()->push( $jobs ); + + $reader->close(); + rename( $this->cdb, $this->cdb . '-' . wfTimestamp() ); + + if ( count( $postponed ) ) { + MessageChangeStorage::writeChanges( $postponed, $this->cdb ); + $this->showChanges( true, $this->getLimit() ); + } else { + $out->addWikiMsg( 'translate-smg-submitted' ); + } + } + + protected static function changeId( $groupId, $code, $type, $key ) { + return 'smg/' . substr( sha1( "$groupId/$code/$type/$key" ), 0, 7 ); + } + + /** + * Adds the task-based tabs on Special:Translate and few other special pages. + * Hook: SkinTemplateNavigation::SpecialPage + * @since 2012-05-14 + * @param Skin $skin + * @param array &$tabs + * @return true + */ + public static function tabify( Skin $skin, array &$tabs ) { + $title = $skin->getTitle(); + list( $alias, ) = TranslateUtils::resolveSpecialPageAlias( $title->getText() ); + + $pagesInGroup = [ + 'ManageMessageGroups' => 'namespaces', + 'AggregateGroups' => 'namespaces', + 'SupportedLanguages' => 'views', + 'TranslationStats' => 'views', + ]; + if ( !isset( $pagesInGroup[$alias] ) ) { + return true; + } + + $skin->getOutput()->addModuleStyles( 'ext.translate.tabgroup' ); + + $tabs['namespaces'] = []; + foreach ( $pagesInGroup as $spName => $section ) { + $spClass = TranslateUtils::getSpecialPage( $spName ); + + // DisabledSpecialPage was added in MW 1.33 + if ( $spClass === null || $spClass instanceof DisabledSpecialPage ) { + continue; // Page explicitly disabled + } + $spTitle = $spClass->getPageTitle(); + + $tabs[$section][strtolower( $spName )] = [ + 'text' => $spClass->getDescription(), + 'href' => $spTitle->getLocalURL(), + 'class' => $alias === $spName ? 'selected' : '', + ]; + } + + return true; + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialManageTranslatorSandbox.php b/www/wiki/extensions/Translate/specials/SpecialManageTranslatorSandbox.php new file mode 100644 index 00000000..28311452 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialManageTranslatorSandbox.php @@ -0,0 +1,334 @@ +<?php +/** + * Contains logic for Special:ManageTranslatorSandbox + * + * @file + * @author Niklas Laxström + * @author Amir E. Aharoni + * @license GPL-2.0-or-later + */ + +/** + * Special page for managing sandboxed users. + * + * @ingroup SpecialPage TranslateSpecialPage + */ +class SpecialManageTranslatorSandbox extends SpecialPage { + /** @var TranslationStashStorage */ + protected $stash; + + public function __construct() { + global $wgTranslateUseSandbox; + parent::__construct( + 'ManageTranslatorSandbox', + 'translate-sandboxmanage', + $wgTranslateUseSandbox + ); + } + + public function doesWrites() { + return true; + } + + protected function getGroupName() { + return 'users'; + } + + public function execute( $params ) { + $this->setHeaders(); + $this->checkPermissions(); + $out = $this->getOutput(); + $out->addModuleStyles( [ + 'ext.translate.special.managetranslatorsandbox.styles', + 'mediawiki.ui.button', + 'jquery.uls.grid' + ] ); + $out->addModules( 'ext.translate.special.managetranslatorsandbox' ); + $this->stash = new TranslationStashStorage( wfGetDB( DB_MASTER ) ); + + $this->prepareForTests(); + $this->showPage(); + } + + /** + * Deletes a user page if it exists. + * This is needed especially when deleting sandbox users + * that were created as part of the integration tests. + * @param User $user + */ + protected function deleteUserPage( $user ) { + $userpage = WikiPage::factory( $user->getUserPage() ); + if ( $userpage->exists() ) { + $dummyError = ''; + $userpage->doDeleteArticleReal( + wfMessage( 'tsb-delete-userpage-summary' )->inContentLanguage()->text(), + false, + 0, + true, + $dummyError, + $this->getUser() + ); + } + } + + /** + * Add users to the sandbox or delete them to facilitate browsers tests. + * Use with caution! + */ + public function prepareForTests() { + global $wgTranslateTestUsers; + + $user = $this->getUser(); + $request = $this->getRequest(); + + if ( !in_array( $user->getName(), $wgTranslateTestUsers, true ) ) { + return; + } + + if ( $request->getVal( 'integrationtesting' ) === 'populate' ) { + // Empty all the users, even if they were created manually + // to ensure the number of users is what the tests expect + $this->emptySandbox(); + + $textUsernamePrefixes = [ 'Pupu', 'Orava' ]; + $testLanguages = [ 'fi', 'uk', 'nl', 'he', 'bn' ]; + $testLanguagesCount = count( $testLanguages ); + + foreach ( $textUsernamePrefixes as $prefix ) { + for ( $i = 0; $i < $testLanguagesCount; $i++ ) { + $name = "$prefix$i"; + + // Get rid of users, even if promoted during tests + $userToDelete = User::newFromName( $name, false ); + $this->deleteUserPage( $userToDelete ); + TranslateSandbox::deleteUser( $userToDelete, 'force' ); + + $user = TranslateSandbox::addUser( $name, "$name@blackhole.io", 'porkkana' ); + $user->setOption( + 'translate-sandbox', + FormatJson::encode( [ + 'languages' => [ $testLanguages[$i] ], + 'comment' => '', + ] ) + ); + + $reminders = []; + for ( $reminderIndex = 0; $reminderIndex < $i; $reminderIndex++ ) { + $reminders[] = wfTimestamp() - $reminderIndex * $i * 10000; + } + + $user->setOption( + 'translate-sandbox-reminders', + implode( '|', $reminders ) + ); + $user->saveSettings(); + + for ( $j = 0; $j < $i; $j++ ) { + $title = Title::makeTitle( + NS_MEDIAWIKI, + wfRandomString( 24 ) . '/' . $testLanguages[$i] + ); + $translation = 'plop'; + $stashedTranslation = new StashedTranslation( $user, $title, $translation ); + $this->stash->addTranslation( $stashedTranslation ); + } + } + } + + // Another account for testing a translator to multiple languages + $oldPolyglotUser = User::newFromName( 'Kissa', false ); + $this->deleteUserPage( $oldPolyglotUser ); + TranslateSandbox::deleteUser( $oldPolyglotUser, 'force' ); + + $polyglotUser = TranslateSandbox::addUser( 'Kissa', 'kissa@blackhole.io', 'porkkana' ); + $polyglotUser->setOption( + 'translate-sandbox', + FormatJson::encode( [ + 'languages' => $testLanguages, + 'comment' => "I know some languages, and I'm a developer.", + ] ) + ); + $polyglotUser->saveSettings(); + for ( $polyglotLang = 0; $polyglotLang < $testLanguagesCount; $polyglotLang++ ) { + $title = Title::makeTitle( + NS_MEDIAWIKI, + wfRandomString( 24 ) . '/' . $testLanguages[$polyglotLang] + ); + $translation = "plop in $testLanguages[$polyglotLang]"; + $stashedTranslation = new StashedTranslation( $polyglotUser, $title, $translation ); + $this->stash->addTranslation( $stashedTranslation ); + } + } elseif ( $request->getVal( 'integrationtesting' ) === 'empty' ) { + $this->emptySandbox(); + } + } + + /** + * Delete all the users in the sandbox. + * Use with caution! + * To facilitate browsers tests. + */ + protected function emptySandbox() { + $users = TranslateSandbox::getUsers(); + foreach ( $users as $user ) { + TranslateSandbox::deleteUser( $user ); + } + } + + /** + * Generates the whole page html and appends it to output + */ + protected function showPage() { + $out = $this->getOutput(); + + $nojs = Html::element( + 'div', + [ 'class' => 'tux-nojs errorbox' ], + $this->msg( 'tux-nojs' )->plain() + ); + $out->addHTML( $nojs ); + + $out->addHTML( <<<HTML +<div class="grid"> + <div class="row"> + <div class="nine columns pane filter">{$this->makeFilter()}</div> + <div class="three columns pane search">{$this->makeSearchBox()}</div> + </div> + <div class="row tsb-body"> + <div class="four columns pane requests"> + {$this->makeList()} + <div class="request-footer"> + <span class="selected-counter"> + {$this->msg( 'tsb-selected-count' )->numParams( 0 )->escaped()} + </span> + + <a href="#" class="older-requests-indicator"></a> + </div> + </div> + <div class="eight columns pane details"></div> + </div> +</div> +HTML + ); + } + + protected function makeFilter() { + return $this->msg( 'tsb-filter-pending' )->escaped(); + } + + protected function makeSearchBox() { + return <<<HTML +<input class="request-filter-box right" + placeholder="{$this->msg( 'tsb-search-requests' )->escaped()}" type="search"> +</input> +HTML; + } + + protected function makeList() { + $items = []; + $requests = []; + $users = TranslateSandbox::getUsers(); + + /** @var User $user */ + foreach ( $users as $user ) { + $reminders = $user->getOption( 'translate-sandbox-reminders' ); + $reminders = $reminders ? explode( '|', $reminders ) : []; + $remindersCount = count( $reminders ); + if ( $remindersCount ) { + $lastReminderTimestamp = new MWTimestamp( end( $reminders ) ); + $lastReminderAgo = htmlspecialchars( + $lastReminderTimestamp->getHumanTimestamp() + ); + } else { + $lastReminderAgo = ''; + } + + $requests[] = [ + 'username' => $user->getName(), + 'email' => $user->getEmail(), + 'gender' => $user->getOption( 'gender' ), + 'registrationdate' => $user->getRegistration(), + 'translations' => count( $this->stash->getTranslations( $user ) ), + 'languagepreferences' => FormatJson::decode( $user->getOption( 'translate-sandbox' ) ), + 'userid' => $user->getId(), + 'reminderscount' => $remindersCount, + 'lastreminder' => $lastReminderAgo, + ]; + } + + // Sort the requests based on translations and registration date + usort( $requests, [ __CLASS__, 'translatorRequestSort' ] ); + + foreach ( $requests as $request ) { + $items[] = $this->makeRequestItem( $request ); + } + + $requestsList = implode( "\n", $items ); + + return <<<HTML +<div class="row request-header"> + <div class="four columns"> + <button class="language-selector unselected"> + {$this->msg( 'tsb-all-languages-button-label' )->escaped()} + </button> + </div> + <div class="five columns request-count"></div> + <div class="three columns center"> + <input class="request-selector-all" name="request" type="checkbox" /> + </div> +</div> +<div class="requests-list"> + {$requestsList} +</div> +HTML; + } + + protected function makeRequestItem( $request ) { + $requestdataEnc = htmlspecialchars( FormatJson::encode( $request ) ); + $nameEnc = htmlspecialchars( $request['username'] ); + $nameEncForId = htmlspecialchars( Sanitizer::escapeId( $request['username'], 'noninitial' ) ); + $emailEnc = htmlspecialchars( $request['email'] ); + $countEnc = htmlspecialchars( $request['translations'] ); + $timestamp = new MWTimestamp( $request['registrationdate'] ); + $agoEnc = htmlspecialchars( $timestamp->getHumanTimestamp() ); + + return <<<HTML +<div class="row request" data-data="$requestdataEnc" id="tsb-request-$nameEncForId"> + <div class="two columns amount"> + <div class="translation-count">$countEnc</div> + </div> + <div class="seven columns request-info"> + <div class="row username">$nameEnc</div> + <div class="row email">$emailEnc</div> + </div> + <div class="three columns approval center"> + <input class="row request-selector" name="request" type="checkbox" /> + <div class="row signup-age">$agoEnc</div> + </div> +</div> +HTML; + } + + /** + * Sorts groups by descending order of number of translations, + * registration date and username + * + * @since 2013.12 + * @param array $a Translation request + * @param array $b Translation request + * @return int comparison result + */ + public static function translatorRequestSort( $a, $b ) { + $translationCountDiff = $b['translations'] - $a['translations']; + if ( $translationCountDiff !== 0 ) { + return $translationCountDiff; + } + + $registrationDateDiff = $b['registrationdate'] - $a['registrationdate']; + if ( $registrationDateDiff !== 0 ) { + return $registrationDateDiff; + } + + return strcmp( $a['username'], $b['username'] ); + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialMessageGroupStats.php b/www/wiki/extensions/Translate/specials/SpecialMessageGroupStats.php new file mode 100644 index 00000000..8698a7f9 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialMessageGroupStats.php @@ -0,0 +1,304 @@ +<?php +/** + * Contains logic for special page Special:MessageGroupStats. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ + +/** + * Implements includable special page Special:MessageGroupStats which provides + * translation statistics for all languages for a group. + * + * @ingroup SpecialPage TranslateSpecialPage Stats + */ +class SpecialMessageGroupStats extends SpecialLanguageStats { + /// Overwritten from SpecialLanguageStats + protected $targetValueName = [ 'group' ]; + /// Overwritten from SpecialLanguageStats + protected $noComplete = false; + /// Overwritten from SpecialLanguageStats + protected $noEmpty = true; + + protected $names; + + protected $translate; + + public function __construct() { + SpecialPage::__construct( 'MessageGroupStats' ); + $this->totals = MessageGroupStats::getEmptyStats(); + } + + /// Overwritten from SpecialPage + public function getDescription() { + return $this->msg( 'translate-mgs-pagename' )->text(); + } + + /// Overwritten from SpecialLanguageStats + protected function loadStatistics( $target, $flags = 0 ) { + return MessageGroupStats::forGroup( $target, $flags ); + } + + /// Overwritten from SpecialLanguageStats + protected function getCacheRebuildJobParameters( $target ) { + return [ 'groupid' => $target ]; + } + + /// Overwritten from SpecialLanguageStats + protected function isValidValue( $value ) { + $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; + } + + /// Overwritten from SpecialLanguageStats + protected function invalidTarget() { + $this->getOutput()->wrapWikiMsg( + "<div class='error'>$1</div>", + [ 'translate-mgs-invalid-group', $this->target ] + ); + } + + /// Overwritten from SpecialLanguageStats + protected function outputIntroduction() { + $priorityLangs = TranslateMetadata::get( $this->target, 'prioritylangs' ); + if ( $priorityLangs ) { + $this->getOutput()->addWikiMsg( 'tpt-priority-languages', $priorityLangs ); + } + } + + /// Overwriten from SpecialLanguageStats + protected function addForm() { + $formDescriptor = [ + 'select' => [ + 'type' => 'select', + 'name' => 'group', + 'id' => 'group', + 'label' => $this->msg( 'translate-mgs-group' )->text(), + 'options' => $this->getGroupOptions(), + 'default' => $this->target + ], + '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' ) + ->setSubmitTextMsg( 'translate-mgs-submit' ) + ->setWrapperLegendMsg( 'translate-mgs-fieldset' ) + ->prepareForm() + ->displayForm( false ); + } + + /// Overwritten from SpecialLanguageStats + protected function getTable( $stats ) { + $table = $this->table; + + $this->addWorkflowStatesColumn(); + $out = ''; + + $this->numberOfShownLanguages = 0; + $languages = array_keys( + TranslateUtils::getLanguageNames( $this->getLanguage()->getCode() ) + ); + sort( $languages ); + $this->filterPriorityLangs( $languages, $this->target, $stats ); + foreach ( $languages as $code ) { + if ( $table->isBlacklisted( $this->target, $code ) !== null ) { + continue; + } + $out .= $this->makeRow( $code, $stats ); + } + + if ( $out ) { + $table->setMainColumnHeader( $this->msg( 'translate-mgs-column-language' ) ); + $out = $table->createHeader() . "\n" . $out; + $out .= Html::closeElement( 'tbody' ); + + $out .= Html::openElement( 'tfoot' ); + $out .= $table->makeTotalRow( + $this->msg( 'translate-mgs-totals' ) + ->numParams( $this->numberOfShownLanguages ), + $this->totals + ); + $out .= Html::closeElement( 'tfoot' ); + + $out .= Html::closeElement( 'table' ); + + return $out; + } else { + $this->nothing = true; + + return ''; + } + } + + /** + * Filter an array of languages based on whether a priority set of + * languages present for the passed group. If priority languages are + * present, to that list add languages with more than 0% translation. + * @param array &$languages Array of Languages to be filtered + * @param string $group + * @param array $cache + */ + protected function filterPriorityLangs( &$languages, $group, $cache ) { + $filterLangs = TranslateMetadata::get( $group, 'prioritylangs' ); + if ( strlen( $filterLangs ) === 0 ) { + // No restrictions, keep everything + return; + } + $filter = array_flip( explode( ',', $filterLangs ) ); + foreach ( $languages as $id => $code ) { + if ( isset( $filter[$code] ) ) { + continue; + } + $translated = $cache[$code][1]; + if ( $translated === 0 ) { + unset( $languages[$id] ); + } + } + } + + /** + * @param string $code + * @param array $cache + * @return string + */ + protected function makeRow( $code, $cache ) { + $stats = $cache[$code]; + $total = $stats[MessageGroupStats::TOTAL]; + $translated = $stats[MessageGroupStats::TRANSLATED]; + $fuzzy = $stats[MessageGroupStats::FUZZY]; + + if ( $total === null ) { + $this->incomplete = true; + $extra = []; + } else { + if ( $this->noComplete && $fuzzy === 0 && $translated === $total ) { + return ''; + } + + if ( $this->noEmpty && $translated === 0 && $fuzzy === 0 ) { + return ''; + } + + // Skip below 2% if "don't show without translations" is checked. + if ( $this->noEmpty && ( $translated / $total ) < 0.02 ) { + return ''; + } + + if ( $translated === $total ) { + $extra = [ 'action' => 'proofread' ]; + } else { + $extra = []; + } + } + $this->numberOfShownLanguages += 1; + $this->totals = MessageGroupStats::multiAdd( $this->totals, $stats ); + + $out = "\t" . Html::openElement( 'tr' ); + $out .= "\n\t\t" . $this->getMainColumnCell( $code, $extra ); + $out .= $this->table->makeNumberColumns( $stats ); + $state = $this->getWorkflowStateValue( $code ); + $out .= $this->getWorkflowStateCell( $code, $state ); + + $out .= "\n\t" . Html::closeElement( 'tr' ) . "\n"; + + return $out; + } + + /** + * @param string $code + * @param array $params + * @return string + */ + protected function getMainColumnCell( $code, $params ) { + if ( !isset( $this->names ) ) { + $this->names = TranslateUtils::getLanguageNames( $this->getLanguage()->getCode() ); + $this->translate = SpecialPage::getTitleFor( 'Translate' ); + } + + $queryParameters = $params + [ + 'group' => $this->target, + 'language' => $code + ]; + + if ( isset( $this->names[$code] ) ) { + $text = "$code: {$this->names[$code]}"; + } else { + $text = $code; + } + $link = $this->getLinkRenderer()->makeKnownLink( + $this->translate, + $text, + [], + $queryParameters + ); + + return Html::rawElement( 'td', [], $link ); + } + + /** + * @param string $field + * @param string $filter + * @return array + */ + protected function getWorkflowStates( $field = 'tgr_lang', $filter = 'tgr_group' ) { + return parent::getWorkflowStates( $field, $filter ); + } + + /** + * Creates a simple message group options. + * + * @return array $options + */ + protected function getGroupOptions() { + $options = []; + $groups = MessageGroups::getAllGroups(); + + foreach ( $groups as $id => $class ) { + if ( MessageGroups::getGroup( $id )->exists() ) { + $options[$class->getLabel()] = $id; + } + } + + return $options; + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialPageMigration.php b/www/wiki/extensions/Translate/specials/SpecialPageMigration.php new file mode 100644 index 00000000..1fd7aa99 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialPageMigration.php @@ -0,0 +1,69 @@ +<?php +/** + * Contains code for special page Special:PageMigration + * + * @file + * @author Pratik Lahoti + * @copyright Copyright © 2014-2015 Pratik Lahoti + * @license GPL-2.0+ + */ + +class SpecialPageMigration extends SpecialPage { + public function __construct() { + parent::__construct( 'PageMigration', 'pagetranslation' ); + } + + protected function getGroupName() { + return 'wiki'; + } + + function getDescription() { + return $this->msg( 'pagemigration' )->text(); + } + + public function execute( $par ) { + $request = $this->getRequest(); + $output = $this->getOutput(); + $this->setHeaders(); + $this->checkPermissions(); + $this->outputHeader( 'pagemigration-summary' ); + $output->addModules( 'ext.translate.special.pagemigration' ); + $output->addModuleStyles( 'jquery.uls.grid' ); + # Get request data from, e.g. + $param = $request->getText( 'param' ); + # Do stuff + # ... + $out = ''; + $out .= Html::openElement( 'div', array( 'class' => 'grid' ) ); + $out .= Html::openElement( 'div', array( 'class' => 'mw-tpm-sp-error row', + 'id' => 'mw-tpm-sp-error-div' ) ); + $out .= Html::element( 'div', + array( 'class' => 'mw-tpm-sp-error__message five columns hide' ) ); + $out .= Html::closeElement( 'div' ); + $out .= Html::openElement( 'form', array( 'class' => 'mw-tpm-sp-form row', + 'id' => 'mw-tpm-sp-primary-form' ) ); + $out .= Html::element( 'input', array( 'id' => 'pm-summary', 'type' => 'hidden', + 'value' => $this->msg( 'pm-summary-import' )->inContentLanguage()->text() ) ); + $out .= "\n"; + $out .= Html::element( 'input', array( 'id' => 'title', 'class' => 'mw-searchInput mw-ui-input', + 'placeholder' => $this->msg( 'pm-pagetitle-placeholder' )->text() ) ); + $out .= "\n"; + $out .= Html::element( 'input', array( 'id' => 'action-import', + 'class' => 'mw-ui-button mw-ui-primary', 'type' => 'button', + 'value' => $this->msg( 'pm-import-button-label' )->text() ) ); + $out .= "\n"; + $out .= Html::element( 'input', array( 'id' => 'action-save', + 'class' => 'mw-ui-button mw-ui-constructive hide', 'type' => 'button', + 'value' => $this->msg( 'pm-savepages-button-label' )->text() ) ); + $out .= "\n"; + $out .= Html::element( 'input', array( 'id' => 'action-cancel', + 'class' => 'mw-ui-button mw-ui-quiet hide', 'type' => 'button', + 'value' => $this->msg( 'pm-cancel-button-label' )->text() ) ); + $out .= Html::closeElement( 'form' ); + $out .= Html::openElement( 'div', array( 'class' => 'mw-tpm-sp-unit-listing' ) ); + $out .= Html::closeElement( 'div' ); + $out .= Html::closeElement( 'div' ); + + $output->addHTML( $out ); + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialPagePreparation.php b/www/wiki/extensions/Translate/specials/SpecialPagePreparation.php new file mode 100644 index 00000000..3c0cbb2b --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialPagePreparation.php @@ -0,0 +1,61 @@ +<?php +/** + * Contains code for special page Special:PagePreparation + * + * @file + * @author Pratik Lahoti + * @copyright Copyright © 2014 Pratik Lahoti + * @license GPL-2.0+ + */ + +class SpecialPagePreparation extends SpecialPage { + public function __construct() { + parent::__construct( 'PagePreparation', 'pagetranslation' ); + } + + protected function getGroupName() { + return 'wiki'; + } + + public function execute( $par ) { + $request = $this->getRequest(); + $output = $this->getOutput(); + $this->setHeaders(); + $this->checkPermissions(); + + $inputValue = htmlspecialchars( $request->getText( 'page', $par ) ); + $pagenamePlaceholder = $this->msg( 'pp-pagename-placeholder' )->escaped(); + $prepareButtonValue = $this->msg( 'pp-prepare-button-label' )->escaped(); + $saveButtonValue = $this->msg( 'pp-save-button-label' )->escaped(); + $cancelButtonValue = $this->msg( 'pp-cancel-button-label' )->escaped(); + $summaryValue = $this->msg( 'pp-save-summary' )->inContentLanguage()->escaped(); + $output->addModules( 'ext.translate.special.pagepreparation' ); + $output->addModuleStyles( 'jquery.uls.grid' ); + + $out = ''; + $diff = new DifferenceEngine( $this->getContext() ); + $diffHeader = $diff->addHeader( ' ', $this->msg( 'pp-diff-old-header' )->escaped(), + $this->msg( 'pp-diff-new-header' )->escaped() ); + + $out = <<<HTML +<div class="grid"> + <form class="mw-tpp-sp-form row" name="mw-tpp-sp-input-form" action=""> + <input id="pp-summary" type="hidden" value="{$summaryValue}" /> + <input name="page" id="page" class="mw-searchInput mw-ui-input" + placeholder="{$pagenamePlaceholder}" value="{$inputValue}"/> + <button id="action-prepare" class="mw-ui-button mw-ui-primary" type="button"> + {$prepareButtonValue}</button> + <button id="action-save" class="mw-ui-button mw-ui-constructive hide" type="button"> + {$saveButtonValue}</button> + <button id="action-cancel" class="mw-ui-button mw-ui-quiet hide" type="button"> + {$cancelButtonValue}</button> + </form> + <div class="messageDiv hide"></div> + <div class="divDiff hide"> + {$diffHeader} + </div> +</div> +HTML; + $output->addHTML( $out ); + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialSearchTranslations.php b/www/wiki/extensions/Translate/specials/SpecialSearchTranslations.php new file mode 100644 index 00000000..68ac1b2e --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialSearchTranslations.php @@ -0,0 +1,595 @@ +<?php +/** + * Contains logic for special page ... + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * ... + * + * @ingroup SpecialPage TranslateSpecialPage + */ +class SpecialSearchTranslations extends SpecialPage { + /** @var FormOptions */ + protected $opts; + + /** + * Placeholders used for highlighting. Solr can mark the beginning and + * end but we need to run htmlspecialchars on the result first and then + * replace the placeholders with the html. It is assumed placeholders + * don't contain any chars that are escaped in html. + * @var array + */ + protected $hl = []; + + /** + * How many search results to display per page + * @var int + */ + protected $limit = 25; + + public function __construct() { + parent::__construct( 'SearchTranslations' ); + $this->hl = [ + TranslateUtils::getPlaceholder(), + TranslateUtils::getPlaceholder(), + ]; + } + + public function setHeaders() { + // Overwritten the parent because it sucks! + // We want to set <title> but not <h1> + $out = $this->getOutput(); + $out->setArticleRelated( false ); + $out->setRobotPolicy( 'noindex,nofollow' ); + $name = $this->msg( 'searchtranslations' ); + $name = Sanitizer::stripAllTags( $name ); + $out->setHTMLTitle( $this->msg( 'pagetitle' )->rawParams( $name ) ); + } + + public function execute( $par ) { + global $wgLanguageCode; + $this->setHeaders(); + $this->checkPermissions(); + + $server = TTMServer::primary(); + if ( !$server instanceof SearchableTTMServer ) { + throw new ErrorPageError( 'tux-sst-nosolr-title', 'tux-sst-nosolr-body' ); + } + + $out = $this->getOutput(); + $out->addModuleStyles( 'jquery.uls.grid' ); + $out->addModuleStyles( 'ext.translate.special.searchtranslations.styles' ); + $out->addModuleStyles( 'ext.translate.special.translate.styles' ); + $out->addModuleStyles( [ 'mediawiki.ui.button', 'mediawiki.ui.input', 'mediawiki.ui.checkbox' ] ); + $out->addModules( 'ext.translate.special.searchtranslations' ); + $out->addModules( 'ext.translate.special.searchtranslations.operatorsuggest' ); + $out->addHelpLink( 'Help:Extension:Translate#searching' ); + $out->addJsConfigVars( 'wgTranslateLanguages', TranslateUtils::getLanguageNames( null ) ); + + $this->opts = $opts = new FormOptions(); + $opts->add( 'query', '' ); + $opts->add( 'sourcelanguage', $wgLanguageCode ); + $opts->add( 'language', '' ); + $opts->add( 'group', '' ); + $opts->add( 'grouppath', '' ); + $opts->add( 'filter', '' ); + $opts->add( 'match', '' ); + $opts->add( 'case', '' ); + $opts->add( 'limit', $this->limit ); + $opts->add( 'offset', 0 ); + + $opts->fetchValuesFromRequest( $this->getRequest() ); + + $queryString = $opts->getValue( 'query' ); + + if ( $queryString === '' ) { + $this->showEmptySearch(); + return; + } + + $search = $this->getSearchInput( $queryString ); + + $options = $params = $opts->getAllValues(); + $filter = $opts->getValue( 'filter' ); + try { + if ( $opts->getValue( 'language' ) === '' ) { + $options['language'] = $this->getLanguage()->getCode(); + } + $translationSearch = new CrossLanguageTranslationSearchQuery( $options, $server ); + if ( in_array( $filter, $translationSearch->getAvailableFilters() ) ) { + if ( $options['language'] === $options['sourcelanguage'] ) { + $this->showSearchError( $search, $this->msg( 'tux-sst-error-language' ) ); + return; + } + + $opts->setValue( 'language', $options['language'] ); + $documents = $translationSearch->getDocuments(); + $total = $translationSearch->getTotalHits(); + $resultset = $translationSearch->getResultSet(); + } else { + $resultset = $server->search( $queryString, $params, $this->hl ); + $documents = $server->getDocuments( $resultset ); + $total = $server->getTotalHits( $resultset ); + } + } catch ( TTMServerException $e ) { + $message = $e->getMessage(); + // Known exceptions + if ( preg_match( '/^Result window is too large/', $message ) ) { + $this->showSearchError( $search, $this->msg( 'tux-sst-error-offset' ) ); + return; + } + + // Other exceptions + error_log( 'Translation search server unavailable: ' . $e->getMessage() ); + throw new ErrorPageError( 'tux-sst-solr-offline-title', 'tux-sst-solr-offline-body' ); + } + + // Part 1: facets + $facets = $server->getFacets( $resultset ); + $facetHtml = ''; + + if ( $facets['language'] !== [] ) { + if ( $filter !== '' ) { + $facets['language'] = array_merge( + $facets['language'], + [ $opts->getValue( 'language' ) => $total ] + ); + } + $facetHtml = Html::element( 'div', + [ 'class' => 'row facet languages', + 'data-facets' => FormatJson::encode( $this->getLanguages( $facets['language'] ) ), + 'data-language' => $opts->getValue( 'language' ), + ], + $this->msg( 'tux-sst-facet-language' )->text() + ); + } + + if ( $facets['group'] !== [] ) { + $facetHtml .= Html::element( 'div', + [ 'class' => 'row facet groups', + 'data-facets' => FormatJson::encode( $this->getGroups( $facets['group'] ) ), + 'data-group' => $opts->getValue( 'group' ) ], + $this->msg( 'tux-sst-facet-group' )->text() + ); + } + + // Part 2: results + $resultsHtml = ''; + + $title = Title::newFromText( $queryString ); + if ( $title && !in_array( $filter, $translationSearch->getAvailableFilters() ) ) { + $handle = new MessageHandle( $title ); + $code = $handle->getCode(); + $language = $opts->getValue( 'language' ); + if ( $code !== '' && $code !== $language && $handle->isValid() ) { + $dataProvider = new TranslationAidDataProvider( $handle ); + $aid = new CurrentTranslationAid( + $handle->getGroup(), + $handle, + $this->getContext(), + $dataProvider + ); + $document['wiki'] = wfWikiID(); + $document['localid'] = $handle->getTitleForBase()->getPrefixedText(); + $document['content'] = $aid->getData()['value']; + $document['language'] = $handle->getCode(); + array_unshift( $documents, $document ); + $total++; + } + } + + foreach ( $documents as $document ) { + $text = $document['content']; + $text = TranslateUtils::convertWhiteSpaceToHTML( $text ); + + list( $pre, $post ) = $this->hl; + $text = str_replace( $pre, '<strong class="tux-search-highlight">', $text ); + $text = str_replace( $post, '</strong>', $text ); + + $title = Title::newFromText( $document['localid'] . '/' . $document['language'] ); + if ( !$title ) { + // Should not ever happen but who knows... + continue; + } + + $resultAttribs = [ + 'class' => 'row tux-message', + 'data-title' => $title->getPrefixedText(), + 'data-language' => $document['language'], + ]; + + $handle = new MessageHandle( $title ); + + if ( $handle->isValid() ) { + $uri = TranslateUtils::getEditorUrl( $handle ); + $link = Html::element( + 'a', + [ 'href' => $uri ], + $this->msg( 'tux-sst-edit' )->text() + ); + } else { + $url = wfParseUrl( $document['uri'] ); + $domain = $url['host']; + $link = Html::element( + 'a', + [ 'href' => $document['uri'] ], + $this->msg( 'tux-sst-view-foreign', $domain )->text() + ); + } + + $access = Html::rawElement( + 'div', + [ 'class' => 'row tux-edit tux-message-item' ], + $link + ); + + $titleText = $title->getPrefixedText(); + $titleAttribs = [ + 'class' => 'row tux-title', + 'dir' => 'ltr', + ]; + + $language = Language::factory( $document['language'] ); + $textAttribs = [ + 'class' => 'row tux-text', + 'lang' => $language->getHtmlCode(), + 'dir' => $language->getDir(), + ]; + + $resultsHtml = $resultsHtml + . Html::openElement( 'div', $resultAttribs ) + . Html::rawElement( 'div', $textAttribs, $text ) + . Html::element( 'div', $titleAttribs, $titleText ) + . $access + . Html::closeElement( 'div' ); + } + + $resultsHtml .= Html::rawElement( 'hr', [ 'class' => 'tux-pagination-line' ] ); + + $prev = $next = ''; + $offset = $this->opts->getValue( 'offset' ); + $params = $this->opts->getChangedValues(); + + if ( $total - $offset > $this->limit ) { + $newParams = [ 'offset' => $offset + $this->limit ] + $params; + $attribs = [ + 'class' => 'mw-ui-button pager-next', + 'href' => $this->getPageTitle()->getLocalURL( $newParams ), + ]; + $next = Html::element( 'a', $attribs, $this->msg( 'tux-sst-next' )->text() ); + } + if ( $offset ) { + $newParams = [ 'offset' => max( 0, $offset - $this->limit ) ] + $params; + $attribs = [ + 'class' => 'mw-ui-button pager-prev', + 'href' => $this->getPageTitle()->getLocalURL( $newParams ), + ]; + $prev = Html::element( 'a', $attribs, $this->msg( 'tux-sst-prev' )->text() ); + } + + $resultsHtml .= Html::rawElement( 'div', [ 'class' => 'tux-pagination-links' ], + "$prev $next" + ); + + $count = $this->msg( 'tux-sst-count' )->numParams( $total ); + + $this->showSearch( $search, $count, $facetHtml, $resultsHtml, $total ); + } + + protected function getLanguages( array $facet ) { + $output = []; + + $nondefaults = $this->opts->getChangedValues(); + $selected = $this->opts->getValue( 'language' ); + $filter = $this->opts->getValue( 'filter' ); + + foreach ( $facet as $key => $value ) { + if ( $filter !== '' && $key === $selected ) { + unset( $nondefaults['language'] ); + unset( $nondefaults['filter'] ); + } elseif ( $filter !== '' ) { + $nondefaults['language'] = $key; + $nondefaults['filter'] = $filter; + } elseif ( $key === $selected ) { + unset( $nondefaults['language'] ); + } else { + $nondefaults['language'] = $key; + } + + $url = $this->getPageTitle()->getLocalURL( $nondefaults ); + $value = $this->getLanguage()->formatNum( $value ); + + $output[$key] = [ + 'count' => $value, + 'url' => $url + ]; + } + + return $output; + } + + protected function getGroups( array $facet ) { + $structure = MessageGroups::getGroupStructure(); + return $this->makeGroupFacetRows( $structure, $facet ); + } + + protected function makeGroupFacetRows( array $groups, $counts, $level = 0, $pathString = '' ) { + $output = []; + + $nondefaults = $this->opts->getChangedValues(); + $selected = $this->opts->getValue( 'group' ); + $path = explode( '|', $this->opts->getValue( 'grouppath' ) ); + + foreach ( $groups as $mixed ) { + $subgroups = $group = $mixed; + + if ( is_array( $mixed ) ) { + $group = array_shift( $subgroups ); + } else { + $subgroups = []; + } + + $id = $group->getId(); + + if ( $id !== $selected && !isset( $counts[$id] ) ) { + continue; + } + + if ( $id === $selected ) { + unset( $nondefaults['group'] ); + $nondefaults['grouppath'] = $pathString; + } else { + $nondefaults['group'] = $id; + $nondefaults['grouppath'] = $pathString . $id; + } + + $value = $counts[$id] ?? 0; + + $output[$id] = [ + 'id' => $id, + 'count' => $value, + 'label' => $group->getLabel(), + ]; + + if ( isset( $path[$level] ) && $path[$level] === $id ) { + $output[$id]['groups'] = $this->makeGroupFacetRows( + $subgroups, + $counts, + $level + 1, + "$pathString$id|" + ); + } + } + + return $output; + } + + protected function showSearch( $search, $count, $facets, $results, $total ) { + $messageSelector = $this->messageSelector(); + $this->getOutput()->addHTML( <<<HTML +<div class="grid tux-searchpage"> + <div class="row tux-searchboxform"> + <div class="tux-search-tabs offset-by-three">$messageSelector</div> + <div class="row tux-search-options"> + <div class="offset-by-three nine columns tux-search-inputs"> + <div class="row searchinput">$search</div> + <div class="row count">$count</div> + </div> + </div> + </div> +HTML + ); + + $query = trim( $this->opts->getValue( 'query' ) ); + $hasSpace = preg_match( '/\s/', $query ); + $match = $this->opts->getValue( 'match' ); + $size = 100; + if ( $total > $size && $match !== 'all' && $hasSpace ) { + $params = $this->opts->getChangedValues(); + $params = [ 'match' => 'all' ] + $params; + $linkText = $this->msg( 'tux-sst-link-all-match' )->text(); + $link = $this->getPageTitle()->getFullURL( $params ); + $link = "<span class='plainlinks'>[$link $linkText]</span>"; + + $this->getOutput()->wrapWikiMsg( + '<div class="successbox">$1</div>', + [ 'tux-sst-match-message', $link ] + ); + } + + $this->getOutput()->addHTML( <<<HTML + <div class="row searchcontent"> + <div class="three columns facets">$facets</div> + <div class="nine columns results">$results</div> + </div> +</div> +HTML + ); + } + + protected function showEmptySearch() { + $search = $this->getSearchInput( '' ); + $this->getOutput()->addHTML( <<<HTML +<div class="grid tux-searchpage"> + <div class="row searchinput"> + <div class="nine columns offset-by-three">$search</div> + </div> +</div> +HTML + ); + } + + protected function showSearchError( $search, Message $message ) { + $messageSelector = $this->messageSelector(); + $this->getOutput()->addHTML( <<<HTML +<div class="grid tux-searchpage"> + <div class="row tux-searchboxform"> + <div class="tux-search-tabs offset-by-three">$messageSelector</div> + <div class="row tux-search-options"> + <div class="offset-by-three nine columns tux-search-inputs"> + <div class="row searchinput">$search</div> + <div class="row errorbox">{$message->escaped()}</div> + </div> + </div> + </div> +</div> +HTML + ); + } + + /** + * Build ellipsis to select options + * @param string $key + * @param string $value + * @return string + */ + protected function ellipsisSelector( $key, $value ) { + $nondefaults = $this->opts->getChangedValues(); + $taskParams = [ 'filter' => $value ] + $nondefaults; + ksort( $taskParams ); + $href = $this->getPageTitle()->getLocalURL( $taskParams ); + $link = Html::element( 'a', + [ 'href' => $href ], + // Messages for grepping: + // tux-sst-ellipsis-untranslated + // tux-sst-ellipsis-outdated + $this->msg( 'tux-sst-ellipsis-' . $key )->text() + ); + + $container = Html::rawElement( 'li', [ + 'class' => 'column', + 'data-filter' => $value, + 'data-title' => $key, + ], $link ); + + return $container; + } + + /** + * Design the tabs + * @return string + */ + protected function messageSelector() { + $nondefaults = $this->opts->getChangedValues(); + $output = Html::openElement( 'div', [ 'class' => 'row tux-messagetable-header' ] ); + $output .= Html::openElement( 'div', [ 'class' => 'nine columns' ] ); + $output .= Html::openElement( 'ul', [ 'class' => 'row tux-message-selector' ] ); + $tabs = [ + 'default' => '', + 'translated' => 'translated', + 'untranslated' => 'untranslated' + ]; + + $ellipsisOptions = [ + 'outdated' => 'fuzzy' + ]; + + $selected = $this->opts->getValue( 'filter' ); + $keys = array_keys( $tabs ); + if ( in_array( $selected, array_values( $ellipsisOptions ) ) ) { + $key = $keys[count( $keys ) - 1]; + $ellipsisOptions = [ $key => $tabs[$key] ]; + + // Remove the last tab + unset( $tabs[$key] ); + $tabs = array_merge( $tabs, [ 'outdated' => $selected ] ); + } elseif ( !in_array( $selected, array_values( $tabs ) ) ) { + $selected = ''; + } + + $container = Html::openElement( 'ul', [ 'class' => 'column tux-message-selector' ] ); + foreach ( $ellipsisOptions as $optKey => $optValue ) { + $container .= $this->ellipsisSelector( $optKey, $optValue ); + } + + $sourcelanguage = $this->opts->getValue( 'sourcelanguage' ); + $sourcelanguage = TranslateUtils::getLanguageName( $sourcelanguage ); + foreach ( $tabs as $tab => $filter ) { + // Messages for grepping: + // tux-sst-default + // tux-sst-translated + // tux-sst-untranslated + // tux-sst-outdated + $tabClass = "tux-sst-$tab"; + $taskParams = [ 'filter' => $filter ] + $nondefaults; + ksort( $taskParams ); + $href = $this->getPageTitle()->getLocalURL( $taskParams ); + if ( $tab === 'default' ) { + $link = Html::element( + 'a', + [ 'href' => $href ], + $this->msg( $tabClass )->text() + ); + } else { + $link = Html::element( + 'a', + [ 'href' => $href ], + $this->msg( $tabClass, $sourcelanguage )->text() + ); + } + + if ( $selected === $filter ) { + $tabClass = $tabClass . ' selected'; + } + $output .= Html::rawElement( 'li', [ + 'class' => [ 'column', $tabClass ], + 'data-filter' => $filter, + 'data-title' => $tab, + ], $link ); + } + + // More column + $output .= Html::openElement( 'li', [ 'class' => 'column more' ] ) . + '...' . + $container . + Html::closeElement( 'li' ); + + $output .= Html::closeElement( 'ul' ); + $output .= Html::closeElement( 'div' ); + $output .= Html::closeElement( 'div' ); + + return $output; + } + + protected function getSearchInput( $query ) { + $attribs = [ + 'placeholder' => $this->msg( 'tux-sst-search-ph' ), + 'class' => 'searchinputbox mw-ui-input', + 'dir' => $this->getLanguage()->getDir(), + ]; + + $title = Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ); + $input = Xml::input( 'query', false, $query, $attribs ); + $submit = Xml::submitButton( + $this->msg( 'tux-sst-search' ), + [ 'class' => 'mw-ui-button' ] + ); + + $nondefaults = $this->opts->getChangedValues(); + $checkLabel = Xml::checkLabel( + $this->msg( 'tux-sst-case-sensitive' )->text(), + 'case', + 'tux-case-sensitive', + isset( $nondefaults['case'] ) + ); + $checkLabel = Html::openElement( + 'div', + [ 'class' => 'tux-search-operators mw-ui-checkbox' ] + ) . + $checkLabel . + Html::closeElement( 'div' ); + + $lang = $this->getRequest()->getVal( 'language' ); + $language = is_null( $lang ) ? '' : Html::hidden( 'language', $lang ); + + $form = Html::rawElement( 'form', [ 'action' => wfScript(), 'name' => 'searchform' ], + $title . $input . $submit . $checkLabel . $language + ); + + return $form; + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialSupportedLanguages.php b/www/wiki/extensions/Translate/specials/SpecialSupportedLanguages.php new file mode 100644 index 00000000..63f78109 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialSupportedLanguages.php @@ -0,0 +1,466 @@ +<?php +/** + * Contains logic for special page Special:SupportedLanguages + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ + +/** + * Implements special page Special:SupportedLanguages. The wiki administrator + * must define NS_PORTAL, otherwise this page does not work. This page displays + * a list of language portals for all portals corresponding with a language + * code defined for MediaWiki and a subpage called "translators". The subpage + * "translators" must contain the template [[:{{ns:template}}:User|User]], + * taking a user name as parameter. + * + * @ingroup SpecialPage TranslateSpecialPage Stats + */ +class SpecialSupportedLanguages extends SpecialPage { + /// Whether to skip and regenerate caches + protected $purge = false; + + /// Cutoff time for inactivity in days + protected $period = 180; + + public function __construct() { + parent::__construct( 'SupportedLanguages' ); + } + + protected function getGroupName() { + return 'wiki'; + } + + public function getDescription() { + return $this->msg( 'supportedlanguages' )->text(); + } + + public function execute( $par ) { + $out = $this->getOutput(); + $lang = $this->getLanguage(); + + // Only for manual debugging nowdays + $this->purge = false; + + $this->setHeaders(); + $out->addModules( 'ext.translate.special.supportedlanguages' ); + $out->addModuleStyles( 'ext.translate.special.supportedlanguages' ); + + $out->addHelpLink( + 'Help:Extension:Translate/Statistics_and_reporting#List_of_languages_and_translators' + ); + + $this->outputHeader( 'supportedlanguages-summary' ); + $dbr = wfGetDB( DB_REPLICA ); + if ( $dbr->getType() === 'sqlite' ) { + $out->wrapWikiMsg( + '<div class="errorbox">$1</div>', + 'supportedlanguages-sqlite-error' + ); + return; + } + + $out->addWikiMsg( 'supportedlanguages-localsummary' ); + + $names = Language::fetchLanguageNames( null, 'all' ); + $languages = $this->languageCloud(); + // There might be all sorts of subpages which are not languages + $languages = array_intersect_key( $languages, $names ); + + $this->outputLanguageCloud( $languages, $names ); + $out->addWikiMsg( 'supportedlanguages-count', $lang->formatNum( count( $languages ) ) ); + + if ( $par && Language::isKnownLanguageTag( $par ) ) { + $code = $par; + + $out->addWikiMsg( 'supportedlanguages-colorlegend', $this->getColorLegend() ); + + $users = $this->fetchTranslators( $code ); + if ( $users === false ) { + // generic-pool-error is from MW core + $out->wrapWikiMsg( '<div class="warningbox">$1</div>', 'generic-pool-error' ); + return; + } + + global $wgTranslateAuthorBlacklist; + $users = $this->filterUsers( $users, $code, $wgTranslateAuthorBlacklist ); + $this->preQueryUsers( $users ); + $this->showLanguage( $code, $users ); + } + } + + protected function showLanguage( $code, $users ) { + $out = $this->getOutput(); + $lang = $this->getLanguage(); + + $usernames = array_keys( $users ); + $userStats = $this->getUserStats( $usernames ); + + // Information to be used inside the foreach loop. + $linkInfo = []; + $linkInfo['rc']['title'] = SpecialPage::getTitleFor( 'Recentchanges' ); + $linkInfo['rc']['msg'] = $this->msg( 'supportedlanguages-recenttranslations' )->text(); + $linkInfo['stats']['title'] = SpecialPage::getTitleFor( 'LanguageStats' ); + $linkInfo['stats']['msg'] = $this->msg( 'languagestats' )->text(); + + $local = Language::fetchLanguageName( $code, $lang->getCode(), 'all' ); + $native = Language::fetchLanguageName( $code, null, 'all' ); + + if ( $local !== $native ) { + $headerText = $this->msg( 'supportedlanguages-portallink' ) + ->params( $code, $local, $native )->escaped(); + } else { + // No CLDR, so a less localised header and link title. + $headerText = $this->msg( 'supportedlanguages-portallink-nocldr' ) + ->params( $code, $native )->escaped(); + } + + $out->addHTML( Html::rawElement( 'h2', [ 'id' => $code ], $headerText ) ); + + // Add useful links for language stats and recent changes for the language. + $links = []; + $links[] = $this->getLinkRenderer()->makeKnownLink( + $linkInfo['stats']['title'], + $linkInfo['stats']['msg'], + [], + [ + 'code' => $code, + 'suppresscomplete' => '1' + ] + ); + $links[] = $this->getLinkRenderer()->makeKnownLink( + $linkInfo['rc']['title'], + $linkInfo['rc']['msg'], + [], + [ + 'translations' => 'only', + 'trailer' => '/' . $code + ] + ); + $linkList = $lang->listToText( $links ); + + $out->addHTML( '<p>' . $linkList . "</p>\n" ); + $this->makeUserList( $users, $userStats ); + } + + protected function languageCloud() { + global $wgTranslateMessageNamespaces; + + $cache = wfGetCache( CACHE_ANYTHING ); + $cachekey = wfMemcKey( 'translate-supportedlanguages-language-cloud' ); + if ( $this->purge ) { + $cache->delete( $cachekey ); + } else { + $data = $cache->get( $cachekey ); + if ( is_array( $data ) ) { + return $data; + } + } + + $dbr = wfGetDB( DB_REPLICA ); + $tables = [ 'recentchanges' ]; + $fields = [ 'substring_index(rc_title, \'/\', -1) as lang', 'count(*) as count' ]; + $timestamp = $dbr->timestamp( wfTimestamp( TS_UNIX ) - 60 * 60 * 24 * $this->period ); + $conds = [ + # Without the quotes the rc_timestamp index isn't used and this query is much slower + "rc_timestamp > '$timestamp'", + 'rc_namespace' => $wgTranslateMessageNamespaces, + 'rc_title' . $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() ), + ]; + $options = [ 'GROUP BY' => 'lang', 'HAVING' => 'count > 20', 'ORDER BY' => 'NULL' ]; + + $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options ); + + $data = []; + foreach ( $res as $row ) { + $data[$row->lang] = $row->count; + } + + $cache->set( $cachekey, $data, 3600 ); + + return $data; + } + + /** + * Fetch the translators for a language with caching + * + * @param string $code + * @return array|bool Map of (user name => page count) or false on failure + */ + public function fetchTranslators( $code ) { + $cache = wfGetCache( CACHE_ANYTHING ); + $cachekey = wfMemcKey( 'translate-supportedlanguages-translator-list-v1', $code ); + + if ( $this->purge ) { + $cache->delete( $cachekey ); + $data = false; + } else { + $staleCutoffUnix = time() - 3600; + $data = $cache->get( $cachekey ); + if ( is_array( $data ) && $data['asOfTime'] > $staleCutoffUnix ) { + return $data['users']; + } + } + + $that = $this; + $work = new PoolCounterWorkViaCallback( + 'TranslateFetchTranslators', + "TranslateFetchTranslators-$code", + [ + 'doWork' => function () use ( $that, $code, $cache, $cachekey ) { + $users = $that->loadTranslators( $code ); + $newData = [ 'users' => $users, 'asOfTime' => time() ]; + $cache->set( $cachekey, $newData, 86400 ); + return $users; + }, + 'doCachedWork' => function () use ( $cache, $cachekey ) { + $newData = $cache->get( $cachekey ); + // Use new cache value from other thread + return is_array( $newData ) ? $newData['users'] : false; + }, + 'fallback' => function () use ( $data ) { + // Use stale cache if possible + return is_array( $data ) ? $data['users'] : false; + } + ] + ); + + return $work->execute(); + } + + /** + * Fetch the translators for a language + * + * @param string $code + * @return array Map of (user name => page count) + */ + public function loadTranslators( $code ) { + global $wgTranslateMessageNamespaces; + + $dbr = wfGetDB( DB_REPLICA, 'vslow' ); + + if ( class_exists( ActorMigration::class ) ) { + $actorQuery = ActorMigration::newMigration()->getJoin( 'rev_user' ); + } else { + $actorQuery = [ + 'tables' => [], + 'fields' => [ 'rev_user_text' => 'rev_user_text' ], + 'joins' => [], + ]; + } + + $tables = [ 'page', 'revision' ] + $actorQuery['tables']; + $fields = [ + 'rev_user_text' => $actorQuery['fields']['rev_user_text'], + 'count(page_id) as count' + ]; + $conds = [ + 'page_title' . $dbr->buildLike( $dbr->anyString(), '/', $code ), + 'page_namespace' => $wgTranslateMessageNamespaces, + ]; + $options = [ 'GROUP BY' => $actorQuery['fields']['rev_user_text'], 'ORDER BY' => 'NULL' ]; + $joins = [ + 'revision' => [ 'JOIN', 'page_id=rev_page' ], + ] + $actorQuery['joins']; + + $res = $dbr->select( $tables, $fields, $conds, __METHOD__, $options, $joins ); + + $data = []; + foreach ( $res as $row ) { + $data[$row->rev_user_text] = $row->count; + } + + return $data; + } + + protected function filterUsers( array $users, $code, $blacklist ) { + foreach ( array_keys( $users ) as $username ) { + # We do not know the group + $hash = "#;$code;$username"; + + $blacklisted = false; + foreach ( $blacklist as $rule ) { + list( $type, $regex ) = $rule; + + if ( preg_match( $regex, $hash ) ) { + if ( $type === 'white' ) { + $blacklisted = false; + break; + } else { + $blacklisted = true; + } + } + } + + if ( $blacklisted ) { + unset( $users[$username] ); + } + } + + return $users; + } + + protected function outputLanguageCloud( array $languages, array $names ) { + $out = $this->getOutput(); + + $out->addHTML( '<div class="tagcloud autonym">' ); + + foreach ( $languages as $k => $v ) { + $name = $names[$k]; + $size = round( log( $v ) * 20 ) + 10; + + $params = [ + 'href' => $this->getPageTitle( $k )->getLocalURL(), + 'class' => 'tag', + 'style' => "font-size:$size%", + 'lang' => $k, + ]; + + $tag = Html::element( 'a', $params, $name ); + $out->addHTML( $tag . "\n" ); + } + $out->addHTML( '</div>' ); + } + + protected function makeUserList( $users, $stats ) { + $day = 60 * 60 * 24; + + // Scale of the activity colors, anything + // longer than this is just inactive + $period = $this->period; + + $links = []; + $statsTable = new StatsTable(); + + arsort( $users ); + foreach ( $users as $username => $count ) { + $title = Title::makeTitleSafe( NS_USER, $username ); + $enc = htmlspecialchars( $username ); + + $attribs = []; + $styles = []; + if ( isset( $stats[$username][0] ) ) { + if ( $count === -1 ) { + $count = $stats[$username][0]; + } + + $styles['font-size'] = round( log( $count, 10 ) * 30 ) + 70 . '%'; + + $last = wfTimestamp( TS_UNIX ) - wfTimestamp( TS_UNIX, $stats[$username][1] ); + $last = round( $last / $day ); + $attribs['title'] = $this->msg( 'supportedlanguages-activity', $username ) + ->numParams( $count, $last )->text(); + $last = max( 1, min( $period, $last ) ); + $styles['border-bottom'] = '3px solid #' . + $statsTable->getBackgroundColor( ( $period - $last ) / $period ); + } else { + $enc = "<del>$enc</del>"; + } + + $stylestr = $this->formatStyle( $styles ); + if ( $stylestr ) { + $attribs['style'] = $stylestr; + } + + $links[] = $this->getLinkRenderer()->makeLink( $title, new HtmlArmor( $enc ), $attribs ); + } + + // for GENDER support + $username = ''; + if ( count( $users ) === 1 ) { + $keys = array_keys( $users ); + $username = $keys[0]; + } + + $linkList = $this->getLanguage()->listToText( $links ); + $html = "<p class='mw-translate-spsl-translators'>"; + $html .= $this->msg( 'supportedlanguages-translators' ) + ->rawParams( $linkList ) + ->numParams( count( $links ) ) + ->params( $username ) + ->escaped(); + $html .= "</p>\n"; + $this->getOutput()->addHTML( $html ); + } + + protected function getUserStats( $users ) { + $cache = wfGetCache( CACHE_ANYTHING ); + $dbr = wfGetDB( DB_REPLICA ); + $keys = []; + + foreach ( $users as $username ) { + $keys[] = wfMemcKey( 'translate', 'sl-usertats', $username ); + } + + $cached = $cache->getMulti( $keys ); + $data = []; + + foreach ( $users as $index => $username ) { + $cachekey = $keys[$index]; + + if ( !$this->purge && isset( $cached[$cachekey] ) ) { + $data[$username] = $cached[$cachekey]; + continue; + } + + if ( class_exists( ActorMigration::class ) ) { + $actorQuery = ActorMigration::newMigration()->getJoin( 'rev_user' ); + $tables = [ 'user', 'r' => [ 'revision' ] + $actorQuery['tables'] ]; + $joins = [ + 'r' => [ 'JOIN', 'user_id = rev_user' ], + ] + $actorQuery['joins']; + } else { + $tables = [ 'user', 'revision' ]; + $joins = [ 'revision' => [ 'JOIN', 'user_id = rev_user' ] ]; + } + + $fields = [ 'user_name', 'user_editcount', 'MAX(rev_timestamp) as lastedit' ]; + $conds = [ + 'user_name' => $username, + ]; + + $res = $dbr->selectRow( $tables, $fields, $conds, __METHOD__, [], $joins ); + $data[$username] = [ $res->user_editcount, $res->lastedit ]; + + $cache->set( $cachekey, $data[$username], 3600 ); + } + + return $data; + } + + protected function formatStyle( $styles ) { + $stylestr = ''; + foreach ( $styles as $key => $value ) { + $stylestr .= "$key:$value;"; + } + + return $stylestr; + } + + protected function preQueryUsers( $users ) { + $lb = new LinkBatch; + foreach ( $users as $user => $count ) { + $user = Title::capitalize( $user, NS_USER ); + $lb->add( NS_USER, $user ); + $lb->add( NS_USER_TALK, $user ); + } + $lb->execute(); + } + + protected function getColorLegend() { + $legend = ''; + $period = $this->period; + $statsTable = new StatsTable(); + + for ( $i = 0; $i <= $period; $i += 30 ) { + $iFormatted = htmlspecialchars( $this->getLanguage()->formatNum( $i ) ); + $legend .= '<span style="background-color:#' . + $statsTable->getBackgroundColor( ( $period - $i ) / $period ) . + "\"> $iFormatted</span>"; + } + + return $legend; + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialTranslate.php b/www/wiki/extensions/Translate/specials/SpecialTranslate.php new file mode 100644 index 00000000..fad30847 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialTranslate.php @@ -0,0 +1,443 @@ +<?php +/** + * Contains logic for special page Special:Translate. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ + +/** + * Implements the core of Translate extension - a special page which shows + * a list of messages in a format defined by Tasks. + * + * @ingroup SpecialPage TranslateSpecialPage + */ +class SpecialTranslate extends SpecialPage { + /** @var MessageGroup */ + protected $group; + + protected $defaults; + protected $nondefaults = []; + protected $options; + + public function __construct() { + parent::__construct( 'Translate' ); + } + + public function doesWrites() { + return true; + } + + protected function getGroupName() { + return 'wiki'; + } + + /** + * Access point for this special page. + * + * @param null|string $parameters + * @throws ErrorPageError + */ + public function execute( $parameters ) { + $out = $this->getOutput(); + $out->addModuleStyles( [ + 'ext.translate.special.translate.styles', + 'jquery.uls.grid', + 'mediawiki.ui.button' + ] ); + + $this->setHeaders(); + + if ( !defined( 'ULS_VERSION' ) ) { + throw new ErrorPageError( + 'translate-ulsdep-title', + 'translate-ulsdep-body' + ); + } + + $this->setup( $parameters ); + $out->addModules( 'ext.translate.special.translate' ); + $out->addJsConfigVars( 'wgTranslateLanguages', TranslateUtils::getLanguageNames( null ) ); + + $out->addHTML( Html::openElement( 'div', [ + 'class' => 'grid ext-translate-container', + ] ) ); + + $out->addHTML( $this->tuxSettingsForm() ); + $out->addHTML( $this->messageSelector() ); + + $table = new TuxMessageTable( $this->getContext(), $this->group, $this->options['language'] ); + $output = $table->fullTable(); + + $out->addHTML( $output ); + $out->addHTML( Html::closeElement( 'div' ) ); + } + + protected function setup( $parameters ) { + $request = $this->getRequest(); + + $defaults = [ + /* str */'taction' => 'translate', + /* str */'language' => $this->getLanguage()->getCode(), + /* str */'group' => '!additions', + ]; + + // Dump everything here + $nondefaults = []; + + $parameters = array_map( 'trim', explode( ';', $parameters ) ); + $pars = []; + + foreach ( $parameters as $_ ) { + if ( $_ === '' ) { + continue; + } + + if ( strpos( $_, '=' ) !== false ) { + list( $key, $value ) = array_map( 'trim', explode( '=', $_, 2 ) ); + } else { + $key = 'group'; + $value = $_; + } + + $pars[$key] = $value; + } + + foreach ( $defaults as $v => $t ) { + if ( is_bool( $t ) ) { + $r = isset( $pars[$v] ) ? (bool)$pars[$v] : $defaults[$v]; + $r = $request->getBool( $v, $r ); + } elseif ( is_int( $t ) ) { + $r = isset( $pars[$v] ) ? (int)$pars[$v] : $defaults[$v]; + $r = $request->getInt( $v, $r ); + } elseif ( is_string( $t ) ) { + $r = isset( $pars[$v] ) ? (string)$pars[$v] : $defaults[$v]; + $r = $request->getText( $v, $r ); + } + + if ( !isset( $r ) ) { + throw new MWException( '$r was not set' ); + } + + wfAppendToArrayIfNotDefault( $v, $r, $defaults, $nondefaults ); + } + + // Fix defaults based on what we got + if ( isset( $nondefaults['taction'] ) ) { + if ( $nondefaults['taction'] === 'export' ) { + // Redirect old export URLs to Special:ExportTranslations + $params = []; + if ( isset( $nondefaults['group'] ) ) { + $params['group'] = $nondefaults['group']; + } + if ( isset( $nondefaults['language'] ) ) { + $params['language'] = $nondefaults['language']; + } + + $export = SpecialPage::getTitleFor( 'ExportTranslations' )->getLocalURL( $params ); + $this->getOutput()->redirect( $export ); + } + } + + $this->defaults = $defaults; + $this->nondefaults = $nondefaults; + Hooks::run( 'TranslateGetSpecialTranslateOptions', [ &$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( $this->defaults['group'] ); + } + + if ( !Language::isKnownLanguageTag( $this->options['language'] ) ) { + $this->options['language'] = $this->defaults['language']; + } + + if ( MessageGroups::isDynamic( $this->group ) ) { + $this->group->setLanguage( $this->options['language'] ); + } + } + + protected function tuxSettingsForm() { + $nojs = Html::element( + 'div', + [ 'class' => 'tux-nojs errorbox' ], + $this->msg( 'tux-nojs' )->plain() + ); + + $attrs = [ 'class' => 'row tux-editor-header' ]; + $selectors = $this->tuxGroupSelector() . + $this->tuxLanguageSelector() . + $this->tuxGroupDescription() . + $this->tuxWorkflowSelector() . + $this->tuxGroupWarning(); + + return Html::rawElement( 'div', $attrs, $selectors ) . $nojs; + } + + protected function messageSelector() { + $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", + ]; + + $params = $this->nondefaults; + + 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"; + $taskParams = [ 'filter' => $filter ] + $params; + ksort( $taskParams ); + $href = $this->getPageTitle()->getLocalURL( $taskParams ); + $link = Html::element( 'a', [ 'href' => $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. + // The array keys are used as the name attribute of the checkbox. + // in the id attribute as tux-option-KEY, + // and and also for the data-filter attribute. + // The message is shown as the check box's label. + $options = [ + 'optional' => $this->msg( 'tux-message-filter-optional-messages-label' )->escaped(), + ]; + + $container = Html::openElement( 'ul', [ 'class' => 'column tux-message-selector' ] ); + foreach ( $options as $optFilter => $optLabel ) { + $container .= Html::rawElement( 'li', + [ 'class' => 'column' ], + Xml::checkLabel( + $optLabel, + $optFilter, + "tux-option-$optFilter", + isset( $this->nondefaults[$optFilter] ), + [ 'data-filter' => $optFilter ] + ) + ); + } + + $container .= Html::closeElement( 'ul' ); + + // @todo FIXME: Hard coded "ellipsis". + $output .= Html::openElement( 'li', [ 'class' => 'column more' ] ) . + '...' . + $container . + Html::closeElement( 'li' ); + + $output .= Html::closeElement( 'ul' ); + $output .= Html::closeElement( 'div' ); // close nine columns + $output .= Html::openElement( 'div', [ 'class' => 'three columns' ] ); + $output .= Html::openElement( 'div', [ 'class' => 'tux-message-filter-wrapper' ] ); + $output .= Html::element( 'input', [ + 'class' => 'tux-message-filter-box', + 'type' => 'search', + ] ); + $output .= Html::closeElement( 'div' ); // close tux-message-filter-wrapper + + $output .= Html::closeElement( 'div' ); // close three columns + + $output .= Html::closeElement( 'div' ); // close the row + + return $output; + } + + protected function tuxGroupSelector() { + $groupClass = [ 'grouptitle', 'grouplink' ]; + if ( $this->group instanceof AggregateMessageGroup ) { + $groupClass[] = 'tux-breadcrumb__item--aggregate'; + } + + // @todo FIXME The selector should have expanded parent-child lists + $output = Html::openElement( 'div', [ + 'class' => 'eight columns tux-breadcrumb', + 'data-language' => $this->options['language'], + ] ) . + Html::element( 'span', + [ 'class' => 'grouptitle' ], + $this->msg( 'translate-msggroupselector-projects' )->text() + ) . + 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(), + ], + $this->group->getLabel() + ) . + Html::closeElement( 'div' ); + + return $output; + } + + protected function tuxLanguageSelector() { + global $wgTranslateDocumentationLanguageCode; + + if ( $this->options['language'] === $wgTranslateDocumentationLanguageCode ) { + $targetLangName = $this->msg( 'translate-documentation-language' )->text(); + } else { + $targetLangName = Language::fetchLanguageName( $this->options['language'] ); + } + + $label = Html::element( + 'span', + [ 'class' => 'ext-translate-language-selector-label' ], + $this->msg( 'tux-languageselector' )->text() + ); + $value = Html::element( + 'span', + [ 'class' => 'uls' ], + $targetLangName + ); + + return Html::rawElement( + 'div', + [ 'class' => 'four columns ext-translate-language-selector' ], + "$label $value" + ); + } + + protected function tuxGroupDescription() { + // Initialize an empty warning box to be filled client-side. + return Html::rawElement( + 'div', + [ 'class' => 'twelve columns description' ], + $this->getGroupDescription( $this->group ) + ); + } + + protected function getGroupDescription( MessageGroup $group ) { + $description = $group->getDescription( $this->getContext() ); + if ( $description !== null ) { + return TranslateUtils::parseAsInterface( + $this->getOutput(), $description + ); + } + return ''; + } + + protected function tuxGroupWarning() { + if ( $this->options['group'] === '' ) { + return Html::rawElement( + 'div', + [ 'class' => 'twelve columns group-warning' ], + $this->msg( 'tux-translate-page-no-such-group' )->parse() + ); + } + + // Initialize an empty warning box to be filled client-side. + return Html::element( + 'div', + [ 'class' => 'twelve columns group-warning' ], + '' + ); + } + + protected function tuxWorkflowSelector() { + return Html::element( 'div', [ 'class' => 'tux-workflow twelve columns' ] ); + } + + /** + * Adds the task-based tabs on Special:Translate and few other special pages. + * Hook: SkinTemplateNavigation::SpecialPage + * @since 2012-02-10 + * @param Skin $skin + * @param array &$tabs + * @return true + */ + public static function tabify( Skin $skin, array &$tabs ) { + $title = $skin->getTitle(); + list( $alias, $sub ) = TranslateUtils::resolveSpecialPageAlias( $title->getText() ); + + $pagesInGroup = [ 'Translate', 'LanguageStats', 'MessageGroupStats' ]; + if ( !in_array( $alias, $pagesInGroup, true ) ) { + return true; + } + + $skin->getOutput()->addModuleStyles( 'ext.translate.tabgroup' ); + + // Extract subpage syntax, otherwise the values are not passed forward + $params = []; + if ( 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->getVal( 'language' ); + $params['group'] = $request->getVal( 'group' ); + + $taction = $request->getVal( 'taction', 'translate' ); + + $translate = SpecialPage::getTitleFor( 'Translate' ); + $languagestats = SpecialPage::getTitleFor( 'LanguageStats' ); + $messagegroupstats = 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' && $taction === 'translate' ) { + $tabs['namespaces']['translate']['class'] .= ' selected'; + } + + $tabs['views']['lstats'] = [ + 'text' => wfMessage( 'translate-taction-lstats' )->text(), + 'href' => $languagestats->getLocalURL( $params ), + 'class' => 'tux-tab', + ]; + if ( $alias === 'LanguageStats' ) { + $tabs['views']['lstats']['class'] .= ' selected'; + } + + $tabs['views']['mstats'] = [ + 'text' => wfMessage( 'translate-taction-mstats' )->text(), + 'href' => $messagegroupstats->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; + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialTranslationStash.php b/www/wiki/extensions/Translate/specials/SpecialTranslationStash.php new file mode 100644 index 00000000..78ff4b38 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialTranslationStash.php @@ -0,0 +1,211 @@ +<?php +/** + * TranslationStash - Translator screening page + * + * @file + * @author Santhosh Thottingal + * @license GPL-2.0-or-later + */ + +/** + * Special page for new users to translate example messages. + * + * @ingroup SpecialPage TranslateSpecialPage + */ +class SpecialTranslationStash extends SpecialPage { + /** @var TranslationStashStorage */ + protected $stash; + + public function __construct() { + parent::__construct( 'TranslationStash' ); + } + + public function doesWrites() { + return true; + } + + protected function getGroupName() { + return 'wiki'; + } + + public function execute( $params ) { + global $wgTranslateSandboxLimit, $wgTranslateSecondaryPermissionUrl; + + $this->setHeaders(); + $out = $this->getOutput(); + + $this->stash = new TranslationStashStorage( wfGetDB( DB_MASTER ) ); + + if ( !$this->hasPermissionToUse() ) { + if ( $wgTranslateSecondaryPermissionUrl && $this->getUser()->isLoggedIn() ) { + $out->redirect( + Title::newFromText( $wgTranslateSecondaryPermissionUrl )->getLocalURL() + ); + + return; + } + + $out->redirect( Title::newMainPage()->getLocalURL() ); + + return; + } + + $out->addJsConfigVars( 'wgTranslateSandboxLimit', $wgTranslateSandboxLimit ); + $out->addModules( 'ext.translate.special.translationstash' ); + $out->addModuleStyles( 'mediawiki.ui.button' ); + $this->showPage(); + } + + /** + * Checks that the user is in the sandbox. Also handles special overrides + * mainly used for integration testing. + * + * @return bool + */ + protected function hasPermissionToUse() { + global $wgTranslateTestUsers; + + $request = $this->getRequest(); + $user = $this->getUser(); + + if ( in_array( $user->getName(), $wgTranslateTestUsers, true ) ) { + if ( $request->getVal( 'integrationtesting' ) === 'activatestash' ) { + $user->addGroup( 'translate-sandboxed' ); + + return true; + } elseif ( $request->getVal( 'integrationtesting' ) === 'deactivatestash' ) { + $user->removeGroup( 'translate-sandboxed' ); + $this->stash->deleteTranslations( $user ); + + return false; + } + } + + if ( !TranslateSandbox::isSandboxed( $user ) ) { + return false; + } + + return true; + } + + /** + * Generates the whole page html and appends it to output + */ + protected function showPage() { + $out = $this->getOutput(); + $user = $this->getUser(); + + $count = count( $this->stash->getTranslations( $user ) ); + if ( $count === 0 ) { + $progress = $this->msg( 'translate-translationstash-initialtranslation' )->parse(); + } else { + $progress = $this->msg( 'translate-translationstash-translations' ) + ->numParams( $count )->parse(); + } + + $out->addHTML( <<<HTML +<div class="grid"> + <div class="row translate-welcome-header"> + <h1> + {$this->msg( 'translate-translationstash-welcome', $user->getName() )->parse()} + </h1> + <p> + {$this->msg( 'translate-translationstash-welcome-note' )->parse()} + </p> + </div> + <div class="row translate-stash-control"> + <div class="six columns stash-stats"> + {$progress} + </div> + <div class="six columns ext-translate-language-selector right"> + {$this->tuxLanguageSelector()} + </div> + </div> + {$this->getMessageTable()} + <div class="row limit-reached hide"></div> +</div> +HTML + ); + } + + protected function getMessageTable() { + $sourceLang = $this->getSourceLanguage(); + $targetLang = $this->getTargetLanguage(); + + $list = Html::element( 'div', [ + 'class' => 'row tux-messagelist', + 'data-sourcelangcode' => $sourceLang->getCode(), + 'data-sourcelangdir' => $sourceLang->getDir(), + 'data-targetlangcode' => $targetLang->getCode(), + 'data-targetlangdir' => $targetLang->getDir(), + ] ); + + return $list; + } + + protected function tuxLanguageSelector() { + // The name will be displayed in the UI language, + // so use for lang and dir + $language = $this->getTargetLanguage(); + $targetLangName = Language::fetchLanguageName( $language->getCode() ); + + $label = Html::element( + 'span', + [ 'class' => 'ext-translate-language-selector-label' ], + $this->msg( 'tux-languageselector' )->text() + ); + + $trigger = Html::element( + 'span', + [ + 'class' => 'uls', + 'lang' => $language->getHtmlCode(), + 'dir' => $language->getDir(), + ], + $targetLangName + ); + + // No-break space is added for spacing after the label + // and to ensure separation of words (in Arabic, for example) + return "$label $trigger"; + } + + /** + * Returns the source language for messages. + * @return Language + */ + protected function getSourceLanguage() { + // Bad + return Language::factory( 'en' ); + } + + /** + * Returns the default target language for messages. + * @return Language + */ + protected function getTargetLanguage() { + $ui = $this->getLanguage(); + $source = $this->getSourceLanguage(); + if ( !$ui->equals( $source ) ) { + return $ui; + } + + $options = FormatJson::decode( $this->getUser()->getOption( 'translate-sandbox' ), true ); + $supported = TranslateUtils::getLanguageNames( 'en' ); + + if ( isset( $options['languages' ] ) ) { + foreach ( $options['languages'] as $code ) { + if ( !isset( $supported[$code] ) ) { + continue; + } + + if ( $code !== $source->getCode() ) { + return Language::factory( $code ); + } + } + } + + // User has not chosen any valid language. Pick the source. + return Language::factory( $source->getCode() ); + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialTranslationStats.php b/www/wiki/extensions/Translate/specials/SpecialTranslationStats.php new file mode 100644 index 00000000..f84fa429 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialTranslationStats.php @@ -0,0 +1,1143 @@ +<?php +/** + * Contains logic for special page Special:TranslationStats. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @license GPL-2.0-or-later + */ + +/** + * @defgroup Stats Statistics + * Collection of code to produce various kinds of statistics. + */ + +/** + * Includable special page for generating graphs on translations. + * + * @ingroup SpecialPage TranslateSpecialPage Stats + */ +class SpecialTranslationStats extends SpecialPage { + /// @since 2012-03-05 + protected static $graphs = [ + 'edits' => 'TranslatePerLanguageStats', + 'users' => 'TranslatePerLanguageStats', + 'registrations' => 'TranslateRegistrationStats', + 'reviews' => 'ReviewPerLanguageStats', + 'reviewers' => 'ReviewPerLanguageStats', + ]; + + public function __construct() { + parent::__construct( 'TranslationStats' ); + } + + public function isIncludable() { + return true; + } + + protected function getGroupName() { + return 'wiki'; + } + + /** + * @since 2012-03-05 + * @return array List of graph types + */ + public function getGraphTypes() { + return array_keys( self::$graphs ); + } + + /** + * @since 2012-03-05 + * @param string $type + * @return string + */ + public function getGraphClass( $type ) { + return self::$graphs[$type]; + } + + public function execute( $par ) { + $this->getOutput()->addModules( 'ext.translate.special.translationstats' ); + + $opts = new FormOptions(); + $opts->add( 'graphit', false ); + $opts->add( 'preview', false ); + $opts->add( 'language', '' ); + $opts->add( 'count', 'edits' ); + $opts->add( 'scale', 'days' ); + $opts->add( 'days', 30 ); + $opts->add( 'width', 600 ); + $opts->add( 'height', 400 ); + $opts->add( 'group', '' ); + $opts->add( 'uselang', '' ); + $opts->add( 'start', '' ); + $opts->add( 'imagescale', 1.0 ); + $opts->fetchValuesFromRequest( $this->getRequest() ); + + $pars = explode( ';', $par ); + + foreach ( $pars as $item ) { + if ( strpos( $item, '=' ) === false ) { + continue; + } + + list( $key, $value ) = array_map( 'trim', explode( '=', $item, 2 ) ); + if ( isset( $opts[$key] ) ) { + $opts[$key] = $value; + } + } + + $opts->validateIntBounds( 'days', 1, 10000 ); + $opts->validateIntBounds( 'width', 200, 1000 ); + $opts->validateIntBounds( 'height', 200, 1000 ); + $opts->validateBounds( 'imagescale', 1.0, 4.0 ); + + if ( $opts['start'] !== '' ) { + $opts['start'] = rtrim( wfTimestamp( TS_ISO_8601, $opts['start'] ), 'Z' ); + } + + $validScales = [ 'months', 'weeks', 'days', 'hours' ]; + if ( !in_array( $opts['scale'], $validScales ) ) { + $opts['scale'] = 'days'; + } + + if ( $opts['scale'] === 'hours' ) { + $opts->validateIntBounds( 'days', 1, 4 ); + } + + $validCounts = $this->getGraphTypes(); + if ( !in_array( $opts['count'], $validCounts ) ) { + $opts['count'] = 'edits'; + } + + foreach ( [ 'group', 'language' ] as $t ) { + $values = array_map( 'trim', explode( ',', $opts[$t] ) ); + $values = array_splice( $values, 0, 4 ); + if ( $t === 'group' ) { + // BC for old syntax which replaced _ to | which was not allowed + $values = preg_replace( '~^page_~', 'page-', $values ); + } + $opts[$t] = implode( ',', $values ); + } + + if ( $this->including() ) { + $this->getOutput()->addHTML( $this->image( $opts ) ); + } elseif ( $opts['graphit'] ) { + if ( !class_exists( PHPlot::class ) ) { + header( 'HTTP/1.0 500 Multi fail' ); + echo 'PHPlot not found'; + } + + if ( !$this->getRequest()->getBool( 'debug' ) ) { + $this->getOutput()->disable(); + header( 'Content-Type: image/png' ); + header( 'Cache-Control: private, max-age=3600' ); + header( 'Expires: ' . wfTimestamp( TS_RFC2822, time() + 3600 ) ); + } + $this->draw( $opts ); + } else { + $this->form( $opts ); + } + } + + /** + * Constructs the form which can be used to generate custom graphs. + * @param FormOptions $opts + * @suppress SecurityCheck-DoubleEscaped Intentionally outputting what user should type + */ + protected function form( FormOptions $opts ) { + global $wgScript; + + $this->setHeaders(); + $out = $this->getOutput(); + $out->addHelpLink( 'Help:Extension:Translate/Statistics_and_reporting' ); + $out->addWikiMsg( 'translate-statsf-intro' ); + + $out->addHTML( + Xml::fieldset( $this->msg( 'translate-statsf-options' )->text() ) . + Html::openElement( 'form', [ 'action' => $wgScript ] ) . + Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . + Html::hidden( 'preview', 1 ) . + '<table>' + ); + + $submit = Xml::submitButton( $this->msg( 'translate-statsf-submit' )->text() ); + + $out->addHTML( + $this->eInput( 'width', $opts ) . + $this->eInput( 'height', $opts ) . + '<tr><td colspan="2"><hr /></td></tr>' . + $this->eInput( 'start', $opts, 24 ) . + $this->eInput( 'days', $opts ) . + $this->eRadio( 'scale', $opts, [ 'months', 'weeks', 'days', 'hours' ] ) . + $this->eRadio( 'count', $opts, $this->getGraphTypes() ) . + '<tr><td colspan="2"><hr /></td></tr>' . + $this->eLanguage( 'language', $opts ) . + $this->eGroup( 'group', $opts ) . + '<tr><td colspan="2"><hr /></td></tr>' . + '<tr><td colspan="2">' . $submit . '</td></tr>' + ); + + $out->addHTML( + '</table>' . + '</form>' . + '</fieldset>' + ); + + if ( !$opts['preview'] ) { + return; + } + + $spiParams = ''; + foreach ( $opts->getChangedValues() as $key => $v ) { + if ( $key === 'preview' ) { + continue; + } + + if ( $spiParams !== '' ) { + $spiParams .= ';'; + } + + $spiParams .= wfEscapeWikiText( "$key=$v" ); + } + + if ( $spiParams !== '' ) { + $spiParams = '/' . $spiParams; + } + + $titleText = $this->getPageTitle()->getPrefixedText(); + + $out->addHTML( + Html::element( 'hr' ) . + Html::element( 'pre', [], "{{{$titleText}{$spiParams}}}" ) + ); + + $out->addHTML( + Html::element( 'hr' ) . + Html::rawElement( + 'div', + [ 'style' => 'margin: 1em auto; text-align: center;' ], + $this->image( $opts ) + ) + ); + } + + /** + * Constructs a table row with label and input in two columns. + * @param string $name Option name. + * @param FormOptions $opts + * @param int $width + * @return string Html. + */ + protected function eInput( $name, FormOptions $opts, $width = 4 ) { + $value = $opts[$name]; + + return '<tr><td>' . $this->eLabel( $name ) . '</td><td>' . + Xml::input( $name, $width, $value, [ 'id' => $name ] ) . + '</td></tr>' . "\n"; + } + + /** + * Constructs a label for option. + * @param string $name Option name. + * @return string Html. + */ + protected function eLabel( $name ) { + // Give grep a chance to find the usages: + // translate-statsf-width, translate-statsf-height, translate-statsf-start, + // translate-statsf-days, translate-statsf-scale, translate-statsf-count, + // translate-statsf-language, translate-statsf-group + $label = 'translate-statsf-' . $name; + $label = $this->msg( $label )->escaped(); + + return Xml::tags( 'label', [ 'for' => $name ], $label ); + } + + /** + * Constructs a table row with label and radio input in two columns. + * @param string $name Option name. + * @param FormOptions $opts + * @param string[] $alts List of alternatives. + * @return string Html. + */ + protected function eRadio( $name, FormOptions $opts, array $alts ) { + // Give grep a chance to find the usages: + // translate-statsf-scale, translate-statsf-count + $label = 'translate-statsf-' . $name; + $label = $this->msg( $label )->escaped(); + $s = '<tr><td>' . $label . '</td><td>'; + + $options = []; + foreach ( $alts as $alt ) { + $id = "$name-$alt"; + $radio = Xml::radio( $name, $alt, $alt === $opts[$name], + [ 'id' => $id ] ) . ' '; + $options[] = $radio . ' ' . $this->eLabel( $id ); + } + + $s .= implode( ' ', $options ); + $s .= '</td></tr>' . "\n"; + + return $s; + } + + /** + * Constructs a table row with label and language selector in two columns. + * @param string $name Option name. + * @param FormOptions $opts + * @return string Html. + */ + protected function eLanguage( $name, FormOptions $opts ) { + $value = $opts[$name]; + + $select = $this->languageSelector(); + $select->setTargetId( 'language' ); + + return '<tr><td>' . $this->eLabel( $name ) . '</td><td>' . + $select->getHtmlAndPrepareJS() . '<br />' . + Xml::input( $name, 20, $value, [ 'id' => $name ] ) . + '</td></tr>' . "\n"; + } + + /** + * Constructs a JavaScript enhanced language selector. + * @return JsSelectToInput + */ + protected function languageSelector() { + $languages = TranslateUtils::getLanguageNames( $this->getLanguage()->getCode() ); + + ksort( $languages ); + + $selector = new XmlSelect( 'mw-language-selector', 'mw-language-selector' ); + foreach ( $languages as $code => $name ) { + $selector->addOption( "$code - $name", $code ); + } + + $jsSelect = new JsSelectToInput( $selector ); + + return $jsSelect; + } + + /** + * Constructs a table row with label and group selector in two columns. + * @param string $name Option name. + * @param FormOptions $opts + * @return string Html. + */ + protected function eGroup( $name, FormOptions $opts ) { + $value = $opts[$name]; + + $select = $this->groupSelector(); + $select->setTargetId( 'group' ); + + return '<tr><td>' . $this->eLabel( $name ) . '</td><td>' . + $select->getHtmlAndPrepareJS() . '<br />' . + Xml::input( $name, 20, $value, [ 'id' => $name ] ) . + '</td></tr>' . "\n"; + } + + /** + * Constructs a JavaScript enhanced group selector. + * @return JsSelectToInput + */ + protected function groupSelector() { + $groups = MessageGroups::singleton()->getGroups(); + /** + * @var MessageGroup $group + */ + foreach ( $groups as $key => $group ) { + if ( !$group->exists() ) { + unset( $groups[$key] ); + continue; + } + } + + ksort( $groups ); + + $selector = new XmlSelect( 'mw-group-selector', 'mw-group-selector' ); + /** + * @var MessageGroup $name + */ + foreach ( $groups as $code => $name ) { + $selector->addOption( $name->getLabel(), $code ); + } + + $jsSelect = new JsSelectToInput( $selector ); + + return $jsSelect; + } + + /** + * Returns an \<img> tag for graph. + * @param FormOptions $opts + * @return string Html. + */ + protected function image( FormOptions $opts ) { + $title = $this->getPageTitle(); + + $params = $opts->getChangedValues(); + $params[ 'graphit' ] = true; + $src = $title->getLocalURL( $params ); + + $srcsets = []; + foreach ( [ 1.5, 2, 3 ] as $scale ) { + $params[ 'imagescale' ] = $scale; + $srcsets[] = "{$title->getLocalURL( $params )} {$scale}x"; + } + + return Xml::element( 'img', + [ + 'src' => $src, + 'srcset' => implode( ', ', $srcsets ), + 'width' => $opts['width'], + 'height' => $opts['height'], + ] + ); + } + + /** + * Fetches and preprocesses graph data that can be fed to graph drawer. + * @param FormOptions $opts + * @return array ( string => array ) Data indexed by their date labels. + */ + protected function getData( FormOptions $opts ) { + $dbr = wfGetDB( DB_REPLICA ); + + $class = $this->getGraphClass( $opts['count'] ); + $so = new $class( $opts ); + + $fixedStart = $opts->getValue( 'start' ) !== ''; + + $now = time(); + $period = 3600 * 24 * $opts->getValue( 'days' ); + + if ( $fixedStart ) { + $cutoff = wfTimestamp( TS_UNIX, $opts->getValue( 'start' ) ); + } else { + $cutoff = $now - $period; + } + $cutoff = self::roundTimestampToCutoff( $opts['scale'], $cutoff, 'earlier' ); + + $start = $cutoff; + + if ( $fixedStart ) { + $end = self::roundTimestampToCutoff( $opts['scale'], $start + $period, 'later' ) - 1; + } else { + $end = null; + } + + $tables = []; + $fields = []; + $conds = []; + $type = __METHOD__; + $options = []; + $joins = []; + + $so->preQuery( $tables, $fields, $conds, $type, $options, $joins, $start, $end ); + $res = $dbr->select( $tables, $fields, $conds, $type, $options, $joins ); + wfDebug( __METHOD__ . "-queryend\n" ); + + // Start processing the data + $dateFormat = $so->getDateFormat(); + $increment = self::getIncrement( $opts['scale'] ); + + $labels = $so->labels(); + $keys = array_keys( $labels ); + $values = array_pad( [], count( $labels ), 0 ); + $defaults = array_combine( $keys, $values ); + + $data = []; + // Allow 10 seconds in the future for processing time + $lastValue = $end !== null ? $end : $now + 10; + $lang = $this->getLanguage(); + while ( $cutoff <= $lastValue ) { + $date = $lang->sprintfDate( $dateFormat, wfTimestamp( TS_MW, $cutoff ) ); + $cutoff += $increment; + $data[$date] = $defaults; + } + + // Processing + $labelToIndex = array_flip( $labels ); + + foreach ( $res as $row ) { + $indexLabels = $so->indexOf( $row ); + if ( $indexLabels === false ) { + continue; + } + + foreach ( (array)$indexLabels as $i ) { + if ( !isset( $labelToIndex[$i] ) ) { + continue; + } + $date = $lang->sprintfDate( $dateFormat, $so->getTimestamp( $row ) ); + // Ignore values outside range + if ( !isset( $data[$date] ) ) { + continue; + } + + $data[$date][$labelToIndex[$i]]++; + } + } + + // Don't display dummy label + if ( count( $labels ) === 1 && $labels[0] === 'all' ) { + $labels = []; + } + + foreach ( $labels as &$label ) { + if ( strpos( $label, '@' ) === false ) { + continue; + } + list( $groupId, $code ) = explode( '@', $label, 2 ); + if ( $code && $groupId ) { + $code = TranslateUtils::getLanguageName( $code, $lang->getCode() ) . " ($code)"; + $group = MessageGroups::getGroup( $groupId ); + $group = $group ? $group->getLabel() : $groupId; + $label = "$group @ $code"; + } elseif ( $code ) { + $label = TranslateUtils::getLanguageName( $code, $lang->getCode() ) . " ($code)"; + } elseif ( $groupId ) { + $group = MessageGroups::getGroup( $groupId ); + $label = $group ? $group->getLabel() : $groupId; + } + } + + if ( $end === null ) { + $last = array_splice( $data, -1, 1 ); + // Indicator that the last value is not full + $data[key( $last ) . '*'] = current( $last ); + } + + return [ $labels, $data ]; + } + + /** + * Gets the closest earlieast timestamp that corresponds to start of a + * period in given scale, like, midnight, monday or first day of the month. + * @param string $scale One of hours, days, weeks, months + * @param int $cutoff Timestamp in unix format. + * @param string $direction One of earlier, later + * @return int + */ + protected static function roundTimestampToCutoff( $scale, $cutoff, $direction = 'earlier' ) { + $dir = $direction === 'earlier' ? -1 : 1; + + /* Ensure that the first item in the graph has full data even + * if it doesn't align with the given 'days' boundary */ + if ( $scale === 'hours' ) { + $cutoff += self::roundingAddition( $cutoff, 3600, $dir ); + } elseif ( $scale === 'days' ) { + $cutoff += self::roundingAddition( $cutoff, 86400, $dir ); + } elseif ( $scale === 'weeks' ) { + /* Here we assume that week starts on monday, which does not + * always hold true. Go Xwards day by day until we are on monday */ + while ( date( 'D', $cutoff ) !== 'Mon' ) { + $cutoff += $dir * 86400; + } + // Round to nearest day + $cutoff -= ( $cutoff % 86400 ); + } elseif ( $scale === 'months' ) { + // Go Xwards/ day by day until we are on the first day of the month + while ( date( 'j', $cutoff ) !== '1' ) { + $cutoff += $dir * 86400; + } + // Round to nearest day + $cutoff -= ( $cutoff % 86400 ); + } + + return $cutoff; + } + + /** + * @param int $ts + * @param int $amount + * @param int $dir + * @return int + */ + protected static function roundingAddition( $ts, $amount, $dir ) { + if ( $dir === -1 ) { + return -1 * ( $ts % $amount ); + } else { + return $amount - ( $ts % $amount ); + } + } + + /** + * Adds raw image data of the graph to the output. + * @param FormOptions $opts + */ + public function draw( FormOptions $opts ) { + global $wgTranslatePHPlotFont; + + $imageScale = $opts->getValue( 'imagescale' ); + $width = $opts->getValue( 'width' ); + $height = $opts->getValue( 'height' ); + // Define the object + $plot = new PHPlot( $width * $imageScale, $height * $imageScale ); + + list( $legend, $resData ) = $this->getData( $opts ); + $count = count( $resData ); + $skip = (int)( $count / ( $width / 60 ) - 1 ); + $i = $count; + $data = []; + + foreach ( $resData as $date => $edits ) { + if ( $skip > 0 && + ( $count - $i ) % $skip !== 0 + ) { + $date = ''; + } + + if ( strpos( $date, ';' ) !== false ) { + list( , $date ) = explode( ';', $date, 2 ); + } + + array_unshift( $edits, $date ); + $data[] = $edits; + $i--; + } + + $font = FCFontFinder::findFile( $this->getLanguage()->getCode() ); + if ( !$font ) { + $font = $wgTranslatePHPlotFont; + } + $numberFont = FCFontFinder::findFile( 'en' ); + $plot->SetDefaultTTFont( $font ); + $plot->SetFontTTF( 'generic', $font, 12 * $imageScale ); + $plot->SetFontTTF( 'legend', $font, 12 * $imageScale ); + $plot->SetFontTTF( 'x_title', $font, 10 * $imageScale ); + $plot->SetFontTTF( 'y_title', $font, 10 * $imageScale ); + $plot->SetFontTTF( 'x_label', $numberFont, 8 * $imageScale ); + $plot->SetFontTTF( 'y_label', $numberFont, 8 * $imageScale ); + + $plot->SetDataValues( $data ); + + if ( $legend !== null ) { + $plot->SetLegend( $legend ); + } + + // Give grep a chance to find the usages: + // translate-stats-edits, translate-stats-users, translate-stats-registrations, + // translate-stats-reviews, translate-stats-reviewers + $yTitle = $this->msg( 'translate-stats-' . $opts['count'] )->escaped(); + + // Turn off X axis ticks and labels because they get in the way: + $plot->SetYTitle( $yTitle ); + $plot->SetXTickLabelPos( 'none' ); + $plot->SetXTickPos( 'none' ); + $plot->SetXLabelAngle( 45 ); + + $max = max( array_map( 'max', $resData ) ); + $max = self::roundToSignificant( $max, 1 ); + $max = round( $max, (int)( -log( $max, 10 ) ) ); + + $yTick = 10; + while ( $max / $yTick > $height / 20 ) { + $yTick *= 2; + } + + // If we have very small case, ensure that there is at least one tick + $yTick = min( $max, $yTick ); + $yTick = self::roundToSignificant( $yTick ); + $plot->SetYTickIncrement( $yTick ); + $plot->SetPlotAreaWorld( null, 0, null, max( $max, 10 ) ); + + $plot->SetTransparentColor( 'white' ); + $plot->SetBackgroundColor( 'white' ); + + // Draw it + $plot->DrawGraph(); + } + + /** + * Enhanced version of round that supports rounding up to a given scale + * relative to the number itself. Examples: + * - roundToSignificant( 1234, 0 ) = 10000 + * - roundToSignificant( 1234, 1 ) = 2000 + * - roundToSignificant( 1234, 2 ) = 1300 + * - roundToSignificant( 1234, 3 ) = 1240 + * + * @param int $number Number to round. + * @param int $significant How many signficant numbers to keep. + * @return int Rounded number. + */ + public static function roundToSignificant( $number, $significant = 1 ) { + $log = (int)log( $number, 10 ); + $nonSignificant = max( 0, $log - $significant + 1 ); + $factor = pow( 10, $nonSignificant ); + + return (int)( ceil( $number / $factor ) * $factor ); + } + + /** + * Returns an increment in seconds for a given scale. + * The increment must be small enough that we will hit every item in the + * scale when using different multiples of the increment. It should be + * large enough to avoid hitting the same item multiple times. + * @param string $scale Either months, weeks, days or hours. + * @return int Number of seconds in the increment. + */ + public static function getIncrement( $scale ) { + $increment = 3600 * 24; + if ( $scale === 'months' ) { + /* We use increment to fill up the values. Use number small enough + * to ensure we hit each month */ + $increment = 3600 * 24 * 15; + } elseif ( $scale === 'weeks' ) { + $increment = 3600 * 24 * 7; + } elseif ( $scale === 'hours' ) { + $increment = 3600; + } + + return $increment; + } +} + +/** + * Interface for producing different kinds of graphs. + * The graphs are based on data queried from the database. + * @ingroup Stats + */ +interface TranslationStatsInterface { + /** + * Constructor. The implementation can access the graph options, but not + * define new ones. + * @param FormOptions $opts + */ + public function __construct( FormOptions $opts ); + + /** + * Query details that the graph must fill. + * @param array &$tables Empty list. Append table names. + * @param array &$fields Empty list. Append field names. + * @param array &$conds Empty array. Append select conditions. + * @param string &$type Append graph type (used to identify queries). + * @param array &$options Empty array. Append extra query options. + * @param array &$joins Empty array. Append extra join conditions. + * @param string $start Precalculated start cutoff timestamp + * @param string $end Precalculated end cutoff timestamp + */ + public function preQuery( &$tables, &$fields, &$conds, &$type, &$options, &$joins, $start, $end ); + + /** + * Return the indexes which this result contributes to. + * Return 'all' if only one variable is measured. Return false if none. + * @param array $row Database Result Row + */ + public function indexOf( $row ); + + /** + * Return the names of the variables being measured. + * Return 'all' if only one variable is measured. Must match indexes + * returned by indexOf() and contain them all. + * @return string[] + */ + public function labels(); + + /** + * Return the timestamp associated with this result row. + * @param array $row Database Result Row + * @return string Timestamp. + */ + public function getTimestamp( $row ); + + /** + * Return time formatting string. + * @see Language::sprintfDate() + * @return string + */ + public function getDateFormat(); +} + +/** + * Provides some hand default implementations for TranslationStatsInterface. + * @ingroup Stats + */ +abstract class TranslationStatsBase implements TranslationStatsInterface { + /** + * @var FormOptions Graph options. + */ + protected $opts; + + public function __construct( FormOptions $opts ) { + $this->opts = $opts; + } + + public function indexOf( $row ) { + return [ 'all' ]; + } + + public function labels() { + return [ 'all' ]; + } + + public function getDateFormat() { + $dateFormat = 'Y-m-d'; + if ( $this->opts['scale'] === 'months' ) { + $dateFormat = 'Y-m'; + } elseif ( $this->opts['scale'] === 'weeks' ) { + $dateFormat = 'Y-\WW'; + } elseif ( $this->opts['scale'] === 'hours' ) { + $dateFormat .= ';H'; + } + + return $dateFormat; + } + + protected static function makeTimeCondition( $field, $start, $end ) { + $db = wfGetDB( DB_REPLICA ); + + $conds = []; + if ( $start !== null ) { + $conds[] = "$field >= '{$db->timestamp( $start )}'"; + } + if ( $end !== null ) { + $conds[] = "$field <= '{$db->timestamp( $end )}'"; + } + + return $conds; + } + + /** + * @since 2012-03-05 + * @param array $groupIds + * @return array + */ + protected static function namespacesFromGroups( $groupIds ) { + $namespaces = []; + foreach ( $groupIds as $id ) { + $group = MessageGroups::getGroup( $id ); + if ( $group ) { + $namespace = $group->getNamespace(); + $namespaces[$namespace] = true; + } + } + + return array_keys( $namespaces ); + } +} + +/** + * Graph which provides statistics on active users and number of translations. + * @ingroup Stats + */ +class TranslatePerLanguageStats extends TranslationStatsBase { + /** @var bool[] array( string => bool ) Cache used to count active users only once per day. */ + protected $usercache; + + protected $codes, $groups; + + public function __construct( FormOptions $opts ) { + parent::__construct( $opts ); + // This query is slow... ensure a lower limit. + $opts->validateIntBounds( 'days', 1, 200 ); + } + + public function preQuery( &$tables, &$fields, &$conds, &$type, &$options, &$joins, $start, $end ) { + global $wgTranslateMessageNamespaces; + + $db = wfGetDB( DB_REPLICA ); + + $tables = [ 'recentchanges' ]; + $fields = [ 'rc_timestamp' ]; + $joins = []; + + $conds = [ + 'rc_namespace' => $wgTranslateMessageNamespaces, + 'rc_bot' => 0, + 'rc_type != ' . RC_LOG, + ]; + + $timeConds = self::makeTimeCondition( 'rc_timestamp', $start, $end ); + $conds = array_merge( $conds, $timeConds ); + + $options = [ 'ORDER BY' => 'rc_timestamp' ]; + + $this->groups = array_filter( array_map( 'trim', explode( ',', $this->opts['group'] ) ) ); + $this->groups = array_map( 'MessageGroups::normalizeId', $this->groups ); + $this->codes = array_filter( array_map( 'trim', explode( ',', $this->opts['language'] ) ) ); + + $namespaces = self::namespacesFromGroups( $this->groups ); + if ( count( $namespaces ) ) { + $conds['rc_namespace'] = $namespaces; + } + + $languages = []; + foreach ( $this->codes as $code ) { + $languages[] = 'rc_title ' . $db->buildLike( $db->anyString(), "/$code" ); + } + if ( count( $languages ) ) { + $conds[] = $db->makeList( $languages, LIST_OR ); + } + + $fields[] = 'rc_title'; + + if ( $this->groups ) { + $fields[] = 'rc_namespace'; + } + + if ( $this->opts['count'] === 'users' ) { + if ( class_exists( ActorMigration::class ) ) { + $actorQuery = ActorMigration::newMigration()->getJoin( 'rc_user' ); + $tables += $actorQuery['tables']; + $fields['rc_user_text'] = $actorQuery['fields']['rc_user_text']; + $joins += $actorQuery['joins']; + } else { + $fields[] = 'rc_user_text'; + } + } + + $type .= '-perlang'; + } + + public function indexOf( $row ) { + // We need to check that there is only one user per day. + if ( $this->opts['count'] === 'users' ) { + $date = $this->formatTimestamp( $row->rc_timestamp ); + + if ( isset( $this->usercache[$date][$row->rc_user_text] ) ) { + return -1; + } else { + $this->usercache[$date][$row->rc_user_text] = 1; + } + } + + // Do not consider language-less pages. + if ( strpos( $row->rc_title, '/' ) === false ) { + return false; + } + + // No filters, just one key to track. + if ( !$this->groups && !$this->codes ) { + return 'all'; + } + + // The key-building needs to be in sync with ::labels(). + list( $key, $code ) = TranslateUtils::figureMessage( $row->rc_title ); + + $groups = []; + $codes = []; + + if ( $this->groups ) { + /* + * Get list of keys that the message belongs to, and filter + * out those which are not requested. + */ + $groups = TranslateUtils::messageKeyToGroups( $row->rc_namespace, $key ); + $groups = array_intersect( $this->groups, $groups ); + } + + if ( $this->codes ) { + $codes = [ $code ]; + } + + return $this->combineTwoArrays( $groups, $codes ); + } + + public function labels() { + return $this->combineTwoArrays( $this->groups, $this->codes ); + } + + public function getTimestamp( $row ) { + return $row->rc_timestamp; + } + + /** + * Makes a label for variable. If group or language code filters, or both + * are used, combine those in a pretty way. + * @param string $group Group name. + * @param string $code Language code. + * @return string Label. + */ + protected function makeLabel( $group, $code ) { + if ( $group || $code ) { + return "$group@$code"; + } else { + return 'all'; + } + } + + /** + * Cross-product of two lists with string results, where either + * list can be empty. + * @param string[] $groups Group names. + * @param string[] $codes Language codes. + * @return string[] Labels. + */ + protected function combineTwoArrays( $groups, $codes ) { + if ( !count( $groups ) ) { + $groups[] = false; + } + + if ( !count( $codes ) ) { + $codes[] = false; + } + + $items = []; + foreach ( $groups as $group ) { + foreach ( $codes as $code ) { + $items[] = $this->makeLabel( $group, $code ); + } + } + + return $items; + } + + /** + * Returns unique index for given item in the scale being used. + * Called a lot, so performance intensive. + * @param string $timestamp Timestamp in mediawiki format. + * @return string + */ + protected function formatTimestamp( $timestamp ) { + global $wgContLang; + + switch ( $this->opts['scale'] ) { + case 'hours' : + $cut = 4; + break; + case 'days' : + $cut = 6; + break; + case 'months': + $cut = 8; + break; + default : + return $wgContLang->sprintfDate( $this->getDateFormat(), $timestamp ); + } + + return substr( $timestamp, 0, -$cut ); + } +} + +/** + * Graph which provides statistics about amount of registered users in a given time. + * @ingroup Stats + */ +class TranslateRegistrationStats extends TranslationStatsBase { + public function preQuery( &$tables, &$fields, &$conds, &$type, &$options, &$joins, $start, $end ) { + $tables = 'user'; + $fields = 'user_registration'; + $conds = self::makeTimeCondition( 'user_registration', $start, $end ); + $type .= '-registration'; + $options = []; + $joins = []; + } + + public function getTimestamp( $row ) { + return $row->user_registration; + } +} + +/** + * Graph which provides statistics on number of reviews and reviewers. + * @since 2012-03-05 + * @ingroup Stats + */ +class ReviewPerLanguageStats extends TranslatePerLanguageStats { + public function preQuery( &$tables, &$fields, &$conds, &$type, &$options, &$joins, $start, $end ) { + global $wgTranslateMessageNamespaces; + + $db = wfGetDB( DB_REPLICA ); + + $tables = [ 'logging' ]; + $fields = [ 'log_timestamp' ]; + $joins = []; + + $conds = [ + 'log_namespace' => $wgTranslateMessageNamespaces, + 'log_action' => 'message', + ]; + + $timeConds = self::makeTimeCondition( 'log_timestamp', $start, $end ); + $conds = array_merge( $conds, $timeConds ); + + $options = [ 'ORDER BY' => 'log_timestamp' ]; + + $this->groups = array_filter( array_map( 'trim', explode( ',', $this->opts['group'] ) ) ); + $this->codes = array_filter( array_map( 'trim', explode( ',', $this->opts['language'] ) ) ); + + $namespaces = self::namespacesFromGroups( $this->groups ); + if ( count( $namespaces ) ) { + $conds['log_namespace'] = $namespaces; + } + + $languages = []; + foreach ( $this->codes as $code ) { + $languages[] = 'log_title ' . $db->buildLike( $db->anyString(), "/$code" ); + } + if ( count( $languages ) ) { + $conds[] = $db->makeList( $languages, LIST_OR ); + } + + $fields[] = 'log_title'; + + if ( $this->groups ) { + $fields[] = 'log_namespace'; + } + + if ( $this->opts['count'] === 'reviewers' ) { + if ( class_exists( ActorMigration::class ) ) { + $actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' ); + $tables += $actorQuery['tables']; + $fields['log_user_text'] = $actorQuery['fields']['log_user_text']; + $joins += $actorQuery['joins']; + } else { + $fields[] = 'log_user_text'; + } + } + + $type .= '-reviews'; + } + + public function indexOf( $row ) { + // We need to check that there is only one user per day. + if ( $this->opts['count'] === 'reviewers' ) { + $date = $this->formatTimestamp( $row->log_timestamp ); + + if ( isset( $this->usercache[$date][$row->log_user_text] ) ) { + return -1; + } else { + $this->usercache[$date][$row->log_user_text] = 1; + } + } + + // Do not consider language-less pages. + if ( strpos( $row->log_title, '/' ) === false ) { + return false; + } + + // No filters, just one key to track. + if ( !$this->groups && !$this->codes ) { + return 'all'; + } + + // The key-building needs to be in sync with ::labels(). + list( $key, $code ) = TranslateUtils::figureMessage( $row->log_title ); + + $groups = []; + $codes = []; + + if ( $this->groups ) { + /* Get list of keys that the message belongs to, and filter + * out those which are not requested. */ + $groups = TranslateUtils::messageKeyToGroups( $row->log_namespace, $key ); + $groups = array_intersect( $this->groups, $groups ); + } + + if ( $this->codes ) { + $codes = [ $code ]; + } + + return $this->combineTwoArrays( $groups, $codes ); + } + + public function labels() { + return $this->combineTwoArrays( $this->groups, $this->codes ); + } + + public function getTimestamp( $row ) { + return $row->log_timestamp; + } +} diff --git a/www/wiki/extensions/Translate/specials/SpecialTranslations.php b/www/wiki/extensions/Translate/specials/SpecialTranslations.php new file mode 100644 index 00000000..4dab4b05 --- /dev/null +++ b/www/wiki/extensions/Translate/specials/SpecialTranslations.php @@ -0,0 +1,265 @@ +<?php +/** + * Contains logic for special page Special:Translations. + * + * @file + * @author Siebrand Mazeland + * @author Niklas Laxstörm + * @license GPL-2.0-or-later + */ + +/** + * Implements a special page which shows all translations for a message. + * Bits taken from SpecialPrefixindex.php and TranslateTasks.php + * + * @ingroup SpecialPage TranslateSpecialPage + */ +class SpecialTranslations extends SpecialAllPages { + public function __construct() { + parent::__construct( 'Translations' ); + } + + protected function getGroupName() { + return 'pages'; + } + + public function getDescription() { + return $this->msg( 'translations' )->text(); + } + + /** + * Entry point : initialise variables and call subfunctions. + * @param string $par Message key. Becomes "MediaWiki:Allmessages" when called like + * Special:Translations/MediaWiki:Allmessages (default null) + */ + public function execute( $par ) { + $this->setHeaders(); + $this->outputHeader(); + + $out = $this->getOutput(); + $out->addModuleStyles( 'ext.translate.legacy' ); + + $par = (string)$par; + + if ( $this->including() ) { + $title = Title::newFromText( $par ); + if ( !$title ) { + $out->addWikiMsg( 'translate-translations-including-no-param' ); + } else { + $this->showTranslations( $title ); + } + + return; + } + + /** + * GET values. + */ + $request = $this->getRequest(); + $message = $request->getText( 'message' ); + $namespace = $request->getInt( 'namespace', NS_MAIN ); + + if ( $message !== '' ) { + $title = Title::newFromText( $message, $namespace ); + } else { + $title = Title::newFromText( $par, $namespace ); + } + + $out->addHelpLink( + 'Help:Extension:Translate/Statistics_and_reporting#Translations_in_all_languages' + ); + + if ( !$title ) { + $title = Title::makeTitle( NS_MEDIAWIKI, '' ); + $this->namespaceMessageForm( $title ); + } else { + $this->namespaceMessageForm( $title ); + $out->addHTML( '<br />' ); + $this->showTranslations( $title ); + } + } + + /** + * Message input fieldset + * + * @param Title|null $title (default: null) + */ + protected function namespaceMessageForm( Title $title = null ) { + $options = []; + + foreach ( $this->getSortedNamespaces() as $text => $index ) { + $options[ $text ] = $index; + } + + $formDescriptor = [ + 'textbox' => [ + 'type' => 'text', + 'name' => 'message', + 'id' => 'message', + 'label-message' => 'translate-translations-messagename', + 'size' => 30, + 'default' => $title->getText(), + ], + 'selector' => [ + 'type' => 'select', + 'name' => 'namespace', + 'id' => 'namespace', + 'label-message' => 'translate-translations-project', + 'options' => $options, + 'default' => $title->getNamespace(), + ] + ]; + + $context = new DerivativeContext( $this->getContext() ); + $context->setTitle( $this->getPageTitle() ); // Remove subpage + + $htmlForm = HTMLForm::factory( 'ooui', $formDescriptor, $context ); + $htmlForm + ->setMethod( 'get' ) + ->setSubmitTextMsg( 'allpagessubmit' ) + ->setWrapperLegendMsg( 'translate-translations-fieldset-title' ) + ->prepareForm() + ->displayForm( false ); + } + + /** + * Returns sorted array of namespaces. + * + * @return array ( string => int ) + */ + public function getSortedNamespaces() { + global $wgTranslateMessageNamespaces, $wgContLang; + + $nslist = []; + foreach ( $wgTranslateMessageNamespaces as $ns ) { + $nslist[$wgContLang->getFormattedNsText( $ns )] = $ns; + } + ksort( $nslist ); + + return $nslist; + } + + /** + * Builds a table with all translations of $title. + * + * @param Title $title (default: null) + */ + protected function showTranslations( Title $title ) { + $handle = new MessageHandle( $title ); + $namespace = $title->getNamespace(); + $message = $handle->getKey(); + + if ( !$handle->isValid() ) { + $this->getOutput()->addWikiMsg( 'translate-translations-no-message', $title->getPrefixedText() ); + + return; + } + + $dbr = wfGetDB( DB_REPLICA ); + + $res = $dbr->select( 'page', + [ 'page_namespace', 'page_title' ], + [ + 'page_namespace' => $namespace, + 'page_title ' . $dbr->buildLike( "$message/", $dbr->anyString() ), + ], + __METHOD__, + [ + 'ORDER BY' => 'page_title', + 'USE INDEX' => 'name_title', + ] + ); + + if ( !$res->numRows() ) { + $this->getOutput()->addWikiMsg( + 'translate-translations-no-message', + $title->getPrefixedText() + ); + + return; + } else { + $this->getOutput()->addWikiMsg( + 'translate-translations-count', + $this->getLanguage()->formatNum( $res->numRows() ) + ); + } + + // Normal output. + $titles = []; + + foreach ( $res as $s ) { + $titles[] = $s->page_title; + } + + $pageInfo = TranslateUtils::getContents( $titles, $namespace ); + + $tableheader = Xml::openElement( 'table', [ + 'class' => 'mw-sp-translate-table sortable' + ] ); + + $tableheader .= Xml::openElement( 'tr' ); + $tableheader .= Xml::element( 'th', null, $this->msg( 'allmessagesname' )->text() ); + $tableheader .= Xml::element( 'th', null, $this->msg( 'allmessagescurrent' )->text() ); + $tableheader .= Xml::closeElement( 'tr' ); + + // Adapted version of TranslateUtils:makeListing() by Nikerabbit. + $out = $tableheader; + + $historyText = ' <sup>' . + $this->msg( 'translate-translations-history-short' )->escaped() . + '</sup> '; + $separator = $this->msg( 'word-separator' )->plain(); + + foreach ( $res as $s ) { + $key = $s->page_title; + $tTitle = Title::makeTitle( $s->page_namespace, $key ); + $tHandle = new MessageHandle( $tTitle ); + + $code = $tHandle->getCode(); + + $text = TranslateUtils::getLanguageName( $code, $this->getLanguage()->getCode() ); + $text .= $separator; + $text .= $this->msg( 'parentheses' )->params( $code )->plain(); + $tools['edit'] = Html::element( + 'a', + [ 'href' => TranslateUtils::getEditorUrl( $tHandle ) ], + $text + ); + + $tools['history'] = $this->getLinkRenderer()->makeLink( + $tTitle, + new HtmlArmor( $historyText ), + [ + 'title' => $this->msg( 'history-title', $tTitle->getPrefixedDBkey() )->text() + ], + [ 'action' => 'history' ] + ); + + if ( MessageHandle::hasFuzzyString( $pageInfo[$key][0] ) || $tHandle->isFuzzy() ) { + $class = 'orig'; + } else { + $class = 'def'; + } + + $languageAttributes = []; + if ( Language::isKnownLanguageTag( $code ) ) { + $language = Language::factory( $code ); + $languageAttributes = [ + 'lang' => $language->getHtmlCode(), + 'dir' => $language->getDir(), + ]; + } + + $formattedContent = TranslateUtils::convertWhiteSpaceToHTML( $pageInfo[$key][0] ); + + $leftColumn = $tools['history'] . $tools['edit']; + $out .= Xml::tags( 'tr', [ 'class' => $class ], + Xml::tags( 'td', null, $leftColumn ) . + Xml::tags( 'td', $languageAttributes, $formattedContent ) + ); + } + + $out .= Xml::closeElement( 'table' ); + $this->getOutput()->addHTML( $out ); + } +} |