diff options
author | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
---|---|---|
committer | Yaco <franco@reevo.org> | 2020-06-04 11:01:00 -0300 |
commit | fc7369835258467bf97eb64f184b93691f9a9fd5 (patch) | |
tree | daabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/extensions/Translate/utils |
first commit
Diffstat (limited to 'www/wiki/extensions/Translate/utils')
37 files changed, 7235 insertions, 0 deletions
diff --git a/www/wiki/extensions/Translate/utils/ArrayFlattener.php b/www/wiki/extensions/Translate/utils/ArrayFlattener.php new file mode 100644 index 00000000..c5e61769 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/ArrayFlattener.php @@ -0,0 +1,297 @@ +<?php +/** + * Flattens message arrays for further processing. Supports parsing CLDR + * plural messages and converting them into MediaWiki's {{PLURAL}} syntax + * in a single message. + * + * @file + * @author Niklas Laxström + * @author Erik Moeller + * @license GPL-2.0-or-later + * @since 2016.01 + */ + +class ArrayFlattener { + protected $sep; + protected $parseCLDRPlurals; + + // For CLDR pluralization rules + protected static $pluralWords = [ + 'zero' => 1, + 'one' => 1, + 'many' => 1, + 'few' => 1, + 'other' => 1, + 'two' => 1 + ]; + + public function __construct( $sep = '.', $parseCLDRPlurals = false ) { + $this->sep = $sep; + $this->parseCLDRPlurals = $parseCLDRPlurals; + } + + /** + * Flattens multidimensional array. + * + * @param array $unflat Array of messages + * @return array + */ + public function flatten( array $unflat ) { + $flat = []; + + foreach ( $unflat as $key => $value ) { + if ( !is_array( $value ) ) { + $flat[$key] = $value; + continue; + } + + $plurals = false; + if ( $this->parseCLDRPlurals ) { + $plurals = $this->flattenCLDRPlurals( $value ); + } + + if ( $this->parseCLDRPlurals && $plurals ) { + $flat[$key] = $plurals; + } else { + $temp = []; + foreach ( $value as $subKey => $subValue ) { + $newKey = "$key{$this->sep}$subKey"; + $temp[$newKey] = $subValue; + } + $flat += $this->flatten( $temp ); + } + + // Can as well keep only one copy around. + unset( $unflat[$key] ); + } + + return $flat; + } + + /** + * Flattens arrays that contain CLDR plural keywords into single values using + * MediaWiki's plural syntax. + * + * @param array $messages Array of messages + * + * @throws MWException + * @return bool|string + */ + public function flattenCLDRPlurals( $messages ) { + $pluralKeys = false; + $nonPluralKeys = false; + foreach ( $messages as $key => $value ) { + if ( is_array( $value ) ) { + // Plurals can only happen in the lowest level of the structure + return false; + } + + // Check if we find any reserved plural keyword + if ( isset( self::$pluralWords[$key] ) ) { + $pluralKeys = true; + } else { + $nonPluralKeys = true; + } + } + + // No plural keys at all, we can skip + if ( !$pluralKeys ) { + return false; + } + + // Mixed plural keys with other keys, should not happen + if ( $nonPluralKeys ) { + $keys = implode( ', ', array_keys( $messages ) ); + throw new MWException( "Reserved plural keywords mixed with other keys: $keys." ); + } + + $pls = '{{PLURAL'; + foreach ( $messages as $key => $value ) { + if ( $key === 'other' ) { + continue; + } + + $pls .= "|$key=$value"; + } + + // Put the "other" alternative last, without other= prefix. + $other = isset( $messages['other'] ) ? '|' . $messages['other'] : ''; + $pls .= "$other}}"; + + return $pls; + } + + /** + * Performs the reverse operation of flatten. + * + * @param array $flat Array of messages + * @return array + */ + public function unflatten( $flat ) { + $unflat = []; + + if ( $this->parseCLDRPlurals ) { + $unflattenedPlurals = []; + foreach ( $flat as $key => $value ) { + $plurals = false; + if ( !is_array( $value ) ) { + $plurals = $this->unflattenCLDRPlurals( $key, $value ); + } + if ( $plurals ) { + $unflattenedPlurals += $plurals; + } else { + $unflattenedPlurals[$key] = $value; + } + } + $flat = $unflattenedPlurals; + } + + foreach ( $flat as $key => $value ) { + $path = explode( $this->sep, $key ); + if ( count( $path ) === 1 ) { + $unflat[$key] = $value; + continue; + } + + $pointer = &$unflat; + do { + /// Extract the level and make sure it exists. + $level = array_shift( $path ); + if ( !isset( $pointer[$level] ) ) { + $pointer[$level] = []; + } + + /// Update the pointer to the new reference. + $tmpPointer = &$pointer[$level]; + unset( $pointer ); + $pointer = &$tmpPointer; + unset( $tmpPointer ); + + /// If next level is the last, add it into the array. + if ( count( $path ) === 1 ) { + $lastKey = array_shift( $path ); + $pointer[$lastKey] = $value; + } + } while ( count( $path ) ); + } + + return $unflat; + } + + /** + * Converts the MediaWiki plural syntax to array of CLDR style plurals + * + * @param string $key Message key prefix + * @param string $message The plural string + * + * @return bool|array + */ + public function unflattenCLDRPlurals( $key, $message ) { + // Quick escape. + if ( strpos( $message, '{{PLURAL' ) === false ) { + return false; + } + + /* + * Replace all variables with placeholders. Possible source of bugs + * if other characters that given below are used. + */ + $regex = '~\{[a-zA-Z_-]+}~'; + $placeholders = []; + $match = []; + + while ( preg_match( $regex, $message, $match ) ) { + $uniqkey = TranslateUtils::getPlaceholder(); + $placeholders[$uniqkey] = $match[0]; + $search = preg_quote( $match[0], '~' ); + $message = preg_replace( "~$search~", $uniqkey, $message ); + } + + // Then replace (possible multiple) plural instances into placeholders. + $regex = '~\{\{PLURAL\|(.*?)}}~s'; + $matches = []; + $match = []; + + while ( preg_match( $regex, $message, $match ) ) { + $uniqkey = TranslateUtils::getPlaceholder(); + $matches[$uniqkey] = $match; + $message = preg_replace( $regex, $uniqkey, $message, 1 ); + } + + // No plurals, should not happen. + if ( !count( $matches ) ) { + return false; + } + + // The final array of alternative plurals forms. + $alts = []; + + /* + * Then loop trough each plural block and replacing the placeholders + * to construct the alternatives. Produces invalid output if there is + * multiple plural bocks which don't have the same set of keys. + */ + $pluralChoice = implode( '|', array_keys( self::$pluralWords ) ); + $regex = "~($pluralChoice)\s*=\s*(.+)~s"; + foreach ( $matches as $ph => $plu ) { + $forms = explode( '|', $plu[1] ); + + foreach ( $forms as $form ) { + if ( $form === '' ) { + continue; + } + + $match = []; + if ( preg_match( $regex, $form, $match ) ) { + $formWord = "$key{$this->sep}{$match[1]}"; + $value = $match[2]; + } else { + $formWord = "$key{$this->sep}other"; + $value = $form; + } + + if ( !isset( $alts[$formWord] ) ) { + $alts[$formWord] = $message; + } + + $string = $alts[$formWord]; + $alts[$formWord] = str_replace( $ph, $value, $string ); + } + } + + // Replace other variables. + foreach ( $alts as &$value ) { + $value = str_replace( array_keys( $placeholders ), array_values( $placeholders ), $value ); + } + + if ( !isset( $alts["$key{$this->sep}other"] ) ) { + wfWarn( "Other not set for key $key" ); + } + + return $alts; + } + + /** + * Compares two strings for equal content, taking PLURAL expansion into account. + * + * @param string $a + * @param string $b + * @return bool Whether two strings are equal + */ + public function compareContent( $a, $b ) { + if ( !$this->parseCLDRPlurals ) { + return $a === $b; + } + + $a2 = $this->unflattenCLDRPlurals( 'prefix', $a ); + $b2 = $this->unflattenCLDRPlurals( 'prefix', $b ); + + // Fall back to regular comparison if parsing fails. + if ( $a2 === false || $b2 === false ) { + return $a === $b; + } + + // Require key-value pairs to match, but ignore order and types (all should be strings). + return $a2 == $b2; + } +} diff --git a/www/wiki/extensions/Translate/utils/ExternalMessageSourceStateComparator.php b/www/wiki/extensions/Translate/utils/ExternalMessageSourceStateComparator.php new file mode 100644 index 00000000..02407fd8 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/ExternalMessageSourceStateComparator.php @@ -0,0 +1,223 @@ +<?php + +/** + * Finds external changes for file based message groups. + * + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2013.12 + */ +class ExternalMessageSourceStateComparator { + /** Process all languages supported by the message group */ + const ALL_LANGUAGES = 'all languages'; + + protected $changes = []; + + /** + * 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 = []; + $processAll = false; + + if ( $languages === self::ALL_LANGUAGES ) { + $processAll = true; + $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. Source language might not + // be included in $group->getTranslatableLanguages(). The expected + // behavior is that source language is always processed when given + // self::ALL_LANGUAGES. + $sourceLanguage = $group->getSourceLanguage(); + $index = array_search( $sourceLanguage, $languages ); + if ( $processAll || $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 ) { + $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(); + } + } + } + + /** + * 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 + ) { + /* This throws a warning if message definitions are not yet + * cached and will read the file for definitions. */ + Wikimedia\suppressWarnings(); + $wiki = $group->initCollection( $code ); + Wikimedia\restoreWarnings(); + $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 ); + throw new MWException( "Source message file for {$group->getId()} does not exist: $path" ); + } + + $file = $ffs->read( $code ); + + // Does not exist + if ( $file === false ) { + 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" ); + + 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(); + + // @todo: Fuzzy checking can also be moved to $ffs->isContentEqual(); + // 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 ( $ffs->isContentEqual( $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 ( + !$ffs->isContentEqual( $wikiContent, $cacheContent ) && + $ffs->isContentEqual( $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 ); + } + } + } + + protected function addChange( $type, $language, $key, $content ) { + $this->changes[$language][$type][] = [ + 'key' => $key, + 'content' => $content, + ]; + } +} diff --git a/www/wiki/extensions/Translate/utils/ExternalMessageSourceStateImporter.php b/www/wiki/extensions/Translate/utils/ExternalMessageSourceStateImporter.php new file mode 100644 index 00000000..495c3fd7 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/ExternalMessageSourceStateImporter.php @@ -0,0 +1,84 @@ +<?php + +/** + * Finds external changes for file based message groups. + * + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2016.02 + */ +class ExternalMessageSourceStateImporter { + + public function importSafe( $changeData ) { + $processed = []; + $skipped = []; + $jobs = []; + $jobs[] = MessageIndexRebuildJob::newJob(); + + foreach ( $changeData as $groupId => $changesForGroup ) { + $group = MessageGroups::getGroup( $groupId ); + if ( !$group ) { + unset( $changeData[$groupId] ); + continue; + } + + $processed[$groupId] = 0; + + foreach ( $changesForGroup as $languageCode => $changesForLanguage ) { + if ( !self::isSafe( $changesForLanguage ) ) { + $skipped[$groupId] = true; + continue; + } + + if ( !isset( $changesForLanguage['addition'] ) ) { + continue; + } + + foreach ( $changesForLanguage['addition'] as $addition ) { + $namespace = $group->getNamespace(); + $name = "{$addition['key']}/$languageCode"; + + $title = Title::makeTitleSafe( $namespace, $name ); + if ( !$title ) { + wfWarn( "Invalid title for group $groupId key {$addition['key']}" ); + continue; + } + + $jobs[] = MessageUpdateJob::newJob( $title, $addition['content'] ); + $processed[$groupId]++; + } + + unset( $changeData[$groupId][$languageCode] ); + + $cache = new MessageGroupCache( $groupId, $languageCode ); + $cache->create(); + } + } + + // Remove groups where everything was imported + $changeData = array_filter( $changeData ); + // Remove groups with no imports + $processed = array_filter( $processed ); + + $name = 'unattended'; + $file = MessageChangeStorage::getCdbPath( $name ); + MessageChangeStorage::writeChanges( $changeData, $file ); + JobQueueGroup::singleton()->push( $jobs ); + + return [ + 'processed' => $processed, + 'skipped' => $skipped, + 'name' => $name, + ]; + } + + protected static function isSafe( array $changesForLanguage ) { + foreach ( array_keys( $changesForLanguage ) as $changeType ) { + if ( $changeType !== 'addition' ) { + return false; + } + } + + return true; + } +} diff --git a/www/wiki/extensions/Translate/utils/Font.php b/www/wiki/extensions/Translate/utils/Font.php new file mode 100644 index 00000000..37fa4ac7 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/Font.php @@ -0,0 +1,138 @@ +<?php +/** + * Contains class with wrapper around font-config. + * + * @author Niklas Laxström + * @author Harry Burt + * @license Unlicense + * @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 string $code 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 string $code 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 = []; + + 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 = [ + '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/www/wiki/extensions/Translate/utils/FuzzyBot.php b/www/wiki/extensions/Translate/utils/FuzzyBot.php new file mode 100644 index 00000000..093e39e1 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/FuzzyBot.php @@ -0,0 +1,25 @@ +<?php +/** + * Do it all maintenance account + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2012-2013, Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * FuzzyBot - the misunderstood workhorse. + * @since 2012-01-02 + */ +class FuzzyBot { + public static function getUser() { + return User::newSystemUser( self::getName(), [ 'steal' => true ] ); + } + + public static function getName() { + global $wgTranslateFuzzyBotName; + + return $wgTranslateFuzzyBotName; + } +} diff --git a/www/wiki/extensions/Translate/utils/HTMLJsSelectToInputField.php b/www/wiki/extensions/Translate/utils/HTMLJsSelectToInputField.php new file mode 100644 index 00000000..57f4443c --- /dev/null +++ b/www/wiki/extensions/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-or-later + */ + +/** + * Implementation of JsSelectToInput class which is extends HTMLTextField. + */ +class HTMLJsSelectToInputField extends HTMLTextField { + /** + * @param string $value + * @return string + */ + public 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 string $value + * @return array + */ + protected function tidy( $value ) { + $value = array_map( 'trim', explode( ',', $value ) ); + $value = array_unique( array_filter( $value ) ); + + return $value; + } + + /** + * @param string $value + * @param array $alldata + * @return bool|string + */ + public 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 string $value + * @param array $alldata + * @return string + */ + public function filter( $value, $alldata ) { + $value = parent::filter( $value, $alldata ); + + return implode( ', ', $this->tidy( $value ) ); + } +} diff --git a/www/wiki/extensions/Translate/utils/JsSelectToInput.php b/www/wiki/extensions/Translate/utils/JsSelectToInput.php new file mode 100644 index 00000000..24f30bc1 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/JsSelectToInput.php @@ -0,0 +1,120 @@ +<?php +/** + * Code for JavaScript enhanced \<option> selectors. + * @file + * @author Niklas Laxström + * @copyright Copyright © 2010 Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * 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; + } + + /** + * @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() { + $this->sourceId = $this->select->getAttribute( 'id' ); + + if ( !is_string( $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', [ + 'type' => 'button', + 'value' => wfMessage( $msg )->text(), + 'onclick' => Xml::encodeJsCall( 'appendFromSelect', [ $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/www/wiki/extensions/Translate/utils/MemProfile.php b/www/wiki/extensions/Translate/utils/MemProfile.php new file mode 100644 index 00000000..7ebc3423 --- /dev/null +++ b/www/wiki/extensions/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-or-later + */ + +/// Memory usage at checkpoints +$wgMemUse = []; +/// Tracks the deepness of the stack +$wgMemStack = 0; + +/** + * Call to start memory counting for a block. + * @param string $a Block name. + */ +function wfMemIn( $a ) { + global $wgLang, $wgMemUse, $wgMemStack; + + $mem = memory_get_usage(); + $memR = memory_get_usage(); + + $wgMemUse[$a][] = [ $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 string $a 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/www/wiki/extensions/Translate/utils/MessageChangeStorage.php b/www/wiki/extensions/Translate/utils/MessageChangeStorage.php new file mode 100644 index 00000000..5c23a3a6 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageChangeStorage.php @@ -0,0 +1,52 @@ +<?php +/** + * Handles storage of message change files. + * + * @author Niklas Laxström + * @license GPL-2.0-or-later + * @since 2016.02 + * @file + */ + +class MessageChangeStorage { + const DEFAULT_NAME = 'default'; + + /** + * Writes change array as a serialized file. + * + * @param array $array Array of changes as returned by processGroup + * indexed by message group id. + * @param string $file Which file to use. + */ + public static function writeChanges( $array, $file ) { + $cache = \Cdb\Writer::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(); + } + + /** + * Validate a name. + * + * @param string $name Which file to use. + * @return bool + */ + public static function isValidCdbName( $name ) { + return preg_match( '/^[a-zA-Z_-]{1,100}$/', $name ); + } + + /** + * Get a full path to file in a known location. + * + * @param string $name Which file to use. + * @return string + */ + public static function getCdbPath( $name ) { + return TranslateUtils::cacheFile( "messagechanges.$name.cdb" ); + } +} diff --git a/www/wiki/extensions/Translate/utils/MessageGroupCache.php b/www/wiki/extensions/Translate/utils/MessageGroupCache.php new file mode 100644 index 00000000..f0b9c4bd --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageGroupCache.php @@ -0,0 +1,276 @@ +<?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-or-later + */ + +/** + * 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 \Cdb\Reader + */ + 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 ); + + 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 === [] ) { + 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 = \Cdb\Writer::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 ?: 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 ) { + $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 = (int)( $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 self + */ + protected function open() { + if ( $this->cache === null ) { + $this->cache = \Cdb\Reader::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/www/wiki/extensions/Translate/utils/MessageGroupStates.php b/www/wiki/extensions/Translate/utils/MessageGroupStates.php new file mode 100644 index 00000000..de20f6c8 --- /dev/null +++ b/www/wiki/extensions/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-or-later + */ + +/** + * 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 []; + } + } +} diff --git a/www/wiki/extensions/Translate/utils/MessageGroupStatesUpdaterJob.php b/www/wiki/extensions/Translate/utils/MessageGroupStatesUpdaterJob.php new file mode 100644 index 00000000..c40bc4ec --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageGroupStatesUpdaterJob.php @@ -0,0 +1,151 @@ +<?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-or-later + */ + +/** + * Logic for handling automatic message group state changes + * + * @ingroup JobQueue + */ +class MessageGroupStatesUpdaterJob extends Job { + /** + * @param Title $title + * @param array $params + */ + public function __construct( $title, $params = [] ) { + parent::__construct( __CLASS__, $title, $params ); + $this->removeDuplicates = true; + } + + /** + * Hook: TranslateEventTranslationReview + * and also on translation changes + * @param MessageHandle $handle + * @return true + */ + public static function onChange( MessageHandle $handle ) { + $job = self::newJob( $handle->getTitle() ); + JobQueueGroup::singleton()->push( $job ); + + return true; + } + + /** + * @param Title $title + * @return self + */ + public static function newJob( $title ) { + $job = new self( $title ); + + return $job; + } + + public function run() { + $title = $this->title; + $handle = new MessageHandle( $title ); + $code = $handle->getCode(); + + if ( !$code && !$handle->isValid() ) { + 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 = []; + 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" ); + } + } + + /** + * @param int[] $stats + * @param array[] $transitions + * + * @return string|bool + */ + 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/www/wiki/extensions/Translate/utils/MessageGroupStats.php b/www/wiki/extensions/Translate/utils/MessageGroupStats.php new file mode 100644 index 00000000..950f45f8 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageGroupStats.php @@ -0,0 +1,646 @@ +<?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 + * @license GPL-2.0-or-later + */ + +use MediaWiki\MediaWikiServices; +use Wikimedia\Rdbms\IDatabase; + +/** + * 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 + + /// If stats are not cached, do not attempt to calculate them on the fly + const FLAG_CACHE_ONLY = 1; + /// Ignore cached values. Useful for updating stale values. + const FLAG_NO_CACHE = 2; + + /** + * @var array[] + */ + protected static $updates = []; + + /** + * @var string[] + */ + private static $languages; + + /** + * Returns empty stats array. Useful because the number of elements + * may change. + * @return int[] + * @since 2012-09-21 + */ + public static function getEmptyStats() { + return [ 0, 0, 0, 0 ]; + } + + /** + * Returns empty stats array that indicates stats are incomplete or + * unknown. + * @return null[] + * @since 2013-01-02 + */ + protected static function getUnknownStats() { + return [ null, null, null, null ]; + } + + private static function isValidLanguage( $code ) { + $languages = self::getLanguages(); + return in_array( $code, $languages ); + } + + private static function isValidMessageGroup( MessageGroup $group = null ) { + /* In case some code calls stats for dynamic groups. Calculating these numbers + * don't make sense for dynamic groups, and would just throw an exception. */ + return $group && !MessageGroups::isDynamic( $group ); + } + + /** + * Returns stats for given group in given language. + * @param string $id Group id + * @param string $code Language code + * @param int $flags Combination of FLAG_* constants. + * @return null[]|int[] + */ + public static function forItem( $id, $code, $flags = 0 ) { + $group = MessageGroups::getGroup( $id ); + if ( !self::isValidMessageGroup( $group ) || !self::isValidLanguage( $code ) ) { + return self::getUnknownStats(); + } + + $res = self::selectRowsIdLang( [ $id ], [ $code ], $flags ); + $stats = self::extractResults( $res, [ $id ] ); + + if ( !isset( $stats[$id][$code] ) ) { + $stats[$id][$code] = self::forItemInternal( $stats, $group, $code, $flags ); + } + + self::queueUpdates( $flags ); + + return $stats[$id][$code]; + } + + /** + * Returns stats for all groups in given language. + * @param string $code Language code + * @param int $flags Combination of FLAG_* constants. + * @return array[] + */ + public static function forLanguage( $code, $flags = 0 ) { + if ( !self::isValidLanguage( $code ) ) { + return self::getUnknownStats(); + } + + $stats = self::forLanguageInternal( $code, [], $flags ); + $flattened = []; + foreach ( $stats as $group => $languages ) { + $flattened[$group] = $languages[$code]; + } + + self::queueUpdates( $flags ); + + return $flattened; + } + + /** + * Returns stats for all languages in given group. + * @param string $id Group id + * @param int $flags Combination of FLAG_* constants. + * @return array[] + */ + public static function forGroup( $id, $flags = 0 ) { + $group = MessageGroups::getGroup( $id ); + if ( !self::isValidMessageGroup( $group ) ) { + return []; + } + + $stats = self::forGroupInternal( $group, [], $flags ); + + self::queueUpdates( $flags ); + + 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. + * @param int $flags Combination of FLAG_* constants. + * @return array[] + */ + public static function forEverything( $flags = 0 ) { + $groups = MessageGroups::singleton()->getGroups(); + $stats = []; + foreach ( $groups as $g ) { + $stats = self::forGroupInternal( $g, $stats, $flags ); + } + + self::queueUpdates( $flags ); + + return $stats; + } + + /** + * Recalculate stats for all groups associated with the message. + * + * Hook: TranslateEventTranslationReview + * @param MessageHandle $handle + */ + public static function clear( MessageHandle $handle ) { + $code = $handle->getCode(); + $groups = self::getSortedGroupsForClearing( $handle->getGroupIds() ); + self::internalClearGroups( $code, $groups ); + } + + /** + * Recalculate stats for given group(s). + * + * @param string|string[] $id Message group ids. + */ + public static function clearGroup( $id ) { + $languages = self::getLanguages(); + $groups = self::getSortedGroupsForClearing( (array)$id ); + + // Do one language at a time, to save memory + foreach ( $languages as $code ) { + self::internalClearGroups( $code, $groups ); + } + } + + /** + * Helper for clear and clearGroup that caches already loaded statistics. + * + * @param string $code + * @param MessageGroup[] $groups + */ + private static function internalClearGroups( $code, array $groups ) { + $stats = []; + foreach ( $groups as $id => $group ) { + // $stats is modified by reference + self::forItemInternal( $stats, $group, $code, 0 ); + } + self::queueUpdates( 0 ); + } + + /** + * Get sorted message groups ids that can be used for efficient clearing. + * + * To optimize performance, we first need to process all non-aggregate groups. + * Because aggregate groups are flattened (see self::expandAggregates), we can + * process them any order and allow use of cache, except for the aggregate groups + * itself. + * + * @param string[] $ids + * @return string[] + */ + private static function getSortedGroupsForClearing( array $ids ) { + $groups = array_map( [ MessageGroups::class, 'getGroup' ], $ids ); + // Sanity: Remove any invalid groups + $groups = array_filter( $groups ); + + $sorted = []; + $aggs = []; + foreach ( $groups as $group ) { + if ( $group instanceof AggregateMessageGroup ) { + $aggs[$group->getId()] = $group; + } else { + $sorted[$group->getId()] = $group; + } + } + + return array_merge( $sorted, $aggs ); + } + + /** + * Get list of supported languages for statistics. + * + * @return string[] + */ + private static function getLanguages() { + if ( self::$languages === null ) { + $languages = array_keys( TranslateUtils::getLanguageNames( 'en' ) ); + sort( $languages ); + self::$languages = $languages; + } + + return self::$languages; + } + + public static function clearLanguage( $code ) { + if ( !count( $code ) ) { + return; + } + $dbw = wfGetDB( DB_MASTER ); + $conds = [ '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 :(' ); + } + + /** + * Use this to extract results returned from selectRowsIdLang. You must pass the + * message group ids you want to retrieve. Entries that do not match are not returned. + * + * @param Traversable $res Database result object + * @param string[] $ids List of message group ids + * @param array[] $stats Optional array to append results to. + * @return array[] + */ + protected static function extractResults( $res, array $ids, array $stats = [] ) { + // Map the internal ids back to real ids + $idmap = array_combine( array_map( 'self::getDatabaseIdForGroupId', $ids ), $ids ); + + foreach ( $res as $row ) { + if ( !isset( $idmap[$row->tgs_group] ) ) { + // Stale entry, ignore for now + // TODO: Schedule for purge + continue; + } + + $realId = $idmap[$row->tgs_group]; + $stats[$realId][$row->tgs_lang] = self::extractNumbers( $row ); + } + + return $stats; + } + + public static function update( MessageHandle $handle, array $changes = [] ) { + $dbids = array_map( 'self::getDatabaseIdForGroupId', $handle->getGroupIds() ); + + $dbw = wfGetDB( DB_MASTER ); + $conds = [ + 'tgs_group' => $dbids, + 'tgs_lang' => $handle->getCode(), + ]; + + $values = []; + foreach ( [ '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 stdClass $row + * @return array + */ + protected static function extractNumbers( $row ) { + return [ + 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 string $code Language code + * @param array[] $stats + * @param int $flags Combination of FLAG_* constants. + * @return array[] + */ + protected static function forLanguageInternal( $code, array $stats = [], $flags ) { + $groups = MessageGroups::singleton()->getGroups(); + + $ids = array_keys( $groups ); + $res = self::selectRowsIdLang( null, [ $code ], $flags ); + $stats = self::extractResults( $res, $ids, $stats ); + + foreach ( $groups as $id => $group ) { + if ( isset( $stats[$id][$code] ) ) { + continue; + } + $stats[$id][$code] = self::forItemInternal( $stats, $group, $code, $flags ); + } + + return $stats; + } + + /** + * @param AggregateMessageGroup $agg + * @return mixed + */ + protected static function expandAggregates( AggregateMessageGroup $agg ) { + $flattened = []; + + /** @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 + * @param int $flags Combination of FLAG_* constants. + * @return array[] + */ + protected static function forGroupInternal( MessageGroup $group, array $stats = [], $flags ) { + $id = $group->getId(); + + $res = self::selectRowsIdLang( [ $id ], null, $flags ); + $stats = self::extractResults( $res, [ $id ], $stats ); + + # Go over each language filling missing entries + $languages = self::getLanguages(); + foreach ( $languages as $code ) { + if ( isset( $stats[$id][$code] ) ) { + continue; + } + $stats[$id][$code] = self::forItemInternal( $stats, $group, $code, $flags ); + } + + // This is for sorting the values added later in correct order + foreach ( array_keys( $stats ) as $key ) { + ksort( $stats[$key] ); + } + + return $stats; + } + + /** + * Fetch rows from the database. Use extractResults to process this value. + * + * @param null|string[] $ids List of message group ids + * @param null|string[] $codes List of language codes + * @param int $flags Combination of FLAG_* constants. + * @return Traversable Database result object + */ + protected static function selectRowsIdLang( array $ids = null, array $codes = null, $flags ) { + if ( $flags & self::FLAG_NO_CACHE ) { + return []; + } + + $conds = []; + if ( $ids !== null ) { + $dbids = array_map( 'self::getDatabaseIdForGroupId', $ids ); + $conds['tgs_group'] = $dbids; + } + + if ( $codes !== null ) { + $conds['tgs_lang'] = $codes; + } + + $dbr = TranslateUtils::getSafeReadDB(); + $res = $dbr->select( self::TABLE, '*', $conds, __METHOD__ ); + + return $res; + } + + /** + * @param array[] &$stats + * @param MessageGroup $group + * @param string $code Language code + * @param int $flags Combination of FLAG_* constants. + * @return null[]|int[] + */ + protected static function forItemInternal( &$stats, MessageGroup $group, $code, $flags ) { + $id = $group->getId(); + + if ( $flags & self::FLAG_CACHE_ONLY ) { + $stats[$id][$code] = self::getUnknownStats(); + return $stats[$id][$code]; + } + + if ( $group instanceof AggregateMessageGroup ) { + $aggregates = self::calculateAggregageGroup( $stats, $group, $code, $flags ); + } else { + $aggregates = self::calculateGroup( $group, $code ); + } + // Cache for use in subsequent forItemInternal calls + $stats[$id][$code] = $aggregates; + + // Don't add nulls to the database, causes annoying warnings + if ( $aggregates[self::TOTAL] === null ) { + return $aggregates; + } + + self::$updates[] = [ + 'tgs_group' => self::getDatabaseIdForGroupId( $id ), + 'tgs_lang' => $code, + 'tgs_total' => $aggregates[self::TOTAL], + 'tgs_translated' => $aggregates[self::TRANSLATED], + 'tgs_fuzzy' => $aggregates[self::FUZZY], + 'tgs_proofread' => $aggregates[self::PROOFREAD], + ]; + + // For big and lengthy updates, attempt some interim saves. This might not have + // any effect, because writes to the database may be deferred. + if ( count( self::$updates ) % 100 === 0 ) { + self::queueUpdates( $flags ); + } + + return $aggregates; + } + + private static function calculateAggregageGroup( &$stats, $group, $code, $flags ) { + $aggregates = self::getEmptyStats(); + + $expanded = self::expandAggregates( $group ); + $subGroupIds = array_keys( $expanded ); + + // Performance: if we have per-call cache of stats, do not query them again. + foreach ( $subGroupIds as $index => $sid ) { + if ( isset( $stats[$sid][$code] ) ) { + unset( $subGroupIds[ $index ] ); + } + } + + if ( $subGroupIds !== [] ) { + $res = self::selectRowsIdLang( $subGroupIds, [ $code ], $flags ); + $stats = self::extractResults( $res, $subGroupIds, $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, $flags ); + } + + $include = Hooks::run( 'Translate:MessageGroupStats:isIncluded', [ $sid, $code ] ); + if ( $include ) { + $aggregates = self::multiAdd( $aggregates, $stats[$sid][$code] ); + } + } + + 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 int[] ( total, translated, fuzzy, proofread ) + */ + protected static function calculateGroup( MessageGroup $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 = []; + 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 [ + self::TOTAL => $total, + self::TRANSLATED => $translated, + self::FUZZY => $fuzzy, + self::PROOFREAD => $proofread, + ]; + } + + /** + * Converts input to "+2" "-4" type of string. + * @param int $number + * @return string + */ + protected static function stringifyNumber( $number ) { + $number = (int)$number; + + return $number < 0 ? "$number" : "+$number"; + } + + protected static function queueUpdates( $flags ) { + if ( wfReadOnly() ) { + return; + } + + if ( self::$updates === [] ) { + return; + } + + $lb = MediaWikiServices::getInstance()->getDBLoadBalancer(); + $dbw = $lb->getLazyConnectionRef( DB_MASTER ); // avoid connecting yet + $table = self::TABLE; + $updates = &self::$updates; + + $updateOp = self::withLock( + $dbw, + 'updates', + __METHOD__, + function ( IDatabase $dbw, $method ) use ( $table, &$updates ) { + // Maybe another deferred update already processed these + if ( $updates === [] ) { + return; + } + + $primaryKey = [ 'tgs_group', 'tgs_lang' ]; + $dbw->replace( $table, [ $primaryKey ], $updates, $method ); + $updates = []; + } + ); + + if ( defined( 'MEDIAWIKI_JOB_RUNNER' ) ) { + call_user_func( $updateOp ); + } else { + DeferredUpdates::addCallableUpdate( $updateOp ); + } + } + + protected static function withLock( IDatabase $dbw, $key, $method, $callback ) { + $fname = __METHOD__; + return function () use ( $dbw, $key, $method, $callback, $fname ) { + $lockName = 'MessageGroupStats:' . $key; + if ( !$dbw->lock( $lockName, $fname, 1 ) ) { + return; // raced out + } + + $dbw->commit( $fname, 'flush' ); + call_user_func( $callback, $dbw, $method ); + $dbw->commit( $fname, 'flush' ); + + $dbw->unlock( $lockName, $fname ); + }; + } + + public static function getDatabaseIdForGroupId( $id ) { + // The column is 100 bytes long, but we don't need to use it all + if ( strlen( $id ) <= 72 ) { + return $id; + } + + $hash = hash( 'sha256', $id, /*asHex*/false ); + $dbid = substr( $id, 0, 50 ) . '||' . substr( $hash, 0, 20 ); + return $dbid; + } +} diff --git a/www/wiki/extensions/Translate/utils/MessageGroupStatsRebuildJob.php b/www/wiki/extensions/Translate/utils/MessageGroupStatsRebuildJob.php new file mode 100644 index 00000000..d6d3b448 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageGroupStatsRebuildJob.php @@ -0,0 +1,50 @@ +<?php +/** + * Contains class with job for rebuilding message group stats. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Job for rebuilding message index. + * + * @ingroup JobQueue + */ +class MessageGroupStatsRebuildJob extends Job { + /** + * @param array $params + * @return self + */ + public static function newJob( $params ) { + $job = new self( Title::newMainPage(), $params ); + return $job; + } + + /** + * @param Title $title + * @param array $params + */ + public function __construct( $title, $params = [] ) { + parent::__construct( __CLASS__, $title, $params ); + } + + public function run() { + $params = $this->params; + $flags = 0; + + if ( isset( $params[ 'purge' ] ) && $params[ 'purge' ] ) { + $flags |= MessageGroupStats::FLAG_NO_CACHE; + } + + if ( isset( $params[ 'groupid' ] ) ) { + MessageGroupStats::forGroup( $params[ 'groupid' ], $flags ); + } + if ( isset( $params[ 'languagecode' ] ) ) { + MessageGroupStats::forGroup( $params[ 'languagecode' ], $flags ); + } + + return true; + } +} diff --git a/www/wiki/extensions/Translate/utils/MessageHandle.php b/www/wiki/extensions/Translate/utils/MessageHandle.php new file mode 100644 index 00000000..65f95bd5 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageHandle.php @@ -0,0 +1,291 @@ +<?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-or-later + */ + +/** + * Class for pointing to messages, like Title class is for titles. + * @since 2011-03-13 + */ +class MessageHandle { + /** + * @var Title + */ + protected $title; + + /** + * @var string|null + */ + protected $key; + + /** + * @var string|null Language code + */ + protected $code; + + /** + * @var string[]|null + */ + protected $groupIds; + + 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 string[] 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 ) !== []; + + $pos = strrpos( $this->key, '/' ); + if ( $known || $pos === false ) { + $this->code = ''; + } else { + // For keys like Foo/, substr returns false instead of '' + $this->code = (string)( substr( $this->key, $pos + 1 ) ); + $this->key = substr( $this->key, 0, $pos ); + } + } + + return [ $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 Language object for the assumed language of the content, which might + * be different from the subpage code (qqq, no subpage). + * @return Language + * @since 2016-01 + */ + public function getEffectiveLanguage() { + global $wgContLang; + $code = $this->getCode(); + if ( $code === '' || $this->isDoc() ) { + return $wgContLang; + } + + return wfGetLangObj( $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()->inNamespace( 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 string[] + */ + 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 string $text 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_REPLICA ); + + $tables = [ 'page', 'revtag' ]; + $field = 'rt_type'; + $conds = [ + '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; + } + + /** + * This returns the key that can be used for showMessage parameter for Special:Translate + * for regular message groups. It is not possible to automatically determine this key + * from the title alone. + * @return string + * @since 2017.10 + */ + public function getInternalKey() { + global $wgContLang; + + $key = $this->getKey(); + + if ( !MWNamespace::isCapitalized( $this->getTitle()->getNamespace() ) ) { + return $key; + } + + $group = $this->getGroup(); + $keys = []; + // We cannot reliably map from the database key to the internal key if + // capital links setting is enabled for the namespace. + if ( method_exists( $group, 'getKeys' ) ) { + $keys = $group->getKeys(); + } else { + $keys = array_keys( $group->getDefinitions() ); + } + + if ( in_array( $key, $keys, true ) ) { + return $key; + } + + $lcKey = $wgContLang->lcfirst( $key ); + if ( in_array( $lcKey, $keys, true ) ) { + return $lcKey; + } + + return "BUG:$key"; + } +} diff --git a/www/wiki/extensions/Translate/utils/MessageIndex.php b/www/wiki/extensions/Translate/utils/MessageIndex.php new file mode 100644 index 00000000..0015d0aa --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageIndex.php @@ -0,0 +1,743 @@ +<?php +/** + * Contains classes for handling the message index. + * + * @file + * @author Niklas Laxstrom + * @copyright Copyright © 2008-2013, Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * 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 self + */ + protected static $instance; + + /** + * @var MapCacheLRU|null + */ + private static $keysCache; + + /** + * @return self + */ + public static function singleton() { + if ( self::$instance === null ) { + global $wgTranslateMessageIndex; + $params = $wgTranslateMessageIndex; + $class = array_shift( $params ); + self::$instance = new $class( $params ); + } + + return self::$instance; + } + + /** + * Override the global instance, for testing. + * + * @since 2015.04 + * @param MessageIndex $instance + */ + public static function setInstance( self $instance ) { + self::$instance = $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 ) { + global $wgTranslateMessageNamespaces; + + $title = $handle->getTitle(); + + if ( !$title->inNamespaces( $wgTranslateMessageNamespaces ) ) { + return []; + } + + $namespace = $title->getNamespace(); + $key = $handle->getKey(); + $normkey = TranslateUtils::normaliseKey( $namespace, $key ); + + $cache = self::getCache(); + $value = $cache->get( $normkey ); + if ( $value === null ) { + $value = self::singleton()->get( $normkey ); + $value = $value !== null + ? (array)$value + : []; + $cache->set( $normkey, $value ); + } + + return $value; + } + + /** + * @return MapCacheLRU + */ + private static function getCache() { + if ( self::$keysCache === null ) { + self::$keysCache = new MapCacheLRU( 30 ); + } + return self::$keysCache; + } + + /** + * @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; + } + } + + /** + * @param bool $forRebuild + * @return array + */ + abstract public function retrieve( $forRebuild = false ); + + /** + * @since 2018.01 + * @return string[] + */ + public function getKeys() { + return array_keys( $this->retrieve() ); + } + + abstract protected function store( array $array, array $diff ); + + protected function lock() { + return true; + } + + protected function unlock() { + return true; + } + + public function rebuild() { + static $recursion = 0; + + if ( $recursion > 0 ) { + $msg = __METHOD__ . ': trying to recurse - building the index first time?'; + wfWarn( $msg ); + + $recursion--; + return []; + } + $recursion++; + + $groups = MessageGroups::singleton()->getGroups(); + + if ( !$this->lock() ) { + throw new Exception( __CLASS__ . ': unable to acquire lock' ); + } + + self::getCache()->clear(); + + $new = $old = []; + $old = $this->retrieve( 'rebuild' ); + $postponed = []; + + /** + * @var MessageGroup $g + */ + foreach ( $groups as $g ) { + if ( !$g->exists() ) { + $id = $g->getId(); + wfWarn( __METHOD__ . ": group '$id' is registered but does not exist" ); + continue; + } + + # Skip meta thingies + if ( $g->isMeta() ) { + $postponed[] = $g; + continue; + } + + $this->checkAndAdd( $new, $g ); + } + + foreach ( $postponed as $g ) { + $this->checkAndAdd( $new, $g, true ); + } + + $diff = self::getArrayDiff( $old, $new ); + $this->store( $new, $diff['keys'] ); + $this->unlock(); + $this->clearMessageGroupStats( $diff ); + + $recursion--; + + return $new; + } + + /** + * Compares two associative arrays. + * + * Values must be a string or list of strings. Returns an array of added, + * deleted and modified keys as well as value changes (you can think values + * as categories and keys as pages). Each of the keys ('add', 'del', 'mod' + * respectively) maps to an array whose keys are the changed keys of the + * original arrays and values are lists where first element contains the + * old value and the second element the new value. + * + * @code + * $a = [ 'a' => '1', 'b' => '2', 'c' => '3' ]; + * $b = [ 'b' => '2', 'c' => [ '3', '2' ], 'd' => '4' ]; + * + * self::getArrayDiff( $a, $b ) ) === [ + * 'keys' => [ + * 'add' => [ 'd' => [ [], [ '4' ] ] ], + * 'del' => [ 'a' => [ [ '1' ], [] ] ], + * 'mod' => [ 'c' => [ [ '3' ], [ '3', '2' ] ] ], + * ], + * 'values' => [ 2, 4, 1 ] + * ]; + * @endcode + * + * @param array $old + * @param array $new + * @return array + */ + public static function getArrayDiff( array $old, array $new ) { + $values = []; + $record = function ( $groups ) use ( &$values ) { + foreach ( $groups as $group ) { + $values[$group] = true; + } + }; + + $keys = [ + 'add' => [], + 'del' => [], + 'mod' => [], + ]; + + foreach ( $new as $key => $groups ) { + if ( !isset( $old[$key] ) ) { + $keys['add'][$key] = [ [], (array)$groups ]; + $record( (array)$groups ); + // Using != here on purpose to ignore the order of items + } elseif ( $groups != $old[$key] ) { + $keys['mod'][$key] = [ (array)$old[$key], (array)$groups ]; + $record( array_diff( (array)$old[$key], (array)$groups ) ); + $record( array_diff( (array)$groups, (array)$old[$key] ) ); + } + } + + foreach ( $old as $key => $groups ) { + if ( !isset( $new[$key] ) ) { + $keys['del'][$key] = [ (array)$groups, [] ]; + $record( (array)$groups, [] ); + } + // We already checked for diffs above + } + + return [ + 'keys' => $keys, + 'values' => array_keys( $values ), + ]; + } + + /** + * Purge stuff when set of keys have changed. + * + * @param array $diff + */ + protected function clearMessageGroupStats( array $diff ) { + MessageGroupStats::clearGroup( $diff['values'] ); + + foreach ( $diff['keys'] as $keys ) { + foreach ( $keys as $key => $data ) { + list( $ns, $pagename ) = explode( ':', $key, 2 ); + $title = Title::makeTitle( $ns, $pagename ); + $handle = new MessageHandle( $title ); + list( $oldGroups, $newGroups ) = $data; + Hooks::run( 'TranslateEventMessageMembershipChange', + [ $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] = [ &$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. + * @param mixed $data + * @return mixed + */ + 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|null + */ + protected $index; + + protected $filename = 'translate_messageindex.ser'; + + /** + * @param bool $forRebuild + * @return array + */ + public function retrieve( $forRebuild = false ) { + if ( $this->index !== null ) { + return $this->index; + } + + $file = TranslateUtils::cacheFile( $this->filename ); + if ( file_exists( $file ) ) { + $this->index = unserialize( file_get_contents( $file ) ); + } else { + $this->index = $this->rebuild(); + } + + return $this->index; + } + + protected function store( array $array, array $diff ) { + $file = TranslateUtils::cacheFile( $this->filename ); + file_put_contents( $file, serialize( $array ) ); + $this->index = $array; + } +} + +/// 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|null + */ + protected $index; + + protected function lock() { + $dbw = wfGetDB( DB_MASTER ); + + // Any transaction should be flushed after getting the lock to avoid + // stale pre-lock REPEATABLE-READ snapshot data. + $ok = $dbw->lock( 'translate-messageindex', __METHOD__, 30 ); + if ( $ok ) { + $dbw->commit( __METHOD__, 'flush' ); + } + + return $ok; + } + + protected function unlock() { + $fname = __METHOD__; + $dbw = wfGetDB( DB_MASTER ); + // Unlock once the rows are actually unlocked to avoid deadlocks + if ( !$dbw->trxLevel() ) { + $dbw->unlock( 'translate-messageindex', $fname ); + } elseif ( method_exists( $dbw, 'onTransactionResolution' ) ) { // 1.28 + $dbw->onTransactionResolution( function () use ( $dbw, $fname ) { + $dbw->unlock( 'translate-messageindex', $fname ); + } ); + } else { + $dbw->onTransactionIdle( function () use ( $dbw, $fname ) { + $dbw->unlock( 'translate-messageindex', $fname ); + } ); + } + + return true; + } + + /** + * @param bool $forRebuild + * @return array + */ + public function retrieve( $forRebuild = false ) { + if ( $this->index !== null && !$forRebuild ) { + return $this->index; + } + + $dbr = wfGetDB( $forRebuild ? DB_MASTER : DB_REPLICA ); + $res = $dbr->select( 'translate_messageindex', '*', [], __METHOD__ ); + $this->index = []; + foreach ( $res as $row ) { + $this->index[$row->tmi_key] = $this->unserialize( $row->tmi_value ); + } + + return $this->index; + } + + protected function get( $key ) { + $dbr = wfGetDB( DB_REPLICA ); + $value = $dbr->selectField( + 'translate_messageindex', + 'tmi_value', + [ 'tmi_key' => $key ], + __METHOD__ + ); + + if ( is_string( $value ) ) { + $value = $this->unserialize( $value ); + } else { + $value = null; + } + + return $value; + } + + protected function store( array $array, array $diff ) { + $updates = []; + + foreach ( [ $diff['add'], $diff['mod'] ] as $changes ) { + foreach ( $changes as $key => $data ) { + list( $old, $new ) = $data; + $updates[] = [ + 'tmi_key' => $key, + 'tmi_value' => $this->serialize( $new ), + ]; + } + } + + $index = [ 'tmi_key' ]; + $deletions = array_keys( $diff['del'] ); + + $dbw = wfGetDB( DB_MASTER ); + $dbw->startAtomic( __METHOD__ ); + + if ( $updates !== [] ) { + $dbw->replace( 'translate_messageindex', [ $index ], $updates, __METHOD__ ); + } + + if ( $deletions !== [] ) { + $dbw->delete( 'translate_messageindex', [ 'tmi_key' => $deletions ], __METHOD__ ); + } + + $dbw->endAtomic( __METHOD__ ); + + $this->index = $array; + } +} + +/** + * 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|null + */ + protected $index; + + protected function __construct( array $params ) { + $this->cache = wfGetCache( CACHE_ANYTHING ); + } + + /** + * @param bool $forRebuild + * @return array + */ + public function retrieve( $forRebuild = false ) { + if ( $this->index !== null ) { + return $this->index; + } + + $key = wfMemcKey( $this->key ); + $data = $this->cache->get( $key ); + if ( is_array( $data ) ) { + $this->index = $data; + } else { + $this->index = $this->rebuild(); + } + + return $this->index; + } + + protected function store( array $array, array $diff ) { + $key = wfMemcKey( $this->key ); + $this->cache->set( $key, $array ); + + $this->index = $array; + } +} + +/** + * 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|null + */ + protected $index; + + /** + * @var \Cdb\Reader|null + */ + protected $reader; + + /** + * @var string + */ + protected $filename = 'translate_messageindex.cdb'; + + /** + * @param bool $forRebuild + * @return array + */ + public function retrieve( $forRebuild = false ) { + $reader = $this->getReader(); + // This must be below the line above, which may fill the index + if ( $this->index !== null ) { + return $this->index; + } + + $this->index = []; + foreach ( $this->getKeys() as $key ) { + $this->index[$key] = $this->unserialize( $reader->get( $key ) ); + } + + return $this->index; + } + + public function getKeys() { + $reader = $this->getReader(); + $keys = []; + while ( true ) { + $key = $keys === [] ? $reader->firstkey() : $reader->nextkey(); + if ( $key === false ) { + break; + } + $keys[] = $key; + } + + return $keys; + } + + 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, array $diff ) { + $this->reader = null; + + $file = TranslateUtils::cacheFile( $this->filename ); + $cache = \Cdb\Writer::open( $file ); + + foreach ( $array as $key => $value ) { + $value = $this->serialize( $value ); + $cache->set( $key, $value ); + } + + $cache->close(); + + $this->index = $array; + } + + 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( [], [] ); + $this->index = $this->rebuild(); + } + + $this->reader = \Cdb\Reader::open( $file ); + return $this->reader; + } +} + +/** + * Storage on hash. + * + * For testing. + * + * @since 2015.04 + */ +class HashMessageIndex extends MessageIndex { + /** + * @var array + */ + protected $index = []; + + /** + * @param bool $forRebuild + * @return array + */ + public function retrieve( $forRebuild = false ) { + return $this->index; + } + + /** + * @param string $key + * + * @return mixed + */ + protected function get( $key ) { + if ( isset( $this->index[$key] ) ) { + return $this->index[$key]; + } else { + return null; + } + } + + protected function store( array $array, array $diff ) { + $this->index = $array; + } + + protected function clearMessageGroupStats( array $diff ) { + } +} diff --git a/www/wiki/extensions/Translate/utils/MessageIndexRebuildJob.php b/www/wiki/extensions/Translate/utils/MessageIndexRebuildJob.php new file mode 100644 index 00000000..2b66205f --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageIndexRebuildJob.php @@ -0,0 +1,55 @@ +<?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-or-later + */ + +/** + * Job for rebuilding message index. + * + * @ingroup JobQueue + */ +class MessageIndexRebuildJob extends Job { + + /** + * @return self + */ + public static function newJob() { + $job = new self( Title::newMainPage() ); + + return $job; + } + + /** + * @param Title $title + * @param array $params + */ + public function __construct( $title, $params = [] ) { + parent::__construct( __CLASS__, $title, $params ); + } + + public 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 insertIntoJobQueue() { + global $wgTranslateDelayedMessageIndexRebuild; + if ( $wgTranslateDelayedMessageIndexRebuild ) { + JobQueueGroup::singleton()->push( $this ); + } else { + $this->run(); + } + } +} diff --git a/www/wiki/extensions/Translate/utils/MessageTable.php b/www/wiki/extensions/Translate/utils/MessageTable.php new file mode 100644 index 00000000..43eac575 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageTable.php @@ -0,0 +1,424 @@ +<?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 + * @param IContextSource $context + * @param MessageCollection $collection + * @param MessageGroup $group + * @return MessageTable + */ + public static function newFromContext( + IContextSource $context, + MessageCollection $collection, + MessageGroup $group + ) { + $table = new self( $collection, $group ); + $table->setContext( $context ); + + Hooks::run( '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. + * + * @param MessageCollection $collection + * @param MessageGroup $group + */ + 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 ); + } + + Hooks::run( + '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->getHtmlCode(); + $dir = $language->getDir(); + + if ( $language->getCode() === $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-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/www/wiki/extensions/Translate/utils/MessageUpdateJob.php b/www/wiki/extensions/Translate/utils/MessageUpdateJob.php new file mode 100644 index 00000000..fe6b1cdd --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageUpdateJob.php @@ -0,0 +1,98 @@ +<?php +/** + * Job for updating translation pages. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2008-2013, Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * 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 = [ + 'content' => $content, + 'fuzzy' => $fuzzy, + ]; + $job = new self( $target, $params ); + + return $job; + } + + /** + * @param Title $title + * @param array $params + */ + public function __construct( $title, $params = [] ) { + parent::__construct( __CLASS__, $title, $params ); + } + + public function run() { + global $wgTranslateDocumentationLanguageCode; + + $title = $this->title; + $params = $this->params; + $user = FuzzyBot::getUser(); + $flags = 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 = [ 'page_id', 'page_latest' ]; + $conds = [ 'page_namespace' => $title->getNamespace() ]; + + $pages = []; + foreach ( $languages as $code ) { + $otherTitle = Title::makeTitleSafe( $title->getNamespace(), "$key/$code" ); + $pages[$otherTitle->getDBkey()] = true; + } + unset( $pages[$title->getDBkey()] ); + if ( $pages === [] ) { + return true; + } + + $conds['page_title'] = array_keys( $pages ); + + $res = $dbw->select( 'page', $fields, $conds, __METHOD__ ); + $inserts = []; + foreach ( $res as $row ) { + $inserts[] = [ + 'rt_type' => RevTag::getType( 'fuzzy' ), + 'rt_page' => $row->page_id, + 'rt_revision' => $row->page_latest, + ]; + } + + if ( $inserts === [] ) { + return true; + } + + $dbw->replace( + 'revtag', + [ [ 'rt_type', 'rt_page', 'rt_revision' ] ], + $inserts, + __METHOD__ + ); + } + + return true; + } +} diff --git a/www/wiki/extensions/Translate/utils/MessageWebImporter.php b/www/wiki/extensions/Translate/utils/MessageWebImporter.php new file mode 100644 index 00000000..fb874dc5 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/MessageWebImporter.php @@ -0,0 +1,619 @@ +<?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-or-later + */ + +/** + * 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; + + /** + * @param Title|null $title + * @param MessageGroup|string|null $group + * @param string $code + */ + 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; + } + + /** + * @param Title $title + */ + public function setTitle( Title $title ) { + $this->title = $title; + } + + /** + * @return User + */ + public function getUser() { + return $this->user ?: RequestContext::getMain()->getUser(); + } + + /** + * @param User $user + */ + 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 ); + } + } + + /** + * @return string + */ + public function getCode() { + return $this->code; + } + + /** + * @param string $code + */ + public function setCode( $code = 'en' ) { + $this->code = $code; + } + + /** + * @return string + */ + protected function getAction() { + return $this->getTitle()->getFullURL(); + } + + /** + * @return string + */ + protected function doHeader() { + $formParams = [ + '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(); + + return $request->wasPosted() + && $request->getBool( 'process', false ) + && $this->getUser()->matchEditToken( $request->getVal( 'token' ) ); + } + + /** + * @return array + */ + protected function getActions() { + if ( $this->code === 'en' ) { + return [ 'import', 'fuzzy', 'ignore' ]; + } + + return [ '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() ); + + // Initialise variable to keep track whether all changes were imported + // or not. If we're allowed to process, initially assume they were. + $alldone = $process; + + // Determine changes for each message. + $changed = []; + + foreach ( $messages as $key => $value ) { + $fuzzy = $old = null; + + if ( isset( $collection[$key] ) ) { + // This returns null if no existing translation is found + $old = $collection[$key]->translation(); + } + + // No changes at all, ignore + if ( (string)$old === (string)$value ) { + continue; + } + + if ( $old === null ) { + // We found a new translation for this message of the + // current group: import it. + $action = 'import'; + self::doAction( + $action, + $group, + $key, + $code, + $value + ); + + // Show the user that we imported the new translation + $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( '', '' ); + + // This is a changed translation. Note it for the next steps. + $type = 'changed'; + + // Get the user instructions for the current message, + // submitted together with the form + $action = $context->getRequest() + ->getVal( self::escapeNameForPHP( "action-$type-$key" ) ); + + if ( $process ) { + if ( $changed === [] ) { + // Initialise the HTML list showing the changes performed + $changed[] = '<ul>'; + } + + if ( $action === null ) { + // We have been told to process the messages, but not + // what to do with this one. Tell the user. + $message = $context->msg( + 'translate-manage-inconsistent', + wfEscapeWikiText( "action-$type-$key" ) + )->parse(); + $changed[] = "<li>$message</li></ul>"; + + // Also stop any further processing for the other messages. + $process = false; + } else { + // Check processing time + if ( !isset( $this->time ) ) { + $this->time = wfTimestamp(); + } + + // We have all the necessary information on this changed + // translation: actually process the message + $messageKeyAndParams = self::doAction( + $action, + $group, + $key, + $code, + $value + ); + + // Show what we just did, adding to the list of changes + $msgKey = array_shift( $messageKeyAndParams ); + $params = $messageKeyAndParams; + $message = $context->msg( $msgKey, $params )->parse(); + $changed[] = "<li>$message</li>"; + + // Stop processing further messages if too much time + // has been spent. + if ( $this->checkProcessTime() ) { + $process = false; + $message = $context->msg( 'translate-manage-toolong' ) + ->numParams( $this->processingTime )->parse(); + $changed[] = "<li>$message</li></ul>"; + } + + continue; + } + } + + // We are not processing messages, or no longer, or this was an + // unactionable translation. We will eventually return false + $alldone = false; + + // Prepare to ask the user what to do with this message + $actions = $this->getActions(); + $defaction = $this->getDefaultAction( $fuzzy, $action ); + + $act = []; + + // 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' ) + ->rawParams( $param, implode( ' ', $act ) ) + ->escaped(); + + $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 || ( $changed === [] && $code !== 'en' ) ) { + if ( $changed === [] ) { + $this->out->addWikiMsg( 'translate-manage-nochanges-other' ); + } + + if ( $changed === [] || strpos( end( $changed ), '<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 ( $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|null $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 [ '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 string $message + * @param string $summary + * @param User|null $user + * @param int $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 [ 'translate-manage-import-ok', + wfEscapeWikiText( $title->getPrefixedText() ) + ]; + } + + $text = "Failed to import new version of page {$title->getPrefixedText()}\n"; + $text .= "{$status->getWikiText()}"; + throw new MWException( $text ); + } + + /** + * @param Title $title + * @param string $message + * @param string $comment + * @param User $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 = [ + 'page_namespace' => $title->getNamespace(), + 'page_latest=rev_id', + 'rev_text_id=old_id', + 'page_title' . $dbw->buildLike( "$titleText/", $dbw->anyString() ), + ]; + + $rows = $dbw->select( + [ 'page', 'revision', 'text' ], + [ '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 = []; + 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 [ '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|null $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 = [ 'class' => "mw-tpt-sp-section mw-tpt-sp-section-type-{$type}" ]; + $legendParams = [ 'class' => 'mw-tpt-sp-legend' ]; + $contentParams = [ 'class' => 'mw-tpt-sp-content' ]; + if ( $lang ) { + $contentParams['dir'] = $lang->getDir(); + $contentParams['lang'] = $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 = [ + '(' => '(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/www/wiki/extensions/Translate/utils/RcFilter.php b/www/wiki/extensions/Translate/utils/RcFilter.php new file mode 100644 index 00000000..7c2334f2 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/RcFilter.php @@ -0,0 +1,253 @@ +<?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-or-later + */ + +/** + * Adds a new filter to Special:RecentChanges which makes it possible to filter + * translations away or show them only. + */ +class TranslateRcFilter { + /** + * Hooks ChangesListSpecialPageQuery. See the hook documentation for + * documentation of the function parameters. + * + * Appends SQL filter conditions into $conds. + * @param string $pageName + * @param array &$tables + * @param array &$fields + * @param array &$conds + * @param array &$query_options + * @param array &$join_conds + * @param FormOptions $opts + * @return bool true + */ + public static function translationFilter( $pageName, &$tables, &$fields, &$conds, + &$query_options, &$join_conds, FormOptions $opts + ) { + global $wgTranslateRcFilterDefault; + + if ( $pageName !== 'Recentchanges' || self::isStructuredFilterUiEnabled() ) { + return true; + } + + $request = RequestContext::getMain()->getRequest(); + $translations = $request->getVal( 'translations', $wgTranslateRcFilterDefault ); + $opts->add( 'translations', $wgTranslateRcFilterDefault ); + $opts->setValue( 'translations', $translations ); + + $dbr = wfGetDB( DB_REPLICA ); + + $namespaces = self::getTranslateNamespaces(); + + 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; + } + + private static function getTranslateNamespaces() { + global $wgTranslateMessageNamespaces; + $namespaces = []; + + foreach ( $wgTranslateMessageNamespaces as $index ) { + $namespaces[] = $index; + $namespaces[] = $index + 1; // Include Talk namespaces + } + + return $namespaces; + } + + /** + * Hooks SpecialRecentChangesPanel. See the hook documentation for + * documentation of the function parameters. + * + * Adds a HTMl selector into $items + * @param array &$items + * @param FormOptions $opts + * @return bool true + */ + public static function translationFilterForm( &$items, $opts ) { + if ( self::isStructuredFilterUiEnabled() ) { + return true; + } + + $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'] = [ $label, $select->getHTML() ]; + + return true; + } + + private static function isStructuredFilterUiEnabled() { + $context = RequestContext::getMain(); + + // This assumes usage only on RC page + $page = new SpecialRecentChanges(); + $page->setContext( $context ); + + // isStructuredFilterUiEnabled used to be a protected method in older versions :( + return is_callable( [ $page, 'isStructuredFilterUiEnabled' ] ) && + $page->isStructuredFilterUiEnabled(); + } + + /** + * Hooks ChangesListSpecialPageStructuredFilters. See the hook documentation for + * documentation of the function parameters. + * + * Adds translations filters to structured UI + * @param ChangesListSpecialPage $special + * @return bool true + */ + public static function onChangesListSpecialPageStructuredFilters( + ChangesListSpecialPage $special + ) { + global $wgTranslateRcFilterDefault; + $defaultFilter = $wgTranslateRcFilterDefault !== 'noaction' ? + $wgTranslateRcFilterDefault : + ChangesListStringOptionsFilterGroup::NONE; + + $translationsGroup = new ChangesListStringOptionsFilterGroup( + [ + 'name' => 'translations', + 'title' => 'translate-rcfilters-translations', + 'priority' => -7, + 'default' => $defaultFilter, + 'isFullCoverage' => true, + 'filters' => [ + [ + 'name' => 'only', + 'label' => 'translate-rcfilters-translations-only-label', + 'description' => 'translate-rcfilters-translations-only-desc', + 'cssClassSuffix' => 'only', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + $namespaces = self::getTranslateNamespaces(); + + return in_array( $rc->getAttribute( 'rc_namespace' ), $namespaces ) && + strpos( $rc->getAttribute( 'rc_title' ), '/' ) !== false; + } + ], + [ + 'name' => 'site', + 'label' => 'translate-rcfilters-translations-site-label', + 'description' => 'translate-rcfilters-translations-site-desc', + 'cssClassSuffix' => 'site', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + $namespaces = self::getTranslateNamespaces(); + + return in_array( $rc->getAttribute( 'rc_namespace' ), $namespaces ) && + strpos( $rc->getAttribute( 'rc_title' ), '/' ) === false; + } + ], + [ + 'name' => 'filter', + 'label' => 'translate-rcfilters-translations-filter-label', + 'description' => 'translate-rcfilters-translations-filter-desc', + 'cssClassSuffix' => 'filter', + 'isRowApplicableCallable' => function ( $ctx, $rc ) { + $namespaces = self::getTranslateNamespaces(); + + return !in_array( $rc->getAttribute( 'rc_namespace' ), $namespaces ); + } + ], + ], + 'queryCallable' => function ( $specialClassName, $ctx, $dbr, &$tables, + &$fields, &$conds, &$query_options, &$join_conds, $selectedValues + ) { + $fields[] = 'rc_title'; + $fields[] = 'rc_namespace'; + + $namespaces = self::getTranslateNamespaces(); + $inNamespaceCond = 'rc_namespace IN (' . + $dbr->makeList( $namespaces ) . ')'; + $notInNamespaceCond = 'rc_namespace NOT IN (' . + $dbr->makeList( $namespaces ) . ')'; + + $onlyCond = $dbr->makeList( [ + $inNamespaceCond, + 'rc_title ' . + $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() ) + ], LIST_AND ); + $siteCond = $dbr->makeList( [ + $inNamespaceCond, + 'rc_title NOT' . + $dbr->buildLike( $dbr->anyString(), '/', $dbr->anyString() ) + ], LIST_AND ); + + if ( count( $selectedValues ) === 3 ) { + // no filters + return; + } + + if ( $selectedValues === [ 'filter', 'only' ] ) { + $conds[] = $dbr->makeList( [ + $notInNamespaceCond, + $onlyCond + ], LIST_OR ); + return; + } + + if ( $selectedValues === [ 'filter', 'site' ] ) { + $conds[] = $dbr->makeList( [ + $notInNamespaceCond, + $siteCond + ], LIST_OR ); + return; + } + + if ( $selectedValues === [ 'only', 'site' ] ) { + $conds[] = $inNamespaceCond; + return; + } + + if ( $selectedValues === [ 'filter' ] ) { + $conds[] = $notInNamespaceCond; + return; + } + + if ( $selectedValues === [ 'only' ] ) { + $conds[] = $onlyCond; + return; + } + + if ( $selectedValues === [ 'site' ] ) { + $conds[] = $siteCond; + } + } + ] + ); + + $special->registerFilterGroup( $translationsGroup ); + return true; + } +} diff --git a/www/wiki/extensions/Translate/utils/ResourceLoader.php b/www/wiki/extensions/Translate/utils/ResourceLoader.php new file mode 100644 index 00000000..1a8e5c29 --- /dev/null +++ b/www/wiki/extensions/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-or-later + */ + +/** + * Stuff for handling configuration files in PHP format. + */ +class PHPVariableLoader { + /** + * Returns a global variable from PHP file by executing the file. + * @param string $_filename Path to the file. + * @param string $_variable 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 $$_variable ?? null; + } + } +} diff --git a/www/wiki/extensions/Translate/utils/RevTag.php b/www/wiki/extensions/Translate/utils/RevTag.php new file mode 100644 index 00000000..40d5b5b5 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/RevTag.php @@ -0,0 +1,31 @@ +<?php +/** + * Code related to revtag database table + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Abstraction for revtag table to handle new and old schemas during migration. + */ +class RevTag { + /** + * Returns value suitable for rt_type field. + * @param string $tag Tag name + * @return string + */ + public static function getType( $tag ) { + return $tag; + } + + /** + * Converts rt_type field back to the tag name. + * @param int $tag rt_type value + * @return string + */ + public static function typeToTag( $tag ) { + return $tag; + } +} diff --git a/www/wiki/extensions/Translate/utils/StatsBar.php b/www/wiki/extensions/Translate/utils/StatsBar.php new file mode 100644 index 00000000..df2801a2 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/StatsBar.php @@ -0,0 +1,104 @@ +<?php +/** + * Compact stats. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2012-2013 Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Compact, colorful stats. + * @since 2012-11-30 + */ +class StatsBar { + /** + * @see MessageGroupStats + * @var int[] + */ + protected $stats; + + /** + * @var string Message group id + */ + protected $group; + + /** + * @var string Language + */ + protected $language; + + /** + * @param string $group + * @param string $language + * @param array[]|null $stats + * + * @return self + */ + 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; + } + + /** + * @param IContextSource $context + * + * @return string HTML + */ + 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', [ + 'class' => 'tux-statsbar', + 'data-total' => $total, + 'data-group' => $this->group, + 'data-language' => $this->language, + ], + Html::element( 'span', [ + 'class' => 'tux-proofread', + 'style' => "width: $wproofread%", + 'data-proofread' => $proofread, + ] ) . Html::element( 'span', [ + 'class' => 'tux-translated', + 'style' => "width: $wtranslated%", + 'data-translated' => $translated, + ] ) . Html::element( 'span', [ + 'class' => 'tux-fuzzy', + 'style' => "width: $wfuzzy%", + 'data-fuzzy' => $fuzzy, + ] ) . Html::element( 'span', [ + 'class' => 'tux-untranslated', + 'style' => "width: $wuntranslated%", + 'data-untranslated' => $untranslated, + ] ) + ); + } +} diff --git a/www/wiki/extensions/Translate/utils/StatsTable.php b/www/wiki/extensions/Translate/utils/StatsTable.php new file mode 100644 index 00000000..0e9bc937 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/StatsTable.php @@ -0,0 +1,331 @@ +<?php +/** + * @file + * @author Siebrand Mazeland + * @author Niklas Laxström + * @copyright Copyright © 2008-2013 Siebrand Mazeland, Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * Implements generation of HTML stats table. + * + * Loosely based on the statistics code in phase3/maintenance/language + * + * @ingroup Stats + */ +class StatsTable { + /** + * @var Language + */ + protected $lang; + + /** + * @var Title + */ + protected $translate; + + /** + * @var string + */ + protected $mainColumnHeader; + + /** + * @var Message[] + */ + protected $extraColumns = []; + + public function __construct() { + $this->lang = RequestContext::getMain()->getLanguage(); + $this->translate = SpecialPage::getTitleFor( 'Translate' ); + } + + /** + * Statistics table element (heading or regular cell) + * + * @param string $in Element contents. + * @param string $bgcolor Backround color in ABABAB format. + * @param string $sort Value used for sorting. + * @return string Html td element. + */ + public function element( $in, $bgcolor = '', $sort = '' ) { + $attributes = []; + + if ( $sort ) { + $attributes['data-sort-value'] = $sort; + } + + if ( $bgcolor ) { + $attributes['style'] = 'background-color: #' . $bgcolor; + } + + $element = Html::element( 'td', $attributes, $in ); + + return $element; + } + + public function getBackgroundColor( $percentage, $fuzzy = false ) { + if ( $fuzzy ) { + // Steeper scale for fuzzy + // (0), [0-2), [2-4), ... [12-100) + $index = min( 7, ceil( 50 * $percentage ) ); + $colors = [ + '', 'fedbd7', 'fecec8', 'fec1b9', + 'fcb5ab', 'fba89d', 'f89b8f', 'f68d81' + ]; + return $colors[ $index ]; + } + + // https://gka.github.io/palettes/#colors=#36c,#eaf3ff|steps=20|bez=1|coL=1 + // Color groups for (0-10], (10-20], ... (90-100], (100) + $index = floor( $percentage * 10 ); + $colors = [ + 'eaf3ff', 'e2ebfc', 'dae3fa', 'd2dbf7', 'c9d4f5', + 'c1ccf2', 'b8c4ef', 'b1bced', 'a8b4ea', '9fade8', + '96a6e5' + ]; + + return $colors[ $index ]; + } + + /** + * @return string + */ + public function getMainColumnHeader() { + return $this->mainColumnHeader; + } + + /** + * @param Message $msg + */ + public function setMainColumnHeader( Message $msg ) { + $this->mainColumnHeader = $this->createColumnHeader( $msg ); + } + + /** + * @param Message $msg + * @return string HTML + */ + public function createColumnHeader( Message $msg ) { + return Html::element( 'th', [], $msg->text() ); + } + + public function addExtraColumn( Message $column ) { + $this->extraColumns[] = $column; + } + + /** + * @return Message[] + */ + public function getOtherColumnHeaders() { + return array_merge( [ + wfMessage( 'translate-total' ), + wfMessage( 'translate-untranslated' ), + wfMessage( 'translate-percentage-complete' ), + wfMessage( 'translate-percentage-proofread' ), + wfMessage( 'translate-percentage-fuzzy' ), + ], $this->extraColumns ); + } + + /** + * @return string HTML + */ + public function createHeader() { + // Create table header + $out = Html::openElement( + 'table', + [ 'class' => 'statstable' ] + ); + + $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', [], $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]; + $proofread = $stats[MessageGroupStats::PROOFREAD]; + + if ( $total === null ) { + $na = "\n\t\t" . Html::element( 'td', [ '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', + [ 'data-sort-value' => $total ], + $this->lang->formatNum( $total ) ); + + $out .= "\n\t\t" . Html::element( 'td', + [ 'data-sort-value' => $total - $translated ], + $this->lang->formatNum( $total - $translated ) ); + + if ( $total === 0 ) { + $transRatio = 0; + $fuzzyRatio = 0; + $proofRatio = 0; + } else { + $transRatio = $translated / $total; + $fuzzyRatio = $fuzzy / $total; + $proofRatio = $translated === 0 ? 0 : $proofread / $translated; + } + + $out .= "\n\t\t" . $this->element( $this->formatPercentage( $transRatio, 'floor' ), + $this->getBackgroundColor( $transRatio ), + sprintf( '%1.5f', $transRatio ) ); + + $out .= "\n\t\t" . $this->element( $this->formatPercentage( $proofRatio, 'floor' ), + $this->getBackgroundColor( $proofRatio ), + sprintf( '%1.5f', $proofRatio ) ); + + $out .= "\n\t\t" . $this->element( $this->formatPercentage( $fuzzyRatio, 'ceil' ), + $this->getBackgroundColor( $fuzzyRatio, true ), + sprintf( '%1.5f', $fuzzyRatio ) ); + + return $out; + } + + /** + * Makes a nice print from plain float. + * @param number $num + * @param string $to 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 MessageGroup $group + * @return string Html + */ + public function getGroupLabel( MessageGroup $group ) { + $groupLabel = htmlspecialchars( $group->getLabel() ); + + // Bold for meta groups. + if ( $group->isMeta() ) { + $groupLabel = Html::rawElement( 'b', [], $groupLabel ); + } + + return $groupLabel; + } + + /** + * Gets the name of group linked to translation tool. + * @param MessageGroup $group + * @param string $code Language code + * @param array $params Any extra query parameters. + * @return string Html + */ + public function makeGroupLink( MessageGroup $group, $code, $params ) { + $queryParameters = $params + [ + 'group' => $group->getId(), + 'language' => $code + ]; + + $attributes = []; + + $translateGroupLink = Linker::link( + $this->translate, $this->getGroupLabel( $group ), $attributes, $queryParameters + ); + + return $translateGroupLink; + } + + /** + * Check whether translations in given group in given language + * has been disabled. + * @param string $groupId Message group id + * @param string $code Language code + * @return bool + */ + public function isBlacklisted( $groupId, $code ) { + global $wgTranslateBlacklist; + + $blacklisted = null; + + $checks = [ + $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 = Hooks::run( 'Translate:MessageGroupStats:isIncluded', [ $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 string $text + * @return string + */ + public static function formatTooltip( $text ) { + $wordSeparator = wfMessage( 'word-separator' )->text(); + + $text = strtr( $text, [ + "\n" => $wordSeparator, + "\r" => $wordSeparator, + "\t" => $wordSeparator, + ] ); + + return $text; + } +} diff --git a/www/wiki/extensions/Translate/utils/ToolBox.php b/www/wiki/extensions/Translate/utils/ToolBox.php new file mode 100644 index 00000000..7efc2980 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/ToolBox.php @@ -0,0 +1,44 @@ +<?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-or-later + */ + +/** + * 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 BaseTemplate $baseTemplate The base skin template + * @param array &$toolbox An array of toolbox items + * + * @return bool + */ + public static function toolboxAllTranslations( $baseTemplate, &$toolbox ) { + $title = $baseTemplate->getSkin()->getTitle(); + $handle = new MessageHandle( $title ); + if ( $handle->isValid() ) { + $message = $title->getNsText() . ':' . $handle->getKey(); + $url = SpecialPage::getTitleFor( 'Translations' ) + ->getLocalURL( [ 'message' => $message ] ); + + // Add the actual toolbox entry. + $toolbox[ 'alltrans' ] = [ + 'href' => $url, + 'id' => 't-alltrans', + 'msg' => 'translate-sidebar-alltrans', + ]; + } + + return true; + } +} diff --git a/www/wiki/extensions/Translate/utils/TranslateLogFormatter.php b/www/wiki/extensions/Translate/utils/TranslateLogFormatter.php new file mode 100644 index 00000000..2925f2cb --- /dev/null +++ b/www/wiki/extensions/Translate/utils/TranslateLogFormatter.php @@ -0,0 +1,81 @@ +<?php +/** + * Class for formatting Translate logs. + * + * @file + * @author Niklas Laxström + * @copyright Copyright © 2013, Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * 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(), + [ '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], + [ '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, array $parameters = [] + ) { + if ( !$this->plaintext ) { + $link = Linker::link( $title, htmlspecialchars( $text ), [], $parameters ); + } else { + $target = '***'; + if ( $title instanceof Title ) { + $target = $title->getPrefixedText(); + } + $link = "[[$target|$text]]"; + } + + return $link; + } +} diff --git a/www/wiki/extensions/Translate/utils/TranslateMetadata.php b/www/wiki/extensions/Translate/utils/TranslateMetadata.php new file mode 100644 index 00000000..4294ca30 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/TranslateMetadata.php @@ -0,0 +1,120 @@ +<?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-or-later + */ + +class TranslateMetadata { + /** @var array Map of (group => key => value) */ + private static $cache = []; + + /** + * @param string[] $groups List of translate groups + */ + public static function preloadGroups( array $groups ) { + $missing = array_diff( $groups, array_keys( self::$cache ) ); + if ( !$missing ) { + return; + } + + self::$cache += array_fill_keys( $missing, null ); // cache negatives + + $dbr = TranslateUtils::getSafeReadDB(); + $conds = count( $groups ) <= 500 ? [ 'tmd_group' => $missing ] : []; + $res = $dbr->select( 'translate_metadata', '*', $conds, __METHOD__ ); + foreach ( $res as $row ) { + self::$cache[$row->tmd_group][$row->tmd_key] = $row->tmd_value; + } + } + + /** + * Get a metadata value for the given group and key. + * @param string $group The group name + * @param string $key Metadata key + * @return string|bool + */ + public static function get( $group, $key ) { + self::preloadGroups( [ $group ] ); + + return self::$cache[$group][$key] ?? false; + } + + /** + * Set a metadata value for the given group and metadata key. Updates the + * value if already existing. + * @param string $group The group id + * @param string $key Metadata key + * @param string $value Metadata value + */ + public static function set( $group, $key, $value ) { + $dbw = wfGetDB( DB_MASTER ); + $data = [ 'tmd_group' => $group, 'tmd_key' => $key, 'tmd_value' => $value ]; + if ( $value === false ) { + unset( $data['tmd_value'] ); + $dbw->delete( 'translate_metadata', $data ); + unset( self::$cache[$group][$key] ); + } else { + $dbw->replace( + 'translate_metadata', + [ [ 'tmd_group', 'tmd_key' ] ], + $data, + __METHOD__ + ); + self::$cache[$group][$key] = $value; + } + } + + /** + * Wrapper for getting subgroups. + * @param string $groupId + * @return string[]|bool + * @since 2012-05-09 + */ + 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 = [ 'tmd_group' => $groupId ]; + $dbw->delete( 'translate_metadata', $conds ); + self::$cache[$groupId] = null; + } +} diff --git a/www/wiki/extensions/Translate/utils/TranslateSandbox.php b/www/wiki/extensions/Translate/utils/TranslateSandbox.php new file mode 100644 index 00000000..999c4a3e --- /dev/null +++ b/www/wiki/extensions/Translate/utils/TranslateSandbox.php @@ -0,0 +1,338 @@ +<?php +/** + * Utilities for the sandbox feature of Translate. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +use MediaWiki\Auth\AuthManager; +use MediaWiki\Auth\AuthenticationRequest; +use MediaWiki\Auth\AuthenticationResponse; + +/** + * Utility class for the sandbox feature of Translate. Do not try this yourself. This code makes a + * lot of assumptions about what happens to the user account. + */ +class TranslateSandbox { + public static $userToCreate = null; + + /** + * 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' ); + } + + $data = [ + 'username' => $user->getName(), + 'password' => $password, + 'retype' => $password, + 'email' => $email, + 'realname' => '', + ]; + + self::$userToCreate = $user; + $reqs = AuthManager::singleton()->getAuthenticationRequests( AuthManager::ACTION_CREATE ); + $reqs = AuthenticationRequest::loadRequestsFromSubmission( $reqs, $data ); + $res = AuthManager::singleton()->beginAccountCreation( $user, $reqs, 'null:' ); + self::$userToCreate = null; + + switch ( $res->status ) { + case AuthenticationResponse::PASS: + break; + case AuthenticationResponse::FAIL: + // Unless things are misconfigured, this will handle errors such as username taken, + // invalid user name or too short password. The WebAPI is prechecking these to + // provide nicer error messages. + $reason = $res->message->inLanguage( 'en' )->useDatabase( false )->text(); + throw new MWException( "Account creation failed: $reason" ); + default: + // Just in case it was a Secondary that failed + $user->clearInstanceCache( 'name' ); + if ( $user->getId() ) { + self::deleteUser( $user, 'force' ); + } + throw new MWException( + 'AuthManager does not support such simplified account creation' + ); + } + + // User now has an id, but we must clear the cache to see it. Without this the group + // addition below would not be saved in the database. + $user->clearInstanceCache( 'name' ); + + // group-translate-sandboxed group-translate-sandboxed-member + $user->addGroup( 'translate-sandboxed' ); + + 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(); + $username = $user->getName(); + + if ( $force !== 'force' && !self::isSandboxed( $user ) ) { + throw new MWException( 'Not a sandboxed user' ); + } + + // Delete from database + $dbw = wfGetDB( DB_MASTER ); + $dbw->delete( 'user', [ 'user_id' => $uid ], __METHOD__ ); + $dbw->delete( 'user_groups', [ 'ug_user' => $uid ], __METHOD__ ); + $dbw->delete( 'user_properties', [ 'up_user' => $uid ], __METHOD__ ); + + if ( class_exists( ActorMigration::class ) ) { + $m = ActorMigration::newMigration(); + + // Assume no joins are needed for logging or recentchanges + $dbw->delete( 'logging', $m->getWhere( $dbw, 'log_user', $user )['conds'], __METHOD__ ); + $dbw->delete( 'recentchanges', $m->getWhere( $dbw, 'rc_user', $user )['conds'], __METHOD__ ); + } else { + $dbw->delete( 'logging', [ 'log_user' => $uid ], __METHOD__ ); + $dbw->delete( + 'recentchanges', + [ 'rc_user' => $uid, 'rc_user_text' => $username ], + __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 + $user->invalidateCache(); + + // 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 = TranslateUtils::getSafeReadDB(); + if ( is_callable( [ User::class, 'getQueryInfo' ] ) ) { + $userQuery = User::getQueryInfo(); + } else { + $userQuery = [ + 'tables' => [ 'user' ], + 'fields' => User::selectFields(), + 'joins' => [], + ]; + } + $tables = array_merge( $userQuery['tables'], [ 'user_groups' ] ); + $fields = $userQuery['fields']; + $conds = [ + 'ug_group' => 'translate-sandboxed', + ]; + $joins = [ + 'user_groups' => [ 'JOIN', 'ug_user = user_id' ], + ] + $userQuery['joins']; + + $res = $dbw->select( $tables, $fields, $conds, __METHOD__, [], $joins ); + + 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 = [ + 'user' => $target->getId(), + 'to' => MailAddress::newFromUser( $target ), + 'from' => MailAddress::newFromUser( $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 + * @param User $user + * @param array &$rights + * @return true + */ + public static function enforcePermissions( User $user, array &$rights ) { + global $wgTranslateUseSandbox; + + if ( !$wgTranslateUseSandbox ) { + return true; + } + + if ( !self::isSandboxed( $user ) ) { + return true; + } + + // right-translate-sandboxaction action-translate-sandboxaction + $rights = [ + 'editmyoptions', + 'editmyprivateinfo', + 'read', + 'readapi', + 'translate-sandboxaction', + 'viewmyprivateinfo', + 'writeapi', + ]; + + // Do not let other hooks add more actions + return false; + } + + /// Hook: UserGetRights + public static function allowAccountCreation( $user, &$rights ) { + if ( self::$userToCreate && $user->equals( self::$userToCreate ) ) { + $rights[] = 'createaccount'; + } + } + + /// Hook: onGetPreferences + public static function onGetPreferences( $user, &$preferences ) { + $preferences['translate-sandbox'] = $preferences['translate-sandbox-reminders'] = + [ 'type' => 'api' ]; + + return true; + } + + /** + * Whitelisting for certain API modules. See also enforcePermissions. + * Hook: ApiCheckCanExecute + * @param ApiBase $module + * @param User $user + * @param string &$message + * @return bool + */ + public static function onApiCheckCanExecute( ApiBase $module, User $user, &$message ) { + $whitelist = [ + // Obviously this is needed to get out of the sandbox + 'ApiTranslationStash', + // Used by UniversalLanguageSelector for example + 'ApiOptions' + ]; + + if ( self::isSandboxed( $user ) ) { + $class = get_class( $module ); + if ( $module->isWriteMode() && !in_array( $class, $whitelist, true ) ) { + $message = ApiMessage::create( 'apierror-writeapidenied' ); + if ( $message->getApiCode() === 'apierror-writeapidenied' ) { + // Backwards compatibility for pre-1.29 MediaWiki + $message = 'writerequired'; + } + return false; + } + } + + return true; + } +} diff --git a/www/wiki/extensions/Translate/utils/TranslateSandboxEmailJob.php b/www/wiki/extensions/Translate/utils/TranslateSandboxEmailJob.php new file mode 100644 index 00000000..955e7156 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/TranslateSandboxEmailJob.php @@ -0,0 +1,46 @@ +<?php + +class TranslateSandboxEmailJob extends Job { + /** + * @param array $params + * @return self + */ + public static function newJob( array $params ) { + return new self( Title::newMainPage(), $params ); + } + + /** + * @param Title $title + * @param array $params + */ + public function __construct( $title, $params ) { + parent::__construct( __CLASS__, $title, $params ); + } + + public function run() { + $status = UserMailer::send( + $this->params['to'], + $this->params['from'], + $this->params['subj'], + $this->params['body'], + [ 'replyTo' => $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 ) : []; + $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/www/wiki/extensions/Translate/utils/TranslateYaml.php b/www/wiki/extensions/Translate/utils/TranslateYaml.php new file mode 100644 index 00000000..699676a9 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/TranslateYaml.php @@ -0,0 +1,203 @@ +<?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-or-later + */ + +/** + * This class is a wrapper class to provide interface to parse + * and generate YAML files with syck or spyc backend. + */ +class TranslateYaml { + /** + * @param string $text + * @return array + * @throws MWException + */ + public static function loadString( $text ) { + global $wgTranslateYamlLibrary; + + switch ( $wgTranslateYamlLibrary ) { + case 'phpyaml': + // Harden: do not support unserializing objects. + // Method 1: PHP ini setting (not supported by HHVM) + // Method 2: Callback handler for !php/object + $previousValue = ini_set( 'yaml.decode_php', false ); + $ignored = 0; + $callback = function ( $value ) { + return $value; + }; + $ret = yaml_parse( $text, 0, $ignored, [ '!php/object' => $callback ] ); + ini_set( 'yaml.decode_php', $previousValue ); + if ( $ret === false ) { + // Convert failures to exceptions + throw new InvalidArgumentException( 'Invalid Yaml string' ); + } + + return $ret; + case 'spyc': + $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 array &$yaml + * @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 array &$yaml + * @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 self::phpyamlDump( $text ); + case 'spyc': + return Spyc::YAMLDump( $text ); + case 'syck': + return self::syckDump( $text ); + default: + throw new MWException( 'Unknown Yaml library' ); + } + } + + protected static function phpyamlDump( $data ) { + if ( !is_array( $data ) ) { + return yaml_emit( $data, YAML_UTF8_ENCODING ); + } + + // Fix decimal-less floats strings such as "2." + // https://bugs.php.net/bug.php?id=76309 + $random = MWCryptRand::generateHex( 8 ); + // Ensure our random does not look like a number + $random = "X$random"; + $mangler = function ( &$item ) use ( $random ) { + if ( preg_match( '/^[0-9]+\.$/', $item ) ) { + $item = "$random$item$random"; + } + }; + + array_walk_recursive( $data, $mangler ); + $yaml = yaml_emit( $data, YAML_UTF8_ENCODING ); + $yaml = str_replace( $random, '"', $yaml ); + return $yaml; + } + + 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 ( (int)$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 ( (int)$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/www/wiki/extensions/Translate/utils/TranslationEditPage.php b/www/wiki/extensions/Translate/utils/TranslationEditPage.php new file mode 100644 index 00000000..eb7a9aac --- /dev/null +++ b/www/wiki/extensions/Translate/utils/TranslationEditPage.php @@ -0,0 +1,296 @@ +<?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 === '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->getHtmlCode(), + 'dir' => $targetLang->getDir(), + ); + + if ( !$groupId || !$context->getUser()->isAllowed( 'translate' ) ) { + $textareaParams['readonly'] = 'readonly'; + } + + $extraInputs = ''; + Hooks::run( '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->getResult()->getResultData(); + + if ( !isset( $data['query']['pages'] ) ) { + throw new MWException( 'Api query failed' ); + } + $data = $data['query']['pages']; + if ( defined( 'ApiResult::META_CONTENT' ) ) { + $data = ApiResult::stripMetadataNonRecursive( $data ); + } + $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 ) { + try { + $supportUrl = SupportAid::getSupportUrl( $title ); + } catch ( TranslationHelperException $e ) { + return ''; + } + + $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' => $supportUrl, + ) + ); + + 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/www/wiki/extensions/Translate/utils/TranslationHelpers.php b/www/wiki/extensions/Translate/utils/TranslationHelpers.php new file mode 100644 index 00000000..1551a1a8 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/TranslationHelpers.php @@ -0,0 +1,545 @@ +<?php +/** + * Contains helper class for interface parts that aid translations in doing + * their thing. + * + * @file + * @author Niklas Laxström + * @license GPL-2.0-or-later + */ + +/** + * 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; + + /** + * @var TranslationAidDataProvider + */ + private $dataProvider; + + /** + * The group object of the message (or null if there isn't any) + * @var MessageGroup + */ + protected $group; + + /** + * The current translation. + * @var string + */ + private $translation; + + /** + * 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->dataProvider = new TranslationAidDataProvider( $this->handle ); + $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 String $id + */ + public function setTextareaId( $id ) { + $this->textareaId = $id; + } + + /** + * Enable or disable extra help for editing. + * @param bool $mode + */ + public function setEditMode( $mode = true ) { + $this->editMode = $mode; + } + + /** + * Gets the message definition. + * @return String + */ + public function getDefinition() { + $this->mustBeKnownMessage(); + + $obj = new MessageDefinitionAid( + $this->group, + $this->handle, + RequestContext::getMain(), + $this->dataProvider + ); + + return $obj->getData()['value']; + } + + /** + * 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(), + $this->dataProvider + ); + $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 + * @return string + */ + 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. + * @return String Block level HTML snippet or empty string. + */ + public function getBoxes() { + // Box filter + $all = $this->getBoxNames(); + + $boxes = []; + foreach ( $all as $type => $cb ) { + $box = $this->callBox( $type, $cb ); + if ( $box ) { + $boxes[$type] = $box; + } + } + + Hooks::run( 'TranslateGetBoxes', [ $this->group, $this->handle, &$boxes ] ); + + if ( count( $boxes ) ) { + return Html::rawElement( + 'div', + [ 'class' => 'mw-sp-translate-edit-fields' ], + implode( "\n\n", $boxes ) + ); + } else { + return ''; + } + } + + /** + * Public since 2012-06-26 + * + * @since 2012-01-04 + * @param string $type + * @param callback $cb + * @param array $params + * @return mixed + */ + public function callBox( $type, $cb, array $params = [] ) { + try { + return call_user_func_array( $cb, $params ); + } catch ( TranslationHelperException $e ) { + return "<!-- Box $type not available: {$e->getMessage()} -->"; + } + } + + /** + * @return array + */ + public function getBoxNames() { + return [ + 'other-languages' => [ $this, 'getOtherLanguagesBox' ], + 'separator' => [ $this, 'getSeparatorBox' ], + 'documentation' => [ $this, 'getDocumentationBox' ], + 'definition' => [ $this, 'getDefinitionBox' ], + ]; + } + + public function getDefinitionBox() { + $this->mustHaveDefinition(); + $en = $this->getDefinition(); + + $title = Linker::link( + SpecialPage::getTitleFor( 'Translate' ), + htmlspecialchars( $this->group->getLabel() ), + [], + [ + 'group' => $this->group->getId(), + 'language' => $this->handle->getCode() + ] + ); + + $label = + wfMessage( 'translate-edit-definition' )->escaped() . + wfMessage( 'word-separator' )->escaped() . + wfMessage( 'parentheses' )->rawParams( $title )->escaped(); + + // 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', + [ + 'class' => 'mw-translate-edit-deftext', + 'dir' => $sl->getDir(), + 'lang' => $sl->getHtmlCode(), + ], + TranslateUtils::convertWhiteSpaceToHTML( $en ) + ); + + $msg .= $this->wrapInsert( $id, $en ); + + $class = [ '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' )->escaped(); + $class = [ 'class' => 'mw-translate-edit-translation' ]; + $msg = Html::rawElement( 'span', + [ 'class' => 'mw-translate-edit-translationtext' ], + TranslateUtils::convertWhiteSpaceToHTML( $en ) + ); + + return TranslateUtils::fieldset( $label, $msg, $class ); + } + + public function getOtherLanguagesBox() { + $code = $this->handle->getCode(); + $page = $this->handle->getKey(); + $ns = $this->handle->getTitle()->getNamespace(); + + $boxes = []; + foreach ( self::getFallbacks( $code ) as $fbcode ) { + $text = TranslateUtils::getMessageContent( $page, $fbcode, $ns ); + if ( $text === null ) { + continue; + } + + $fbLanguage = Language::factory( $fbcode ); + $context = RequestContext::getMain(); + $label = TranslateUtils::getLanguageName( $fbcode, $context->getLanguage()->getCode() ) . + $context->msg( 'word-separator' )->text() . + $context->msg( 'parentheses', $fbLanguage->getHtmlCode() )->text(); + + $target = $this->handle->getTitleForLanguage( $fbcode ); + + if ( $target ) { + $label = self::ajaxEditLink( $target, $label ); + } + + $dialogID = $this->dialogID(); + $id = Sanitizer::escapeId( "other-$fbcode-$dialogID" ); + + $params = [ 'class' => 'mw-translate-edit-item' ]; + + $display = TranslateUtils::convertWhiteSpaceToHTML( $text ); + $display = Html::rawElement( 'div', [ + 'lang' => $fbLanguage->getHtmlCode(), + 'dir' => $fbLanguage->getDir() ], + $display + ); + + $contents = self::legend( $label ) . "\n" . $this->adder( $id, $fbLanguage ) . + $display . self::clear(); + + $boxes[] = Html::rawElement( 'div', $params, $contents ) . + $this->wrapInsert( $id, $text ); + } + + if ( count( $boxes ) ) { + $sep = Html::element( 'hr', [ 'class' => 'mw-translate-sep' ] ); + + return TranslateUtils::fieldset( + wfMessage( + 'translate-edit-in-other-languages', + $page + )->escaped(), + implode( "$sep\n", $boxes ), + [ 'class' => 'mw-sp-translate-edit-inother' ] + ); + } + + return null; + } + + public function getSeparatorBox() { + return Html::element( 'div', [ '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 = $this->handle->getTitleForLanguage( $wgTranslateDocumentationLanguageCode ); + $edit = self::ajaxEditLink( + $title, + $context->msg( 'translate-edit-contribute' )->text() + ); + $info = TranslateUtils::getMessageContent( $page, $wgTranslateDocumentationLanguageCode, $ns ); + + $class = 'mw-sp-translate-edit-info'; + + // The information is most likely in English + $divAttribs = [ 'dir' => 'ltr', 'lang' => 'en', 'class' => 'mw-content-ltr' ]; + + if ( (string)$info === '' ) { + $info = $context->msg( 'translate-edit-no-information' )->plain(); + $class = 'mw-sp-translate-edit-noinfo'; + $lang = $context->getLanguage(); + // The message saying that there's no info, should be translated + $divAttribs = [ 'dir' => $lang->getDir(), 'lang' => $lang->getHtmlCode() ]; + } + $class .= ' mw-sp-translate-message-documentation'; + + $contents = TranslateUtils::parseInlineAsInterface( + $context->getOutput(), $info + ); + + return TranslateUtils::fieldset( + $context->msg( 'translate-edit-information' )->rawParams( $edit )->escaped(), + Html::rawElement( 'div', $divAttribs, $contents ), [ 'class' => $class ] + ); + } + + /** + * @param string $label + * @return string + */ + protected static function legend( $label ) { + # Float it to the opposite direction + return Html::rawElement( 'div', [ 'class' => 'mw-translate-legend' ], $label ); + } + + /** + * @return string + */ + protected static function clear() { + return Html::element( 'div', [ 'style' => 'clear:both;' ] ); + } + + /** + * @param string $code + * @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 = []; + 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 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 Language $lang Language object + * @return string + */ + public function adder( $source, $lang ) { + if ( !$this->editMode ) { + return ''; + } + $target = self::jQueryPathId( $this->getTextareaId() ); + $source = self::jQueryPathId( $source ); + $dir = $lang->getDir(); + $params = [ + '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 string|int $id + * @param string $text + * @return string + */ + public function wrapInsert( $id, $text ) { + return Html::element( 'pre', [ 'id' => $id, 'style' => 'display: none;' ], $text ); + } + + /** + * Ajax-enabled message editing link. + * @param Title $target Title of the target message. + * @param string $text Link text for Linker::link() + * @return string HTML link + */ + public static function ajaxEditLink( Title $target, $text ) { + $handle = new MessageHandle( $target ); + $uri = TranslateUtils::getEditorUrl( $handle ); + $link = Html::element( + 'a', + [ 'href' => $uri ], + $text + ); + + return $link; + } + + /** + * Escapes $id such that it can be used in jQuery selector. + * @param string $id + * @return string + */ + public static function jQueryPathId( $id ) { + $id = preg_replace( '/[^A-Za-z0-9_-]/', '\\\\$0', $id ); + + return Xml::encodeJsVar( "#$id" ); + } + + public static function addModules( OutputPage $out ) { + $out->addModuleStyles( 'ext.translate.quickedit' ); + + // 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 mustHaveDefinition() { + if ( (string)$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() + * - mustHaveDefinition() + * @since 2012-01-04 (Renamed in 2012-07-24 to fix typo in name) + */ +class TranslationHelperException extends MWException { +} diff --git a/www/wiki/extensions/Translate/utils/TranslationStats.php b/www/wiki/extensions/Translate/utils/TranslationStats.php new file mode 100644 index 00000000..8ec59075 --- /dev/null +++ b/www/wiki/extensions/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-or-later + */ + +/** + * Contains methods that provide statistics for message groups. + * + * @ingroup Stats + */ +class TranslationStats { + /** + * Returns translated percentage for message group in given + * languages + * + * @param string $group Unique key identifying the group + * @param string[] $languages List of language codes + * @param bool|int $threshold Minimum required percentage translated to + * return. Other given language codes will not be returned. + * @param bool $simple Return only codes or code/pecentage pairs + * + * @return (float|string)[] 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 = []; + + $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/www/wiki/extensions/Translate/utils/TranslationsUpdateJob.php b/www/wiki/extensions/Translate/utils/TranslationsUpdateJob.php new file mode 100644 index 00000000..6150e321 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/TranslationsUpdateJob.php @@ -0,0 +1,97 @@ +<?php +/** + * Job for updating translation units and translation pages when + * a translatable page is marked for translation. + * + * @note MessageUpdateJobs from getTranslationUnitJobs() should be run + * before the TranslateRenderJobs are run so that the latest changes can + * take effect on the translation pages. + * + * @since 2016.03 + */ +class TranslationsUpdateJob extends Job { + + /** + * @param Title $title + * @param array $params + * @param int $id + */ + public function __construct( Title $title, array $params, $id = 0 ) { + parent::__construct( __CLASS__, $title, $params, $id ); + + $this->page = TranslatablePage::newFromTitle( $title ); + $this->sections = $params['sections']; + } + + public function run() { + // Units should be updated before the render jobs are run + $unitJobs = self::getTranslationUnitJobs( $this->page, $this->sections ); + foreach ( $unitJobs as $job ) { + $job->run(); + } + + // Ensure fresh definitions for MessageIndex and stats + $this->page->getMessageGroup()->clearCaches(); + + MessageIndex::singleton()->rebuild(); + + // Refresh translations statistics + $id = $this->page->getMessageGroupId(); + MessageGroupStats::clearGroup( $id ); + MessageGroupStats::forGroup( $id ); + + $wikiPage = WikiPage::factory( $this->page->getTitle() ); + $wikiPage->doPurge(); + + $renderJobs = self::getRenderJobs( $this->page ); + JobQueueGroup::singleton()->push( $renderJobs ); + return true; + } + + /** + * Creates jobs needed to create or update all translation page definitions. + * @param TranslatablePage $page + * @param TPSection[] $sections + * @return Job[] + * @since 2013-01-28 + */ + public static function getTranslationUnitJobs( TranslatablePage $page, array $sections ) { + $jobs = array(); + + $code = $page->getSourceLanguageCode(); + $prefix = $page->getTitle()->getPrefixedText(); + + foreach ( $sections as $s ) { + $unit = $s->name; + $title = Title::makeTitle( NS_TRANSLATIONS, "$prefix/$unit/$code" ); + + $fuzzy = $s->type === 'changed'; + $jobs[] = MessageUpdateJob::newJob( $title, $s->getTextWithVariables(), $fuzzy ); + } + + return $jobs; + } + + /** + * Creates jobs needed to create or update all translation pages. + * @param TranslatablePage $page + * @return Job[] + * @since 2013-01-28 + */ + public static function getRenderJobs( TranslatablePage $page ) { + $jobs = array(); + + $jobTitles = $page->getTranslationPages(); + // $jobTitles may have the source language title already but duplicate TranslateRenderJobs + // are not executed so it's not run twice for the source language page present. This is + // added to ensure that we create the source language page from the very beginning. + $sourceLangTitle = $page->getTitle()->getSubpage( $page->getSourceLanguageCode() ); + $jobTitles[] = $sourceLangTitle; + foreach ( $jobTitles as $t ) { + $jobs[] = TranslateRenderJob::newJob( $t ); + } + + return $jobs; + } + +} diff --git a/www/wiki/extensions/Translate/utils/TuxMessageTable.php b/www/wiki/extensions/Translate/utils/TuxMessageTable.php new file mode 100644 index 00000000..41d52a8a --- /dev/null +++ b/www/wiki/extensions/Translate/utils/TuxMessageTable.php @@ -0,0 +1,75 @@ +<?php + +class TuxMessageTable extends ContextSource { + protected $group; + protected $language; + + public function __construct( IContextSource $context, MessageGroup $group, $language ) { + $this->setContext( $context ); + $this->group = $group; + if ( Language::isKnownLanguageTag( $language ) ) { + $this->language = $language; + } else { + $this->language = $context->getLanguage()->getCode(); + } + } + + public function fullTable() { + $modules = []; + Hooks::run( 'TranslateBeforeAddModules', [ &$modules ] ); + $this->getOutput()->addModules( $modules ); + + $sourceLang = Language::factory( $this->group->getSourceLanguage() ); + $targetLang = Language::factory( $this->language ); + $batchSize = 100; + + $list = Html::element( 'div', [ + '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-info">$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 hide 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 down translate-mode-button">$modeTranslate + </button><button class="toggle down page-mode-button">$modePage + </button><button class="toggle 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/www/wiki/extensions/Translate/utils/UserToggles.php b/www/wiki/extensions/Translate/utils/UserToggles.php new file mode 100644 index 00000000..b3205598 --- /dev/null +++ b/www/wiki/extensions/Translate/utils/UserToggles.php @@ -0,0 +1,101 @@ +<?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-or-later + */ + +/** + * 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 array &$preferences + * @return bool + */ + public static function onGetPreferences( User $user, array &$preferences ) { + global $wgTranslateNewsletterPreference; + + if ( !$wgTranslateNewsletterPreference ) { + return true; + } + + global $wgEnableEmail; + + // 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 + $preferences['translate-nonewsletter'] = [ + 'type' => 'toggle', + 'section' => 'personal/email', + 'label-message' => 'translate-pref-nonewsletter' + ]; + + } + } + + /** + * 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'] = [ + 'class' => 'HTMLJsSelectToInputField', + // prefs-translate + '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( [ 'LanguageNames', 'getNames' ] ) ) { + $lang = RequestContext::getMain()->getLanguage(); + $languages = LanguageNames::getNames( $lang->getCode(), + LanguageNames::FALLBACK_NORMAL + ); + } else { + $languages = Language::fetchLanguageNames(); + } + + ksort( $languages ); + + $selector = new XmlSelect( false, 'mw-language-selector' ); + foreach ( $languages as $code => $name ) { + $selector->addOption( "$code - $name", $code ); + } + + $jsSelect = new JsSelectToInput( $selector ); + + return $jsSelect; + } +} |