summaryrefslogtreecommitdiff
path: root/www/wiki/extensions/Translate/specials/SpecialSupportedLanguages.php
diff options
context:
space:
mode:
Diffstat (limited to 'www/wiki/extensions/Translate/specials/SpecialSupportedLanguages.php')
-rw-r--r--www/wiki/extensions/Translate/specials/SpecialSupportedLanguages.php466
1 files changed, 466 insertions, 0 deletions
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;
+ }
+}