summaryrefslogtreecommitdiff
path: root/www/wiki/extensions/Translate/utils
diff options
context:
space:
mode:
authorYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
committerYaco <franco@reevo.org>2020-06-04 11:01:00 -0300
commitfc7369835258467bf97eb64f184b93691f9a9fd5 (patch)
treedaabd60089d2dd76d9f5fb416b005fbe159c799d /www/wiki/extensions/Translate/utils
first commit
Diffstat (limited to 'www/wiki/extensions/Translate/utils')
-rw-r--r--www/wiki/extensions/Translate/utils/ArrayFlattener.php297
-rw-r--r--www/wiki/extensions/Translate/utils/ExternalMessageSourceStateComparator.php223
-rw-r--r--www/wiki/extensions/Translate/utils/ExternalMessageSourceStateImporter.php84
-rw-r--r--www/wiki/extensions/Translate/utils/Font.php138
-rw-r--r--www/wiki/extensions/Translate/utils/FuzzyBot.php25
-rw-r--r--www/wiki/extensions/Translate/utils/HTMLJsSelectToInputField.php85
-rw-r--r--www/wiki/extensions/Translate/utils/JsSelectToInput.php120
-rw-r--r--www/wiki/extensions/Translate/utils/MemProfile.php63
-rw-r--r--www/wiki/extensions/Translate/utils/MessageChangeStorage.php52
-rw-r--r--www/wiki/extensions/Translate/utils/MessageGroupCache.php276
-rw-r--r--www/wiki/extensions/Translate/utils/MessageGroupStates.php40
-rw-r--r--www/wiki/extensions/Translate/utils/MessageGroupStatesUpdaterJob.php151
-rw-r--r--www/wiki/extensions/Translate/utils/MessageGroupStats.php646
-rw-r--r--www/wiki/extensions/Translate/utils/MessageGroupStatsRebuildJob.php50
-rw-r--r--www/wiki/extensions/Translate/utils/MessageHandle.php291
-rw-r--r--www/wiki/extensions/Translate/utils/MessageIndex.php743
-rw-r--r--www/wiki/extensions/Translate/utils/MessageIndexRebuildJob.php55
-rw-r--r--www/wiki/extensions/Translate/utils/MessageTable.php424
-rw-r--r--www/wiki/extensions/Translate/utils/MessageUpdateJob.php98
-rw-r--r--www/wiki/extensions/Translate/utils/MessageWebImporter.php619
-rw-r--r--www/wiki/extensions/Translate/utils/RcFilter.php253
-rw-r--r--www/wiki/extensions/Translate/utils/ResourceLoader.php29
-rw-r--r--www/wiki/extensions/Translate/utils/RevTag.php31
-rw-r--r--www/wiki/extensions/Translate/utils/StatsBar.php104
-rw-r--r--www/wiki/extensions/Translate/utils/StatsTable.php331
-rw-r--r--www/wiki/extensions/Translate/utils/ToolBox.php44
-rw-r--r--www/wiki/extensions/Translate/utils/TranslateLogFormatter.php81
-rw-r--r--www/wiki/extensions/Translate/utils/TranslateMetadata.php120
-rw-r--r--www/wiki/extensions/Translate/utils/TranslateSandbox.php338
-rw-r--r--www/wiki/extensions/Translate/utils/TranslateSandboxEmailJob.php46
-rw-r--r--www/wiki/extensions/Translate/utils/TranslateYaml.php203
-rw-r--r--www/wiki/extensions/Translate/utils/TranslationEditPage.php296
-rw-r--r--www/wiki/extensions/Translate/utils/TranslationHelpers.php545
-rw-r--r--www/wiki/extensions/Translate/utils/TranslationStats.php61
-rw-r--r--www/wiki/extensions/Translate/utils/TranslationsUpdateJob.php97
-rw-r--r--www/wiki/extensions/Translate/utils/TuxMessageTable.php75
-rw-r--r--www/wiki/extensions/Translate/utils/UserToggles.php101
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;
+ }
+}