diff options
Diffstat (limited to 'www/wiki/extensions/Translate/specials/SpecialSupportedLanguages.php')
-rw-r--r-- | www/wiki/extensions/Translate/specials/SpecialSupportedLanguages.php | 466 |
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; + } +} |