'TranslatePerLanguageStats', 'users' => 'TranslatePerLanguageStats', 'registrations' => 'TranslateRegistrationStats', 'reviews' => 'ReviewPerLanguageStats', 'reviewers' => 'ReviewPerLanguageStats', ]; public function __construct() { parent::__construct( 'TranslationStats' ); } public function isIncludable() { return true; } protected function getGroupName() { return 'wiki'; } /** * @since 2012-03-05 * @return array List of graph types */ public function getGraphTypes() { return array_keys( self::$graphs ); } /** * @since 2012-03-05 * @param string $type * @return string */ public function getGraphClass( $type ) { return self::$graphs[$type]; } public function execute( $par ) { $this->getOutput()->addModules( 'ext.translate.special.translationstats' ); $opts = new FormOptions(); $opts->add( 'graphit', false ); $opts->add( 'preview', false ); $opts->add( 'language', '' ); $opts->add( 'count', 'edits' ); $opts->add( 'scale', 'days' ); $opts->add( 'days', 30 ); $opts->add( 'width', 600 ); $opts->add( 'height', 400 ); $opts->add( 'group', '' ); $opts->add( 'uselang', '' ); $opts->add( 'start', '' ); $opts->add( 'imagescale', 1.0 ); $opts->fetchValuesFromRequest( $this->getRequest() ); $pars = explode( ';', $par ); foreach ( $pars as $item ) { if ( strpos( $item, '=' ) === false ) { continue; } list( $key, $value ) = array_map( 'trim', explode( '=', $item, 2 ) ); if ( isset( $opts[$key] ) ) { $opts[$key] = $value; } } $opts->validateIntBounds( 'days', 1, 10000 ); $opts->validateIntBounds( 'width', 200, 1000 ); $opts->validateIntBounds( 'height', 200, 1000 ); $opts->validateBounds( 'imagescale', 1.0, 4.0 ); if ( $opts['start'] !== '' ) { $opts['start'] = rtrim( wfTimestamp( TS_ISO_8601, $opts['start'] ), 'Z' ); } $validScales = [ 'months', 'weeks', 'days', 'hours' ]; if ( !in_array( $opts['scale'], $validScales ) ) { $opts['scale'] = 'days'; } if ( $opts['scale'] === 'hours' ) { $opts->validateIntBounds( 'days', 1, 4 ); } $validCounts = $this->getGraphTypes(); if ( !in_array( $opts['count'], $validCounts ) ) { $opts['count'] = 'edits'; } foreach ( [ 'group', 'language' ] as $t ) { $values = array_map( 'trim', explode( ',', $opts[$t] ) ); $values = array_splice( $values, 0, 4 ); if ( $t === 'group' ) { // BC for old syntax which replaced _ to | which was not allowed $values = preg_replace( '~^page_~', 'page-', $values ); } $opts[$t] = implode( ',', $values ); } if ( $this->including() ) { $this->getOutput()->addHTML( $this->image( $opts ) ); } elseif ( $opts['graphit'] ) { if ( !class_exists( PHPlot::class ) ) { header( 'HTTP/1.0 500 Multi fail' ); echo 'PHPlot not found'; } if ( !$this->getRequest()->getBool( 'debug' ) ) { $this->getOutput()->disable(); header( 'Content-Type: image/png' ); header( 'Cache-Control: private, max-age=3600' ); header( 'Expires: ' . wfTimestamp( TS_RFC2822, time() + 3600 ) ); } $this->draw( $opts ); } else { $this->form( $opts ); } } /** * Constructs the form which can be used to generate custom graphs. * @param FormOptions $opts * @suppress SecurityCheck-DoubleEscaped Intentionally outputting what user should type */ protected function form( FormOptions $opts ) { global $wgScript; $this->setHeaders(); $out = $this->getOutput(); $out->addHelpLink( 'Help:Extension:Translate/Statistics_and_reporting' ); $out->addWikiMsg( 'translate-statsf-intro' ); $out->addHTML( Xml::fieldset( $this->msg( 'translate-statsf-options' )->text() ) . Html::openElement( 'form', [ 'action' => $wgScript ] ) . Html::hidden( 'title', $this->getPageTitle()->getPrefixedText() ) . Html::hidden( 'preview', 1 ) . '' ); $submit = Xml::submitButton( $this->msg( 'translate-statsf-submit' )->text() ); $out->addHTML( $this->eInput( 'width', $opts ) . $this->eInput( 'height', $opts ) . '' . $this->eInput( 'start', $opts, 24 ) . $this->eInput( 'days', $opts ) . $this->eRadio( 'scale', $opts, [ 'months', 'weeks', 'days', 'hours' ] ) . $this->eRadio( 'count', $opts, $this->getGraphTypes() ) . '' . $this->eLanguage( 'language', $opts ) . $this->eGroup( 'group', $opts ) . '' . '' ); $out->addHTML( '



' . $submit . '
' . '' . '' ); if ( !$opts['preview'] ) { return; } $spiParams = ''; foreach ( $opts->getChangedValues() as $key => $v ) { if ( $key === 'preview' ) { continue; } if ( $spiParams !== '' ) { $spiParams .= ';'; } $spiParams .= wfEscapeWikiText( "$key=$v" ); } if ( $spiParams !== '' ) { $spiParams = '/' . $spiParams; } $titleText = $this->getPageTitle()->getPrefixedText(); $out->addHTML( Html::element( 'hr' ) . Html::element( 'pre', [], "{{{$titleText}{$spiParams}}}" ) ); $out->addHTML( Html::element( 'hr' ) . Html::rawElement( 'div', [ 'style' => 'margin: 1em auto; text-align: center;' ], $this->image( $opts ) ) ); } /** * Constructs a table row with label and input in two columns. * @param string $name Option name. * @param FormOptions $opts * @param int $width * @return string Html. */ protected function eInput( $name, FormOptions $opts, $width = 4 ) { $value = $opts[$name]; return '' . $this->eLabel( $name ) . '' . Xml::input( $name, $width, $value, [ 'id' => $name ] ) . '' . "\n"; } /** * Constructs a label for option. * @param string $name Option name. * @return string Html. */ protected function eLabel( $name ) { // Give grep a chance to find the usages: // translate-statsf-width, translate-statsf-height, translate-statsf-start, // translate-statsf-days, translate-statsf-scale, translate-statsf-count, // translate-statsf-language, translate-statsf-group $label = 'translate-statsf-' . $name; $label = $this->msg( $label )->escaped(); return Xml::tags( 'label', [ 'for' => $name ], $label ); } /** * Constructs a table row with label and radio input in two columns. * @param string $name Option name. * @param FormOptions $opts * @param string[] $alts List of alternatives. * @return string Html. */ protected function eRadio( $name, FormOptions $opts, array $alts ) { // Give grep a chance to find the usages: // translate-statsf-scale, translate-statsf-count $label = 'translate-statsf-' . $name; $label = $this->msg( $label )->escaped(); $s = '' . $label . ''; $options = []; foreach ( $alts as $alt ) { $id = "$name-$alt"; $radio = Xml::radio( $name, $alt, $alt === $opts[$name], [ 'id' => $id ] ) . ' '; $options[] = $radio . ' ' . $this->eLabel( $id ); } $s .= implode( ' ', $options ); $s .= '' . "\n"; return $s; } /** * Constructs a table row with label and language selector in two columns. * @param string $name Option name. * @param FormOptions $opts * @return string Html. */ protected function eLanguage( $name, FormOptions $opts ) { $value = $opts[$name]; $select = $this->languageSelector(); $select->setTargetId( 'language' ); return '' . $this->eLabel( $name ) . '' . $select->getHtmlAndPrepareJS() . '
' . Xml::input( $name, 20, $value, [ 'id' => $name ] ) . '' . "\n"; } /** * Constructs a JavaScript enhanced language selector. * @return JsSelectToInput */ protected function languageSelector() { $languages = TranslateUtils::getLanguageNames( $this->getLanguage()->getCode() ); ksort( $languages ); $selector = new XmlSelect( 'mw-language-selector', 'mw-language-selector' ); foreach ( $languages as $code => $name ) { $selector->addOption( "$code - $name", $code ); } $jsSelect = new JsSelectToInput( $selector ); return $jsSelect; } /** * Constructs a table row with label and group selector in two columns. * @param string $name Option name. * @param FormOptions $opts * @return string Html. */ protected function eGroup( $name, FormOptions $opts ) { $value = $opts[$name]; $select = $this->groupSelector(); $select->setTargetId( 'group' ); return '' . $this->eLabel( $name ) . '' . $select->getHtmlAndPrepareJS() . '
' . Xml::input( $name, 20, $value, [ 'id' => $name ] ) . '' . "\n"; } /** * Constructs a JavaScript enhanced group selector. * @return JsSelectToInput */ protected function groupSelector() { $groups = MessageGroups::singleton()->getGroups(); /** * @var MessageGroup $group */ foreach ( $groups as $key => $group ) { if ( !$group->exists() ) { unset( $groups[$key] ); continue; } } ksort( $groups ); $selector = new XmlSelect( 'mw-group-selector', 'mw-group-selector' ); /** * @var MessageGroup $name */ foreach ( $groups as $code => $name ) { $selector->addOption( $name->getLabel(), $code ); } $jsSelect = new JsSelectToInput( $selector ); return $jsSelect; } /** * Returns an \ tag for graph. * @param FormOptions $opts * @return string Html. */ protected function image( FormOptions $opts ) { $title = $this->getPageTitle(); $params = $opts->getChangedValues(); $params[ 'graphit' ] = true; $src = $title->getLocalURL( $params ); $srcsets = []; foreach ( [ 1.5, 2, 3 ] as $scale ) { $params[ 'imagescale' ] = $scale; $srcsets[] = "{$title->getLocalURL( $params )} {$scale}x"; } return Xml::element( 'img', [ 'src' => $src, 'srcset' => implode( ', ', $srcsets ), 'width' => $opts['width'], 'height' => $opts['height'], ] ); } /** * Fetches and preprocesses graph data that can be fed to graph drawer. * @param FormOptions $opts * @return array ( string => array ) Data indexed by their date labels. */ protected function getData( FormOptions $opts ) { $dbr = wfGetDB( DB_REPLICA ); $class = $this->getGraphClass( $opts['count'] ); $so = new $class( $opts ); $fixedStart = $opts->getValue( 'start' ) !== ''; $now = time(); $period = 3600 * 24 * $opts->getValue( 'days' ); if ( $fixedStart ) { $cutoff = wfTimestamp( TS_UNIX, $opts->getValue( 'start' ) ); } else { $cutoff = $now - $period; } $cutoff = self::roundTimestampToCutoff( $opts['scale'], $cutoff, 'earlier' ); $start = $cutoff; if ( $fixedStart ) { $end = self::roundTimestampToCutoff( $opts['scale'], $start + $period, 'later' ) - 1; } else { $end = null; } $tables = []; $fields = []; $conds = []; $type = __METHOD__; $options = []; $joins = []; $so->preQuery( $tables, $fields, $conds, $type, $options, $joins, $start, $end ); $res = $dbr->select( $tables, $fields, $conds, $type, $options, $joins ); wfDebug( __METHOD__ . "-queryend\n" ); // Start processing the data $dateFormat = $so->getDateFormat(); $increment = self::getIncrement( $opts['scale'] ); $labels = $so->labels(); $keys = array_keys( $labels ); $values = array_pad( [], count( $labels ), 0 ); $defaults = array_combine( $keys, $values ); $data = []; // Allow 10 seconds in the future for processing time $lastValue = $end !== null ? $end : $now + 10; $lang = $this->getLanguage(); while ( $cutoff <= $lastValue ) { $date = $lang->sprintfDate( $dateFormat, wfTimestamp( TS_MW, $cutoff ) ); $cutoff += $increment; $data[$date] = $defaults; } // Processing $labelToIndex = array_flip( $labels ); foreach ( $res as $row ) { $indexLabels = $so->indexOf( $row ); if ( $indexLabels === false ) { continue; } foreach ( (array)$indexLabels as $i ) { if ( !isset( $labelToIndex[$i] ) ) { continue; } $date = $lang->sprintfDate( $dateFormat, $so->getTimestamp( $row ) ); // Ignore values outside range if ( !isset( $data[$date] ) ) { continue; } $data[$date][$labelToIndex[$i]]++; } } // Don't display dummy label if ( count( $labels ) === 1 && $labels[0] === 'all' ) { $labels = []; } foreach ( $labels as &$label ) { if ( strpos( $label, '@' ) === false ) { continue; } list( $groupId, $code ) = explode( '@', $label, 2 ); if ( $code && $groupId ) { $code = TranslateUtils::getLanguageName( $code, $lang->getCode() ) . " ($code)"; $group = MessageGroups::getGroup( $groupId ); $group = $group ? $group->getLabel() : $groupId; $label = "$group @ $code"; } elseif ( $code ) { $label = TranslateUtils::getLanguageName( $code, $lang->getCode() ) . " ($code)"; } elseif ( $groupId ) { $group = MessageGroups::getGroup( $groupId ); $label = $group ? $group->getLabel() : $groupId; } } if ( $end === null ) { $last = array_splice( $data, -1, 1 ); // Indicator that the last value is not full $data[key( $last ) . '*'] = current( $last ); } return [ $labels, $data ]; } /** * Gets the closest earlieast timestamp that corresponds to start of a * period in given scale, like, midnight, monday or first day of the month. * @param string $scale One of hours, days, weeks, months * @param int $cutoff Timestamp in unix format. * @param string $direction One of earlier, later * @return int */ protected static function roundTimestampToCutoff( $scale, $cutoff, $direction = 'earlier' ) { $dir = $direction === 'earlier' ? -1 : 1; /* Ensure that the first item in the graph has full data even * if it doesn't align with the given 'days' boundary */ if ( $scale === 'hours' ) { $cutoff += self::roundingAddition( $cutoff, 3600, $dir ); } elseif ( $scale === 'days' ) { $cutoff += self::roundingAddition( $cutoff, 86400, $dir ); } elseif ( $scale === 'weeks' ) { /* Here we assume that week starts on monday, which does not * always hold true. Go Xwards day by day until we are on monday */ while ( date( 'D', $cutoff ) !== 'Mon' ) { $cutoff += $dir * 86400; } // Round to nearest day $cutoff -= ( $cutoff % 86400 ); } elseif ( $scale === 'months' ) { // Go Xwards/ day by day until we are on the first day of the month while ( date( 'j', $cutoff ) !== '1' ) { $cutoff += $dir * 86400; } // Round to nearest day $cutoff -= ( $cutoff % 86400 ); } return $cutoff; } /** * @param int $ts * @param int $amount * @param int $dir * @return int */ protected static function roundingAddition( $ts, $amount, $dir ) { if ( $dir === -1 ) { return -1 * ( $ts % $amount ); } else { return $amount - ( $ts % $amount ); } } /** * Adds raw image data of the graph to the output. * @param FormOptions $opts */ public function draw( FormOptions $opts ) { global $wgTranslatePHPlotFont; $imageScale = $opts->getValue( 'imagescale' ); $width = $opts->getValue( 'width' ); $height = $opts->getValue( 'height' ); // Define the object $plot = new PHPlot( $width * $imageScale, $height * $imageScale ); list( $legend, $resData ) = $this->getData( $opts ); $count = count( $resData ); $skip = (int)( $count / ( $width / 60 ) - 1 ); $i = $count; $data = []; foreach ( $resData as $date => $edits ) { if ( $skip > 0 && ( $count - $i ) % $skip !== 0 ) { $date = ''; } if ( strpos( $date, ';' ) !== false ) { list( , $date ) = explode( ';', $date, 2 ); } array_unshift( $edits, $date ); $data[] = $edits; $i--; } $font = FCFontFinder::findFile( $this->getLanguage()->getCode() ); if ( !$font ) { $font = $wgTranslatePHPlotFont; } $numberFont = FCFontFinder::findFile( 'en' ); $plot->SetDefaultTTFont( $font ); $plot->SetFontTTF( 'generic', $font, 12 * $imageScale ); $plot->SetFontTTF( 'legend', $font, 12 * $imageScale ); $plot->SetFontTTF( 'x_title', $font, 10 * $imageScale ); $plot->SetFontTTF( 'y_title', $font, 10 * $imageScale ); $plot->SetFontTTF( 'x_label', $numberFont, 8 * $imageScale ); $plot->SetFontTTF( 'y_label', $numberFont, 8 * $imageScale ); $plot->SetDataValues( $data ); if ( $legend !== null ) { $plot->SetLegend( $legend ); } // Give grep a chance to find the usages: // translate-stats-edits, translate-stats-users, translate-stats-registrations, // translate-stats-reviews, translate-stats-reviewers $yTitle = $this->msg( 'translate-stats-' . $opts['count'] )->escaped(); // Turn off X axis ticks and labels because they get in the way: $plot->SetYTitle( $yTitle ); $plot->SetXTickLabelPos( 'none' ); $plot->SetXTickPos( 'none' ); $plot->SetXLabelAngle( 45 ); $max = max( array_map( 'max', $resData ) ); $max = self::roundToSignificant( $max, 1 ); $max = round( $max, (int)( -log( $max, 10 ) ) ); $yTick = 10; while ( $max / $yTick > $height / 20 ) { $yTick *= 2; } // If we have very small case, ensure that there is at least one tick $yTick = min( $max, $yTick ); $yTick = self::roundToSignificant( $yTick ); $plot->SetYTickIncrement( $yTick ); $plot->SetPlotAreaWorld( null, 0, null, max( $max, 10 ) ); $plot->SetTransparentColor( 'white' ); $plot->SetBackgroundColor( 'white' ); // Draw it $plot->DrawGraph(); } /** * Enhanced version of round that supports rounding up to a given scale * relative to the number itself. Examples: * - roundToSignificant( 1234, 0 ) = 10000 * - roundToSignificant( 1234, 1 ) = 2000 * - roundToSignificant( 1234, 2 ) = 1300 * - roundToSignificant( 1234, 3 ) = 1240 * * @param int $number Number to round. * @param int $significant How many signficant numbers to keep. * @return int Rounded number. */ public static function roundToSignificant( $number, $significant = 1 ) { $log = (int)log( $number, 10 ); $nonSignificant = max( 0, $log - $significant + 1 ); $factor = pow( 10, $nonSignificant ); return (int)( ceil( $number / $factor ) * $factor ); } /** * Returns an increment in seconds for a given scale. * The increment must be small enough that we will hit every item in the * scale when using different multiples of the increment. It should be * large enough to avoid hitting the same item multiple times. * @param string $scale Either months, weeks, days or hours. * @return int Number of seconds in the increment. */ public static function getIncrement( $scale ) { $increment = 3600 * 24; if ( $scale === 'months' ) { /* We use increment to fill up the values. Use number small enough * to ensure we hit each month */ $increment = 3600 * 24 * 15; } elseif ( $scale === 'weeks' ) { $increment = 3600 * 24 * 7; } elseif ( $scale === 'hours' ) { $increment = 3600; } return $increment; } } /** * Interface for producing different kinds of graphs. * The graphs are based on data queried from the database. * @ingroup Stats */ interface TranslationStatsInterface { /** * Constructor. The implementation can access the graph options, but not * define new ones. * @param FormOptions $opts */ public function __construct( FormOptions $opts ); /** * Query details that the graph must fill. * @param array &$tables Empty list. Append table names. * @param array &$fields Empty list. Append field names. * @param array &$conds Empty array. Append select conditions. * @param string &$type Append graph type (used to identify queries). * @param array &$options Empty array. Append extra query options. * @param array &$joins Empty array. Append extra join conditions. * @param string $start Precalculated start cutoff timestamp * @param string $end Precalculated end cutoff timestamp */ public function preQuery( &$tables, &$fields, &$conds, &$type, &$options, &$joins, $start, $end ); /** * Return the indexes which this result contributes to. * Return 'all' if only one variable is measured. Return false if none. * @param array $row Database Result Row */ public function indexOf( $row ); /** * Return the names of the variables being measured. * Return 'all' if only one variable is measured. Must match indexes * returned by indexOf() and contain them all. * @return string[] */ public function labels(); /** * Return the timestamp associated with this result row. * @param array $row Database Result Row * @return string Timestamp. */ public function getTimestamp( $row ); /** * Return time formatting string. * @see Language::sprintfDate() * @return string */ public function getDateFormat(); } /** * Provides some hand default implementations for TranslationStatsInterface. * @ingroup Stats */ abstract class TranslationStatsBase implements TranslationStatsInterface { /** * @var FormOptions Graph options. */ protected $opts; public function __construct( FormOptions $opts ) { $this->opts = $opts; } public function indexOf( $row ) { return [ 'all' ]; } public function labels() { return [ 'all' ]; } public function getDateFormat() { $dateFormat = 'Y-m-d'; if ( $this->opts['scale'] === 'months' ) { $dateFormat = 'Y-m'; } elseif ( $this->opts['scale'] === 'weeks' ) { $dateFormat = 'Y-\WW'; } elseif ( $this->opts['scale'] === 'hours' ) { $dateFormat .= ';H'; } return $dateFormat; } protected static function makeTimeCondition( $field, $start, $end ) { $db = wfGetDB( DB_REPLICA ); $conds = []; if ( $start !== null ) { $conds[] = "$field >= '{$db->timestamp( $start )}'"; } if ( $end !== null ) { $conds[] = "$field <= '{$db->timestamp( $end )}'"; } return $conds; } /** * @since 2012-03-05 * @param array $groupIds * @return array */ protected static function namespacesFromGroups( $groupIds ) { $namespaces = []; foreach ( $groupIds as $id ) { $group = MessageGroups::getGroup( $id ); if ( $group ) { $namespace = $group->getNamespace(); $namespaces[$namespace] = true; } } return array_keys( $namespaces ); } } /** * Graph which provides statistics on active users and number of translations. * @ingroup Stats */ class TranslatePerLanguageStats extends TranslationStatsBase { /** @var bool[] array( string => bool ) Cache used to count active users only once per day. */ protected $usercache; protected $codes, $groups; public function __construct( FormOptions $opts ) { parent::__construct( $opts ); // This query is slow... ensure a lower limit. $opts->validateIntBounds( 'days', 1, 200 ); } public function preQuery( &$tables, &$fields, &$conds, &$type, &$options, &$joins, $start, $end ) { global $wgTranslateMessageNamespaces; $db = wfGetDB( DB_REPLICA ); $tables = [ 'recentchanges' ]; $fields = [ 'rc_timestamp' ]; $joins = []; $conds = [ 'rc_namespace' => $wgTranslateMessageNamespaces, 'rc_bot' => 0, 'rc_type != ' . RC_LOG, ]; $timeConds = self::makeTimeCondition( 'rc_timestamp', $start, $end ); $conds = array_merge( $conds, $timeConds ); $options = [ 'ORDER BY' => 'rc_timestamp' ]; $this->groups = array_filter( array_map( 'trim', explode( ',', $this->opts['group'] ) ) ); $this->groups = array_map( 'MessageGroups::normalizeId', $this->groups ); $this->codes = array_filter( array_map( 'trim', explode( ',', $this->opts['language'] ) ) ); $namespaces = self::namespacesFromGroups( $this->groups ); if ( count( $namespaces ) ) { $conds['rc_namespace'] = $namespaces; } $languages = []; foreach ( $this->codes as $code ) { $languages[] = 'rc_title ' . $db->buildLike( $db->anyString(), "/$code" ); } if ( count( $languages ) ) { $conds[] = $db->makeList( $languages, LIST_OR ); } $fields[] = 'rc_title'; if ( $this->groups ) { $fields[] = 'rc_namespace'; } if ( $this->opts['count'] === 'users' ) { if ( class_exists( ActorMigration::class ) ) { $actorQuery = ActorMigration::newMigration()->getJoin( 'rc_user' ); $tables += $actorQuery['tables']; $fields['rc_user_text'] = $actorQuery['fields']['rc_user_text']; $joins += $actorQuery['joins']; } else { $fields[] = 'rc_user_text'; } } $type .= '-perlang'; } public function indexOf( $row ) { // We need to check that there is only one user per day. if ( $this->opts['count'] === 'users' ) { $date = $this->formatTimestamp( $row->rc_timestamp ); if ( isset( $this->usercache[$date][$row->rc_user_text] ) ) { return -1; } else { $this->usercache[$date][$row->rc_user_text] = 1; } } // Do not consider language-less pages. if ( strpos( $row->rc_title, '/' ) === false ) { return false; } // No filters, just one key to track. if ( !$this->groups && !$this->codes ) { return 'all'; } // The key-building needs to be in sync with ::labels(). list( $key, $code ) = TranslateUtils::figureMessage( $row->rc_title ); $groups = []; $codes = []; if ( $this->groups ) { /* * Get list of keys that the message belongs to, and filter * out those which are not requested. */ $groups = TranslateUtils::messageKeyToGroups( $row->rc_namespace, $key ); $groups = array_intersect( $this->groups, $groups ); } if ( $this->codes ) { $codes = [ $code ]; } return $this->combineTwoArrays( $groups, $codes ); } public function labels() { return $this->combineTwoArrays( $this->groups, $this->codes ); } public function getTimestamp( $row ) { return $row->rc_timestamp; } /** * Makes a label for variable. If group or language code filters, or both * are used, combine those in a pretty way. * @param string $group Group name. * @param string $code Language code. * @return string Label. */ protected function makeLabel( $group, $code ) { if ( $group || $code ) { return "$group@$code"; } else { return 'all'; } } /** * Cross-product of two lists with string results, where either * list can be empty. * @param string[] $groups Group names. * @param string[] $codes Language codes. * @return string[] Labels. */ protected function combineTwoArrays( $groups, $codes ) { if ( !count( $groups ) ) { $groups[] = false; } if ( !count( $codes ) ) { $codes[] = false; } $items = []; foreach ( $groups as $group ) { foreach ( $codes as $code ) { $items[] = $this->makeLabel( $group, $code ); } } return $items; } /** * Returns unique index for given item in the scale being used. * Called a lot, so performance intensive. * @param string $timestamp Timestamp in mediawiki format. * @return string */ protected function formatTimestamp( $timestamp ) { global $wgContLang; switch ( $this->opts['scale'] ) { case 'hours' : $cut = 4; break; case 'days' : $cut = 6; break; case 'months': $cut = 8; break; default : return $wgContLang->sprintfDate( $this->getDateFormat(), $timestamp ); } return substr( $timestamp, 0, -$cut ); } } /** * Graph which provides statistics about amount of registered users in a given time. * @ingroup Stats */ class TranslateRegistrationStats extends TranslationStatsBase { public function preQuery( &$tables, &$fields, &$conds, &$type, &$options, &$joins, $start, $end ) { $tables = 'user'; $fields = 'user_registration'; $conds = self::makeTimeCondition( 'user_registration', $start, $end ); $type .= '-registration'; $options = []; $joins = []; } public function getTimestamp( $row ) { return $row->user_registration; } } /** * Graph which provides statistics on number of reviews and reviewers. * @since 2012-03-05 * @ingroup Stats */ class ReviewPerLanguageStats extends TranslatePerLanguageStats { public function preQuery( &$tables, &$fields, &$conds, &$type, &$options, &$joins, $start, $end ) { global $wgTranslateMessageNamespaces; $db = wfGetDB( DB_REPLICA ); $tables = [ 'logging' ]; $fields = [ 'log_timestamp' ]; $joins = []; $conds = [ 'log_namespace' => $wgTranslateMessageNamespaces, 'log_action' => 'message', ]; $timeConds = self::makeTimeCondition( 'log_timestamp', $start, $end ); $conds = array_merge( $conds, $timeConds ); $options = [ 'ORDER BY' => 'log_timestamp' ]; $this->groups = array_filter( array_map( 'trim', explode( ',', $this->opts['group'] ) ) ); $this->codes = array_filter( array_map( 'trim', explode( ',', $this->opts['language'] ) ) ); $namespaces = self::namespacesFromGroups( $this->groups ); if ( count( $namespaces ) ) { $conds['log_namespace'] = $namespaces; } $languages = []; foreach ( $this->codes as $code ) { $languages[] = 'log_title ' . $db->buildLike( $db->anyString(), "/$code" ); } if ( count( $languages ) ) { $conds[] = $db->makeList( $languages, LIST_OR ); } $fields[] = 'log_title'; if ( $this->groups ) { $fields[] = 'log_namespace'; } if ( $this->opts['count'] === 'reviewers' ) { if ( class_exists( ActorMigration::class ) ) { $actorQuery = ActorMigration::newMigration()->getJoin( 'log_user' ); $tables += $actorQuery['tables']; $fields['log_user_text'] = $actorQuery['fields']['log_user_text']; $joins += $actorQuery['joins']; } else { $fields[] = 'log_user_text'; } } $type .= '-reviews'; } public function indexOf( $row ) { // We need to check that there is only one user per day. if ( $this->opts['count'] === 'reviewers' ) { $date = $this->formatTimestamp( $row->log_timestamp ); if ( isset( $this->usercache[$date][$row->log_user_text] ) ) { return -1; } else { $this->usercache[$date][$row->log_user_text] = 1; } } // Do not consider language-less pages. if ( strpos( $row->log_title, '/' ) === false ) { return false; } // No filters, just one key to track. if ( !$this->groups && !$this->codes ) { return 'all'; } // The key-building needs to be in sync with ::labels(). list( $key, $code ) = TranslateUtils::figureMessage( $row->log_title ); $groups = []; $codes = []; if ( $this->groups ) { /* Get list of keys that the message belongs to, and filter * out those which are not requested. */ $groups = TranslateUtils::messageKeyToGroups( $row->log_namespace, $key ); $groups = array_intersect( $this->groups, $groups ); } if ( $this->codes ) { $codes = [ $code ]; } return $this->combineTwoArrays( $groups, $codes ); } public function labels() { return $this->combineTwoArrays( $this->groups, $this->codes ); } public function getTimestamp( $row ) { return $row->log_timestamp; } }