summaryrefslogtreecommitdiff
path: root/www/wiki/extensions/Translate/specials
diff options
context:
space:
mode:
authorYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
committerYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
commitfc7369835258467bf97eb64f184b93691f9a9fd5 (patch)
treedaabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/extensions/Translate/specials
first commit
Diffstat (limited to 'www/wiki/extensions/Translate/specials')
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialAggregateGroups.php289
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialExportTranslations.php265
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialImportTranslations.php244
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialLanguageStats.php565
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialMagic.php243
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialManageGroups.php374
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialManageTranslatorSandbox.php334
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialMessageGroupStats.php304
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialPageMigration.php69
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialPagePreparation.php61
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialSearchTranslations.php595
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialSupportedLanguages.php466
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialTranslate.php443
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialTranslationStash.php211
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialTranslationStats.php1143
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialTranslations.php265
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>
+ &nbsp;
+ <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&#160;$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 = '&#160;<sup>' .
+ $this->msg( 'translate-translations-history-short' )->escaped() .
+ '</sup>&#160;';
+ $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 );
+ }
+}