diff options
Diffstat (limited to 'MLEB/Translate/utils')
32 files changed, 6971 insertions, 0 deletions
diff --git a/MLEB/Translate/utils/ExternalMessageSourceStateComparator.php b/MLEB/Translate/utils/ExternalMessageSourceStateComparator.php new file mode 100644 index 00000000..505266ce --- /dev/null +++ b/MLEB/Translate/utils/ExternalMessageSourceStateComparator.php @@ -0,0 +1,264 @@ +<?php + +/** + * Finds external changes for file based message groups. + * + * @author Niklas Laxström + * @license GPL-2.0+ + * @since 2013.12 + */ +class ExternalMessageSourceStateComparator { + /** Process all languages supported by the message group */ + const ALL_LANGUAGES = 'all languages'; + + protected $changes = array(); + + /** + * Finds changes in external sources compared to wiki state. + * + * The returned array is as following: + * - First level is indexed by language code + * - Second level is indexed by change type: + * - - addition (new message in the file) + * - - deletion (message in wiki not present in the file) + * - - change (difference in content) + * - Third level is a list of changes + * - Fourth level is change properties + * - - key (the message key) + * - - content (the message content in external source, null for deletions) + * + * @param FileBasedMessageGroup $group + * @param array|string $languages + * @throws MWException + * @return array array[language code][change type] = change. + */ + public function processGroup( FileBasedMessageGroup $group, $languages ) { + $this->changes = array(); + + if ( $languages === self::ALL_LANGUAGES ) { + $languages = $group->getTranslatableLanguages(); + + // This means all languages + if ( $languages === null ) { + $languages = TranslateUtils::getLanguageNames( 'en' ); + } + + $languages = array_keys( $languages ); + } elseif ( !is_array( $languages ) ) { + throw new MWException( 'Invalid input given for $languages' ); + } + + // Process the source language before others + $sourceLanguage = $group->getSourceLanguage(); + $index = array_search( $sourceLanguage, $languages ); + if ( $index !== false ) { + unset( $languages[$index] ); + $this->processLanguage( $group, $sourceLanguage ); + } + + foreach ( $languages as $code ) { + $this->processLanguage( $group, $code ); + } + + return $this->changes; + } + + protected function processLanguage( FileBasedMessageGroup $group, $code ) { + wfProfileIn( __METHOD__ ); + $cache = new MessageGroupCache( $group, $code ); + $reason = 0; + if ( !$cache->isValid( $reason ) ) { + $this->addMessageUpdateChanges( $group, $code, $reason, $cache ); + + if ( !isset( $this->changes[$code] ) ) { + /* Update the cache immediately if file and wiki state match. + * Otherwise the cache will get outdated compared to file state + * and will give false positive conflicts later. */ + $cache->create(); + } + } + wfProfileOut( __METHOD__ ); + } + + /** + * This is the detective novel. We have three sources of information: + * - current message state in the file + * - current message state in the wiki + * - cached message state since cache was last build + * (usually after export from wiki) + * + * Now we must try to guess what in earth has driven the file state and + * wiki state out of sync. Then we must compile list of events that would + * bring those to sync. Types of events are addition, deletion, (content) + * change and possible rename in the future. After that the list of events + * are stored for later processing of a translation administrator, who can + * decide what actions to take on those events to bring the state more or + * less in sync. + * + * @param FileBasedMessageGroup $group + * @param string $code Language code. + * @param int $reason + * @param MessageGroupCache $cache + * @throws MWException + */ + protected function addMessageUpdateChanges( FileBasedMessageGroup $group, $code, + $reason, $cache + ) { + wfProfileIn( __METHOD__ ); + /* This throws a warning if message definitions are not yet + * cached and will read the file for definitions. */ + wfSuppressWarnings(); + $wiki = $group->initCollection( $code ); + wfRestoreWarnings(); + $wiki->filter( 'hastranslation', false ); + $wiki->loadTranslations(); + $wikiKeys = $wiki->getMessageKeys(); + + // By-pass cached message definitions + /** @var FFS $ffs */ + $ffs = $group->getFFS(); + if ( $code === $group->getSourceLanguage() && !$ffs->exists( $code ) ) { + $path = $group->getSourceFilePath( $code ); + wfProfileOut( __METHOD__ ); + throw new MWException( "Source message file for {$group->getId()} does not exist: $path" ); + } + + $file = $ffs->read( $code ); + + // Does not exist + if ( $file === false ) { + wfProfileOut( __METHOD__ ); + + return; + } + + // Something went wrong + if ( !isset( $file['MESSAGES'] ) ) { + $id = $group->getId(); + $ffsClass = get_class( $ffs ); + + error_log( "$id has an FFS ($ffsClass) - it didn't return cake for $code" ); + wfProfileOut( __METHOD__ ); + + return; + } + + $fileKeys = array_keys( $file['MESSAGES'] ); + + $common = array_intersect( $fileKeys, $wikiKeys ); + + $supportsFuzzy = $ffs->supportsFuzzy(); + + foreach ( $common as $key ) { + $sourceContent = $file['MESSAGES'][$key]; + /** @var TMessage $wikiMessage */ + $wikiMessage = $wiki[$key]; + $wikiContent = $wikiMessage->translation(); + + // If FFS doesn't support it, ignore fuzziness as difference + $wikiContent = str_replace( TRANSLATE_FUZZY, '', $wikiContent ); + + // But if it does, ensure we have exactly one fuzzy marker prefixed + if ( $supportsFuzzy === 'yes' && $wikiMessage->hasTag( 'fuzzy' ) ) { + $wikiContent = TRANSLATE_FUZZY . $wikiContent; + } + + if ( self::compareContent( $sourceContent, $wikiContent ) ) { + // File and wiki stage agree, nothing to do + continue; + } + + // Check against interim cache to see whether we have changes + // in the wiki, in the file or both. + + if ( $reason !== MessageGroupCache::NO_CACHE ) { + $cacheContent = $cache->get( $key ); + + /* We want to ignore the common situation that the string + * in the wiki has been changed since the last export. + * Hence we check that source === cache && cache !== wiki + * and if so we skip this string. */ + if ( + !self::compareContent( $wikiContent, $cacheContent ) && + self::compareContent( $sourceContent, $cacheContent ) + ) { + continue; + } + } + + $this->addChange( 'change', $code, $key, $sourceContent ); + } + + $added = array_diff( $fileKeys, $wikiKeys ); + foreach ( $added as $key ) { + $sourceContent = $file['MESSAGES'][$key]; + if ( trim( $sourceContent ) === '' ) { + continue; + } + $this->addChange( 'addition', $code, $key, $sourceContent ); + } + + /* Should the cache not exist, don't consider the messages + * missing from the file as deleted - they probably aren't + * yet exported. For example new language translations are + * exported the first time. */ + if ( $reason !== MessageGroupCache::NO_CACHE ) { + $deleted = array_diff( $wikiKeys, $fileKeys ); + foreach ( $deleted as $key ) { + if ( $cache->get( $key ) === false ) { + /* This message has never existed in the cache, so it + * must be a newly made in the wiki. */ + continue; + } + $this->addChange( 'deletion', $code, $key, null ); + } + } + + wfProfileOut( __METHOD__ ); + } + + protected function addChange( $type, $language, $key, $content ) { + $this->changes[$language][$type][] = array( + 'key' => $key, + 'content' => $content, + ); + } + + /** + * Compares two strings. + * @todo Ignore changes in different way inlined plurals. + * @todo Handle fuzzy state changes if FFS supports it. + * + * @param string $a + * @param string $b + * @return bool Whether two strings are equal + */ + protected static function compareContent( $a, $b ) { + return $a === $b; + } + + /** + * Writes change array as a serialized file into a known place. + * @param array $array Array of changes as returned by processGroup + * indexed by message group id. + * @todo does not belong to this class. + */ + public static function writeChanges( $array ) { + // This method is almost identical with MessageIndex::store + wfProfileIn( __METHOD__ ); + /* This will overwrite the previous cache file if any. Once the cache + * file is processed with Special:ManageMessageGroups, it is + * renamed so that it wont be processed again. */ + $file = TranslateUtils::cacheFile( SpecialManageGroups::CHANGEFILE ); + $cache = CdbWriter::open( $file ); + $keys = array_keys( $array ); + $cache->set( '#keys', serialize( $keys ) ); + + foreach ( $array as $key => $value ) { + $value = serialize( $value ); + $cache->set( $key, $value ); + } + $cache->close(); + wfProfileOut( __METHOD__ ); + } +} diff --git a/MLEB/Translate/utils/Font.php b/MLEB/Translate/utils/Font.php new file mode 100644 index 00000000..1b35b614 --- /dev/null +++ b/MLEB/Translate/utils/Font.php @@ -0,0 +1,139 @@ +<?php +/** + * Contains class with wrapper around font-config. + * + * @author Niklas Laxström + * @author Harry Burt + * @copyright Copyright © 2008-2013, Niklas Laxström + * @license Public Domain + * @file + */ + +/** + * Wrapper around font-config to get useful ttf font given a language code. + * Uses wfShellExec, wfEscapeShellArg and wfDebugLog, wfGetCache and + * wfMemckey from %MediaWiki. + * + * @ingroup Stats + */ +class FCFontFinder { + /** + * Searches for suitable font in the system. + * @param $code \string Language code. + * @return bool|string Full path to the font file, false on failure + */ + public static function findFile( $code ) { + $data = self::callFontConfig( $code ); + if ( is_array( $data ) ) { + return $data['file']; + } + + return false; + } + + /** + * Searches for suitable font family in the system. + * @param $code \string Language code. + * @return bool|string Name of font family, false on failure + */ + public static function findFamily( $code ) { + $data = self::callFontConfig( $code ); + if ( is_array( $data ) ) { + return $data['family']; + } + + return false; + } + + protected static function callFontConfig( $code ) { + if ( ini_get( 'open_basedir' ) ) { + wfDebugLog( 'fcfont', 'Disabled because of open_basedir is active' ); + + // Most likely we can't access any fonts we might find + return false; + } + + $cache = self::getCache(); + $cachekey = wfMemckey( 'fcfont', $code ); + $timeout = 60 * 60 * 12; + + $cached = $cache->get( $cachekey ); + if ( is_array( $cached ) ) { + return $cached; + } elseif ( $cached === 'NEGATIVE' ) { + return false; + } + + $code = wfEscapeShellArg( ":lang=$code" ); + $ok = 0; + $cmd = "fc-match $code"; + $suggestion = wfShellExec( $cmd, $ok ); + + wfDebugLog( 'fcfont', "$cmd returned $ok" ); + + if ( $ok !== 0 ) { + wfDebugLog( 'fcfont', "fc-match error output: $suggestion" ); + $cache->set( $cachekey, 'NEGATIVE', $timeout ); + + return false; + } + + $pattern = '/^(.*?): "(.*)" "(.*)"$/'; + $matches = array(); + + if ( !preg_match( $pattern, $suggestion, $matches ) ) { + wfDebugLog( 'fcfont', "fc-match: return format not understood: $suggestion" ); + $cache->set( $cachekey, 'NEGATIVE', $timeout ); + + return false; + } + + list( , $file, $family, $type ) = $matches; + wfDebugLog( 'fcfont', "fc-match: got $file: $family $type" ); + + $file = wfEscapeShellArg( $file ); + $family = wfEscapeShellArg( $family ); + $type = wfEscapeShellArg( $type ); + $cmd = "fc-list $family $type $code file | grep $file"; + + $candidates = trim( wfShellExec( $cmd, $ok ) ); + + wfDebugLog( 'fcfont', "$cmd returned $ok" ); + + if ( $ok !== 0 ) { + wfDebugLog( 'fcfont', "fc-list error output: $candidates" ); + $cache->set( $cachekey, 'NEGATIVE', $timeout ); + + return false; + } + + # trim spaces + $files = array_map( 'trim', explode( "\n", $candidates ) ); + $count = count( $files ); + if ( !$count ) { + wfDebugLog( 'fcfont', "fc-list got zero canditates: $candidates" ); + } + + # remove the trailing ":" + $chosen = substr( $files[0], 0, -1 ); + + wfDebugLog( 'fcfont', "fc-list got $count candidates; using $chosen" ); + + $data = array( + 'family' => $family, + 'type' => $type, + 'file' => $chosen, + ); + + $cache->set( $cachekey, $data, $timeout ); + + return $data; + } + + /** + * @return BagOStuff + */ + protected static function getCache() { + return wfGetCache( CACHE_ANYTHING ); + } +} diff --git a/MLEB/Translate/utils/FuzzyBot.php b/MLEB/Translate/utils/FuzzyBot.php new file mode 100644 index 00000000..e9992117 --- /dev/null +++ b/MLEB/Translate/utils/FuzzyBot.php @@ -0,0 +1,30 @@ +<?php +/** + * Do it all maintenance account + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2012-2013, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * FuzzyBot - the misunderstood workhorse. + * @since 2012-01-02 + */ +class FuzzyBot { + public static function getUser() { + $bot = User::newFromName( self::getName() ); + if ( $bot->isAnon() ) { + $bot->addToDatabase(); + } + + return $bot; + } + + public static function getName() { + global $wgTranslateFuzzyBotName; + + return $wgTranslateFuzzyBotName; + } +} diff --git a/MLEB/Translate/utils/HTMLJsSelectToInputField.php b/MLEB/Translate/utils/HTMLJsSelectToInputField.php new file mode 100644 index 00000000..8035f2f4 --- /dev/null +++ b/MLEB/Translate/utils/HTMLJsSelectToInputField.php @@ -0,0 +1,85 @@ +<?php +/** + * Implementation of JsSelectToInput class which is compatible with MediaWiki's preferences system. + * @file + * @author Niklas Laxström + * @copyright Copyright © 2010 Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Implementation of JsSelectToInput class which is extends HTMLTextField. + */ +class HTMLJsSelectToInputField extends HTMLTextField { + /** + * @param $value + * @return string + */ + function getInputHTML( $value ) { + $input = parent::getInputHTML( $value ); + + if ( isset( $this->mParams['select'] ) ) { + /** + * @var JsSelectToInput $select + */ + $select = $this->mParams['select']; + $input = $select->getHtmlAndPrepareJs() . '<br />' . $input; + } + + return $input; + } + + /** + * @param $value + * @return array + */ + function tidy( $value ) { + $value = array_map( 'trim', explode( ',', $value ) ); + $value = array_unique( array_filter( $value ) ); + + return $value; + } + + /** + * @param $value + * @param $alldata + * @return bool|String + */ + function validate( $value, $alldata ) { + $p = parent::validate( $value, $alldata ); + + if ( $p !== true ) { + return $p; + } + + if ( !isset( $this->mParams['valid-values'] ) ) { + return true; + } + + if ( $value === 'default' ) { + return true; + } + + $codes = $this->tidy( $value ); + $valid = array_flip( $this->mParams['valid-values'] ); + + foreach ( $codes as $code ) { + if ( !isset( $valid[$code] ) ) { + return wfMessage( 'translate-pref-editassistlang-bad', $code )->parseAsBlock(); + } + } + + return true; + } + + /** + * @param $value + * @param $alldata + * @return string + */ + function filter( $value, $alldata ) { + $value = parent::filter( $value, $alldata ); + + return implode( ', ', $this->tidy( $value ) ); + } +} diff --git a/MLEB/Translate/utils/JsSelectToInput.php b/MLEB/Translate/utils/JsSelectToInput.php new file mode 100644 index 00000000..12bea5a7 --- /dev/null +++ b/MLEB/Translate/utils/JsSelectToInput.php @@ -0,0 +1,126 @@ +<?php +/** + * Code for JavaScript enhanced \<option> selectors. + * @file + * @author Niklas Laxström + * @copyright Copyright © 2010 Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Code for JavaScript enhanced \<option> selectors. + */ +class JsSelectToInput { + /// Id of the text field where stuff is appended + protected $targetId; + /// Id of the \<option> field + protected $sourceId; + + /** + * @var XmlSelect + */ + protected $select; + + /// Id on the button + protected $buttonId; + + /** + * @var string Text for the append button + */ + protected $msg = 'translate-jssti-add'; + + public function __construct( XmlSelect $select = null ) { + $this->select = $select; + } + + /** + * Set the source id of the selector + * @param string $id + */ + public function setSourceId( $id ) { + $this->sourceId = $id; + } + + /// @return string + public function getSourceId() { + return $this->sourceId; + } + + /** + * Set the id of the target text field + * @param string $id + */ + public function setTargetId( $id ) { + $this->targetId = $id; + } + + /** + * @return string + */ + public function getTargetId() { + return $this->targetId; + } + + /** + * Set the message key. + * @param string $message + */ + public function setMessage( $message ) { + $this->msg = $message; + } + + /// @return string Message key. + public function getMessage() { + return $this->msg; + } + + /** + * Returns the whole input element and injects needed JavaScript + * @throws MWException + * @return string Html code. + */ + public function getHtmlAndPrepareJS() { + if ( $this->sourceId === false ) { + if ( is_callable( array( $this->select, 'getAttribute' ) ) ) { + $this->sourceId = $this->select->getAttribute['id']; + } + + if ( !$this->sourceId ) { + throw new MWException( "ID needs to be specified for the selector" ); + } + } + + self::injectJs(); + $html = $this->select->getHtml(); + $html .= $this->getButton( $this->msg, $this->sourceId, $this->targetId ); + + return $html; + } + + /** + * Constructs the append button. + * @param string $msg Message key. + * @param string $source Html id. + * @param string $target Html id. + * @return string + */ + protected function getButton( $msg, $source, $target ) { + $html = Xml::element( 'input', array( + 'type' => 'button', + 'value' => wfMessage( $msg )->text(), + 'onclick' => Xml::encodeJsCall( 'appendFromSelect', array( $source, $target ) ) + ) ); + + return $html; + } + + /// Inject needed JavaScript in the page. + public static function injectJs() { + static $done = false; + if ( $done ) { + return; + } + + RequestContext::getMain()->getOutput()->addModules( 'ext.translate.selecttoinput' ); + } +} diff --git a/MLEB/Translate/utils/MemProfile.php b/MLEB/Translate/utils/MemProfile.php new file mode 100644 index 00000000..4d626d8f --- /dev/null +++ b/MLEB/Translate/utils/MemProfile.php @@ -0,0 +1,63 @@ +<?php +if ( !defined( 'MEDIAWIKI' ) ) { + die(); +} +/** + * Very crude tools to track memory usage + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2008, Niklas Laxström + * @license GPL-2.0+ + */ + +/// Memory usage at checkpoints +$wgMemUse = array(); +/// Tracks the deepness of the stack +$wgMemStack = 0; + +/** + * Call to start memory counting for a block. + * @param $a \string Block name. + */ +function wfMemIn( $a ) { + global $wgLang, $wgMemUse, $wgMemStack; + + $mem = memory_get_usage(); + $memR = memory_get_usage(); + + $wgMemUse[$a][] = array( $mem, $memR ); + + $memF = $wgLang->formatNum( $mem ); + $memRF = $wgLang->formatNum( $memR ); + + $pad = str_repeat( ".", $wgMemStack ); + wfDebug( "$pad$a-IN: \t$memF\t\t$memRF\n" ); + $wgMemStack++; +} + +/** + * Call to start stop counting for a block. Difference from start is shown. + * @param $a \string Block name. + */ +function wfMemOut( $a ) { + global $wgLang, $wgMemUse, $wgMemStack; + + $mem = memory_get_usage(); + $memR = memory_get_usage(); + + list( $memO, $memOR ) = array_pop( $wgMemUse[$a] ); + + $memF = $wgLang->formatNum( $mem ); + $memRF = $wgLang->formatNum( $memR ); + + $memD = $mem - $memO; + $memRD = $memR - $memOR; + + $memDF = $wgLang->formatNum( $memD ); + $memRDF = $wgLang->formatNum( $memRD ); + + $pad = str_repeat( ".", $wgMemStack - 1 ); + wfDebug( "$pad$a-OUT:\t$memF ($memDF)\t$memRF ($memRDF)\n" ); + $wgMemStack--; +} diff --git a/MLEB/Translate/utils/MessageGroupCache.php b/MLEB/Translate/utils/MessageGroupCache.php new file mode 100644 index 00000000..74298471 --- /dev/null +++ b/MLEB/Translate/utils/MessageGroupCache.php @@ -0,0 +1,282 @@ +<?php +/** + * Code for caching the messages of file based message groups. + * @file + * @author Niklas Laxström + * @copyright Copyright © 2009-2013 Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Caches messages of file based message group source file. Can also track + * that the cache is up to date. Parsing the source files can be slow, so + * constructing CDB cache makes accessing that data constant speed regardless + * of the actual format. + * + * @ingroup MessageGroups + */ +class MessageGroupCache { + const NO_SOURCE = 1; + const NO_CACHE = 2; + const CHANGED = 3; + + /** + * @var MessageGroup + */ + protected $group; + + /** + * @var CdbReader + */ + protected $cache; + + /** + * @var string + */ + protected $code; + + /** + * Contructs a new cache object for given group and language code. + * @param string|FileBasedMessageGroup $group Group object or id. + * @param string $code Language code. Default value 'en'. + */ + public function __construct( $group, $code = 'en' ) { + if ( is_object( $group ) ) { + $this->group = $group; + } else { + $this->group = MessageGroups::getGroup( $group ); + } + $this->code = $code; + } + + /** + * Returns whether cache exists for this language and group. + * @return bool + */ + public function exists() { + $old = $this->getOldCacheFileName(); + $new = $this->getCacheFileName(); + $exists = file_exists( $new ); + + if ( $exists ) { + return true; + } + + // Perform migration if possible + if ( file_exists( $old ) ) { + wfMkdirParents( dirname( $new ) ); + rename( $old, $new ); + return true; + } + + return false; + } + + /** + * Returns list of message keys that are stored. + * @return string[] Message keys that can be passed one-by-one to get() method. + */ + public function getKeys() { + $value = $this->open()->get( '#keys' ); + $array = unserialize( $value ); + + // Debugging for bug 69830 + if ( !is_array( $array ) ) { + $filename = $this->getCacheFileName(); + throw new MWException( "Unable to get keys from '$filename'" ); + } + + return $array; + } + + /** + * Returns timestamp in unix-format about when this cache was first created. + * @return string Unix timestamp. + */ + public function getTimestamp() { + return $this->open()->get( '#created' ); + } + + /** + * ... + * @return string Unix timestamp. + */ + public function getUpdateTimestamp() { + return $this->open()->get( '#updated' ); + } + + /** + * Get an item from the cache. + * @param string $key + * @return string + */ + public function get( $key ) { + return $this->open()->get( $key ); + } + + /** + * Populates the cache from current state of the source file. + * @param bool|string $created Unix timestamp when the cache is created (for automatic updates). + */ + public function create( $created = false ) { + $this->close(); // Close the reader instance just to be sure + + $messages = $this->group->load( $this->code ); + if ( $messages === array() ) { + if ( $this->exists() ) { + // Delete stale cache files + unlink( $this->getCacheFileName() ); + } + + return; // Don't create empty caches + } + $hash = md5( file_get_contents( $this->group->getSourceFilePath( $this->code ) ) ); + + wfMkdirParents( dirname( $this->getCacheFileName() ) ); + $cache = CdbWriter::open( $this->getCacheFileName() ); + $keys = array_keys( $messages ); + $cache->set( '#keys', serialize( $keys ) ); + + foreach ( $messages as $key => $value ) { + $cache->set( $key, $value ); + } + + $cache->set( '#created', $created ? $created : wfTimestamp() ); + $cache->set( '#updated', wfTimestamp() ); + $cache->set( '#filehash', $hash ); + $cache->set( '#msgcount', count( $messages ) ); + ksort( $messages ); + $cache->set( '#msghash', md5( serialize( $messages ) ) ); + $cache->set( '#version', '3' ); + $cache->close(); + } + + /** + * Checks whether the cache still reflects the source file. + * It uses multiple conditions to speed up the checking from file + * modification timestamps to hashing. + * @param int $reason + * @return bool Whether the cache is up to date. + */ + public function isValid( &$reason = 0 ) { + $group = $this->group; + $groupId = $group->getId(); + + $pattern = $group->getSourceFilePath( '*' ); + $filename = $group->getSourceFilePath( $this->code ); + + // If the file pattern is not dependent on the language, we will assume + // that all translations are stored in one file. This means we need to + // actually parse the file to know if a language is present. + if ( strpos( $pattern, '*' ) === false ) { + $source = $group->getFFS()->read( $this->code ) !== false; + } else { + static $globCache = null; + if ( !isset( $globCache[$groupId] ) ) { + $globCache[$groupId] = array_flip( glob( $pattern, GLOB_NOESCAPE ) ); + // Definition file might not match the above pattern + $globCache[$groupId][$group->getSourceFilePath( 'en' )] = true; + } + $source = isset( $globCache[$groupId][$filename] ); + } + + $cache = $this->exists(); + + // Timestamp and existence checks + if ( !$cache && !$source ) { + return true; + } elseif ( !$cache && $source ) { + $reason = self::NO_CACHE; + + return false; + } elseif ( $cache && !$source ) { + $reason = self::NO_SOURCE; + + return false; + } elseif ( filemtime( $filename ) <= $this->get( '#updated' ) ) { + return true; + } + + // From now on cache and source file exists, but source file mtime is newer + $created = $this->get( '#created' ); + + // File hash check + $newhash = md5( file_get_contents( $filename ) ); + if ( $this->get( '#filehash' ) === $newhash ) { + // Update cache so that we don't need to compare hashes next time + $this->create( $created ); + + return true; + } + + // Message count check + $messages = $group->load( $this->code ); + // CDB converts numbers to strings + $count = intval( $this->get( '#msgcount' ) ); + if ( $count !== count( $messages ) ) { + // Number of messsages has changed + $reason = self::CHANGED; + + return false; + } + + // Content hash check + ksort( $messages ); + if ( $this->get( '#msghash' ) === md5( serialize( $messages ) ) ) { + // Update cache so that we don't need to do slow checks next time + $this->create( $created ); + + return true; + } + + $reason = self::CHANGED; + + return false; + } + + /** + * Open the cache for reading. + * @return MessageGroupCache + */ + protected function open() { + if ( $this->cache === null ) { + $this->cache = CdbReader::open( $this->getCacheFileName() ); + if ( $this->cache->get( '#version' ) !== '3' ) { + $this->close(); + unlink( $this->getCacheFileName() ); + } + } + + return $this->cache; + } + + /** + * Close the cache from reading. + */ + protected function close() { + if ( $this->cache !== null ) { + $this->cache->close(); + $this->cache = null; + } + } + + /** + * Returns full path to the cache file. + * @return string + */ + protected function getCacheFileName() { + $cacheFileName = "translate_groupcache-{$this->group->getId()}/{$this->code}.cdb"; + + return TranslateUtils::cacheFile( $cacheFileName ); + } + + /** + * Returns full path to the old cache file location. + * @return string + */ + protected function getOldCacheFileName() { + $cacheFileName = "translate_groupcache-{$this->group->getId()}-{$this->code}.cdb"; + + return TranslateUtils::cacheFile( $cacheFileName ); + } +} diff --git a/MLEB/Translate/utils/MessageGroupStates.php b/MLEB/Translate/utils/MessageGroupStates.php new file mode 100644 index 00000000..1838381c --- /dev/null +++ b/MLEB/Translate/utils/MessageGroupStates.php @@ -0,0 +1,40 @@ +<?php +/** + * Wrapper class for using message group states. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @copyright Copyright © 2012-2013 Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Class for making the use of message group state easier. + * @since 2012-10-05 + */ +class MessageGroupStates { + const CONDKEY = 'state conditions'; + + protected $config; + + public function __construct( array $config = null ) { + $this->config = $config; + } + + public function getStates() { + $conf = $this->config; + unset( $conf[self::CONDKEY] ); + + return $conf; + } + + public function getConditions() { + $conf = $this->config; + if ( isset( $conf[self::CONDKEY] ) ) { + return $conf[self::CONDKEY]; + } else { + return array(); + } + } +} diff --git a/MLEB/Translate/utils/MessageGroupStatesUpdaterJob.php b/MLEB/Translate/utils/MessageGroupStatesUpdaterJob.php new file mode 100644 index 00000000..18b34343 --- /dev/null +++ b/MLEB/Translate/utils/MessageGroupStatesUpdaterJob.php @@ -0,0 +1,139 @@ +<?php +/** + * Logic for handling automatic message group state changes + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2012-2013, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Logic for handling automatic message group state changes + * + * @ingroup JobQueue + */ +class MessageGroupStatesUpdaterJob extends Job { + + /** + * Hook: TranslateEventTranslationEdit + * Hook: TranslateEventTranslationReview + */ + public static function onChange( MessageHandle $handle ) { + $job = self::newJob( $handle->getTitle() ); + JobQueueGroup::singleton()->push( $job ); + + return true; + } + + /** + * @param $title + * @return MessageGroupStatesUpdaterJob + */ + public static function newJob( $title ) { + $job = new self( $title ); + + return $job; + } + + public function __construct( $title, $params = array(), $id = 0 ) { + parent::__construct( __CLASS__, $title, $params, $id ); + } + + public function run() { + $title = $this->title; + $handle = new MessageHandle( $title ); + $code = $handle->getCode(); + + if ( !$handle->isValid() && !$code ) { + return true; + } + + $groups = self::getGroupsWithTransitions( $handle ); + foreach ( $groups as $id => $transitions ) { + $group = MessageGroups::getGroup( $id ); + $stats = MessageGroupStats::forItem( $id, $code ); + $state = self::getNewState( $stats, $transitions ); + if ( $state ) { + ApiGroupReview::changeState( $group, $code, $state, FuzzyBot::getUser() ); + } + } + + return true; + } + + public static function getGroupsWithTransitions( MessageHandle $handle ) { + $listeners = array(); + foreach ( $handle->getGroupIds() as $id ) { + $group = MessageGroups::getGroup( $id ); + + // No longer exists? + if ( !$group ) { + continue; + } + + $conds = $group->getMessageGroupStates()->getConditions(); + if ( $conds ) { + $listeners[$id] = $conds; + } + } + + return $listeners; + } + + public static function getStatValue( $stats, $type ) { + $total = $stats[MessageGroupStats::TOTAL]; + $translated = $stats[MessageGroupStats::TRANSLATED]; + $outdated = $stats[MessageGroupStats::FUZZY]; + $proofread = $stats[MessageGroupStats::PROOFREAD]; + + switch ( $type ) { + case 'UNTRANSLATED': + return $total - $translated - $outdated; + case 'OUTDATED': + return $outdated; + case 'TRANSLATED': + return $translated; + case 'PROOFREAD': + return $proofread; + default: + throw new MWException( "Unknown condition $type" ); + } + } + + public static function matchCondition( $value, $condition, $max ) { + switch ( $condition ) { + case 'ZERO': + return $value === 0; + case 'NONZERO': + return $value > 0; + case 'MAX': + return $value === $max; + default: + throw new MWException( "Unknown condition value $condition" ); + } + } + + public static function getNewState( $stats, $transitions ) { + foreach ( $transitions as $transition ) { + list( $newState, $conds ) = $transition; + $match = true; + + foreach ( $conds as $type => $cond ) { + $statValue = self::getStatValue( $stats, $type ); + $max = $stats[MessageGroupStats::TOTAL]; + $match = $match && self::matchCondition( $statValue, $cond, $max ); + // Conditions are AND, so no point trying more if no match + if ( !$match ) { + break; + } + } + + if ( $match ) { + return $newState; + } + } + + return false; + } +} diff --git a/MLEB/Translate/utils/MessageGroupStats.php b/MLEB/Translate/utils/MessageGroupStats.php new file mode 100644 index 00000000..05ca3f42 --- /dev/null +++ b/MLEB/Translate/utils/MessageGroupStats.php @@ -0,0 +1,471 @@ +<?php +/** + * This file aims to provide efficient mechanism for fetching translation completion stats. + * + * @file + * @author Wikia (trac.wikia-code.com/browser/wikia/trunk/extensions/wikia/TranslationStatistics) + * @author Niklas Laxström + * @copyright Copyright © 2012-2013 Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * This class abstract MessageGroup statistics calculation and storing. + * You can access stats easily per language or per group. + * Stat array for each item is of format array( total, translate, fuzzy ). + * + * @ingroup Stats MessageGroups + */ +class MessageGroupStats { + /// Name of the database table + const TABLE = 'translate_groupstats'; + + const TOTAL = 0; ///< Array index + const TRANSLATED = 1; ///< Array index + const FUZZY = 2; ///< Array index + const PROOFREAD = 3; ///< Array index + + /// @var float + protected static $timeStart = null; + /// @var float + protected static $limit = null; + + /** + * Set the maximum time statistics are calculated. + * If the time limit is exceeded, the missing + * entries will be null. + * @param $limit float time in seconds + */ + public static function setTimeLimit( $limit ) { + self::$timeStart = microtime( true ); + self::$limit = $limit; + } + + /** + * Returns empty stats array. Useful because the number of elements + * may change. + * @return array + * @since 2012-09-21 + */ + public static function getEmptyStats() { + return array( 0, 0, 0, 0 ); + } + + /** + * Returns empty stats array that indicates stats are incomplete or + * unknown. + * @return array + * @since 2013-01-02 + */ + protected static function getUnknownStats() { + return array( null, null, null, null ); + } + + /** + * Returns stats for given group in given language. + * @param $id string Group id + * @param $code string Language code + * @return Array + */ + public static function forItem( $id, $code ) { + $res = self::selectRowsIdLang( $id, $code ); + $stats = self::extractResults( $res ); + + /* In case some code calls this for dynamic groups, return the default + * values for unknown/incomplete stats. Calculating these numbers don't + * make sense for dynamic groups, and would just throw an exception. */ + $group = MessageGroups::getGroup( $id ); + if ( MessageGroups::isDynamic( $group ) ) { + $stats[$id][$code] = self::getUnknownStats(); + } + + if ( !isset( $stats[$id][$code] ) ) { + $stats[$id][$code] = self::forItemInternal( $stats, $group, $code ); + } + + return $stats[$id][$code]; + } + + /** + * Returns stats for all groups in given language. + * @param $code string Language code + * @return Array + */ + public static function forLanguage( $code ) { + $stats = self::forLanguageInternal( $code ); + $flattened = array(); + foreach ( $stats as $group => $languages ) { + $flattened[$group] = $languages[$code]; + } + + return $flattened; + } + + /** + * Returns stats for all languages in given group. + * @param $id string Group id + * @return Array + */ + public static function forGroup( $id ) { + $group = MessageGroups::getGroup( $id ); + if ( $group === null ) { + return array(); + } + $stats = self::forGroupInternal( $group ); + + return $stats[$id]; + } + + /** + * Returns stats for all group in all languages. + * Might be slow, might use lots of memory. + * Returns two dimensional array indexed by group and language. + * @return Array + */ + public static function forEverything() { + $groups = MessageGroups::singleton()->getGroups(); + $stats = array(); + foreach ( $groups as $g ) { + $stats = self::forGroupInternal( $g, $stats ); + } + + return $stats; + } + + /** + * Clears the cache for all groups associated with the message. + * + * Hook: TranslateEventTranslationEdit + * Hook: TranslateEventTranslationReview + */ + public static function clear( MessageHandle $handle ) { + $code = $handle->getCode(); + $ids = $handle->getGroupIds(); + $dbw = wfGetDB( DB_MASTER ); + + $locked = false; + // Try to avoid deadlocks with duplicated deletes where there is no row + // @note: this only helps in auto-commit mode (which job runners use) + if ( !$dbw->getFlag( DBO_TRX ) && count( $ids ) == 1 ) { + $key = __CLASS__ . ":modify:{$ids[0]}"; + $locked = $dbw->lock( $key, __METHOD__, 1 ); + if ( !$locked ) { + return true; // raced out + } + } + + $conds = array( 'tgs_group' => $ids, 'tgs_lang' => $code ); + $dbw->delete( self::TABLE, $conds, __METHOD__ ); + wfDebugLog( 'messagegroupstats', "Cleared " . serialize( $conds ) ); + + if ( $locked ) { + $dbw->unlock( $key, __METHOD__ ); + } + + // Hooks must return value + return true; + } + + public static function clearGroup( $id ) { + if ( !count( $id ) ) { + return; + } + $dbw = wfGetDB( DB_MASTER ); + $conds = array( 'tgs_group' => $id ); + $dbw->delete( self::TABLE, $conds, __METHOD__ ); + wfDebugLog( 'messagegroupstats', "Cleared " . serialize( $conds ) ); + } + + public static function clearLanguage( $code ) { + if ( !count( $code ) ) { + return; + } + $dbw = wfGetDB( DB_MASTER ); + $conds = array( 'tgs_lang' => $code ); + $dbw->delete( self::TABLE, $conds, __METHOD__ ); + wfDebugLog( 'messagegroupstats', "Cleared " . serialize( $conds ) ); + } + + /** + * Purges all cached stats. + */ + public static function clearAll() { + $dbw = wfGetDB( DB_MASTER ); + $dbw->delete( self::TABLE, '*' ); + wfDebugLog( 'messagegroupstats', "Cleared everything :(" ); + } + + protected static function extractResults( $res, $stats = array() ) { + foreach ( $res as $row ) { + $stats[$row->tgs_group][$row->tgs_lang] = self::extractNumbers( $row ); + } + + return $stats; + } + + public static function update( MessageHandle $handle, $changes = array() ) { + $dbw = wfGetDB( DB_MASTER ); + $conds = array( + 'tgs_group' => $handle->getGroupIds(), + 'tgs_lang' => $handle->getCode(), + ); + + $values = array(); + foreach ( array( 'total', 'translated', 'fuzzy', 'proofread' ) as $type ) { + if ( isset( $changes[$type] ) ) { + $values[] = "tgs_$type=tgs_$type" . + self::stringifyNumber( $changes[$type] ); + } + } + + $dbw->update( self::TABLE, $values, $conds, __METHOD__ ); + } + + /** + * Returns an array of needed database fields. + * @param $row + * @return array + */ + protected static function extractNumbers( $row ) { + return array( + self::TOTAL => (int)$row->tgs_total, + self::TRANSLATED => (int)$row->tgs_translated, + self::FUZZY => (int)$row->tgs_fuzzy, + self::PROOFREAD => (int)$row->tgs_proofread, + ); + } + + /** + * @param $code + * @param array $stats + * @return array + */ + protected static function forLanguageInternal( $code, $stats = array() ) { + $res = self::selectRowsIdLang( null, $code ); + $stats = self::extractResults( $res, $stats ); + + $groups = MessageGroups::singleton()->getGroups(); + foreach ( $groups as $id => $group ) { + if ( isset( $stats[$id][$code] ) ) { + continue; + } + $stats[$id][$code] = self::forItemInternal( $stats, $group, $code ); + } + + return $stats; + } + + /** + * @param AggregateMessageGroup $agg + * @return mixed + */ + protected static function expandAggregates( AggregateMessageGroup $agg ) { + $flattened = array(); + + /** @var MessageGroup|AggregateMessageGroup $group */ + foreach ( $agg->getGroups() as $group ) { + if ( $group instanceof AggregateMessageGroup ) { + $flattened += self::expandAggregates( $group ); + } else { + $flattened[$group->getId()] = $group; + } + } + + return $flattened; + } + + /** + * @param MessageGroup $group + * @param array $stats + * @return array + */ + protected static function forGroupInternal( $group, $stats = array() ) { + $id = $group->getId(); + $res = self::selectRowsIdLang( $id, null ); + $stats = self::extractResults( $res, $stats ); + + # Go over each language filling missing entries + $languages = array_keys( Language::fetchLanguageNames() ); + // This is for calculating things in correct order + sort( $languages ); + foreach ( $languages as $code ) { + if ( isset( $stats[$id][$code] ) ) { + continue; + } + $stats[$id][$code] = self::forItemInternal( $stats, $group, $code ); + } + + // This is for sorting the values added later in correct order + foreach ( array_keys( $stats ) as $key ) { + ksort( $stats[$key] ); + } + + return $stats; + } + + protected static function selectRowsIdLang( $ids = null, $codes = null ) { + $conds = array(); + if ( $ids !== null ) { + $conds['tgs_group'] = $ids; + } + + if ( $codes !== null ) { + $conds['tgs_lang'] = $codes; + } + + $dbr = wfGetDB( DB_MASTER ); + $res = $dbr->select( self::TABLE, '*', $conds, __METHOD__ ); + + return $res; + } + + protected static function forItemInternal( &$stats, $group, $code ) { + $id = $group->getId(); + + if ( self::$timeStart !== null && ( microtime( true ) - self::$timeStart ) > self::$limit ) { + return $stats[$id][$code] = self::getUnknownStats(); + } + + if ( $group instanceof AggregateMessageGroup ) { + $aggregates = self::getEmptyStats(); + + $expanded = self::expandAggregates( $group ); + if ( $expanded === array() ) { + return $aggregates; + } + $res = self::selectRowsIdLang( array_keys( $expanded ), $code ); + $stats = self::extractResults( $res, $stats ); + + foreach ( $expanded as $sid => $subgroup ) { + # Discouraged groups may belong to another group, usually if there + # is an aggregate group for all translatable pages. In that case + # calculate and store the statistics, but don't count them as part of + # the aggregate group, so that the numbers in Special:LanguageStats + # add up. The statistics for discouraged groups can still be viewed + # through Special:MessageGroupStats. + if ( !isset( $stats[$sid][$code] ) ) { + $stats[$sid][$code] = self::forItemInternal( $stats, $subgroup, $code ); + } + + $include = wfRunHooks( 'Translate:MessageGroupStats:isIncluded', array( $sid, $code ) ); + if ( $include ) { + $aggregates = self::multiAdd( $aggregates, $stats[$sid][$code] ); + } + } + $stats[$id][$code] = $aggregates; + } else { + $aggregates = self::calculateGroup( $group, $code ); + } + + // Don't add nulls to the database, causes annoying warnings + if ( $aggregates[self::TOTAL] === null ) { + return $aggregates; + } + + $data = array( + 'tgs_group' => $id, + 'tgs_lang' => $code, + 'tgs_total' => $aggregates[self::TOTAL], + 'tgs_translated' => $aggregates[self::TRANSLATED], + 'tgs_fuzzy' => $aggregates[self::FUZZY], + 'tgs_proofread' => $aggregates[self::PROOFREAD], + ); + + $dbw = wfGetDB( DB_MASTER ); + // Try to avoid deadlocks with S->X lock upgrades in MySQL + // @note: this only helps in auto-commit mode (which job runners use) + $key = __CLASS__ . ":modify:$id"; + $locked = false; + if ( !$dbw->getFlag( DBO_TRX ) ) { + $locked = $dbw->lock( $key, __METHOD__, 1 ); + if ( !$locked ) { + return $aggregates; // raced out + } + } + + $dbw->insert( + self::TABLE, + $data, + __METHOD__, + array( 'IGNORE' ) + ); + + if ( $locked ) { + $dbw->unlock( $key, __METHOD__ ); + } + + return $aggregates; + } + + public static function multiAdd( &$a, $b ) { + if ( $a[0] === null || $b[0] === null ) { + return array_fill( 0, count( $a ), null ); + } + foreach ( $a as $i => &$v ) { + $v += $b[$i]; + } + + return $a; + } + + /** + * @param MessageGroup $group + * @param string $code Language code + * @return array ( total, translated, fuzzy, proofread ) + */ + protected static function calculateGroup( $group, $code ) { + global $wgTranslateDocumentationLanguageCode; + # Calculate if missing and store in the db + $collection = $group->initCollection( $code ); + + if ( $code === $wgTranslateDocumentationLanguageCode ) { + $ffs = $group->getFFS(); + if ( $ffs instanceof GettextFFS ) { + $template = $ffs->read( 'en' ); + $infile = array(); + foreach ( $template['TEMPLATE'] as $key => $data ) { + if ( isset( $data['comments']['.'] ) ) { + $infile[$key] = '1'; + } + } + $collection->setInFile( $infile ); + } + } + + $collection->filter( 'ignored' ); + $collection->filter( 'optional' ); + // Store the count of real messages for later calculation. + $total = count( $collection ); + + // Count fuzzy first. + $collection->filter( 'fuzzy' ); + $fuzzy = $total - count( $collection ); + + // Count the completed translations. + $collection->filter( 'hastranslation', false ); + $translated = count( $collection ); + + // Count how many of the completed translations + // have been proofread + $collection->filter( 'reviewer', false ); + $proofread = count( $collection ); + + return array( + self::TOTAL => $total, + self::TRANSLATED => $translated, + self::FUZZY => $fuzzy, + self::PROOFREAD => $proofread, + ); + } + + /** + * Converts input to "+2" "-4" type of string. + * @param $number int + * @return string + */ + protected static function stringifyNumber( $number ) { + $number = intval( $number ); + + return $number < 0 ? "$number" : "+$number"; + } +} diff --git a/MLEB/Translate/utils/MessageHandle.php b/MLEB/Translate/utils/MessageHandle.php new file mode 100644 index 00000000..dcb217eb --- /dev/null +++ b/MLEB/Translate/utils/MessageHandle.php @@ -0,0 +1,244 @@ +<?php +/** + * Class that enhances Title with stuff related to message groups + * @file + * @author Niklas Laxström + * @copyright Copyright © 2011-2013 Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Class for pointing to messages, like Title class is for titles. + * @since 2011-03-13 + */ +class MessageHandle { + /// @var Title + protected $title = null; + /// @var String + protected $key = null; + /// @var String + protected $code = null; + /// @var String + protected $groupIds = null; + /// @var MessageGroup + protected $group = false; + + public function __construct( Title $title ) { + $this->title = $title; + } + + /** + * Check if this handle is in a message namespace. + * @return bool + */ + public function isMessageNamespace() { + global $wgTranslateMessageNamespaces; + $namespace = $this->getTitle()->getNamespace(); + + return in_array( $namespace, $wgTranslateMessageNamespaces ); + } + + /** + * Recommended to use getCode and getKey instead. + * @return Array of the message key and the language code + */ + public function figureMessage() { + if ( $this->key === null ) { + $title = $this->getTitle(); + // Check if this is a valid message first + $this->key = $title->getDBKey(); + $known = MessageIndex::singleton()->getGroupIds( $this ) !== array(); + + $pos = strrpos( $this->key, '/' ); + if ( $known || $pos === false ) { + $this->code = ''; + } else { + // For keys like Foo/, substr returns false instead of '' + $this->code = strval( substr( $this->key, $pos + 1 ) ); + $this->key = substr( $this->key, 0, $pos ); + } + } + + return array( $this->key, $this->code ); + } + + /** + * Returns the identified or guessed message key. + * @return String + */ + public function getKey() { + $this->figureMessage(); + + return $this->key; + } + + /** + * Returns the language code. + * For language codeless source messages will return empty string. + * @return String + */ + public function getCode() { + $this->figureMessage(); + + return $this->code; + } + + /** + * Return the code for the assumed language of the content, which might + * be different from the subpage code (qqq, no subpage). + * @return String + * @since 2012-08-05 + */ + public function getEffectiveLanguageCode() { + global $wgContLang; + $code = $this->getCode(); + if ( $code === '' || $this->isDoc() ) { + return $wgContLang->getCode(); + } + + return $code; + } + + /** + * Determine whether the current handle is for message documentation. + * @return bool + */ + public function isDoc() { + global $wgTranslateDocumentationLanguageCode; + + return $this->getCode() === $wgTranslateDocumentationLanguageCode; + } + + /** + * Determine whether the current handle is for page translation feature. + * This does not consider whether the handle corresponds to any message. + * @return bool + */ + public function isPageTranslation() { + return $this->getTitle()->getNamespace() == NS_TRANSLATIONS; + } + + /** + * Returns all message group ids this message belongs to. + * The primary message group id is always the first one. + * If the handle does not correspond to any message, the returned array + * is empty. + * @return array + */ + public function getGroupIds() { + if ( $this->groupIds === null ) { + $this->groupIds = MessageIndex::singleton()->getGroupIds( $this ); + } + + return $this->groupIds; + } + + /** + * Get the primary MessageGroup this message belongs to. + * You should check first that the handle is valid. + * @throws MWException + * @return MessageGroup + */ + public function getGroup() { + $ids = $this->getGroupIds(); + if ( !isset( $ids[0] ) ) { + throw new MWException( 'called before isValid' ); + } + + return MessageGroups::getGroup( $ids[0] ); + } + + /** + * Checks if the handle corresponds to a known message. + * @since 2011-03-16 + * @return bool + */ + public function isValid() { + if ( !$this->isMessageNamespace() ) { + return false; + } + + $groups = $this->getGroupIds(); + if ( !$groups ) { + return false; + } + + // Do another check that the group actually exists + $group = $this->getGroup(); + if ( !$group ) { + $warning = "MessageIndex is out of date – refers to unknown group {$groups[0]}. "; + $warning .= "Doing a rebuild."; + wfWarn( $warning ); + MessageIndexRebuildJob::newJob()->run(); + + return false; + } + + return true; + } + + /** + * Get the original title. + * @return Title + */ + public function getTitle() { + return $this->title; + } + + /** + * Get the original title. + * @param string $code Language code. + * @return Title + * @since 2014.04 + */ + public function getTitleForLanguage( $code ) { + return Title::makeTitle( + $this->title->getNamespace(), + $this->getKey() . "/$code" + ); + } + + /** + * Get the title for the page base. + * @return Title + * @since 2014.04 + */ + public function getTitleForBase() { + return Title::makeTitle( + $this->title->getNamespace(), + $this->getKey() + ); + } + + /** + * Check if a string contains the fuzzy string. + * + * @param $text string Arbitrary text + * @return bool If string contains fuzzy string. + */ + public static function hasFuzzyString( $text ) { + return strpos( $text, TRANSLATE_FUZZY ) !== false; + } + + /** + * Check if a title is marked as fuzzy. + * @return bool If title is marked fuzzy. + */ + public function isFuzzy() { + $dbr = wfGetDB( DB_SLAVE ); + + $tables = array( 'page', 'revtag' ); + $field = 'rt_type'; + $conds = array( + 'page_namespace' => $this->title->getNamespace(), + 'page_title' => $this->title->getDBkey(), + 'rt_type' => RevTag::getType( 'fuzzy' ), + 'page_id=rt_page', + 'page_latest=rt_revision' + ); + + $res = $dbr->selectField( $tables, $field, $conds, __METHOD__ ); + + return $res !== false; + } +} diff --git a/MLEB/Translate/utils/MessageIndex.php b/MLEB/Translate/utils/MessageIndex.php new file mode 100644 index 00000000..53ab2c7e --- /dev/null +++ b/MLEB/Translate/utils/MessageIndex.php @@ -0,0 +1,526 @@ +<?php +/** + * Contains classes for handling the message index. + * + * @file + * @author Niklas Laxstrom + * @copyright Copyright © 2008-2013, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Creates a database of keys in all groups, so that namespace and key can be + * used to get the groups they belong to. This is used as a fallback when + * loadgroup parameter is not provided in the request, which happens if someone + * reaches a messages from somewhere else than Special:Translate. Also used + * by Special:TranslationStats and alike which need to map lots of titles + * to message groups. + */ +abstract class MessageIndex { + /// @var MessageIndex + protected static $instance; + + /** + * @return MessageIndex + */ + public static function singleton() { + if ( self::$instance === null ) { + global $wgTranslateMessageIndex; + $params = $wgTranslateMessageIndex; + $class = array_shift( $params ); + self::$instance = new $class( $params ); + } + + return self::$instance; + } + + /** + * Retrieves a list of groups given MessageHandle belongs to. + * @since 2012-01-04 + * @param MessageHandle $handle + * @return array + */ + public static function getGroupIds( MessageHandle $handle ) { + $namespace = $handle->getTitle()->getNamespace(); + $key = $handle->getKey(); + $normkey = TranslateUtils::normaliseKey( $namespace, $key ); + + $value = self::singleton()->get( $normkey ); + if ( $value !== null ) { + return (array)$value; + } else { + return array(); + } + } + + /** + * @since 2012-01-04 + * @param MessageHandle $handle + * @return MessageGroup|null + */ + public static function getPrimaryGroupId( MessageHandle $handle ) { + $groups = self::getGroupIds( $handle ); + + return count( $groups ) ? array_shift( $groups ) : null; + } + + /** + * Looks up the stored value for single key. Only for testing. + * @since 2012-04-10 + * @param string $key + * @return string|array|null + */ + protected function get( $key ) { + // Default implementation + $mi = $this->retrieve(); + if ( isset( $mi[$key] ) ) { + return $mi[$key]; + } else { + return null; + } + } + + /// @return array + abstract public function retrieve(); + + abstract protected function store( array $array ); + + public function rebuild() { + static $recursion = 0; + + if ( $recursion > 0 ) { + $msg = __METHOD__ . ': trying to recurse - building the index first time?'; + wfWarn( $msg ); + + $recursion--; + return array(); + } + $recursion++; + + $groups = MessageGroups::singleton()->getGroups(); + + $new = $old = array(); + $old = $this->retrieve(); + $postponed = array(); + + /** + * @var MessageGroup $g + */ + foreach ( $groups as $g ) { + if ( !$g->exists() ) { + continue; + } + + # Skip meta thingies + if ( $g->isMeta() ) { + $postponed[] = $g; + continue; + } + + $this->checkAndAdd( $new, $g ); + } + + foreach ( $postponed as $g ) { + $this->checkAndAdd( $new, $g, true ); + } + + $this->store( $new ); + $this->clearMessageGroupStats( $old, $new ); + $recursion--; + + return $new; + } + + /** + * Purge message group stats when set of keys have changed. + * @param array $old + * @param array $new + */ + protected function clearMessageGroupStats( array $old, array $new ) { + $changes = array(); + + foreach ( $new as $key => $groups ) { + // Using != here on purpose to ignore order of items + if ( !isset( $old[$key] ) ) { + $changes[$key] = array( array(), (array)$groups ); + } elseif ( $groups != $old[$key] ) { + $changes[$key] = array( (array)$old[$key], (array)$groups ); + } + } + + foreach ( $old as $key => $groups ) { + if ( !isset( $new[$key] ) ) { + $changes[$key] = array( (array)$groups, array() ); + } + // We already checked for diffs above + } + + $changedGroups = array(); + foreach ( $changes as $data ) { + foreach ( $data[0] as $group ) { + $changedGroups[$group] = true; + } + foreach ( $data[1] as $group ) { + $changedGroups[$group] = true; + } + } + + MessageGroupStats::clearGroup( array_keys( $changedGroups ) ); + + foreach ( $changes as $key => $data ) { + list( $ns, $pagename ) = explode( ':', $key, 2 ); + $title = Title::makeTitle( $ns, $pagename ); + $handle = new MessageHandle( $title ); + list ( $oldGroups, $newGroups ) = $data; + wfRunHooks( 'TranslateEventMessageMembershipChange', + array( $handle, $oldGroups, $newGroups ) ); + } + } + + /** + * @param array $hugearray + * @param MessageGroup $g + * @param bool $ignore + */ + protected function checkAndAdd( &$hugearray, MessageGroup $g, $ignore = false ) { + if ( method_exists( $g, 'getKeys' ) ) { + $keys = $g->getKeys(); + } else { + $messages = $g->getDefinitions(); + + if ( !is_array( $messages ) ) { + return; + } + + $keys = array_keys( $messages ); + } + + $id = $g->getId(); + + $namespace = $g->getNamespace(); + + foreach ( $keys as $key ) { + # Force all keys to lower case, because the case doesn't matter and it is + # easier to do comparing when the case of first letter is unknown, because + # mediawiki forces it to upper case + $key = TranslateUtils::normaliseKey( $namespace, $key ); + if ( isset( $hugearray[$key] ) ) { + if ( !$ignore ) { + $to = implode( ', ', (array)$hugearray[$key] ); + wfWarn( "Key $key already belongs to $to, conflict with $id" ); + } + + if ( is_array( $hugearray[$key] ) ) { + // Hard work is already done, just add a new reference + $hugearray[$key][] = & $id; + } else { + // Store the actual reference, then remove it from array, to not + // replace the references value, but to store an array of new + // references instead. References are hard! + $value = & $hugearray[$key]; + unset( $hugearray[$key] ); + $hugearray[$key] = array( &$value, &$id ); + } + } else { + $hugearray[$key] = & $id; + } + } + unset( $id ); // Disconnect the previous references to this $id + } + + /* These are probably slower than serialize and unserialize, + * but they are more space efficient because we only need + * strings and arrays. */ + protected function serialize( $data ) { + if ( is_array( $data ) ) { + return implode( '|', $data ); + } else { + return $data; + } + } + + protected function unserialize( $data ) { + if ( strpos( $data, '|' ) !== false ) { + return explode( '|', $data ); + } + + return $data; + } +} + +/** + * Storage on serialized file. + * + * This serializes the whole array. Because this format can preserve + * the values which are stored as references inside the array, this is + * the most space efficient storage method and fastest when you want + * the full index. + * + * Unfortunately when the size of index grows to about 50000 items, even + * though it is only 3,5M on disk, it takes 35M when loaded into memory + * and the loading can take more than 0,5 seconds. Because usually we + * need to look up only few keys, it is better to use another backend + * which provides random access - this backend doesn't support that. + */ +class SerializedMessageIndex extends MessageIndex { + /// @var array + protected $index; + + protected $filename = 'translate_messageindex.ser'; + + /** @return array */ + public function retrieve() { + if ( $this->index !== null ) { + return $this->index; + } + + wfProfileIn( __METHOD__ ); + $file = TranslateUtils::cacheFile( $this->filename ); + if ( file_exists( $file ) ) { + $this->index = unserialize( file_get_contents( $file ) ); + } else { + $this->index = $this->rebuild(); + } + wfProfileOut( __METHOD__ ); + + return $this->index; + } + + protected function store( array $array ) { + wfProfileIn( __METHOD__ ); + $file = TranslateUtils::cacheFile( $this->filename ); + file_put_contents( $file, serialize( $array ) ); + $this->index = $array; + wfProfileOut( __METHOD__ ); + } +} + +/// BC +class FileCachedMessageIndex extends SerializedMessageIndex { +} + +/** + * Storage on the database itself. + * + * This is likely to be the slowest backend. However it scales okay + * and provides random access. It also doesn't need any special setup, + * the database table is added with update.php together with other tables, + * which is the reason this is the default backend. It also works well + * on multi-server setup without needing for shared file storage. + * + * @since 2012-04-12 + */ +class DatabaseMessageIndex extends MessageIndex { + /// @var array + protected $index; + + /** @return array */ + public function retrieve() { + if ( $this->index !== null ) { + return $this->index; + } + + wfProfileIn( __METHOD__ ); + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'translate_messageindex', '*', array(), __METHOD__ ); + $this->index = array(); + foreach ( $res as $row ) { + $this->index[$row->tmi_key] = $this->unserialize( $row->tmi_value ); + } + wfProfileOut( __METHOD__ ); + + return $this->index; + } + + protected function get( $key ) { + wfProfileIn( __METHOD__ ); + $dbr = wfGetDB( DB_SLAVE ); + $value = $dbr->selectField( + 'translate_messageindex', + 'tmi_value', + array( 'tmi_key' => $key ), + __METHOD__ + ); + + if ( is_string( $value ) ) { + $value = $this->unserialize( $value ); + } else { + $value = null; + } + + wfProfileOut( __METHOD__ ); + + return $value; + } + + protected function store( array $array ) { + wfProfileIn( __METHOD__ ); + $dbw = wfGetDB( DB_MASTER ); + $rows = array(); + + foreach ( $array as $key => $value ) { + $value = $this->serialize( $value ); + $rows[] = array( 'tmi_key' => $key, 'tmi_value' => $value ); + } + + // BC for <= MW 1.22 + if ( method_exists( $dbw, 'startAtomic' ) ) { + $dbw->startAtomic( __METHOD__ ); + } + $dbw->delete( 'translate_messageindex', '*', __METHOD__ ); + $dbw->insert( 'translate_messageindex', $rows, __METHOD__ ); + if ( method_exists( $dbw, 'endAtomic' ) ) { + $dbw->endAtomic( __METHOD__ ); + } + + $this->index = $array; + wfProfileOut( __METHOD__ ); + } +} + +/** + * Storage on the object cache. + * + * This can be faster than DatabaseMessageIndex, but it doesn't + * provide random access, and the data is not guaranteed to be persistent. + * + * This is unlikely to be the best backend for you, so don't use it. + */ +class CachedMessageIndex extends MessageIndex { + protected $key = 'translate-messageindex'; + protected $cache; + + /// @var array + protected $index; + + protected function __construct( array $params ) { + $this->cache = wfGetCache( CACHE_ANYTHING ); + } + + /** @return array */ + public function retrieve() { + if ( $this->index !== null ) { + return $this->index; + } + + wfProfileIn( __METHOD__ ); + $key = wfMemckey( $this->key ); + $data = $this->cache->get( $key ); + if ( is_array( $data ) ) { + $this->index = $data; + } else { + $this->index = $this->rebuild(); + } + wfProfileOut( __METHOD__ ); + + return $this->index; + } + + protected function store( array $array ) { + wfProfileIn( __METHOD__ ); + $key = wfMemckey( $this->key ); + $this->cache->set( $key, $array ); + + $this->index = $array; + wfProfileOut( __METHOD__ ); + } +} + +/** + * Storage on CDB files. + * + * This is improved version of SerializedMessageIndex. It uses CDB files + * for storage, which means it provides random access. The CDB files are + * about double the size of serialized files (~7M for 50000 keys). + * + * Loading the whole index is slower than serialized, but about the same + * as for database. Suitable for single-server setups where + * SerializedMessageIndex is too slow for sloading the whole index. + * + * @since 2012-04-10 + */ +class CDBMessageIndex extends MessageIndex { + /// @var array + protected $index; + + /// @var CdbReader + protected $reader; + + /// @var string + protected $filename = 'translate_messageindex.cdb'; + + /** @return array */ + public function retrieve() { + $reader = $this->getReader(); + // This must be below the line above, which may fill the index + if ( $this->index !== null ) { + return $this->index; + } + + wfProfileIn( __METHOD__ ); + $keys = (array)$this->unserialize( $reader->get( '#keys' ) ); + $this->index = array(); + foreach ( $keys as $key ) { + $this->index[$key] = $this->unserialize( $reader->get( $key ) ); + } + wfProfileOut( __METHOD__ ); + + return $this->index; + } + + protected function get( $key ) { + $reader = $this->getReader(); + // We might have the full cache loaded + if ( $this->index !== null ) { + if ( isset( $this->index[$key] ) ) { + return $this->index[$key]; + } else { + return null; + } + } + + $value = $reader->get( $key ); + if ( !is_string( $value ) ) { + $value = null; + } else { + $value = $this->unserialize( $value ); + } + + return $value; + } + + protected function store( array $array ) { + wfProfileIn( __METHOD__ ); + $this->reader = null; + + $file = TranslateUtils::cacheFile( $this->filename ); + $cache = CdbWriter::open( $file ); + $keys = array_keys( $array ); + $cache->set( '#keys', $this->serialize( $keys ) ); + + foreach ( $array as $key => $value ) { + $value = $this->serialize( $value ); + $cache->set( $key, $value ); + } + + $cache->close(); + + $this->index = $array; + wfProfileOut( __METHOD__ ); + } + + protected function getReader() { + if ( $this->reader ) { + return $this->reader; + } + + $file = TranslateUtils::cacheFile( $this->filename ); + if ( !file_exists( $file ) ) { + // Create an empty index to allow rebuild + $this->store( array() ); + $this->index = $this->rebuild(); + } + + return $this->reader = CdbReader::open( $file ); + } +} diff --git a/MLEB/Translate/utils/MessageIndexRebuildJob.php b/MLEB/Translate/utils/MessageIndexRebuildJob.php new file mode 100644 index 00000000..413df987 --- /dev/null +++ b/MLEB/Translate/utils/MessageIndexRebuildJob.php @@ -0,0 +1,53 @@ +<?php +/** + * Contains class with job for rebuilding message index. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2011-2013, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Job for rebuilding message index. + * + * @ingroup JobQueue + */ +class MessageIndexRebuildJob extends Job { + + /** + * @return MessageIndexRebuildJob + */ + public static function newJob() { + $job = new self( Title::newMainPage() ); + + return $job; + } + + function __construct( $title, $params = array(), $id = 0 ) { + parent::__construct( __CLASS__, $title, $params, $id ); + } + + function run() { + MessageIndex::singleton()->rebuild(); + + return true; + } + + /** + * Usually this job is fast enough to be executed immediately, + * in which case having it go through jobqueue only causes problems + * in installations with errant job queue processing. + * @override + */ + public function insert() { + global $wgTranslateDelayedMessageIndexRebuild; + if ( $wgTranslateDelayedMessageIndexRebuild ) { + return JobQueueGroup::singleton()->push( $this ); + } else { + $this->run(); + + return true; + } + } +} diff --git a/MLEB/Translate/utils/MessageTable.php b/MLEB/Translate/utils/MessageTable.php new file mode 100644 index 00000000..d2dc83a7 --- /dev/null +++ b/MLEB/Translate/utils/MessageTable.php @@ -0,0 +1,418 @@ +<?php +/** + * Contains classes to build tables for MessageCollection objects. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Pretty formatter for MessageCollection objects. + */ +class MessageTable { + /* + * @var bool + */ + protected $reviewMode = false; + + /** + * @var MessageCollection + */ + protected $collection; + + /** + * @var MessageGroup + */ + protected $group; + + /** + * @var IContextSource + */ + protected $context; + + /** + * @var array + */ + protected $headers = array( + 'table' => array( 'msg', 'allmessagesname' ), + 'current' => array( 'msg', 'allmessagescurrent' ), + 'default' => array( 'msg', 'allmessagesdefault' ), + ); + + /** + * Use this rather than the constructor directly + * to allow alternative implementations. + * + * @since 2012-11-29 + */ + public static function newFromContext( + IContextSource $context, + MessageCollection $collection, + MessageGroup $group + ) { + $table = new self( $collection, $group ); + $table->setContext( $context ); + + wfRunHooks( 'TranslateMessageTableInit', array( &$table, $context, $collection, $group ) ); + + return $table; + } + + public function setContext( IContextSource $context ) { + $this->context = $context; + } + + /** + * Use the newFromContext() function rather than the constructor directly + * to construct the object to allow alternative implementations. + */ + public function __construct( MessageCollection $collection, MessageGroup $group ) { + $this->collection = $collection; + $this->group = $group; + $this->setHeaderText( 'table', $group->getLabel() ); + } + + public function setReviewMode( $mode = true ) { + $this->reviewMode = $mode; + } + + public function setHeaderTextMessage( $type, $value ) { + if ( !isset( $this->headers[$type] ) ) { + throw new MWException( "Unexpected type $type" ); + } + + $this->headers[$type] = array( 'msg', $value ); + } + + public function setHeaderText( $type, $value ) { + if ( !isset( $this->headers[$type] ) ) { + throw new MWException( "Unexpected type $type" ); + } + + $this->headers[$type] = array( 'raw', htmlspecialchars( $value ) ); + } + + public function includeAssets() { + TranslationHelpers::addModules( $this->context->getOutput() ); + $pages = array(); + + foreach ( $this->collection->getTitles() as $title ) { + $pages[] = $title->getPrefixedDBKey(); + } + + $vars = array( 'trlKeys' => $pages ); + $this->context->getOutput()->addScript( Skin::makeVariablesScript( $vars ) ); + } + + public function header() { + $tableheader = Xml::openElement( 'table', array( + 'class' => 'mw-sp-translate-table' + ) ); + + if ( $this->reviewMode ) { + $tableheader .= Xml::openElement( 'tr' ); + $tableheader .= Xml::element( 'th', + array( 'rowspan' => '2' ), + $this->headerText( 'table' ) + ); + $tableheader .= Xml::tags( 'th', null, $this->headerText( 'default' ) ); + $tableheader .= Xml::closeElement( 'tr' ); + + $tableheader .= Xml::openElement( 'tr' ); + $tableheader .= Xml::tags( 'th', null, $this->headerText( 'current' ) ); + $tableheader .= Xml::closeElement( 'tr' ); + } else { + $tableheader .= Xml::openElement( 'tr' ); + $tableheader .= Xml::tags( 'th', null, $this->headerText( 'table' ) ); + $tableheader .= Xml::tags( 'th', null, $this->headerText( 'current' ) ); + $tableheader .= Xml::closeElement( 'tr' ); + } + + return $tableheader . "\n"; + } + + public function contents() { + $optional = $this->context->msg( 'translate-optional' )->escaped(); + + $this->doLinkBatch(); + + $sourceLang = Language::factory( $this->group->getSourceLanguage() ); + $targetLang = Language::factory( $this->collection->getLanguage() ); + $titleMap = $this->collection->keys(); + + $output = ''; + + $this->collection->initMessages(); // Just to be sure + + /** + * @var TMessage $m + */ + foreach ( $this->collection as $key => $m ) { + $tools = array(); + /** + * @var Title $title + */ + $title = $titleMap[$key]; + + $original = $m->definition(); + $translation = $m->translation(); + + $hasTranslation = $translation !== null; + + if ( $hasTranslation ) { + $message = $translation; + $extraAttribs = self::getLanguageAttributes( $targetLang ); + } else { + $message = $original; + $extraAttribs = self::getLanguageAttributes( $sourceLang ); + } + + wfRunHooks( + 'TranslateFormatMessageBeforeTable', + array( &$message, $m, $this->group, $targetLang, &$extraAttribs ) + ); + + // Using Html::element( a ) because Linker::link is memory hog. + // It takes about 20 KiB per call, and that times 5000 is quite + // a lot of memory. + $niceTitle = htmlspecialchars( $this->context->getLanguage()->truncate( + $title->getPrefixedText(), + -35 + ) ); + $linkAttribs = array( + 'href' => $title->getLocalUrl( array( 'action' => 'edit' ) ), + ); + $linkAttribs += TranslationEditPage::jsEdit( $title, $this->group->getId() ); + + $tools['edit'] = Html::element( 'a', $linkAttribs, $niceTitle ); + + $anchor = 'msg_' . $key; + $anchor = Xml::element( 'a', array( 'id' => $anchor, 'href' => "#$anchor" ), "↓" ); + + $extra = ''; + if ( $m->hasTag( 'optional' ) ) { + $extra = '<br />' . $optional; + } + + $tqeData = $extraAttribs + array( + 'data-title' => $title->getPrefixedText(), + 'data-group' => $this->group->getId(), + 'id' => 'tqe-anchor-' . substr( sha1( $title->getPrefixedText() ), 0, 12 ), + 'class' => 'tqe-inlineeditable ' . ( $hasTranslation ? 'translated' : 'untranslated' ) + ); + + $button = $this->getReviewButton( $m ); + $status = $this->getReviewStatus( $m ); + $leftColumn = $button . $anchor . $tools['edit'] . $extra . $status; + + if ( $this->reviewMode ) { + $output .= Xml::tags( 'tr', array( 'class' => 'orig' ), + Xml::tags( 'td', array( 'rowspan' => '2' ), $leftColumn ) . + Xml::tags( 'td', self::getLanguageAttributes( $sourceLang ), + TranslateUtils::convertWhiteSpaceToHTML( $original ) + ) + ); + + $output .= Xml::tags( 'tr', null, + Xml::tags( 'td', $tqeData, TranslateUtils::convertWhiteSpaceToHTML( $message ) ) + ); + } else { + $output .= Xml::tags( 'tr', array( 'class' => 'def' ), + Xml::tags( 'td', null, $leftColumn ) . + Xml::tags( 'td', $tqeData, TranslateUtils::convertWhiteSpaceToHTML( $message ) ) + ); + } + + $output .= "\n"; + } + + return $output; + } + + public function fullTable( $offsets, $nondefaults ) { + $this->includeAssets(); + + $content = $this->header() . $this->contents() . '</table>'; + $pager = $this->doStupidLinks( $offsets, $nondefaults ); + + if ( $offsets['count'] === 0 ) { + return $pager; + } elseif ( $offsets['count'] === $offsets['total'] ) { + return $content . $pager; + } else { + return $pager . $content . $pager; + } + } + + protected function headerText( $type ) { + if ( !isset( $this->headers[$type] ) ) { + throw new MWException( "Unexpected type $type" ); + } + + list( $format, $value ) = $this->headers[$type]; + if ( $format === 'msg' ) { + return wfMessage( $value )->escaped(); + } elseif ( $format === 'raw' ) { + return $value; + } else { + throw new MWException( "Unexcepted format $format" ); + } + } + + protected static function getLanguageAttributes( Language $language ) { + global $wgTranslateDocumentationLanguageCode; + + $code = $language->getCode(); + $dir = $language->getDir(); + + if ( $code === $wgTranslateDocumentationLanguageCode ) { + // Should be good enough for now + $code = 'en'; + } + + return array( 'lang' => $code, 'dir' => $dir ); + } + + protected function getReviewButton( TMessage $message ) { + $revision = $message->getProperty( 'revision' ); + $user = $this->context->getUser(); + + if ( !$this->reviewMode || !$user->isAllowed( 'translate-messagereview' ) || !$revision ) { + return ''; + } + + $attribs = array( + 'type' => 'button', + 'class' => 'mw-translate-messagereviewbutton', + 'data-token' => ApiTranslationReview::getToken( 0, '' ), + 'data-revision' => $revision, + 'name' => 'acceptbutton-' . $revision, // Otherwise Firefox disables buttons on page load + ); + + $reviewers = (array)$message->getProperty( 'reviewers' ); + if ( in_array( $user->getId(), $reviewers ) ) { + $attribs['value'] = wfMessage( 'translate-messagereview-done' )->text(); + $attribs['disabled'] = 'disabled'; + $attribs['title'] = wfMessage( 'translate-messagereview-doit' )->text(); + } elseif ( $message->hasTag( 'fuzzy' ) ) { + $attribs['value'] = wfMessage( 'translate-messagereview-submit' )->text(); + $attribs['disabled'] = 'disabled'; + $attribs['title'] = wfMessage( 'translate-messagereview-no-fuzzy' )->text(); + } elseif ( $user->getName() === $message->getProperty( 'last-translator-text' ) ) { + $attribs['value'] = wfMessage( 'translate-messagereview-submit' )->text(); + $attribs['disabled'] = 'disabled'; + $attribs['title'] = wfMessage( 'translate-messagereview-no-own' )->text(); + } else { + $attribs['value'] = wfMessage( 'translate-messagereview-submit' )->text(); + } + + $review = Html::element( 'input', $attribs ); + + return $review; + } + + /// For optimization + protected $reviewStatusCache = array(); + + protected function getReviewStatus( TMessage $message ) { + if ( !$this->reviewMode ) { + return ''; + } + + $reviewers = (array)$message->getProperty( 'reviewers' ); + $count = count( $reviewers ); + + if ( $count === 0 ) { + return ''; + } + + $userId = $this->context->getUser()->getId(); + $you = in_array( $userId, $reviewers ); + $key = $you ? "y$count" : "n$count"; + + // ->text() (and ->parse()) invokes the parser. Each call takes + // about 70 KiB, so it makes sense to cache these messages which + // have high repetition. + if ( isset( $this->reviewStatusCache[$key] ) ) { + return $this->reviewStatusCache[$key]; + } elseif ( $you ) { + $msg = wfMessage( 'translate-messagereview-reviewswithyou' )->numParams( $count )->text(); + } else { + $msg = wfMessage( 'translate-messagereview-reviews' )->numParams( $count )->text(); + } + + $wrap = Html::rawElement( 'div', array( 'class' => 'mw-translate-messagereviewstatus' ), $msg ); + $this->reviewStatusCache[$key] = $wrap; + + return $wrap; + } + + protected function doLinkBatch() { + $batch = new LinkBatch(); + $batch->setCaller( __METHOD__ ); + + foreach ( $this->collection->getTitles() as $title ) { + $batch->addObj( $title ); + } + + $batch->execute(); + } + + protected function doStupidLinks( $info, $nondefaults ) { + // Total number of messages for this query + $total = $info['total']; + // Messages in this page + $count = $info['count']; + + $allInThisPage = $info['start'] === 0 && $total === $count; + + if ( $info['count'] === 0 ) { + $navigation = wfMessage( 'translate-page-showing-none' )->parse(); + } elseif ( $allInThisPage ) { + $navigation = wfMessage( 'translate-page-showing-all' )->numParams( $total )->parse(); + } else { + $previous = wfMessage( 'translate-prev' )->escaped(); + + if ( $info['backwardsOffset'] !== false ) { + $previous = $this->makeOffsetLink( $previous, $info['backwardsOffset'], $nondefaults ); + } + + $nextious = wfMessage( 'translate-next' )->escaped(); + if ( $info['forwardsOffset'] !== false ) { + $nextious = $this->makeOffsetLink( $nextious, $info['forwardsOffset'], $nondefaults ); + } + + $start = $info['start'] + 1; + $stop = $start + $info['count'] - 1; + $total = $info['total']; + + $navigation = wfMessage( 'translate-page-showing' ) + ->numParams( $start, $stop, $total )->parse(); + $navigation .= ' '; + $navigation .= wfMessage( 'translate-page-paging-links' ) + ->rawParams( $previous, $nextious )->escaped(); + } + + return Html::openElement( 'fieldset' ) . + Html::element( 'legend', array(), wfMessage( 'translate-page-navigation-legend' )->text() ) . + $navigation . + Html::closeElement( 'fieldset' ); + } + + protected function makeOffsetLink( $label, $offset, $nondefaults ) { + $query = array_merge( + $nondefaults, + array( 'offset' => $offset ) + ); + + $link = Linker::link( + $this->context->getTitle(), + $label, + array(), + $query + ); + + return $link; + } +} diff --git a/MLEB/Translate/utils/MessageUpdateJob.php b/MLEB/Translate/utils/MessageUpdateJob.php new file mode 100644 index 00000000..a12972a3 --- /dev/null +++ b/MLEB/Translate/utils/MessageUpdateJob.php @@ -0,0 +1,91 @@ +<?php +/** + * Job for updating translation pages. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2008-2013, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Job for updating translation pages when translation or message definition changes. + * + * @ingroup JobQueue + */ +class MessageUpdateJob extends Job { + public static function newJob( Title $target, $content, $fuzzy = false ) { + $params = array( + 'content' => $content, + 'fuzzy' => $fuzzy, + ); + $job = new self( $target, $params ); + + return $job; + } + + function __construct( $title, $params = array(), $id = 0 ) { + parent::__construct( __CLASS__, $title, $params, $id ); + $this->params = $params; + } + + function run() { + global $wgTranslateDocumentationLanguageCode; + + $title = $this->title; + $params = $this->params; + $user = FuzzyBot::getUser(); + $flags = EDIT_DEFER_UPDATES | EDIT_FORCE_BOT; + + $wikiPage = WikiPage::factory( $title ); + $summary = wfMessage( 'translate-manage-import-summary' ) + ->inContentLanguage()->plain(); + $content = ContentHandler::makeContent( $params['content'], $title ); + $wikiPage->doEditContent( $content, $summary, $flags, false, $user ); + + // NOTE: message documentation is excluded from fuzzying! + if ( $params['fuzzy'] ) { + $handle = new MessageHandle( $title ); + $key = $handle->getKey(); + + $languages = TranslateUtils::getLanguageNames( 'en' ); + unset( $languages[$wgTranslateDocumentationLanguageCode] ); + $languages = array_keys( $languages ); + + $dbw = wfGetDB( DB_MASTER ); + $fields = array( 'page_id', 'page_latest' ); + $conds = array( 'page_namespace' => $title->getNamespace() ); + + $pages = array(); + foreach ( $languages as $code ) { + $otherTitle = Title::makeTitleSafe( $title->getNamespace(), "$key/$code" ); + $pages[$otherTitle->getDBKey()] = true; + } + unset( $pages[$title->getDBKey()] ); + if ( count( $pages ) === 0 ) { + return true; + } + + $conds['page_title'] = array_keys( $pages ); + + $res = $dbw->select( 'page', $fields, $conds, __METHOD__ ); + $inserts = array(); + foreach ( $res as $row ) { + $inserts[] = array( + 'rt_type' => RevTag::getType( 'fuzzy' ), + 'rt_page' => $row->page_id, + 'rt_revision' => $row->page_latest, + ); + } + + $dbw->replace( + 'revtag', + array( array( 'rt_type', 'rt_page', 'rt_revision' ) ), + $inserts, + __METHOD__ + ); + } + + return true; + } +} diff --git a/MLEB/Translate/utils/MessageWebImporter.php b/MLEB/Translate/utils/MessageWebImporter.php new file mode 100644 index 00000000..ec9c968b --- /dev/null +++ b/MLEB/Translate/utils/MessageWebImporter.php @@ -0,0 +1,577 @@ +<?php +/** + * Class which encapsulates message importing. It scans for changes (new, changed, deleted), + * displays them in pretty way with diffs and finally executes the actions the user choices. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @copyright Copyright © 2009-2013, Niklas Laxström, Siebrand Mazeland + * @license GPL-2.0+ + */ + +/** + * Class which encapsulates message importing. It scans for changes (new, changed, deleted), + * displays them in pretty way with diffs and finally executes the actions the user choices. + */ +class MessageWebImporter { + /** + * @var Title + */ + protected $title; + + /** + * @var User + */ + protected $user; + + /** + * @var MessageGroup + */ + protected $group; + protected $code; + protected $time; + + /** + * @var OutputPage + */ + protected $out; + + /** + * Maximum processing time in seconds. + */ + protected $processingTime = 43; + + public function __construct( Title $title = null, $group = null, $code = 'en' ) { + $this->setTitle( $title ); + $this->setGroup( $group ); + $this->setCode( $code ); + } + + /** + * Wrapper for consistency with SpecialPage + * + * @return Title + */ + public function getTitle() { + return $this->title; + } + + public function setTitle( Title $title ) { + $this->title = $title; + } + + /** + * @return User + */ + public function getUser() { + return $this->user ? $this->user : RequestContext::getMain()->getUser(); + } + + public function setUser( User $user ) { + $this->user = $user; + } + + /** + * @return MessageGroup + */ + public function getGroup() { + return $this->group; + } + + /** + * Group is either MessageGroup object or group id. + * @param MessageGroup|string $group + */ + public function setGroup( $group ) { + if ( $group instanceof MessageGroup ) { + $this->group = $group; + } else { + $this->group = MessageGroups::getGroup( $group ); + } + } + + public function getCode() { + return $this->code; + } + + public function setCode( $code = 'en' ) { + $this->code = $code; + } + + /** + * @return string + */ + protected function getAction() { + return $this->getTitle()->getFullURL(); + } + + /** + * @return string + */ + protected function doHeader() { + $formParams = array( + 'method' => 'post', + 'action' => $this->getAction(), + 'class' => 'mw-translate-manage' + ); + + return + Xml::openElement( 'form', $formParams ) . + Html::hidden( 'title', $this->getTitle()->getPrefixedText() ) . + Html::hidden( 'token', $this->getUser()->getEditToken() ) . + Html::hidden( 'process', 1 ); + } + + /** + * @return string + */ + protected function doFooter() { + return '</form>'; + } + + /** + * @return bool + */ + protected function allowProcess() { + $request = RequestContext::getMain()->getRequest(); + + if ( $request->wasPosted() && + $request->getBool( 'process', false ) && + $this->getUser()->matchEditToken( $request->getVal( 'token' ) ) + ) { + + return true; + } + + return false; + } + + /** + * @return array + */ + protected function getActions() { + if ( $this->code === 'en' ) { + return array( 'import', 'fuzzy', 'ignore' ); + } else { + return array( 'import', 'conflict', 'ignore' ); + } + } + + /** + * @param bool $fuzzy + * @param string $action + * @return string + */ + protected function getDefaultAction( $fuzzy, $action ) { + if ( $action ) { + return $action; + } + + return $fuzzy ? 'conflict' : 'import'; + } + + public function execute( $messages ) { + $context = RequestContext::getMain(); + $this->out = $context->getOutput(); + + // Set up diff engine + $diff = new DifferenceEngine; + $diff->showDiffStyle(); + $diff->setReducedLineNumbers(); + + // Check whether we do processing + $process = $this->allowProcess(); + + // Initialise collection + $group = $this->getGroup(); + $code = $this->getCode(); + $collection = $group->initCollection( $code ); + $collection->loadTranslations(); + + $this->out->addHTML( $this->doHeader() ); + + // Determine changes + $alldone = $process; + $changed = array(); + + foreach ( $messages as $key => $value ) { + $fuzzy = $old = false; + + if ( isset( $collection[$key] ) ) { + $old = $collection[$key]->translation(); + } + + // No changes at all, ignore + if ( strval( $old ) === strval( $value ) ) { + continue; + } + + if ( $old === false ) { + $para = '<code class="mw-tmi-new">' . htmlspecialchars( $key ) . '</code>'; + $name = $context->msg( 'translate-manage-import-new' )->rawParams( $para ) + ->escaped(); + $text = TranslateUtils::convertWhiteSpaceToHTML( $value ); + $changed[] = self::makeSectionElement( $name, 'new', $text ); + } else { + $oldContent = ContentHandler::makeContent( $old, $diff->getTitle() ); + $newContent = ContentHandler::makeContent( $value, $diff->getTitle() ); + + $diff->setContent( $oldContent, $newContent ); + + $text = $diff->getDiff( '', '' ); + $type = 'changed'; + + $action = $context->getRequest() + ->getVal( self::escapeNameForPHP( "action-$type-$key" ) ); + + if ( $process ) { + if ( !count( $changed ) ) { + $changed[] = '<ul>'; + } + + if ( $action === null ) { + $message = $context->msg( + 'translate-manage-inconsistent', + wfEscapeWikiText( "action-$type-$key" ) + )->parse(); + $changed[] = "<li>$message</li></ul>"; + $process = false; + } else { + // Check processing time + if ( !isset( $this->time ) ) { + $this->time = wfTimestamp(); + } + + $message = self::doAction( + $action, + $group, + $key, + $code, + $value + ); + + $key = array_shift( $message ); + $params = $message; + $message = $context->msg( $key, $params )->parse(); + $changed[] = "<li>$message</li>"; + + if ( $this->checkProcessTime() ) { + $process = false; + $message = $context->msg( 'translate-manage-toolong' ) + ->numParams( $this->processingTime )->parse(); + $changed[] = "<li>$message</li></ul>"; + } + continue; + } + } + + $alldone = false; + + $actions = $this->getActions(); + $defaction = $this->getDefaultAction( $fuzzy, $action ); + + $act = array(); + + // Give grep a chance to find the usages: + // translate-manage-action-import, translate-manage-action-conflict, + // translate-manage-action-ignore, translate-manage-action-fuzzy + foreach ( $actions as $action ) { + $label = $context->msg( "translate-manage-action-$action" )->text(); + $name = self::escapeNameForPHP( "action-$type-$key" ); + $id = Sanitizer::escapeId( "action-$key-$action" ); + $act[] = Xml::radioLabel( $label, $name, $action, $id, $action === $defaction ); + } + + $param = '<code class="mw-tmi-diff">' . htmlspecialchars( $key ) . '</code>'; + $name = $context->msg( 'translate-manage-import-diff', $param, + implode( ' ', $act ) + )->text(); + + $changed[] = self::makeSectionElement( $name, $type, $text ); + } + } + + if ( !$process ) { + $collection->filter( 'hastranslation', false ); + $keys = $collection->getMessageKeys(); + + $diff = array_diff( $keys, array_keys( $messages ) ); + + foreach ( $diff as $s ) { + $para = '<code class="mw-tmi-deleted">' . htmlspecialchars( $s ) . '</code>'; + $name = $context->msg( 'translate-manage-import-deleted' )->rawParams( $para )->escaped(); + $text = TranslateUtils::convertWhiteSpaceToHTML( $collection[$s]->translation() ); + $changed[] = self::makeSectionElement( $name, 'deleted', $text ); + } + } + + if ( $process || ( !count( $changed ) && $code !== 'en' ) ) { + if ( !count( $changed ) ) { + $this->out->addWikiMsg( 'translate-manage-nochanges-other' ); + } + + if ( !count( $changed ) || strpos( $changed[count( $changed ) - 1], '<li>' ) !== 0 ) { + $changed[] = '<ul>'; + } + + $message = $context->msg( 'translate-manage-import-done' )->parse(); + $changed[] = "<li>$message</li></ul>"; + $this->out->addHTML( implode( "\n", $changed ) ); + } else { + // END + if ( count( $changed ) ) { + if ( $code === 'en' ) { + $this->out->addWikiMsg( 'translate-manage-intro-en' ); + } else { + $lang = TranslateUtils::getLanguageName( + $code, + $context->getLanguage()->getCode() + ); + $this->out->addWikiMsg( 'translate-manage-intro-other', $lang ); + } + $this->out->addHTML( Html::hidden( 'language', $code ) ); + $this->out->addHTML( implode( "\n", $changed ) ); + $this->out->addHTML( Xml::submitButton( $context->msg( 'translate-manage-submit' )->text() ) ); + } else { + $this->out->addWikiMsg( 'translate-manage-nochanges' ); + } + } + + $this->out->addHTML( $this->doFooter() ); + + return $alldone; + } + + /** + * Perform an action on a given group/key/code + * + * @param string $action Options: 'import', 'conflict' or 'ignore' + * @param MessageGroup $group Group object + * @param string $key Message key + * @param string $code Language code + * @param string $message Contents for the $key/code combination + * @param string $comment Edit summary (default: empty) - see Article::doEdit + * @param User $user User that will make the edit (default: null - RequestContext user). + * See Article::doEdit. + * @param int $editFlags Integer bitfield: see Article::doEdit + * @throws MWException + * @return string Action result + */ + public static function doAction( $action, $group, $key, $code, $message, $comment = '', + $user = null, $editFlags = 0 + ) { + global $wgTranslateDocumentationLanguageCode; + + $title = self::makeTranslationTitle( $group, $key, $code ); + + if ( $action === 'import' || $action === 'conflict' ) { + if ( $action === 'import' ) { + $comment = wfMessage( 'translate-manage-import-summary' )->inContentLanguage()->plain(); + } else { + $comment = wfMessage( 'translate-manage-conflict-summary' )->inContentLanguage()->plain(); + $message = self::makeTextFuzzy( $message ); + } + + return self::doImport( $title, $message, $comment, $user, $editFlags ); + } elseif ( $action === 'ignore' ) { + return array( 'translate-manage-import-ignore', $key ); + } elseif ( $action === 'fuzzy' && $code !== 'en' && + $code !== $wgTranslateDocumentationLanguageCode + ) { + $message = self::makeTextFuzzy( $message ); + + return self::doImport( $title, $message, $comment, $user, $editFlags ); + } elseif ( $action === 'fuzzy' && $code == 'en' ) { + return self::doFuzzy( $title, $message, $comment, $user, $editFlags ); + } else { + throw new MWException( "Unhandled action $action" ); + } + } + + protected function checkProcessTime() { + return wfTimestamp() - $this->time >= $this->processingTime; + } + + /** + * @throws MWException + * @param Title $title + * @param $message + * @param $summary + * @param User $user + * @param $editFlags + * @return array + */ + public static function doImport( $title, $message, $summary, $user = null, $editFlags = 0 ) { + $wikiPage = WikiPage::factory( $title ); + $content = ContentHandler::makeContent( $message, $title ); + $status = $wikiPage->doEditContent( $content, $summary, $editFlags, false, $user ); + $success = $status->isOK(); + + if ( $success ) { + return array( 'translate-manage-import-ok', + wfEscapeWikiText( $title->getPrefixedText() ) + ); + } else { + $text = "Failed to import new version of page {$title->getPrefixedText()}\n"; + $text .= "{$status->getWikiText()}"; + throw new MWException( $text ); + } + } + + /** + * @param Title $title + * @param $message + * @param $comment + * @param $user + * @param int $editFlags + * @return array|String + */ + public static function doFuzzy( $title, $message, $comment, $user, $editFlags = 0 ) { + $context = RequestContext::getMain(); + + if ( !$context->getUser()->isAllowed( 'translate-manage' ) ) { + return $context->msg( 'badaccess-group0' )->text(); + } + + $dbw = wfGetDB( DB_MASTER ); + + // Work on all subpages of base title. + $handle = new MessageHandle( $title ); + $titleText = $handle->getKey(); + + $conds = array( + 'page_namespace' => $title->getNamespace(), + 'page_latest=rev_id', + 'rev_text_id=old_id', + 'page_title' . $dbw->buildLike( "$titleText/", $dbw->anyString() ), + ); + + $rows = $dbw->select( + array( 'page', 'revision', 'text' ), + array( 'page_title', 'page_namespace', 'old_text', 'old_flags' ), + $conds, + __METHOD__ + ); + + // Edit with fuzzybot if there is no user. + if ( !$user ) { + $user = FuzzyBot::getUser(); + } + + // Process all rows. + $changed = array(); + foreach ( $rows as $row ) { + global $wgTranslateDocumentationLanguageCode; + + $ttitle = Title::makeTitle( $row->page_namespace, $row->page_title ); + + // No fuzzy for English original or documentation language code. + if ( $ttitle->getSubpageText() === 'en' || + $ttitle->getSubpageText() === $wgTranslateDocumentationLanguageCode + ) { + // Use imported text, not database text. + $text = $message; + } else { + $text = Revision::getRevisionText( $row ); + $text = self::makeTextFuzzy( $text ); + } + + // Do actual import + $changed[] = self::doImport( + $ttitle, + $text, + $comment, + $user, + $editFlags + ); + } + + // Format return text + $text = ''; + foreach ( $changed as $c ) { + $key = array_shift( $c ); + $text .= "* " . $context->msg( $key, $c )->plain() . "\n"; + } + + return array( 'translate-manage-import-fuzzy', "\n" . $text ); + } + + /** + * Given a group, message key and language code, creates a title for the + * translation page. + * + * @param MessageGroup $group + * @param string $key Message key + * @param string $code Language code + * @return Title + */ + public static function makeTranslationTitle( $group, $key, $code ) { + $ns = $group->getNamespace(); + + return Title::makeTitleSafe( $ns, "$key/$code" ); + } + + /** + * Make section elements. + * + * @param string $legend Legend as raw html. + * @param string $type Contents of type class. + * @param string $content Contents as raw html. + * @param Language $lang The language in which the text is written. + * @return string Section element as html. + */ + public static function makeSectionElement( $legend, $type, $content, $lang = null ) { + $containerParams = array( 'class' => "mw-tpt-sp-section mw-tpt-sp-section-type-{$type}" ); + $legendParams = array( 'class' => 'mw-tpt-sp-legend' ); + $contentParams = array( 'class' => 'mw-tpt-sp-content' ); + if ( $lang ) { + $contentParams['dir'] = wfGetLangObj( $lang )->getDir(); + $contentParams['lang'] = wfGetLangObj( $lang )->getCode(); + } + + $output = Html::rawElement( 'div', $containerParams, + Html::rawElement( 'div', $legendParams, $legend ) . + Html::rawElement( 'div', $contentParams, $content ) + ); + + return $output; + } + + /** + * Prepends translation with fuzzy tag and ensures there is only one of them. + * + * @param string $message Message content + * @return string Message prefixed with TRANSLATE_FUZZY tag + */ + public static function makeTextFuzzy( $message ) { + $message = str_replace( TRANSLATE_FUZZY, '', $message ); + + return TRANSLATE_FUZZY . $message; + } + + /** + * Escape name such that it validates as name and id parameter in html, and + * so that we can get it back with WebRequest::getVal(). Especially dot and + * spaces are difficult for the latter. + * @param string $name + * @return string + */ + public static function escapeNameForPHP( $name ) { + $replacements = array( + "(" => '(OP)', + " " => '(SP)', + "\t" => '(TAB)', + "." => '(DOT)', + "'" => '(SQ)', + "\"" => '(DQ)', + "%" => '(PC)', + "&" => '(AMP)', + ); + + /* How nice of you PHP. No way to split array into keys and values in one + * function or have str_replace which takes one array? */ + + return str_replace( array_keys( $replacements ), array_values( $replacements ), $name ); + } +} diff --git a/MLEB/Translate/utils/RcFilter.php b/MLEB/Translate/utils/RcFilter.php new file mode 100644 index 00000000..8dbda197 --- /dev/null +++ b/MLEB/Translate/utils/RcFilter.php @@ -0,0 +1,91 @@ +<?php +/** + * Contains class with filter to Special:RecentChanges to enable additional + * filtering. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2010, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Adds a new filter to Special:RecentChanges which makes it possible to filter + * translations away or show them only. + */ +class TranslateRcFilter { + /** + * Hooks SpecialRecentChangesQuery. See the hook documentation for + * documentation of the function parameters. + * + * Appends SQL filter conditions into $conds. + * @param array $conds + * @param array|string $tables + * @param array $join_conds + * @param FormOptions $opts + * @return bool true + */ + public static function translationFilter( &$conds, &$tables, &$join_conds, $opts ) { + global $wgTranslateMessageNamespaces, $wgTranslateRcFilterDefault; + + $request = RequestContext::getMain()->getRequest(); + $translations = $request->getVal( 'translations', $wgTranslateRcFilterDefault ); + $opts->add( 'translations', $wgTranslateRcFilterDefault ); + $opts->setValue( 'translations', $translations ); + + $dbr = wfGetDB( DB_SLAVE ); + + $namespaces = array(); + + foreach ( $wgTranslateMessageNamespaces as $index ) { + $namespaces[] = $index; + $namespaces[] = $index + 1; // Talk too + } + + if ( $translations === 'only' ) { + $conds[] = 'rc_namespace IN (' . $dbr->makeList( $namespaces ) . ')'; + $conds[] = 'rc_title like \'%%/%%\''; + } elseif ( $translations === 'filter' ) { + $conds[] = 'rc_namespace NOT IN (' . $dbr->makeList( $namespaces ) . ')'; + } elseif ( $translations === 'site' ) { + $conds[] = 'rc_namespace IN (' . $dbr->makeList( $namespaces ) . ')'; + $conds[] = 'rc_title not like \'%%/%%\''; + } + + return true; + } + + /** + * Hooks SpecialRecentChangesPanel. See the hook documentation for + * documentation of the function parameters. + * + * Adds a HTMl selector into $items + * @param $items + * @param FormOptions $opts + * @return bool true + */ + public static function translationFilterForm( &$items, $opts ) { + $opts->consumeValue( 'translations' ); + $default = $opts->getValue( 'translations' ); + + $label = Xml::label( + wfMessage( 'translate-rc-translation-filter' )->text(), + 'mw-translation-filter' + ); + $select = new XmlSelect( 'translations', 'mw-translation-filter', $default ); + $select->addOption( + wfMessage( 'translate-rc-translation-filter-no' )->text(), + 'noaction' + ); + $select->addOption( wfMessage( 'translate-rc-translation-filter-only' )->text(), 'only' ); + $select->addOption( + wfMessage( 'translate-rc-translation-filter-filter' )->text(), + 'filter' + ); + $select->addOption( wfMessage( 'translate-rc-translation-filter-site' )->text(), 'site' ); + + $items['translations'] = array( $label, $select->getHTML() ); + + return true; + } +} diff --git a/MLEB/Translate/utils/ResourceLoader.php b/MLEB/Translate/utils/ResourceLoader.php new file mode 100644 index 00000000..9c9e1716 --- /dev/null +++ b/MLEB/Translate/utils/ResourceLoader.php @@ -0,0 +1,29 @@ +<?php +/** + * Stuff for handling configuration files in PHP format. + * @file + * @author Niklas Laxström + * @copyright Copyright © 2010 Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Stuff for handling configuration files in PHP format. + */ +class PHPVariableLoader { + /** + * Returns a global variable from PHP file by executing the file. + * @param $_filename \string Path to the file. + * @param $_variable \string Name of the variable. + * @return \mixed The variable contents or null. + */ + public static function loadVariableFromPHPFile( $_filename, $_variable ) { + if ( !file_exists( $_filename ) ) { + return null; + } else { + require $_filename; + + return isset( $$_variable ) ? $$_variable : null; + } + } +} diff --git a/MLEB/Translate/utils/RevTag.php b/MLEB/Translate/utils/RevTag.php new file mode 100644 index 00000000..c918e578 --- /dev/null +++ b/MLEB/Translate/utils/RevTag.php @@ -0,0 +1,102 @@ +<?php +/** + * Code related to revtag database table + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2011 Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Abstraction for revtag table to handle new and old schemas during migration. + */ +class RevTag { + protected static $schema = false; + + /** + * Determines the schema version. + * + * @return int + */ + public static function checkSchema() { + if ( self::$schema !== false ) { + return self::$schema; + } else { + $dbr = wfGetDB( DB_SLAVE ); + if ( $dbr->tableExists( 'revtag_type' ) ) { + return self::$schema = 1; + } else { + return self::$schema = 2; + } + } + } + + /** + * Returns value suitable for rt_type field. + * @param string $tag Tag name + * @throws MWException + * @return int|string + */ + public static function getType( $tag ) { + if ( self::checkSchema() === 2 ) { + return $tag; + } + + $tags = self::loadTags(); + + if ( isset( $tags[$tag] ) ) { + return $tags[$tag]; + } else { + $text = "Unknown revtag $tag. Known are " . implode( ', ', array_keys( $tags ) ); + throw new MWException( $text ); + } + } + + /** + * Converts rt_type field back to the tag name. + * @param $tag int rt_type value + * @throws MWException + * @return string + */ + public static function typeToTag( $tag ) { + if ( self::checkSchema() === 2 ) { + return $tag; + } + + $tags = self::loadTags(); + $tags = array_flip( $tags ); + + if ( isset( $tags[$tag] ) ) { + return $tags[$tag]; + } else { + $text = "Unknown revtag type $tag. Known are " . implode( ', ', array_keys( $tags ) ); + throw new MWException( $text ); + } + } + + /** + * Loads the list of tags from database using the old schema + * @return array tag names => tag id + */ + protected static function loadTags() { + static $tags = null; + if ( $tags === null ) { + $tags = array(); + + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( + 'revtag_type', + array( 'rtt_name', 'rtt_id' ), + array(), + __METHOD__ + ); + + foreach ( $res as $row ) { + $tags[$row->rtt_name] = $row->rtt_id; + } + } + + return $tags; + } +} diff --git a/MLEB/Translate/utils/StatsBar.php b/MLEB/Translate/utils/StatsBar.php new file mode 100644 index 00000000..39bd66a0 --- /dev/null +++ b/MLEB/Translate/utils/StatsBar.php @@ -0,0 +1,88 @@ +<?php +/** + * Compact stats. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2012-2013 Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Compact, colorful stats. + * @since 2012-11-30 + */ +class StatsBar { + /** + * @see MessageGroupStats + * @var array + */ + protected $stats; + + /// @var string Message group id + protected $group; + + /// @var string Language + protected $language; + + public static function getNew( $group, $language, array $stats = null ) { + $self = new self(); + $self->group = $group; + $self->language = $language; + + if ( is_array( $stats ) ) { + $self->stats = $stats; + } else { + $self->stats = MessageGroupStats::forItem( $group, $language ); + } + + return $self; + } + + public function getHtml( IContextSource $context ) { + $context->getOutput()->addModules( 'ext.translate.statsbar' ); + + $total = $this->stats[MessageGroupStats::TOTAL]; + $proofread = $this->stats[MessageGroupStats::PROOFREAD]; + $translated = $this->stats[MessageGroupStats::TRANSLATED]; + $fuzzy = $this->stats[MessageGroupStats::FUZZY]; + + if ( !$total ) { + $untranslated = null; + $wproofread = $wtranslated = $wfuzzy = $wuntranslated = 0; + } else { + // Proofread is subset of translated + $untranslated = $total - $translated - $fuzzy; + + $wproofread = round( 100 * $proofread / $total, 2 ); + $wtranslated = round( 100 * ( $translated - $proofread ) / $total, 2 ); + $wfuzzy = round( 100 * $fuzzy / $total, 2 ); + $wuntranslated = round( 100 - $wproofread - $wtranslated - $wfuzzy, 2 ); + } + + return Html::rawElement( 'div', array( + 'class' => 'tux-statsbar', + 'data-total' => $total, + 'data-group' => $this->group, + 'data-language' => $this->language, + ), + Html::element( 'span', array( + 'class' => 'tux-proofread', + 'style' => "width: $wproofread%", + 'data-proofread' => $proofread, + ) ) . Html::element( 'span', array( + 'class' => 'tux-translated', + 'style' => "width: $wtranslated%", + 'data-translated' => $translated, + ) ) . Html::element( 'span', array( + 'class' => 'tux-fuzzy', + 'style' => "width: $wfuzzy%", + 'data-fuzzy' => $fuzzy, + ) ) . Html::element( 'span', array( + 'class' => 'tux-untranslated', + 'style' => "width: $wuntranslated%", + 'data-untranslated' => $untranslated, + ) ) + ); + } +} diff --git a/MLEB/Translate/utils/StatsTable.php b/MLEB/Translate/utils/StatsTable.php new file mode 100644 index 00000000..52fd4cae --- /dev/null +++ b/MLEB/Translate/utils/StatsTable.php @@ -0,0 +1,344 @@ +<?php +/** + * Contains logic for special page Special:LanguageStats. + * + * @file + * @author Siebrand Mazeland + * @author Niklas Laxström + * @copyright Copyright © 2008-2013 Siebrand Mazeland, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * 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 suppres completely + * translated groups. + * + * @ingroup Stats + */ +class StatsTable { + /// @var Language + protected $lang; + /// @var SpecialPage + protected $translate; + /// @var string + protected $mainColumnHeader; + /// @var array + protected $extraColumns = array(); + + public function __construct() { + $this->lang = RequestContext::getMain()->getLanguage(); + $this->translate = SpecialPage::getTitleFor( 'Translate' ); + } + + /** + * Statistics table element (heading or regular cell) + * + * @param $in \string Element contents. + * @param $bgcolor \string Backround color in ABABAB format. + * @param $sort \string Value used for sorting. + * @return \string Html td element. + */ + public function element( $in, $bgcolor = '', $sort = '' ) { + $attributes = array(); + + if ( $sort ) { + $attributes['data-sort-value'] = $sort; + } + + if ( $bgcolor ) { + $attributes['style'] = "background-color: #" . $bgcolor; + $attributes['class'] = 'hover-color'; + } + + $element = Html::element( 'td', $attributes, $in ); + + return $element; + } + + public function getBackgroundColor( $subset, $total, $fuzzy = false ) { + wfSuppressWarnings(); + $v = round( 255 * $subset / $total ); + wfRestoreWarnings(); + + if ( $fuzzy ) { + // Weigh fuzzy with factor 20. + $v = $v * 20; + + if ( $v > 255 ) { + $v = 255; + } + + $v = 255 - $v; + } + + if ( $v < 128 ) { + // Red to Yellow + $red = 'FF'; + $green = sprintf( '%02X', 2 * $v ); + } else { + // Yellow to Green + $red = sprintf( '%02X', 2 * ( 255 - $v ) ); + $green = 'FF'; + } + $blue = '00'; + + return $red . $green . $blue; + } + + public function getMainColumnHeader() { + return $this->mainColumnHeader; + } + + public function setMainColumnHeader( Message $msg ) { + $this->mainColumnHeader = $this->createColumnHeader( $msg ); + } + + public function createColumnHeader( Message $msg ) { + return Html::element( 'th', array(), $msg->text() ); + } + + public function addExtraColumn( Message $column ) { + $this->extraColumns[] = $column; + } + + public function getOtherColumnHeaders() { + return array_merge( array( + wfMessage( 'translate-total' ), + wfMessage( 'translate-untranslated' ), + wfMessage( 'translate-percentage-complete' ), + wfMessage( 'translate-percentage-fuzzy' ), + ), $this->extraColumns ); + } + + public function createHeader() { + // Create table header + $out = Html::openElement( + 'table', + array( 'class' => "statstable wikitable mw-sp-translate-table" ) + ); + + $out .= "\n\t" . Html::openElement( 'thead' ); + $out .= "\n\t" . Html::openElement( 'tr' ); + + $out .= "\n\t\t" . $this->getMainColumnHeader(); + foreach ( $this->getOtherColumnHeaders() as $label ) { + $out .= "\n\t\t" . $this->createColumnHeader( $label ); + } + $out .= "\n\t" . Html::closeElement( 'tr' ); + $out .= "\n\t" . Html::closeElement( 'thead' ); + $out .= "\n\t" . Html::openElement( 'tbody' ); + + return $out; + } + + /** + * Makes a row with aggregate numbers. + * @param Message $message + * @param array $stats ( total, translate, fuzzy ) + * @return string Html + */ + public function makeTotalRow( Message $message, $stats ) { + $out = "\t" . Html::openElement( 'tr' ); + $out .= "\n\t\t" . Html::element( 'td', array(), $message->text() ); + $out .= $this->makeNumberColumns( $stats ); + $out .= "\n\t" . Xml::closeElement( 'tr' ) . "\n"; + + return $out; + } + + /** + * Makes partial row from completion numbers + * @param array $stats + * @return string Html + */ + public function makeNumberColumns( $stats ) { + $total = $stats[MessageGroupStats::TOTAL]; + $translated = $stats[MessageGroupStats::TRANSLATED]; + $fuzzy = $stats[MessageGroupStats::FUZZY]; + + if ( $total === null ) { + $na = "\n\t\t" . Html::element( 'td', array( 'data-sort-value' => -1 ), '...' ); + $nap = "\n\t\t" . $this->element( '...', 'AFAFAF', -1 ); + $out = $na . $na . $nap . $nap; + + return $out; + } + + $out = "\n\t\t" . Html::element( 'td', + array( 'data-sort-value' => $total ), + $this->lang->formatNum( $total ) ); + + $out .= "\n\t\t" . Html::element( 'td', + array( 'data-sort-value' => $total - $translated ), + $this->lang->formatNum( $total - $translated ) ); + + if ( $total === 0 ) { + $transRatio = 0; + $fuzzyRatio = 0; + } else { + $transRatio = $translated / $total; + $fuzzyRatio = $fuzzy / $total; + } + + $out .= "\n\t\t" . $this->element( $this->formatPercentage( $transRatio, 'floor' ), + $this->getBackgroundColor( $translated, $total ), + sprintf( '%1.5f', $transRatio ) ); + + $out .= "\n\t\t" . $this->element( $this->formatPercentage( $fuzzyRatio, 'ceil' ), + $this->getBackgroundColor( $fuzzy, $total, true ), + sprintf( '%1.5f', $fuzzyRatio ) ); + + return $out; + } + + /** + * Makes a nice print from plain float. + * @param $num float + * @param $to string floor or ceil + * @return string Plain text + */ + public function formatPercentage( $num, $to = 'floor' ) { + $num = $to === 'floor' ? floor( 100 * $num ) : ceil( 100 * $num ); + $fmt = $this->lang->formatNum( $num ); + + return wfMessage( 'percent', $fmt )->text(); + } + + /** + * Gets the name of group with some extra formatting. + * @param $group MessageGroup + * @return string Html + */ + public function getGroupLabel( MessageGroup $group ) { + $groupLabel = htmlspecialchars( $group->getLabel() ); + + // Bold for meta groups. + if ( $group->isMeta() ) { + $groupLabel = Html::rawElement( 'b', array(), $groupLabel ); + } + + return $groupLabel; + } + + /** + * Gets the name of group linked to translation tool. + * @param $group MessageGroup + * @param $code string Language code + * @param $params array Any extra query parameters. + * @return string Html + */ + public function makeGroupLink( MessageGroup $group, $code, $params ) { + $queryParameters = $params + array( + 'group' => $group->getId(), + 'language' => $code + ); + + $attributes = array( + 'title' => $this->getGroupDescription( $group ) + ); + + $translateGroupLink = Linker::link( + $this->translate, $this->getGroupLabel( $group ), $attributes, $queryParameters + ); + + return $translateGroupLink; + } + + /** + * Gets the description of a group. This is a bit slow thing to do for + * thousand+ groups, so some caching is involved. + * @param $group MessageGroup + * @return string Plain text + */ + public function getGroupDescription( MessageGroup $group ) { + $code = $this->lang->getCode(); + + $cache = wfGetCache( CACHE_ANYTHING ); + $key = wfMemckey( "translate-groupdesc-$code-" . $group->getId() ); + $desc = $cache->get( $key ); + + if ( is_string( $desc ) ) { + return $desc; + } + + $realFunction = array( 'MessageCache', 'singleton' ); + + if ( is_callable( $realFunction ) ) { + $mc = MessageCache::singleton(); + } else { + global $wgMessageCache; + + $mc = $wgMessageCache; + } + + $desc = $mc->transform( $group->getDescription(), true, $this->lang ); + $cache->set( $key, $desc ); + + return $desc; + } + + /** + * Check whether translations in given group in given language + * has been disabled. + * @param $groupId string Message group id + * @param $code string Language code + * @return bool + */ + public function isBlacklisted( $groupId, $code ) { + global $wgTranslateBlacklist; + + $blacklisted = null; + + $checks = array( + $groupId, + strtok( $groupId, '-' ), + '*' + ); + + foreach ( $checks as $check ) { + if ( isset( $wgTranslateBlacklist[$check] ) && isset( $wgTranslateBlacklist[$check][$code] ) ) { + $blacklisted = $wgTranslateBlacklist[$check][$code]; + } + + if ( $blacklisted !== null ) { + break; + } + } + + $group = MessageGroups::getGroup( $groupId ); + $languages = $group->getTranslatableLanguages(); + if ( $languages !== null && !isset( $languages[$code] ) ) { + $blacklisted = true; + } + + $include = wfRunHooks( 'Translate:MessageGroupStats:isIncluded', array( $groupId, $code ) ); + if ( !$include ) { + $blacklisted = true; + } + + return $blacklisted; + } + + /** + * Used to circumvent ugly tooltips when newlines are used in the + * message content ("x\ny" becomes "x y"). + * @param $text + * @return string + */ + public static function formatTooltip( $text ) { + $wordSeparator = wfMessage( 'word-separator' )->text(); + + $text = strtr( $text, array( + "\n" => $wordSeparator, + "\r" => $wordSeparator, + "\t" => $wordSeparator, + ) ); + + return $text; + } +} diff --git a/MLEB/Translate/utils/ToolBox.php b/MLEB/Translate/utils/ToolBox.php new file mode 100644 index 00000000..ba04013e --- /dev/null +++ b/MLEB/Translate/utils/ToolBox.php @@ -0,0 +1,41 @@ +<?php +/** + * Classes for adding extension specific toolbox menu items. + * + * @file + * @author Siebrand Mazeland + * @author Niklas Laxström + * @copyright Copyright © 2008-2010, Siebrand Mazeland, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Adds extension specific context aware toolbox menu items. + */ +class TranslateToolbox { + /** + * Adds link in toolbox to Special:Prefixindex to show all other + * available translations for a message. Only shown when it + * actually is a translatable/translated message. + * + * @param $quickTemplate QuickTemplate + * + * @return bool + */ + static function toolboxAllTranslations( &$quickTemplate ) { + $title = $quickTemplate->getSkin()->getTitle(); + $handle = new MessageHandle( $title ); + if ( $handle->isValid() ) { + $message = $title->getNsText() . ':' . $handle->getKey(); + $desc = wfMessage( 'translate-sidebar-alltrans' )->text(); + $url = htmlspecialchars( SpecialPage::getTitleFor( 'Translations' ) + ->getLocalURL( array ('message' => $message ) ) ); + + // Add the actual toolbox entry. + // Add newlines and tabs for nicer HTML output. + echo "\n\t\t\t\t<li id=\"t-alltrans\"><a href=\"$url\">$desc</a></li>\n"; + } + + return true; + } +} diff --git a/MLEB/Translate/utils/TranslateLogFormatter.php b/MLEB/Translate/utils/TranslateLogFormatter.php new file mode 100644 index 00000000..019d6c5e --- /dev/null +++ b/MLEB/Translate/utils/TranslateLogFormatter.php @@ -0,0 +1,79 @@ +<?php +/** + * Class for formatting Translate logs. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2013, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Class for formatting Translate logs. + */ +class TranslateLogFormatter extends LogFormatter { + + public function getMessageParameters() { + $params = parent::getMessageParameters(); + + $type = $this->entry->getFullType(); + + if ( $type === 'translationreview/message' ) { + $targetPage = $this->makePageLink( + $this->entry->getTarget(), + array( 'oldid' => $params[3] ) + ); + + $params[2] = Message::rawParam( $targetPage ); + } elseif ( $type === 'translationreview/group' ) { + /* + * - 3: language code + * - 4: label of the message group + * - 5: old state + * - 6: new state + */ + + $uiLanguage = $this->context->getLanguage(); + $language = $params[3]; + + $targetPage = $this->makePageLinkWithText( + $this->entry->getTarget(), + $params[4], + array( 'language' => $language ) + ); + + $params[2] = Message::rawParam( $targetPage ); + $params[3] = TranslateUtils::getLanguageName( $language, $uiLanguage->getCode() ); + $params[5] = $this->formatStateMessage( $params[5] ); + $params[6] = $this->formatStateMessage( $params[6] ); + } elseif ( $type === 'translatorsandbox/rejected' ) { + // No point linking to the user page which cannot have existed + $params[2] = $this->entry->getTarget()->getText(); + } elseif ( $type === 'translatorsandbox/promoted' ) { + // Gender for the target + $params[3] = User::newFromId( $params[3] )->getName(); + } + + return $params; + } + + protected function formatStateMessage( $value ) { + $message = $this->msg( "translate-workflow-state-$value" ); + + return $message->isBlank() ? $value : $message->text(); + } + + protected function makePageLinkWithText( Title $title = null, $text, $parameters = array() ) { + if ( !$this->plaintext ) { + $link = Linker::link( $title, htmlspecialchars( $text ), array(), $parameters ); + } else { + $target = '***'; + if ( $title instanceof Title ) { + $target = $title->getPrefixedText(); + } + $link = "[[$target|$text]]"; + } + + return $link; + } +} diff --git a/MLEB/Translate/utils/TranslateMetadata.php b/MLEB/Translate/utils/TranslateMetadata.php new file mode 100644 index 00000000..540128ce --- /dev/null +++ b/MLEB/Translate/utils/TranslateMetadata.php @@ -0,0 +1,110 @@ +<?php +/** + * Contains class which offers functionality for reading and updating Translate group + * related metadata + * + * @file + * @author Niklas Laxström + * @author Santhosh Thottingal + * @copyright Copyright © 2012-2013, Niklas Laxström, Santhosh Thottingal + * @license GPL-2.0+ + */ + +class TranslateMetadata { + protected static $cache = null; + + /** + * Get a metadata value for the given group and key. + * @param $group string The group name + * @param $key string Metadata key + * @return String + */ + public static function get( $group, $key ) { + if ( self::$cache === null ) { + $dbr = wfGetDB( DB_SLAVE ); + $res = $dbr->select( 'translate_metadata', '*', array(), __METHOD__ ); + foreach ( $res as $row ) { + self::$cache[$row->tmd_group][$row->tmd_key] = $row->tmd_value; + } + } + + if ( isset( self::$cache[$group][$key] ) ) { + return self::$cache[$group][$key]; + } + + return false; + } + + /** + * Set a metadata value for the given group and metadata key. Updates the + * value if already existing. + * @param $group string The group id + * @param $key string Metadata key + * @param $value string Metadata value + */ + public static function set( $group, $key, $value ) { + $dbw = wfGetDB( DB_MASTER ); + $data = array( 'tmd_group' => $group, 'tmd_key' => $key, 'tmd_value' => $value ); + if ( $value === false ) { + unset( $data['tmd_value'] ); + $dbw->delete( 'translate_metadata', $data ); + } else { + $dbw->replace( + 'translate_metadata', + array( array( 'tmd_group', 'tmd_key' ) ), + $data, + __METHOD__ + ); + } + + self::$cache = null; + } + + /** + * Wrapper for getting subgroups. + * @param string $groupId + * @return array|String + * @since 2012-05-09 + * return array|false + */ + public static function getSubgroups( $groupId ) { + $groups = self::get( $groupId, 'subgroups' ); + if ( $groups !== false ) { + if ( strpos( $groups, '|' ) !== false ) { + $groups = explode( '|', $groups ); + } else { + $groups = array_map( 'trim', explode( ',', $groups ) ); + } + + foreach ( $groups as $index => $id ) { + if ( trim( $id ) === '' ) { + unset( $groups[$index] ); + } + } + } + + return $groups; + } + + /** + * Wrapper for setting subgroups. + * @param string $groupId + * @param array $subgroupIds + * @since 2012-05-09 + */ + public static function setSubgroups( $groupId, $subgroupIds ) { + $subgroups = implode( '|', $subgroupIds ); + self::set( $groupId, 'subgroups', $subgroups ); + } + + /** + * Wrapper for deleting one wiki aggregate group at once. + * @param string $groupId + * @since 2012-05-09 + */ + public static function deleteGroup( $groupId ) { + $dbw = wfGetDB( DB_MASTER ); + $conds = array( 'tmd_group' => $groupId ); + $dbw->delete( 'translate_metadata', $conds ); + } +} diff --git a/MLEB/Translate/utils/TranslateSandbox.php b/MLEB/Translate/utils/TranslateSandbox.php new file mode 100644 index 00000000..12d3c0d6 --- /dev/null +++ b/MLEB/Translate/utils/TranslateSandbox.php @@ -0,0 +1,255 @@ +<?php +/** + * Utilities for the sandbox feature of Translate. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Utility class for the sandbox feature of Translate. + */ +class TranslateSandbox { + /** + * Adds a new user without doing much validation. + * @param string $name User name. + * @param string $email Email address. + * @param string $password User provided password. + * @return User + * @throws MWException + */ + public static function addUser( $name, $email, $password ) { + $user = User::newFromName( $name, 'creatable' ); + if ( !$user instanceof User ) { + throw new MWException( "Invalid user name" ); + } + + $user->setEmail( $email ); + $user->setPassword( $password ); + $status = $user->addToDatabase(); + + if ( !$status->isOK() ) { + throw new MWException( $status->getWikiText() ); + } + + // Need to have an id first + $user->addGroup( 'translate-sandboxed' ); + $user->clearInstanceCache( 'name' ); + $user->sendConfirmationMail(); + + return $user; + } + + /** + * Deletes a sandboxed user without doing much validation. + * + * @param User $user + * @param string $force If set to 'force' will skip the little validation we have. + * @throws MWException + */ + public static function deleteUser( User $user, $force = '' ) { + $uid = $user->getId(); + + if ( $force !== 'force' && !self::isSandboxed( $user ) ) { + throw new MWException( "Not a sandboxed user" ); + } + + // Delete from database + $dbw = wfGetDB( DB_MASTER ); + $dbw->delete( 'user', array( 'user_id' => $uid ), __METHOD__ ); + $dbw->delete( 'user_groups', array( 'ug_user' => $uid ), __METHOD__ ); + + // If someone tries to access still object still, they will get anon user + // data. + $user->clearInstanceCache( 'defaults' ); + + // Nobody should access the user by id anymore, but in case they do, purge + // the cache so they wont get stale data + // @todo why the bunny is this private?! + // $user->clearSharedCache(); + global $wgMemc; + $wgMemc->delete( wfMemcKey( 'user', 'id', $uid ) ); + + // In case we create an user with same name as was deleted during the same + // request, we must also reset this cache or the User class will try to load + // stuff for the old id, which is no longer present since we just deleted + // the cache above. But it would have the side effect or overwriting all + // member variables with null data. This used to manifest as a bug where + // inserting a new user fails because the mName properpty is set to null, + // which is then converted as the ip of the current user, and trying to + // add that twice results in a name conflict. It was fun to debug. + User::resetIdByNameCache(); + } + + /** + * Get all sandboxed users. + * @return UserArray List of users. + */ + public static function getUsers() { + $dbw = wfGetDB( DB_MASTER ); + $tables = array( 'user', 'user_groups' ); + $fields = User::selectFields(); + $conds = array( + 'ug_group' => 'translate-sandboxed', + 'ug_user = user_id', + ); + + $res = $dbw->select( $tables, $fields, $conds, __METHOD__ ); + + return UserArray::newFromResult( $res ); + } + + /** + * Removes the user from the sandbox. + * @param User $user + * @throws MWException + */ + public static function promoteUser( User $user ) { + global $wgTranslateSandboxPromotedGroup; + + if ( !self::isSandboxed( $user ) ) { + throw new MWException( "Not a sandboxed user" ); + } + + $user->removeGroup( 'translate-sandboxed' ); + if ( $wgTranslateSandboxPromotedGroup ) { + $user->addGroup( $wgTranslateSandboxPromotedGroup ); + } + + $user->setOption( 'translate-sandbox-reminders', '' ); + $user->saveSettings(); + } + + /** + * Sends a reminder to the user. + * @param User $sender + * @param User $target + * @param string $type 'reminder' or 'promotion' + * @throws MWException + * @since 2013.12 + */ + public static function sendEmail( User $sender, User $target, $type ) { + global $wgNoReplyAddress; + + $targetLang = $target->getOption( 'language' ); + + switch ( $type ) { + case 'reminder': + if ( !self::isSandboxed( $target ) ) { + throw new MWException( 'Not a sandboxed user' ); + } + + $subjectMsg = 'tsb-reminder-title-generic'; + $bodyMsg = 'tsb-reminder-content-generic'; + $targetSpecialPage = 'TranslationStash'; + + break; + case 'promotion': + $subjectMsg = 'tsb-email-promoted-subject'; + $bodyMsg = 'tsb-email-promoted-body'; + $targetSpecialPage = 'Translate'; + + break; + case 'rejection': + $subjectMsg = 'tsb-email-rejected-subject'; + $bodyMsg = 'tsb-email-rejected-body'; + $targetSpecialPage = 'TwnMainPage'; + + break; + default: + throw new MWException( "'$type' is an invalid type of translate sandbox email" ); + } + + $subject = wfMessage( $subjectMsg )->inLanguage( $targetLang )->text(); + $body = wfMessage( + $bodyMsg, + $target->getName(), + SpecialPage::getTitleFor( $targetSpecialPage )->getCanonicalUrl(), + $sender->getName() + )->inLanguage( $targetLang )->text(); + + $params = array( + 'user' => $target->getId(), + 'to' => new MailAddress( $target ), + 'from' => new MailAddress( $sender ), + 'replyto' => new MailAddress( $wgNoReplyAddress ), + 'subj' => $subject, + 'body' => $body, + 'emailType' => $type, + ); + + JobQueueGroup::singleton()->push( TranslateSandboxEmailJob::newJob( $params ) ); + } + + /** + * Shortcut for checking if given user is in the sandbox. + * @param User $user + * @return bool + * @since 2013.06 + */ + public static function isSandboxed( User $user ) { + if ( in_array( 'translate-sandboxed', $user->getGroups(), true ) ) { + return true; + } + + return false; + } + + /// Hook: UserGetRights + public static function enforcePermissions( User $user, array &$rights ) { + global $wgTranslateUseSandbox; + + if ( !$wgTranslateUseSandbox ) { + return true; + } + + if ( !self::isSandboxed( $user ) ) { + return true; + } + + $rights = array( + 'editmyoptions', + 'editmyprivateinfo', + 'read', + 'readapi', + 'translate-sandboxaction', + 'viewmyprivateinfo', + 'writeapi', + ); + + // Do not let other hooks add more actions + return false; + } + + /// Hook: onGetPreferences + public static function onGetPreferences( $user, &$preferences ) { + $preferences['translate-sandbox'] = $preferences['translate-sandbox-reminders'] = + array( 'type' => 'api' ); + + return true; + } + + /** + * Whitelisting for certain API modules. See also enforcePermissions. + * Hook: ApiCheckCanExecute + */ + public static function onApiCheckCanExecute( ApiBase $module, User $user, &$message ) { + $whitelist = array( + // Obviously this is needed to get out of the sandbox + 'ApiTranslationStash', + // Used by UniversalLanguageSelector for example + 'ApiOptions' + ); + + if ( TranslateSandbox::isSandboxed( $user ) ) { + $class = get_class( $module ); + if ( $module->isWriteMode() && !in_array( $class, $whitelist, true ) ) { + $message = 'writerequired'; + return false; + } + } + + return true; + } +} diff --git a/MLEB/Translate/utils/TranslateSandboxEmailJob.php b/MLEB/Translate/utils/TranslateSandboxEmailJob.php new file mode 100644 index 00000000..1496bfda --- /dev/null +++ b/MLEB/Translate/utils/TranslateSandboxEmailJob.php @@ -0,0 +1,38 @@ +<?php + +class TranslateSandboxEmailJob extends Job { + public static function newJob( array $params ) { + return new self( Title::newMainPage(), $params ); + } + + function __construct( $title, $params, $id = 0 ) { + parent::__construct( __CLASS__, $title, $params, $id ); + } + + function run() { + $status = UserMailer::send( + $this->params['to'], + $this->params['from'], + $this->params['subj'], + $this->params['body'], + $this->params['replyto'] + ); + + $isOK = $status->isOK(); + + if ( $isOK && $this->params['emailType'] === 'reminder' ) { + $user = User::newFromId( $this->params['user'] ); + + $reminders = $user->getOption( 'translate-sandbox-reminders' ); + $reminders = $reminders ? explode( '|', $reminders ) : array(); + $reminders[] = wfTimestamp(); + $user->setOption( 'translate-sandbox-reminders', implode( '|', $reminders ) ); + + $reminders = $user->getOption( 'translate-sandbox-reminders' ); + $user->setOption( 'translate-sandbox-reminders', $reminders ); + $user->saveSettings(); + } + + return $isOK; + } +} diff --git a/MLEB/Translate/utils/TranslateYaml.php b/MLEB/Translate/utils/TranslateYaml.php new file mode 100644 index 00000000..d3ae3630 --- /dev/null +++ b/MLEB/Translate/utils/TranslateYaml.php @@ -0,0 +1,224 @@ +<?php +/** + * Contains wrapper class for interface to parse and generate YAML files. + * + * @file + * @author Ævar Arnfjörð Bjarmason + * @author Niklas Laxström + * @copyright Copyright © 2009-2013, Niklas Laxström, Ævar Arnfjörð Bjarmason + * @license GPL-2.0+ + */ + +/** + * This class is a wrapper class to provide interface to parse + * and generate YAML files with syck or spyc backend. + */ +class TranslateYaml { + /** + * @param $filename string + * @return array + */ + public static function parseGroupFile( $filename ) { + $data = file_get_contents( $filename ); + $documents = preg_split( "/^---$/m", $data, -1, PREG_SPLIT_NO_EMPTY ); + $groups = array(); + $template = false; + foreach ( $documents as $document ) { + $document = self::loadString( $document ); + + if ( isset( $document['TEMPLATE'] ) ) { + $template = $document['TEMPLATE']; + } else { + if ( !isset( $document['BASIC']['id'] ) ) { + $error = "No path ./BASIC/id (group id not defined) "; + $error .= "in YAML document located in $filename"; + trigger_error( $error ); + continue; + } + $groups[$document['BASIC']['id']] = $document; + } + } + + foreach ( $groups as $i => $group ) { + $groups[$i] = self::mergeTemplate( $template, $group ); + } + + return $groups; + } + + /** + * Merges a document template (base) to actual definition (specific) + * @param $base + * @param $specific + * @return array + */ + public static function mergeTemplate( $base, $specific ) { + foreach ( $specific as $key => $value ) { + if ( is_array( $value ) && isset( $base[$key] ) && is_array( $base[$key] ) ) { + $base[$key] = self::mergeTemplate( $base[$key], $value ); + } else { + $base[$key] = $value; + } + } + + return $base; + } + + /** + * @param $text string + * @return array + * @throws MWException + */ + public static function loadString( $text ) { + global $wgTranslateYamlLibrary; + + switch ( $wgTranslateYamlLibrary ) { + case 'phpyaml': + return yaml_parse( $text ); + + case 'spyc': + // Load the bundled version if not otherwise available + if ( !class_exists( 'Spyc' ) ) { + require_once __DIR__ . '/../libs/spyc/spyc.php'; + } + $yaml = spyc_load( $text ); + + return self::fixSpycSpaces( $yaml ); + case 'syck': + $yaml = self::syckLoad( $text ); + + return self::fixSyckBooleans( $yaml ); + default: + throw new MWException( "Unknown Yaml library" ); + } + } + + /** + * @param $yaml array + * @return array + */ + public static function fixSyckBooleans( &$yaml ) { + foreach ( $yaml as &$value ) { + if ( is_array( $value ) ) { + self::fixSyckBooleans( $value ); + } elseif ( $value === 'yes' ) { + $value = true; + } + } + + return $yaml; + } + + /** + * @param $yaml array + * @return array + */ + public static function fixSpycSpaces( &$yaml ) { + foreach ( $yaml as $key => &$value ) { + if ( is_array( $value ) ) { + self::fixSpycSpaces( $value ); + } elseif ( is_string( $value ) && $key === 'header' ) { + $value = preg_replace( '~^\*~m', ' *', $value ) . "\n"; + } + } + + return $yaml; + } + + public static function load( $file ) { + $text = file_get_contents( $file ); + + return self::loadString( $text ); + } + + public static function dump( $text ) { + global $wgTranslateYamlLibrary; + + switch ( $wgTranslateYamlLibrary ) { + case 'phpyaml': + return yaml_emit( $text, YAML_UTF8_ENCODING ); + + case 'spyc': + require_once __DIR__ . '/../libs/spyc/spyc.php'; + + return Spyc::YAMLDump( $text ); + case 'syck': + return self::syckDump( $text ); + default: + throw new MWException( "Unknown Yaml library" ); + } + } + + protected static function syckLoad( $data ) { + # Make temporary file + $td = wfTempDir(); + $tf = tempnam( $td, 'yaml-load-' ); + + # Write to file + file_put_contents( $tf, $data ); + + $cmd = "perl -MYAML::Syck=LoadFile -MPHP::Serialization=serialize -wle '" . + 'my $tf = q[' . $tf . '];' . + 'my $yaml = LoadFile($tf);' . + 'open my $fh, ">", "$tf.serialized" or die qq[Can not open "$tf.serialized"];' . + 'print $fh serialize($yaml);' . + 'close($fh);' . + "' 2>&1"; + + $out = wfShellExec( $cmd, $ret ); + + if ( $ret != 0 ) { + throw new MWException( "The command '$cmd' died in execution with exit code '$ret': $out" ); + } + + $serialized = file_get_contents( "$tf.serialized" ); + $php_data = unserialize( $serialized ); + + unlink( $tf ); + unlink( "$tf.serialized" ); + + return $php_data; + } + + protected static function syckDump( $data ) { + # Make temporary file + $td = wfTempDir(); + $tf = tempnam( $td, 'yaml-load-' ); + + # Write to file + $sdata = serialize( $data ); + file_put_contents( $tf, $sdata ); + + $cmd = "perl -MYAML::Syck=DumpFile -MPHP::Serialization=unserialize -MFile::Slurp=slurp -we '" . + '$YAML::Syck::Headless = 1;' . + '$YAML::Syck::SortKeys = 1;' . + 'my $tf = q[' . $tf . '];' . + 'my $serialized = slurp($tf);' . + 'my $unserialized = unserialize($serialized);' . + 'my $unserialized_utf8 = deutf8($unserialized);' . + 'DumpFile(qq[$tf.yaml], $unserialized_utf8);' . + 'sub deutf8 {' . + 'if(ref($_[0]) eq "HASH") {' . + 'return { map { deutf8($_) } %{$_[0]} };' . + '} elsif(ref($_[0]) eq "ARRAY") {' . + 'return [ map { deutf8($_) } @{$_[0]} ];' . + '} else {' . + 'my $s = $_[0];' . + 'utf8::decode($s);' . + 'return $s;' . + '}' . + '}' . + "' 2>&1"; + $out = wfShellExec( $cmd, $ret ); + if ( $ret != 0 ) { + throw new MWException( "The command '$cmd' died in execution with exit code '$ret': $out" ); + } + + $yaml = file_get_contents( "$tf.yaml" ); + + unlink( $tf ); + unlink( "$tf.yaml" ); + + return $yaml; + } +} diff --git a/MLEB/Translate/utils/TranslationEditPage.php b/MLEB/Translate/utils/TranslationEditPage.php new file mode 100644 index 00000000..ffcbd8ca --- /dev/null +++ b/MLEB/Translate/utils/TranslationEditPage.php @@ -0,0 +1,307 @@ +<?php +/** + * Contains classes that imeplement the server side component of AJAX + * translation page. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * This class together with some JavaScript implements the AJAX translation + * page. + */ +class TranslationEditPage { + // Instance of an Title object + protected $title; + protected $suggestions = 'sync'; + + /** + * Constructor. + * @param $title Title A title object + */ + public function __construct( Title $title ) { + $this->setTitle( $title ); + } + + /** + * Constructs a page from WebRequest. + * This interface is a big klunky. + * @param $request WebRequest + * @return TranslationEditPage + */ + public static function newFromRequest( WebRequest $request ) { + $title = Title::newFromText( $request->getText( 'page' ) ); + + if ( !$title ) { + return null; + } + + $obj = new self( $title ); + $obj->suggestions = $request->getText( 'suggestions' ); + + return $obj; + } + + /** + * Change the title of the page we are working on. + * @param $title Title + */ + public function setTitle( Title $title ) { + $this->title = $title; + } + + /** + * Get the title of the page we are working on. + * @return Title + */ + public function getTitle() { + return $this->title; + } + + /** + * Generates the html snippet for ajax edit. Echoes it to the output and + * disabled all other output. + */ + public function execute() { + global $wgServer, $wgScriptPath; + + $context = RequestContext::getMain(); + + $context->getOutput()->disable(); + + $data = $this->getEditInfo(); + $helpers = new TranslationHelpers( $this->getTitle(), '' ); + + $id = "tm-target-{$helpers->dialogID()}"; + $helpers->setTextareaId( $id ); + + if ( $this->suggestions === 'only' ) { + echo $helpers->getBoxes( $this->suggestions ); + + return; + } + + if ( $this->suggestions === 'checks' ) { + echo $helpers->getBoxes( $this->suggestions ); + + return; + } + + $handle = new MessageHandle( $this->getTitle() ); + $groupId = MessageIndex::getPrimaryGroupId( $handle ); + + $translation = ''; + if ( $groupId ) { + $translation = $helpers->getTranslation(); + } + + $targetLang = Language::factory( $helpers->getTargetLanguage() ); + $textareaParams = array( + 'name' => 'text', + 'class' => 'mw-translate-edit-area', + 'id' => $id, + /* Target language might differ from interface language. Set + * a suitable default direction */ + 'lang' => $targetLang->getCode(), + 'dir' => $targetLang->getDir(), + ); + + if ( !$groupId || !$context->getUser()->isAllowed( 'translate' ) ) { + $textareaParams['readonly'] = 'readonly'; + } + + $extraInputs = ''; + wfRunHooks( 'TranslateGetExtraInputs', array( &$translation, &$extraInputs ) ); + + $textarea = Html::element( 'textarea', $textareaParams, $translation ); + + $hidden = array(); + $hidden[] = Html::hidden( 'title', $this->getTitle()->getPrefixedDbKey() ); + + if ( isset( $data['revisions'][0]['timestamp'] ) ) { + $hidden[] = Html::hidden( 'basetimestamp', $data['revisions'][0]['timestamp'] ); + } + + $hidden[] = Html::hidden( 'starttimestamp', $data['starttimestamp'] ); + if ( isset( $data['edittoken'] ) ) { + $hidden[] = Html::hidden( 'token', $data['edittoken'] ); + } + $hidden[] = Html::hidden( 'format', 'json' ); + $hidden[] = Html::hidden( 'action', 'edit' ); + + $summary = Xml::inputLabel( + $context->msg( 'translate-js-summary' )->text(), + 'summary', + 'summary', + 40 + ); + $save = Xml::submitButton( + $context->msg( 'translate-js-save' )->text(), + array( 'class' => 'mw-translate-save' ) + ); + $saveAndNext = Xml::submitButton( + $context->msg( 'translate-js-next' )->text(), + array( 'class' => 'mw-translate-next' ) + ); + $skip = Html::element( 'input', array( + 'class' => 'mw-translate-skip', + 'type' => 'button', + 'value' => $context->msg( 'translate-js-skip' )->text() + ) ); + + if ( $this->getTitle()->exists() ) { + $history = Html::element( + 'input', + array( + 'class' => 'mw-translate-history', + 'type' => 'button', + 'value' => $context->msg( 'translate-js-history' )->text() + ) + ); + } else { + $history = ''; + } + + $support = $this->getSupportButton( $this->getTitle() ); + + if ( $context->getUser()->isAllowed( 'translate' ) ) { + $bottom = "$summary$save$saveAndNext$skip$history$support"; + } else { + $text = $context->msg( 'translate-edit-nopermission' )->escaped(); + $button = $this->getPermissionPageButton(); + $bottom = "$text $button$skip$history$support"; + } + + // Use the api to submit edits + $formParams = array( + 'action' => "{$wgServer}{$wgScriptPath}/api.php", + 'method' => 'post', + ); + + $form = Html::rawElement( 'form', $formParams, + implode( "\n", $hidden ) . "\n" . + $helpers->getBoxes( $this->suggestions ) . "\n" . + Html::rawElement( + 'div', + array( 'class' => 'mw-translate-inputs' ), + "$textarea\n$extraInputs" + ) . "\n" . + Html::rawElement( 'div', array( 'class' => 'mw-translate-bottom' ), $bottom ) + ); + + echo Html::rawElement( 'div', array( 'class' => 'mw-ajax-dialog' ), $form ); + } + + /** + * Gets the edit token and timestamps in some ugly array structure. Needs to + * be cleaned up. + * @throws MWException + * @return \array + */ + protected function getEditInfo() { + $params = new FauxRequest( array( + 'action' => 'query', + 'prop' => 'info|revisions', + 'intoken' => 'edit', + 'titles' => $this->getTitle(), + 'rvprop' => 'timestamp', + ) ); + + $api = new ApiMain( $params ); + $api->execute(); + $data = $api->getResultData(); + + if ( !isset( $data['query']['pages'] ) ) { + throw new MWException( 'Api query failed' ); + } + $data = $data['query']['pages']; + $data = array_shift( $data ); + + return $data; + } + + /** + * Returns link attributes that enable javascript translation dialog. + * Will degrade gracefully if user does not have permissions or JavaScript + * is not enabled. + * @param $title Title Title object for the translatable message. + * @param $group \string The group in which this message belongs to. + * Optional, but avoids a lookup later if provided. + * @param $type \string Force the type of editor to be used. Use dialog + * where embedded editor is no applicable. + * @return \array + */ + public static function jsEdit( Title $title, $group = "", $type = 'default' ) { + $context = RequestContext::getMain(); + + if ( $type === 'default' ) { + $text = 'tqe-anchor-' . substr( sha1( $title->getPrefixedText() ), 0, 12 ); + $onclick = "jQuery( '#$text' ).dblclick(); return false;"; + } else { + $onclick = Xml::encodeJsCall( + 'return mw.translate.openDialog', array( $title->getPrefixedDbKey(), $group ) + ); + } + + return array( + 'onclick' => $onclick, + 'title' => $context->msg( 'translate-edit-title', $title->getPrefixedText() )->text() + ); + } + + protected function getSupportButton( $title ) { + global $wgTranslateSupportUrl; + if ( !$wgTranslateSupportUrl ) { + return ''; + } + + $supportTitle = Title::newFromText( $wgTranslateSupportUrl['page'] ); + if ( !$supportTitle ) { + return ''; + } + + $supportParams = $wgTranslateSupportUrl['params']; + foreach ( $supportParams as &$value ) { + $value = str_replace( '%MESSAGE%', $title->getPrefixedText(), $value ); + } + + $support = Html::element( + 'input', + array( + 'class' => 'mw-translate-support', + 'type' => 'button', + 'value' => wfMessage( 'translate-js-support' )->text(), + 'title' => wfMessage( 'translate-js-support-title' )->text(), + 'data-load-url' => $supportTitle->getLocalUrl( $supportParams ), + ) + ); + + return $support; + } + + protected function getPermissionPageButton() { + global $wgTranslatePermissionUrl; + if ( !$wgTranslatePermissionUrl ) { + return ''; + } + + $title = Title::newFromText( $wgTranslatePermissionUrl ); + if ( !$title ) { + return ''; + } + + $button = Html::element( + 'input', + array( + 'class' => 'mw-translate-askpermission', + 'type' => 'button', + 'value' => wfMessage( 'translate-edit-askpermission' )->text(), + 'data-load-url' => $title->getLocalUrl(), + ) + ); + + return $button; + } +} diff --git a/MLEB/Translate/utils/TranslationHelpers.php b/MLEB/Translate/utils/TranslationHelpers.php new file mode 100644 index 00000000..e33e7e86 --- /dev/null +++ b/MLEB/Translate/utils/TranslationHelpers.php @@ -0,0 +1,1473 @@ +<?php +/** + * Contains helper class for interface parts that aid translations in doing + * their thing. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Provides the nice boxes that aid the translators to do their job. + * Boxes contain definition, documentation, other languages, translation memory + * suggestions, highlighted changes etc. + */ +class TranslationHelpers { + /** + * @var MessageHandle + * @since 2012-01-04 + */ + protected $handle; + /** + * The group object of the message (or null if there isn't any) + * @var MessageGroup + */ + protected $group; + + /** + * The current translation as a string. + */ + protected $translation; + /** + * The message definition as a string. + */ + protected $definition; + /** + * HTML id to the text area that contains the translation. Used to insert + * suggestion directly into the text area, for example. + */ + protected $textareaId = 'wpTextbox1'; + /** + * Whether to include extra tools to aid translating. + */ + protected $editMode = 'true'; + + /** + * @param Title $title Title of a page that holds a translation. + * @param string $groupId Group id that should be used, otherwise autodetected from title. + */ + public function __construct( Title $title, $groupId ) { + $this->handle = new MessageHandle( $title ); + $this->group = $this->getMessageGroup( $this->handle, $groupId ); + } + + /** + * Tries to determine to which group this message belongs. Falls back to the + * message index if valid group id was not supplied. + * + * @param MessageHandle $handle + * @param string $groupId + * @return MessageGroup|null Group the key belongs to, or null. + */ + protected function getMessageGroup( MessageHandle $handle, $groupId ) { + $mg = MessageGroups::getGroup( $groupId ); + + # If we were not given (a valid) group + if ( $mg === null ) { + $groupId = MessageIndex::getPrimaryGroupId( $handle ); + $mg = MessageGroups::getGroup( $groupId ); + } + + return $mg; + } + + /** + * Gets the HTML id of the text area that contains the translation. + * @return String + */ + public function getTextareaId() { + return $this->textareaId; + } + + /** + * Sets the HTML id of the text area that contains the translation. + * @param $id String + */ + public function setTextareaId( $id ) { + $this->textareaId = $id; + } + + /** + * Enable or disable extra help for editing. + * @param $mode Boolean + */ + public function setEditMode( $mode = true ) { + $this->editMode = $mode; + } + + /** + * Gets the message definition. + * @return String + */ + public function getDefinition() { + if ( $this->definition !== null ) { + return $this->definition; + } + + $this->mustBeKnownMessage(); + + if ( method_exists( $this->group, 'getMessageContent' ) ) { + $this->definition = $this->group->getMessageContent( $this->handle ); + } else { + $this->definition = $this->group->getMessage( + $this->handle->getKey(), + $this->group->getSourceLanguage() + ); + } + + return $this->definition; + } + + /** + * Gets the current message translation. Fuzzy messages will be marked as + * such unless translation is provided manually. + * @return string + */ + public function getTranslation() { + if ( $this->translation === null ) { + $obj = new CurrentTranslationAid( $this->group, $this->handle, RequestContext::getMain() ); + $aid = $obj->getData(); + $this->translation = $aid['value']; + + if ( $aid['fuzzy'] ) { + $this->translation = TRANSLATE_FUZZY . $this->translation; + } + } + + return $this->translation; + } + + /** + * Manual override for the translation. If not given or it is null, the code + * will try to fetch it automatically. + * @param string|null $translation + */ + public function setTranslation( $translation ) { + $this->translation = $translation; + } + + /** + * Gets the linguistically correct language code for translation + */ + public function getTargetLanguage() { + global $wgLanguageCode, $wgTranslateDocumentationLanguageCode; + + $code = $this->handle->getCode(); + if ( !$code ) { + $this->mustBeKnownMessage(); + $code = $this->group->getSourceLanguage(); + } + if ( $code === $wgTranslateDocumentationLanguageCode ) { + return $wgLanguageCode; + } + + return $code; + } + + /** + * Returns block element HTML snippet that contains the translation aids. + * Not all boxes are shown all the time depending on whether they have + * any information to show and on configuration variables. + * @param $suggestions string + * @return String. Block level HTML snippet or empty string. + */ + public function getBoxes( $suggestions = 'sync' ) { + // Box filter + $all = $this->getBoxNames(); + + if ( $suggestions === 'async' ) { + $all['translation-memory'] = array( $this, 'getLazySuggestionBox' ); + } elseif ( $suggestions === 'only' ) { + return (string)$this->callBox( + 'translation-memory', + $all['translation-memory'], + array( 'lazy' ) + ); + } elseif ( $suggestions === 'checks' ) { + $request = RequestContext::getMain()->getRequest(); + $this->translation = $request->getText( 'translation' ); + + return (string)$this->callBox( 'check', $all['check'] ); + } + + if ( $this->group instanceof RecentMessageGroup ) { + $all['last-diff'] = array( $this, 'getLastDiff' ); + } + + $boxes = array(); + foreach ( $all as $type => $cb ) { + $box = $this->callBox( $type, $cb ); + if ( $box ) { + $boxes[$type] = $box; + } + } + + wfRunHooks( 'TranslateGetBoxes', array( $this->group, $this->handle, &$boxes ) ); + + if ( count( $boxes ) ) { + return Html::rawElement( + 'div', + array( 'class' => 'mw-sp-translate-edit-fields' ), + implode( "\n\n", $boxes ) + ); + } else { + return ''; + } + } + + /** + * Public since 2012-06-26 + * @since 2012-01-04 + */ + public function callBox( $type, $cb, $params = array() ) { + try { + return call_user_func_array( $cb, $params ); + } catch ( TranslationHelperException $e ) { + return "<!-- Box $type not available: {$e->getMessage()} -->"; + } + } + + /** + * @return array + */ + public function getBoxNames() { + return array( + 'other-languages' => array( $this, 'getOtherLanguagesBox' ), + 'translation-memory' => array( $this, 'getSuggestionBox' ), + 'translation-diff' => array( $this, 'getPageDiff' ), + 'separator' => array( $this, 'getSeparatorBox' ), + 'documentation' => array( $this, 'getDocumentationBox' ), + 'definition' => array( $this, 'getDefinitionBox' ), + 'check' => array( $this, 'getCheckBox' ), + ); + } + + /** + * Returns suggestions from a translation memory. + * @param $serviceName + * @param $config + * @throws TranslationHelperException + * @return string Html snippet which contains the suggestions. + */ + protected function getTTMServerBox( $serviceName, $config ) { + $this->mustHaveDefinition(); + $this->mustBeTranslation(); + + $source = $this->group->getSourceLanguage(); + $code = $this->handle->getCode(); + $definition = $this->getDefinition(); + $TTMServer = TTMServer::factory( $config ); + $suggestions = $TTMServer->query( $source, $code, $definition ); + if ( count( $suggestions ) === 0 ) { + throw new TranslationHelperException( 'No suggestions' ); + } + + return $suggestions; + } + + /** + * Returns suggestions from a translation memory. + * @param $serviceName + * @param $config + * @throws TranslationHelperException + * @return string Html snippet which contains the suggestions. + */ + protected function getRemoteTTMServerBox( $serviceName, $config ) { + $this->mustHaveDefinition(); + $this->mustBeTranslation(); + + self::checkTranslationServiceFailure( $serviceName ); + + $source = $this->group->getSourceLanguage(); + $code = $this->handle->getCode(); + $definition = $this->getDefinition(); + $params = array( + 'format' => 'json', + 'action' => 'ttmserver', + 'sourcelanguage' => $source, + 'targetlanguage' => $code, + 'text' => $definition, + '*', // Because we hate IE + ); + + wfProfileIn( 'TranslateWebServiceRequest-' . $serviceName ); + $json = Http::get( wfAppendQuery( $config['url'], $params ) ); + wfProfileOut( 'TranslateWebServiceRequest-' . $serviceName ); + + $response = FormatJson::decode( $json, true ); + + if ( $json === false ) { + // Most likely a timeout or other general error + self::reportTranslationServiceFailure( $serviceName ); + throw new TranslationHelperException( 'No reply from remote server' ); + } elseif ( !is_array( $response ) ) { + error_log( __METHOD__ . ': Unable to parse reply: ' . strval( $json ) ); + throw new TranslationHelperException( 'Malformed reply from remote server' ); + } + + if ( !isset( $response['ttmserver'] ) ) { + self::reportTranslationServiceFailure( $serviceName ); + throw new TranslationHelperException( 'Unexpected reply from remote server' ); + } + + $suggestions = $response['ttmserver']; + if ( count( $suggestions ) === 0 ) { + throw new TranslationHelperException( 'No suggestions' ); + } + + return $suggestions; + } + + /// Since 2012-03-05 + protected function formatTTMServerSuggestions( $data ) { + $sugFields = array(); + + foreach ( $data as $serviceWrapper ) { + $config = $serviceWrapper['config']; + $suggestions = $serviceWrapper['suggestions']; + + foreach ( $suggestions as $s ) { + $tooltip = wfMessage( 'translate-edit-tmmatch-source', $s['source'] )->plain(); + $text = wfMessage( + 'translate-edit-tmmatch', + sprintf( '%.2f', $s['quality'] * 100 ) + )->plain(); + $accuracy = Html::element( 'span', array( 'title' => $tooltip ), $text ); + $legend = array( $accuracy => array() ); + + $TTMServer = TTMServer::factory( $config ); + if ( $TTMServer->isLocalSuggestion( $s ) ) { + $title = Title::newFromText( $s['location'] ); + $symbol = isset( $config['symbol'] ) ? $config['symbol'] : '•'; + $legend[$accuracy][] = self::ajaxEditLink( $title, $symbol ); + } else { + if ( $TTMServer instanceof RemoteTTMServer ) { + $displayName = $config['displayname']; + } else { + $wiki = WikiMap::getWiki( $s['wiki'] ); + $displayName = $wiki->getDisplayName() . ': ' . $s['location']; + } + + $params = array( + 'href' => $TTMServer->expandLocation( $s ), + 'target' => '_blank', + 'title' => $displayName, + ); + + $symbol = isset( $config['symbol'] ) ? $config['symbol'] : '‣'; + $legend[$accuracy][] = Html::element( 'a', $params, $symbol ); + } + + $suggestion = $s['target']; + $text = $this->suggestionField( $suggestion ); + $params = array( 'class' => 'mw-sp-translate-edit-tmsug' ); + + // Group identical suggestions together + if ( isset( $sugFields[$suggestion] ) ) { + $sugFields[$suggestion][2] = array_merge_recursive( $sugFields[$suggestion][2], $legend ); + } else { + $sugFields[$suggestion] = array( $text, $params, $legend ); + } + } + } + + $boxes = array(); + foreach ( $sugFields as $field ) { + list( $text, $params, $label ) = $field; + $legend = array(); + + foreach ( $label as $acc => $links ) { + $legend[] = $acc . ' ' . implode( " ", $links ); + } + + $legend = implode( ' | ', $legend ); + $boxes[] = Html::rawElement( + 'div', + $params, + self::legend( $legend ) . $text . self::clear() + ) . "\n"; + } + + // Limit to three best + $boxes = array_slice( $boxes, 0, 3 ); + $result = implode( "\n", $boxes ); + + return $result; + } + + /** + * @return string + * @throws MWException + */ + public function getSuggestionBox() { + global $wgTranslateTranslationServices; + + $handlers = array( + 'microsoft' => 'getMicrosoftSuggestion', + 'apertium' => 'getApertiumSuggestion', + 'yandex' => 'getYandexSuggestion', + ); + + $errors = ''; + $boxes = array(); + $TTMSSug = array(); + foreach ( $wgTranslateTranslationServices as $name => $config ) { + $type = $config['type']; + + if ( !isset( $config['timeout'] ) ) { + $config['timeout'] = 3; + } + + $method = null; + if ( isset( $handlers[$type] ) ) { + $method = $handlers[$type]; + + try { + $boxes[] = $this->$method( $name, $config ); + } catch ( TranslationHelperException $e ) { + $errors .= "<!-- Box $name not available: {$e->getMessage()} -->\n"; + } + continue; + } + + $server = TTMServer::factory( $config ); + if ( $server instanceof RemoteTTMServer ) { + $method = 'getRemoteTTMServerBox'; + } elseif ( $server instanceof ReadableTTMServer ) { + $method = 'getTTMServerBox'; + } + + if ( !$method ) { + throw new MWException( __METHOD__ . ": Unsupported type {$config['type']}" ); + } + + try { + $TTMSSug[$name] = array( + 'config' => $config, + 'suggestions' => $this->$method( $name, $config ), + ); + } catch ( TranslationHelperException $e ) { + $errors .= "<!-- Box $name not available: {$e->getMessage()} -->\n"; + } + } + + if ( count( $TTMSSug ) ) { + array_unshift( $boxes, $this->formatTTMServerSuggestions( $TTMSSug ) ); + } + + // Remove nulls and falses + $boxes = array_filter( $boxes ); + + // Enclose if there is more than one box + if ( count( $boxes ) ) { + $sep = Html::element( 'hr', array( 'class' => 'mw-translate-sep' ) ); + + return $errors . TranslateUtils::fieldset( + wfMessage( 'translate-edit-tmsugs' )->escaped(), + implode( "$sep\n", $boxes ), + array( 'class' => 'mw-translate-edit-tmsugs' ) + ); + } else { + return $errors; + } + } + + protected static function makeGoogleQueryParams( $definition, $pair, $config ) { + global $wgSitename, $wgVersion, $wgSecretKey; + + $app = "$wgSitename (MediaWiki $wgVersion; Translate " . TRANSLATE_VERSION . ")"; + $context = RequestContext::getMain(); + $options = array(); + $options['timeout'] = $config['timeout']; + + $options['postData'] = array( + 'q' => $definition, + 'v' => '1.0', + 'langpair' => $pair, + // Unique but not identifiable + 'userip' => sha1( $wgSecretKey . $context->getUser()->getName() ), + 'x-application' => $app, + ); + + if ( $config['key'] ) { + $options['postData']['key'] = $config['key']; + } + + return $options; + } + + protected function getMicrosoftSuggestion( $serviceName, $config ) { + $this->mustHaveDefinition(); + self::checkTranslationServiceFailure( $serviceName ); + + $code = $this->handle->getCode(); + $definition = trim( strval( $this->getDefinition() ) ); + $definition = self::wrapUntranslatable( $definition ); + + $memckey = wfMemckey( 'translate-tmsug-badcodes-' . $serviceName ); + $unsupported = wfGetCache( CACHE_ANYTHING )->get( $memckey ); + + if ( isset( $unsupported[$code] ) ) { + throw new TranslationHelperException( 'Unsupported language' ); + } + + $options = array(); + $options['timeout'] = $config['timeout']; + + $params = array( + 'text' => $definition, + 'to' => $code, + ); + + if ( isset( $config['key'] ) ) { + $params['appId'] = $config['key']; + } else { + throw new TranslationHelperException( 'API key is not set' ); + } + + $url = $config['url'] . '?' . wfArrayToCgi( $params ); + $url = wfExpandUrl( $url ); + + $options['method'] = 'GET'; + + $req = MWHttpRequest::factory( $url, $options ); + + wfProfileIn( 'TranslateWebServiceRequest-' . $serviceName ); + $status = $req->execute(); + wfProfileOut( 'TranslateWebServiceRequest-' . $serviceName ); + + if ( !$status->isOK() ) { + $error = $req->getContent(); + if ( strpos( $error, 'must be a valid language' ) !== false ) { + $unsupported[$code] = true; + wfGetCache( CACHE_ANYTHING )->set( $memckey, $unsupported, 60 * 60 * 8 ); + throw new TranslationHelperException( 'Unsupported language code' ); + } + + if ( $error ) { + error_log( __METHOD__ . ': Http::get failed:' . $error ); + } else { + error_log( __METHOD__ . ': Unknown error, grr' ); + } + // Most likely a timeout or other general error + self::reportTranslationServiceFailure( $serviceName ); + } + + $ret = $req->getContent(); + $text = preg_replace( '~<string.*>(.*)</string>~', '\\1', $ret ); + $text = Sanitizer::decodeCharReferences( $text ); + $text = self::unwrapUntranslatable( $text ); + $text = $this->suggestionField( $text ); + + return Html::rawElement( 'div', array(), self::legend( $serviceName ) . $text . self::clear() ); + } + + protected static function wrapUntranslatable( $text ) { + $text = str_replace( "\n", "!N!", $text ); + $wrap = '<span class="notranslate">\0</span>'; + $pattern = '~%[^% ]+%|\$\d|{VAR:[^}]+}|{?{(PLURAL|GRAMMAR|GENDER):[^|]+\||%(\d\$)?[sd]~'; + $text = preg_replace( $pattern, $wrap, $text ); + + return $text; + } + + protected static function unwrapUntranslatable( $text ) { + $text = str_replace( '!N!', "\n", $text ); + $text = preg_replace( '~<span class="notranslate">(.*?)</span>~', '\1', $text ); + + return $text; + } + + protected function getApertiumSuggestion( $serviceName, $config ) { + self::checkTranslationServiceFailure( $serviceName ); + + $page = $this->handle->getKey(); + $code = $this->handle->getCode(); + $ns = $this->handle->getTitle()->getNamespace(); + + $memckey = wfMemckey( 'translate-tmsug-pairs-' . $serviceName ); + $pairs = wfGetCache( CACHE_ANYTHING )->get( $memckey ); + + if ( !$pairs ) { + + $pairs = array(); + $json = Http::get( $config['pairs'], 5 ); + $response = FormatJson::decode( $json ); + + if ( $json === false ) { + self::reportTranslationServiceFailure( $serviceName ); + } elseif ( !is_object( $response ) ) { + error_log( __METHOD__ . ': Unable to parse reply: ' . strval( $json ) ); + throw new TranslationHelperException( 'Malformed reply from remote server' ); + } + + foreach ( $response->responseData as $pair ) { + $source = $pair->sourceLanguage; + $target = $pair->targetLanguage; + if ( !isset( $pairs[$target] ) ) { + $pairs[$target] = array(); + } + $pairs[$target][$source] = true; + } + + wfGetCache( CACHE_ANYTHING )->set( $memckey, $pairs, 60 * 60 * 24 ); + } + + if ( isset( $config['codemap'][$code] ) ) { + $code = $config['codemap'][$code]; + } + + $code = str_replace( '-', '_', wfBCP47( $code ) ); + + if ( !isset( $pairs[$code] ) ) { + throw new TranslationHelperException( 'Unsupported language' ); + } + + $suggestions = array(); + + $codemap = array_flip( $config['codemap'] ); + foreach ( $pairs[$code] as $candidate => $unused ) { + $mwcode = str_replace( '_', '-', strtolower( $candidate ) ); + + if ( isset( $codemap[$mwcode] ) ) { + $mwcode = $codemap[$mwcode]; + } + + $text = TranslateUtils::getMessageContent( $page, $mwcode, $ns ); + if ( $text === null || MessageHandle::hasFuzzyString( $text ) ) { + continue; + } + + $title = Title::makeTitleSafe( $ns, "$page/$mwcode" ); + $handle = new MessageHandle( $title ); + if ( $handle->isFuzzy() ) { + continue; + } + + $options = self::makeGoogleQueryParams( $text, "$candidate|$code", $config ); + $options['postData']['format'] = 'html'; + + wfProfileIn( 'TranslateWebServiceRequest-' . $serviceName ); + $json = Http::post( $config['url'], $options ); + wfProfileOut( 'TranslateWebServiceRequest-' . $serviceName ); + + $response = FormatJson::decode( $json ); + if ( $json === false || !is_object( $response ) ) { + self::reportTranslationServiceFailure( $serviceName ); + } elseif ( $response->responseStatus !== 200 ) { + error_log( __METHOD__ . + " (HTTP {$response->responseStatus}) with ($serviceName ($candidate|$code)): " . + $response->responseDetails + ); + } else { + $sug = Sanitizer::decodeCharReferences( $response->responseData->translatedText ); + $sug = trim( $sug ); + $sug = $this->suggestionField( $sug ); + $suggestions[] = Html::rawElement( 'div', + array( 'title' => $text ), + self::legend( "$serviceName ($candidate)" ) . $sug . self::clear() + ); + } + } + + if ( !count( $suggestions ) ) { + throw new TranslationHelperException( 'No suggestions' ); + } + + $divider = Html::element( 'div', array( 'style' => 'margin-bottom: 0.5ex' ) ); + + return implode( "$divider\n", $suggestions ); + } + + protected function getYandexSuggestion( $serviceName, $config ) { + self::checkTranslationServiceFailure( $serviceName ); + + $page = $this->handle->getKey(); + $code = $this->handle->getCode(); + $ns = $this->handle->getTitle()->getNamespace(); + + $memckey = wfMemckey( 'translate-tmsug-pairs-' . $serviceName ); + $pairs = wfGetCache( CACHE_ANYTHING )->get( $memckey ); + + if ( !$pairs ) { + $pairs = array(); + $json = Http::get( $config['pairs'], $config['timeout'] ); + $response = FormatJson::decode( $json ); + + if ( $json === false ) { + self::reportTranslationServiceFailure( $serviceName ); + } elseif ( !is_object( $response ) ) { + error_log( __METHOD__ . ': Unable to parse reply: ' . strval( $json ) ); + throw new TranslationHelperException( 'Malformed reply from remote server' ); + } + + foreach ( $response->dirs as $pair ) { + list( $source, $target ) = explode( '-', $pair ); + if ( !isset( $pairs[$target] ) ) { + $pairs[$target] = array(); + } + $pairs[$target][$source] = true; + } + + $weights = array_flip( $config['langorder'] ); + $cmpLangs = function ( $lang1, $lang2 ) use ( $weights ) { + $weight1 = isset( $weights[$lang1] ) ? $weights[$lang1] : PHP_INT_MAX; + $weight2 = isset( $weights[$lang2] ) ? $weights[$lang2] : PHP_INT_MAX; + + if ( $weight1 === $weight2 ) { + return 0; + } + + return ( $weight1 < $weight2 ) ? -1 : 1; + }; + + foreach ( $pairs as &$langs ) { + uksort( $langs, $cmpLangs ); + } + + wfGetCache( CACHE_ANYTHING )->set( $memckey, $pairs, 60 * 60 * 24 ); + } + + if ( !isset( $pairs[$code] ) ) { + throw new TranslationHelperException( 'Unsupported language' ); + } + + $suggestions = array(); + + foreach ( $pairs[$code] as $candidate => $unused ) { + $text = TranslateUtils::getMessageContent( $page, $candidate, $ns ); + if ( $text === null || MessageHandle::hasFuzzyString( $text ) ) { + continue; + } + + $title = Title::makeTitleSafe( $ns, "$page/$candidate" ); + $handle = new MessageHandle( $title ); + if ( $handle->isFuzzy() ) { + continue; + } + + $options = array( + 'timeout' => $config['timeout'], + 'postData' => array( + 'lang' => "$candidate-$code", + 'text' => $text, + ) + ); + wfProfileIn( 'TranslateWebServiceRequest-' . $serviceName ); + $json = Http::post( $config['url'], $options ); + wfProfileOut( 'TranslateWebServiceRequest-' . $serviceName ); + $response = FormatJson::decode( $json ); + + if ( $json === false || !is_object( $response ) ) { + self::reportTranslationServiceFailure( $serviceName ); + } elseif ( $response->code !== 200 ) { + error_log( __METHOD__ . " (HTTP {$response->code}) with ($serviceName ($candidate|$code))" ); + } else { + $sug = Sanitizer::decodeCharReferences( $response->text[0] ); + $sug = $this->suggestionField( $sug ); + $suggestions[] = Html::rawElement( 'div', + array( 'title' => $text ), + self::legend( "$serviceName ($candidate)" ) . $sug . self::clear() + ); + if ( count( $suggestions ) === $config['langlimit'] ) { + break; + } + } + } + + if ( $suggestions === array() ) { + throw new TranslationHelperException( 'No suggestions' ); + } + + $divider = Html::element( 'div', array( 'style' => 'margin-bottom: 0.5ex' ) ); + + return implode( "$divider\n", $suggestions ); + } + + public function getDefinitionBox() { + $this->mustHaveDefinition(); + $en = $this->getDefinition(); + + $title = Linker::link( + SpecialPage::getTitleFor( 'Translate' ), + htmlspecialchars( $this->group->getLabel() ), + array(), + array( + 'group' => $this->group->getId(), + 'language' => $this->handle->getCode() + ) + ); + + $label = + wfMessage( 'translate-edit-definition' )->text() . + wfMessage( 'word-separator' )->text() . + wfMessage( 'parentheses', $title )->text(); + + // Source language object + $sl = Language::factory( $this->group->getSourceLanguage() ); + + $dialogID = $this->dialogID(); + $id = Sanitizer::escapeId( "def-$dialogID" ); + $msg = $this->adder( $id, $sl ) . "\n" . Html::rawElement( 'div', + array( + 'class' => 'mw-translate-edit-deftext', + 'dir' => $sl->getDir(), + 'lang' => $sl->getCode(), + ), + TranslateUtils::convertWhiteSpaceToHTML( $en ) + ); + + $msg .= $this->wrapInsert( $id, $en ); + + $class = array( 'class' => 'mw-sp-translate-edit-definition mw-translate-edit-definition' ); + + return TranslateUtils::fieldset( $label, $msg, $class ); + } + + public function getTranslationDisplayBox() { + $en = $this->getTranslation(); + if ( $en === null ) { + return null; + } + $label = wfMessage( 'translate-edit-translation' )->text(); + $class = array( 'class' => 'mw-translate-edit-translation' ); + $msg = Html::rawElement( 'span', + array( 'class' => 'mw-translate-edit-translationtext' ), + TranslateUtils::convertWhiteSpaceToHTML( $en ) + ); + + return TranslateUtils::fieldset( $label, $msg, $class ); + } + + public function getCheckBox() { + $this->mustBeKnownMessage(); + + global $wgTranslateDocumentationLanguageCode; + + $context = RequestContext::getMain(); + $title = $context->getOutput()->getTitle(); + list( $alias, ) = SpecialPageFactory::resolveAlias( $title->getText() ); + + $tux = SpecialTranslate::isBeta( $context->getRequest() ) + && $title->isSpecialPage() + && ( $alias === 'Translate' ); + + $formattedChecks = $tux ? + FormatJson::encode( array() ) : + Html::element( 'div', array( 'class' => 'mw-translate-messagechecks' ) ); + + $page = $this->handle->getKey(); + $translation = $this->getTranslation(); + $code = $this->handle->getCode(); + $en = $this->getDefinition(); + + if ( strval( $translation ) === '' ) { + return $formattedChecks; + } + + if ( $code === $wgTranslateDocumentationLanguageCode ) { + return $formattedChecks; + } + + // We need to get the primary group of the message. It may differ from + // the supplied group (aggregate groups, dynamic groups). + $checker = $this->handle->getGroup()->getChecker(); + if ( !$checker ) { + return $formattedChecks; + } + + $message = new FatMessage( $page, $en ); + // Take the contents from edit field as a translation + $message->setTranslation( $translation ); + + $checks = $checker->checkMessage( $message, $code ); + if ( !count( $checks ) ) { + return $formattedChecks; + } + + $checkMessages = array(); + + foreach ( $checks as $checkParams ) { + $key = array_shift( $checkParams ); + $checkMessages[] = $context->msg( $key, $checkParams )->parse(); + } + + if ( $tux ) { + $formattedChecks = FormatJson::encode( $checkMessages ); + } else { + $formattedChecks = Html::rawElement( + 'div', + array( 'class' => 'mw-translate-messagechecks' ), + TranslateUtils::fieldset( + $context->msg( 'translate-edit-warnings' )->escaped(), + implode( '<hr />', $checkMessages ), + array( 'class' => 'mw-sp-translate-edit-warnings' ) + ) + ); + } + + return $formattedChecks; + } + + public function getOtherLanguagesBox() { + $code = $this->handle->getCode(); + $page = $this->handle->getKey(); + $ns = $this->handle->getTitle()->getNamespace(); + + $boxes = array(); + foreach ( self::getFallbacks( $code ) as $fbcode ) { + $text = TranslateUtils::getMessageContent( $page, $fbcode, $ns ); + if ( $text === null ) { + continue; + } + + $context = RequestContext::getMain(); + $label = TranslateUtils::getLanguageName( $fbcode, $context->getLanguage()->getCode() ) . + $context->msg( 'word-separator' )->text() . + $context->msg( 'parentheses', wfBCP47( $fbcode ) )->text(); + + $target = Title::makeTitleSafe( $ns, "$page/$fbcode" ); + if ( $target ) { + $label = self::ajaxEditLink( $target, htmlspecialchars( $label ) ); + } + + $dialogID = $this->dialogID(); + $id = Sanitizer::escapeId( "other-$fbcode-$dialogID" ); + + $params = array( 'class' => 'mw-translate-edit-item' ); + + $display = TranslateUtils::convertWhiteSpaceToHTML( $text ); + $display = Html::rawElement( 'div', array( + 'lang' => $fbcode, + 'dir' => Language::factory( $fbcode )->getDir() ), + $display + ); + + $contents = self::legend( $label ) . "\n" . $this->adder( $id, $fbcode ) . + $display . self::clear(); + + $boxes[] = Html::rawElement( 'div', $params, $contents ) . + $this->wrapInsert( $id, $text ); + } + + if ( count( $boxes ) ) { + $sep = Html::element( 'hr', array( 'class' => 'mw-translate-sep' ) ); + + return TranslateUtils::fieldset( + wfMessage( + 'translate-edit-in-other-languages', + $page + )->escaped(), + implode( "$sep\n", $boxes ), + array( 'class' => 'mw-sp-translate-edit-inother' ) + ); + } + + return null; + } + + public function getSeparatorBox() { + return Html::element( 'div', array( 'class' => 'mw-translate-edit-extra' ) ); + } + + public function getDocumentationBox() { + global $wgTranslateDocumentationLanguageCode; + + if ( !$wgTranslateDocumentationLanguageCode ) { + throw new TranslationHelperException( 'Message documentation language code is not defined' ); + } + + $context = RequestContext::getMain(); + $page = $this->handle->getKey(); + $ns = $this->handle->getTitle()->getNamespace(); + + $title = Title::makeTitle( $ns, $page . '/' . $wgTranslateDocumentationLanguageCode ); + $edit = self::ajaxEditLink( + $title, + $context->msg( 'translate-edit-contribute' )->escaped() + ); + $info = TranslateUtils::getMessageContent( $page, $wgTranslateDocumentationLanguageCode, $ns ); + + $class = 'mw-sp-translate-edit-info'; + + $gettext = $this->formatGettextComments(); + if ( $info !== null && $gettext ) { + $info .= Html::element( 'hr' ); + } + $info .= $gettext; + + // The information is most likely in English + $divAttribs = array( 'dir' => 'ltr', 'lang' => 'en', 'class' => 'mw-content-ltr' ); + + if ( strval( $info ) === '' ) { + $info = $context->msg( 'translate-edit-no-information' )->text(); + $class = 'mw-sp-translate-edit-noinfo'; + $lang = $context->getLanguage(); + // The message saying that there's no info, should be translated + $divAttribs = array( 'dir' => $lang->getDir(), 'lang' => $lang->getCode() ); + } + $class .= ' mw-sp-translate-message-documentation'; + + $contents = $context->getOutput()->parse( $info ); + // Remove whatever block element wrapup the parser likes to add + $contents = preg_replace( '~^<([a-z]+)>(.*)</\1>$~us', '\2', $contents ); + + return TranslateUtils::fieldset( + $context->msg( 'translate-edit-information' )->rawParams( $edit )->escaped(), + Html::rawElement( 'div', $divAttribs, $contents ), array( 'class' => $class ) + ); + } + + protected function formatGettextComments() { + if ( !$this->handle->isValid() ) { + return ''; + } + + // We need to get the primary group to get the correct file + // So $group can be different from $this->group + $group = $this->handle->getGroup(); + if ( !$group instanceof FileBasedMessageGroup ) { + return ''; + } + + $ffs = $group->getFFS(); + if ( $ffs instanceof GettextFFS ) { + global $wgContLang; + $mykey = $wgContLang->lcfirst( $this->handle->getKey() ); + $mykey = str_replace( ' ', '_', $mykey ); + $data = $ffs->read( $group->getSourceLanguage() ); + $help = $data['TEMPLATE'][$mykey]['comments']; + // Do not display an empty comment. That's no help and takes up unnecessary space. + $conf = $group->getConfiguration(); + if ( isset( $conf['BASIC']['codeBrowser'] ) ) { + $out = ''; + $pattern = $conf['BASIC']['codeBrowser']; + $pattern = str_replace( '%FILE%', '\1', $pattern ); + $pattern = str_replace( '%LINE%', '\2', $pattern ); + $pattern = "[$pattern \\1:\\2]"; + foreach ( $help as $type => $lines ) { + if ( $type === ':' ) { + $files = ''; + foreach ( $lines as $line ) { + $files .= ' ' . preg_replace( '/([^ :]+):(\d+)/', $pattern, $line ); + } + $out .= "<nowiki>#:</nowiki> $files<br />"; + } else { + foreach ( $lines as $line ) { + $out .= "<nowiki>#$type</nowiki> $line<br />"; + } + } + } + + return "$out"; + } + } + + return ''; + } + + protected function getPageDiff() { + $this->mustBeKnownMessage(); + + $title = $this->handle->getTitle(); + $key = $this->handle->getKey(); + + if ( !$title->exists() ) { + return null; + } + + $definitionTitle = Title::makeTitleSafe( $title->getNamespace(), "$key/en" ); + if ( !$definitionTitle || !$definitionTitle->exists() ) { + return null; + } + + $db = wfGetDB( DB_MASTER ); + $conds = array( + 'rt_page' => $title->getArticleID(), + 'rt_type' => RevTag::getType( 'tp:transver' ), + ); + $options = array( + 'ORDER BY' => 'rt_revision DESC', + ); + + $latestRevision = $definitionTitle->getLatestRevID(); + + $translationRevision = $db->selectField( 'revtag', 'rt_value', $conds, __METHOD__, $options ); + if ( $translationRevision === false ) { + return null; + } + + // Using newFromId instead of newFromTitle, because the page might have been renamed + $oldrev = Revision::newFromId( $translationRevision ); + if ( !$oldrev ) { + // And someone might still have deleted it + return null; + } + + $oldtext = ContentHandler::getContentText( $oldrev->getContent() ); + $newContent = Revision::newFromTitle( $definitionTitle, $latestRevision )->getContent(); + $newtext = ContentHandler::getContentText( $newContent ); + + if ( $oldtext === $newtext ) { + return null; + } + + $diff = new DifferenceEngine; + if ( method_exists( 'DifferenceEngine', 'setTextLanguage' ) ) { + $diff->setTextLanguage( $this->group->getSourceLanguage() ); + } + + $oldContent = ContentHandler::makeContent( $oldtext, $diff->getTitle() ); + $newContent = ContentHandler::makeContent( $newtext, $diff->getTitle() ); + + $diff->setContent( $oldContent, $newContent ); + $diff->setReducedLineNumbers(); + $diff->showDiffStyle(); + + return $diff->getDiff( + wfMessage( 'tpt-diff-old' )->escaped(), + wfMessage( 'tpt-diff-new' )->escaped() + ); + } + + protected function getLastDiff() { + // Shortcuts + $title = $this->handle->getTitle(); + $latestRevId = $title->getLatestRevID(); + $previousRevId = $title->getPreviousRevisionID( $latestRevId ); + + $latestRev = Revision::newFromTitle( $title, $latestRevId ); + $previousRev = Revision::newFromTitle( $title, $previousRevId ); + + $diffText = ''; + + if ( $latestRev && $previousRev ) { + $latest = ContentHandler::getContentText( $latestRev->getContent() ); + $previous = ContentHandler::getContentText( $previousRev->getContent() ); + + if ( $previous !== $latest ) { + $diff = new DifferenceEngine; + + if ( method_exists( 'DifferenceEngine', 'setTextLanguage' ) ) { + $diff->setTextLanguage( $this->getTargetLanguage() ); + } + + $oldContent = ContentHandler::makeContent( $previous, $diff->getTitle() ); + $newContent = ContentHandler::makeContent( $latest, $diff->getTitle() ); + + $diff->setContent( $oldContent, $newContent ); + $diff->setReducedLineNumbers(); + $diff->showDiffStyle(); + $diffText = $diff->getDiff( false, false ); + } + } + + if ( !$latestRev ) { + return null; + } + + $context = RequestContext::getMain(); + $user = $latestRev->getUserText( Revision::FOR_THIS_USER, $context->getUser() ); + $comment = $latestRev->getComment(); + + if ( $diffText === '' ) { + if ( strval( $comment ) !== '' ) { + $text = $context->msg( 'translate-dynagroup-byc', $user, $comment )->escaped(); + } else { + $text = $context->msg( 'translate-dynagroup-by', $user )->escaped(); + } + } else { + if ( strval( $comment ) !== '' ) { + $text = $context->msg( 'translate-dynagroup-lastc', $user, $comment )->escaped(); + } else { + $text = $context->msg( 'translate-dynagroup-last', $user )->escaped(); + } + } + + return TranslateUtils::fieldset( + $text, + $diffText, + array( 'class' => 'mw-sp-translate-latestchange' ) + ); + } + + /** + * @param $label string + * @return string + */ + protected static function legend( $label ) { + # Float it to the opposite direction + return Html::rawElement( 'div', array( 'class' => 'mw-translate-legend' ), $label ); + } + + /** + * @return string + */ + protected static function clear() { + return Html::element( 'div', array( 'style' => 'clear:both;' ) ); + } + + /** + * @param $code string + * @return array + */ + protected static function getFallbacks( $code ) { + global $wgTranslateLanguageFallbacks; + + // User preference has the final say + $user = RequestContext::getMain()->getUser(); + $preference = $user->getOption( 'translate-editlangs' ); + if ( $preference !== 'default' ) { + $fallbacks = array_map( 'trim', explode( ',', $preference ) ); + foreach ( $fallbacks as $k => $v ) { + if ( $v === $code ) { + unset( $fallbacks[$k] ); + } + } + + return $fallbacks; + } + + // Global configuration settings + $fallbacks = array(); + if ( isset( $wgTranslateLanguageFallbacks[$code] ) ) { + $fallbacks = (array)$wgTranslateLanguageFallbacks[$code]; + } + + $list = Language::getFallbacksFor( $code ); + array_pop( $list ); // Get 'en' away from the end + $fallbacks = array_merge( $list, $fallbacks ); + + return array_unique( $fallbacks ); + } + + /** + * @return null|string + */ + public function getLazySuggestionBox() { + $this->mustBeKnownMessage(); + if ( !$this->handle->getCode() ) { + return null; + } + + $url = SpecialPage::getTitleFor( 'Translate', 'editpage' )->getLocalUrl( array( + 'suggestions' => 'only', + 'page' => $this->handle->getTitle()->getPrefixedDbKey(), + 'loadgroup' => $this->group->getId(), + ) ); + $url = Xml::encodeJsVar( $url ); + + $id = Sanitizer::escapeId( 'tm-lazysug-' . $this->dialogID() ); + $target = self::jQueryPathId( $id ); + + $script = Html::inlineScript( "jQuery($target).load($url)" ); + $spinner = Html::element( 'div', array( 'class' => 'mw-ajax-loader' ) ); + + return Html::rawElement( 'div', array( 'id' => $id ), $script . $spinner ); + } + + /** + * @return string + */ + public function dialogID() { + $hash = sha1( $this->handle->getTitle()->getPrefixedDbKey() ); + + return substr( $hash, 0, 4 ); + } + + /** + * @param string $source jQuery selector for element containing the source + * @param string|Language $lang Language code or object + * @return string + */ + public function adder( $source, $lang ) { + if ( !$this->editMode ) { + return ''; + } + $target = self::jQueryPathId( $this->getTextareaId() ); + $source = self::jQueryPathId( $source ); + $dir = wfGetLangObj( $lang )->getDir(); + $params = array( + 'onclick' => "jQuery($target).val(jQuery($source).text()).focus(); return false;", + 'href' => '#', + 'title' => wfMessage( 'translate-use-suggestion' )->text(), + 'class' => 'mw-translate-adder mw-translate-adder-' . $dir, + ); + + return Html::element( 'a', $params, '↓' ); + } + + /** + * @param $id string|int + * @param $text string + * @return string + */ + public function wrapInsert( $id, $text ) { + return Html::element( 'pre', array( 'id' => $id, 'style' => 'display: none;' ), $text ); + } + + /** + * @param $text string + * @return string + */ + public function suggestionField( $text ) { + static $counter = 0; + + $code = $this->getTargetLanguage(); + + $counter++; + $dialogID = $this->dialogID(); + $id = Sanitizer::escapeId( "tmsug-$dialogID-$counter" ); + $contents = Html::rawElement( 'div', array( 'lang' => $code, + 'dir' => Language::factory( $code )->getDir() ), + TranslateUtils::convertWhiteSpaceToHTML( $text ) ); + $contents .= $this->wrapInsert( $id, $text ); + + return $this->adder( $id, $code ) . "\n" . $contents; + } + + /** + * Ajax-enabled message editing link. + * @param $target Title: Title of the target message. + * @param $text String: Link text for Linker::link() + * @return string HTML link + */ + public static function ajaxEditLink( $target, $text ) { + $handle = new MessageHandle( $target ); + $groupId = MessageIndex::getPrimaryGroupId( $handle ); + + $params = array(); + $params['action'] = 'edit'; + $params['loadgroup'] = $groupId; + + $jsEdit = TranslationEditPage::jsEdit( $target, $groupId, 'dialog' ); + + return Linker::link( $target, $text, $jsEdit, $params ); + } + + /** + * Escapes $id such that it can be used in jQuery selector. + * @param $id string + * @return string + */ + public static function jQueryPathId( $id ) { + $id = preg_replace( '/[^A-Za-z0-9_-]/', '\\\\$0', $id ); + + return Xml::encodeJsVar( "#$id" ); + } + + /** + * How many failures during failure period need to happen to consider + * the service being temporarily off-line. */ + protected static $serviceFailureCount = 5; + /** + * How long after the last detected failure we clear the status and + * try again. + */ + protected static $serviceFailurePeriod = 900; + + /** + * Checks whether the given service has exceeded failure count + * @param $service string + * @throws TranslationHelperException + */ + public static function checkTranslationServiceFailure( $service ) { + $key = wfMemckey( "translate-service-$service" ); + $value = wfGetCache( CACHE_ANYTHING )->get( $key ); + if ( !is_string( $value ) ) { + return; + } + list( $count, $failed ) = explode( '|', $value, 2 ); + + if ( $failed + ( 2 * self::$serviceFailurePeriod ) < wfTimestamp() ) { + if ( $count >= self::$serviceFailureCount ) { + error_log( "Translation service $service (was) restored" ); + } + wfGetCache( CACHE_ANYTHING )->delete( $key ); + + return; + } elseif ( $failed + self::$serviceFailurePeriod < wfTimestamp() ) { + /* We are in suspicious mode and one failure is enough to update + * failed timestamp. If the service works however, let's use it. + * Previous failures are forgotten after another failure period + * has passed */ + return; + } + + if ( $count >= self::$serviceFailureCount ) { + throw new TranslationHelperException( "web service $service is temporarily disabled" ); + } + } + + /** + * Increases the failure count for a given service + * @param $service + * @throws TranslationHelperException + */ + public static function reportTranslationServiceFailure( $service ) { + $key = wfMemckey( "translate-service-$service" ); + $value = wfGetCache( CACHE_ANYTHING )->get( $key ); + if ( !is_string( $value ) ) { + $count = 0; + } else { + list( $count, ) = explode( '|', $value, 2 ); + } + + $count += 1; + $failed = wfTimestamp(); + wfGetCache( CACHE_ANYTHING )->set( $key, "$count|$failed", self::$serviceFailurePeriod * 5 ); + + if ( $count == self::$serviceFailureCount ) { + error_log( "Translation service $service suspended" ); + } elseif ( $count > self::$serviceFailureCount ) { + error_log( "Translation service $service still suspended" ); + } + + throw new TranslationHelperException( "web service $service failed to provide valid response" ); + } + + public static function addModules( OutputPage $out ) { + $modules = array( 'ext.translate.quickedit' ); + wfRunHooks( 'TranslateBeforeAddModules', array( &$modules ) ); + $out->addModules( $modules ); + + // Might be needed, but ajax doesn't load it + // Globals :( + $diff = new DifferenceEngine; + $diff->showDiffStyle(); + } + + /// @since 2012-01-04 + protected function mustBeKnownMessage() { + if ( !$this->group ) { + throw new TranslationHelperException( 'unknown group' ); + } + } + + /// @since 2012-01-04 + protected function mustBeTranslation() { + if ( !$this->handle->getCode() ) { + throw new TranslationHelperException( 'editing source language' ); + } + } + + /// @since 2012-01-04 + protected function mustHaveDefinition() { + if ( strval( $this->getDefinition() ) === '' ) { + throw new TranslationHelperException( 'message does not have definition' ); + } + } +} + +/** + * Translation helpers can throw this exception when they cannot do + * anything useful with the current message. This helps in debugging + * why some fields are not shown. See also helpers in TranslationHelpers: + * - mustBeKnownMessage() + * - mustBeTranslation() + * - mustHaveDefinition() + * @since 2012-01-04 (Renamed in 2012-07-24 to fix typo in name) + */ +class TranslationHelperException extends MWException { +} diff --git a/MLEB/Translate/utils/TranslationStats.php b/MLEB/Translate/utils/TranslationStats.php new file mode 100644 index 00000000..0a918f47 --- /dev/null +++ b/MLEB/Translate/utils/TranslationStats.php @@ -0,0 +1,61 @@ +<?php +/** + * Contains class which offers functionality for statistics reporting. + * + * @file + * @author Niklas Laxström + * @author Siebrand Mazeland + * @copyright Copyright © 2010-2013, Niklas Laxström, Siebrand Mazeland + * @license GPL-2.0+ + */ + +/** + * Contains methods that provide statistics for message groups. + * + * @ingroup Stats + */ +class TranslationStats { + /** + * Returns translated percentage for message group in given + * languages + * + * @param $group \string Unique key identifying the group + * @param $languages \array List of language codes + * @param bool|int $threshold \int Minimum required percentage translated to + * return. Other given language codes will not be returned. + * @param $simple \bool Return only codes or code/pecentage pairs + * + * @return \array Array of key value pairs code (string)/percentage + * (float) or array of codes, depending on $simple + */ + public static function getPercentageTranslated( $group, $languages, $threshold = false, + $simple = false + ) { + $stats = array(); + + $g = MessageGroups::singleton()->getGroup( $group ); + + $collection = $g->initCollection( 'en' ); + foreach ( $languages as $code ) { + $collection->resetForNewLanguage( $code ); + // Initialise messages + $collection->filter( 'ignored' ); + $collection->filter( 'optional' ); + // Store the count of real messages for later calculation. + $total = count( $collection ); + $collection->filter( 'translated', false ); + $translated = count( $collection ); + + $translatedPercentage = ( $translated * 100 ) / $total; + if ( $translatedPercentage >= $threshold ) { + if ( $simple ) { + $stats[] = $code; + } else { + $stats[$code] = $translatedPercentage; + } + } + } + + return $stats; + } +} diff --git a/MLEB/Translate/utils/TuxMessageTable.php b/MLEB/Translate/utils/TuxMessageTable.php new file mode 100644 index 00000000..bc77fa68 --- /dev/null +++ b/MLEB/Translate/utils/TuxMessageTable.php @@ -0,0 +1,72 @@ +<?php + +class TuxMessageTable extends ContextSource { + protected $group; + protected $language; + + public function __construct( IContextSource $context, MessageGroup $group, $language ) { + $this->setContext( $context ); + $this->group = $group; + $this->language = $language; + } + + public function fullTable() { + $modules = array( 'ext.translate.editor' ); + wfRunHooks( 'TranslateBeforeAddModules', array( &$modules ) ); + $this->getOutput()->addModules( $modules ); + + $sourceLang = Language::factory( $this->group->getSourceLanguage() ); + $targetLang = Language::factory( $this->language ); + $batchSize = 100; + + $list = Html::element( 'div', array( + 'class' => 'row tux-messagelist', + 'data-grouptype' => get_class( $this->group ), + 'data-sourcelangcode' => $sourceLang->getCode(), + 'data-sourcelangdir' => $sourceLang->getDir(), + 'data-targetlangcode' => $targetLang->getCode(), + 'data-targetlangdir' => $targetLang->getDir(), + ) ); + + $groupId = htmlspecialchars( $this->group->getId() ); + $msg = $this->msg( 'tux-messagetable-loading-messages' ) + ->numParams( $batchSize ) + ->escaped(); + + $loader = <<<HTML +<div class="tux-messagetable-loader hide" data-messagegroup="$groupId" data-pagesize="$batchSize"> + <span class="tux-loading-indicator"></span> + <div class="tux-messagetable-loader-count"></div> + <div class="tux-messagetable-loader-more">$msg</div> +</div> +HTML; + + $hideOwn = $this->msg( 'tux-editor-proofreading-hide-own-translations' )->escaped(); + $clearTranslated = $this->msg( 'tux-editor-clear-translated' )->escaped(); + $modeTranslate = $this->msg( 'tux-editor-translate-mode' )->escaped(); + $modePage = $this->msg( 'tux-editor-page-mode' )->escaped(); + $modeProofread = $this->msg( 'tux-editor-proofreading-mode' )->escaped(); + + $actionbar = <<<HTML +<div class="tux-action-bar row"> + <div class="three columns tux-message-list-statsbar" data-messagegroup="$groupId"></div> + <div class="three columns text-center"> + <button class="toggle button tux-proofread-own-translations-button hide-own hide"> + $hideOwn + </button> + <button class="toggle button tux-editor-clear-translated hide">$clearTranslated</button> + </div> + <div class="six columns tux-view-switcher text-center"> + <button class="toggle button down translate-mode-button">$modeTranslate + </button><button class="toggle button down page-mode-button">$modePage + </button><button class="toggle button hide proofread-mode-button">$modeProofread + </button> + </div> +</div> +HTML; + + // Actual message table is fetched and rendered at client side. This just provides + // the loader and action bar. + return $list . $loader . $actionbar; + } +} diff --git a/MLEB/Translate/utils/UserToggles.php b/MLEB/Translate/utils/UserToggles.php new file mode 100644 index 00000000..030a3024 --- /dev/null +++ b/MLEB/Translate/utils/UserToggles.php @@ -0,0 +1,109 @@ +<?php +/** + * Contains classes for addition of extension specific preference settings. + * + * @file + * @author Siebrand Mazeland + * @author Niklas Laxström + * @copyright Copyright © 2008-2010 Siebrand Mazeland, Niklas Laxström + * @license GPL-2.0+ + */ + +/** + * Class to add Translate specific preference settings. + */ +class TranslatePreferences { + /** + * Add 'translate-pref-nonewsletter' preference. + * This is most probably specific to translatewiki.net. Can be enabled + * with $wgTranslateNewsletterPreference. + * + * @param $user User + * @param $preferences array + * @return bool true + */ + public static function onGetPreferences( $user, &$preferences ) { + global $wgTranslateNewsletterPreference; + + if ( !$wgTranslateNewsletterPreference ) { + return true; + } + + global $wgEnableEmail, $wgEnotifRevealEditorAddress; + + // Only show if email is enabled and user has a confirmed email address. + if ( $wgEnableEmail && $user->isEmailConfirmed() ) { + // 'translate-pref-nonewsletter' is used as opt-out for + // users with a confirmed email address + $prefs = array( + 'translate-nonewsletter' => array( + 'type' => 'toggle', + 'section' => 'personal/email', + 'label-message' => 'translate-pref-nonewsletter' + ) + ); + + // Add setting after 'enotifrevealaddr'. + $preferences = wfArrayInsertAfter( $preferences, $prefs, + $wgEnotifRevealEditorAddress ? 'enotifrevealaddr' : 'enotifminoredits' ); + } + + return true; + } + + /** + * Add 'translate-editlangs' preference. + * These are the languages also shown when translating. + * + * @param User $user + * @param array $preferences + * @return bool true + */ + public static function translationAssistLanguages( User $user, &$preferences ) { + // Get selector. + $select = self::languageSelector(); + // Set target ID. + $select->setTargetId( 'mw-input-translate-editlangs' ); + // Get available languages. + $languages = Language::fetchLanguageNames(); + + $preferences['translate-editlangs'] = array( + 'class' => 'HTMLJsSelectToInputField', + 'section' => 'editing/translate', + 'label-message' => 'translate-pref-editassistlang', + 'help-message' => 'translate-pref-editassistlang-help', + 'select' => $select, + 'valid-values' => array_keys( $languages ), + 'name' => 'translate-editlangs', + ); + + return true; + } + + /** + * JavsScript selector for language codes. + * @return JsSelectToInput + */ + protected static function languageSelector() { + if ( is_callable( array( 'LanguageNames', 'getNames' ) ) ) { + $lang = RequestContext::getMain()->getLanguage(); + $languages = LanguageNames::getNames( $lang->getCode(), + LanguageNames::FALLBACK_NORMAL + ); + } else { + $languages = Language::fetchLanguageNames(); + } + + 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 ); + $jsSelect->setSourceId( 'mw-language-selector' ); + + return $jsSelect; + } +} |