Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 130
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
TranslationStatsDataProvider
0.00% covered (danger)
0.00%
0 / 130
0.00% covered (danger)
0.00%
0 / 8
1722
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getGraphSpecifications
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGraphTypes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGraphData
0.00% covered (danger)
0.00%
0 / 86
0.00% covered (danger)
0.00%
0 / 1
420
 getStatsProvider
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 roundTimestampToCutoff
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
110
 roundingAddition
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getIncrement
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\Statistics;
5
6use Language;
7use MediaWiki\Config\ServiceOptions;
8use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups;
9use MediaWiki\Extension\Translate\Utilities\Utilities;
10use Wikimedia\ObjectFactory\ObjectFactory;
11use Wikimedia\Rdbms\ILoadBalancer;
12use const TS_MW;
13
14/**
15 * Provides translation stats data
16 * @author Abijeet Patro
17 * @license GPL-2.0-or-later
18 * @since 2020.09
19 */
20class TranslationStatsDataProvider {
21    public const CONSTRUCTOR_OPTIONS = [
22        'TranslateStatsProviders'
23    ];
24
25    private ObjectFactory $objectFactory;
26    private ServiceOptions $options;
27    private ILoadBalancer $loadBalancer;
28
29    public function __construct( ServiceOptions $options, ObjectFactory $objectFactory, ILoadBalancer $loadBalancer ) {
30        $this->options = $options;
31        $this->objectFactory = $objectFactory;
32        $this->loadBalancer = $loadBalancer;
33    }
34
35    private function getGraphSpecifications(): array {
36        return array_filter( $this->options->get( 'TranslateStatsProviders' ) );
37    }
38
39    public function getGraphTypes(): array {
40        return array_keys( $this->getGraphSpecifications() );
41    }
42
43    /**
44     * Fetches and preprocesses graph data that can be fed to graph drawer.
45     * @param TranslationStatsGraphOptions $opts
46     * @param Language $language
47     * @return array ( string => array ) Data indexed by their date labels.
48     */
49    public function getGraphData( TranslationStatsGraphOptions $opts, Language $language ): array {
50        $dbr = $this->loadBalancer->getConnection( DB_REPLICA );
51
52        $so = $this->getStatsProvider( $opts->getValue( 'count' ), $opts );
53
54        $fixedStart = $opts->getValue( 'start' ) !== '';
55
56        $now = time();
57        $period = 3600 * 24 * $opts->getValue( 'days' );
58
59        if ( $fixedStart ) {
60            $cutoff = (int)wfTimestamp( TS_UNIX, $opts->getValue( 'start' ) );
61        } else {
62            $cutoff = $now - $period;
63        }
64        $cutoff = self::roundTimestampToCutoff( $opts->getValue( 'scale' ), $cutoff, 'earlier' );
65
66        $start = $cutoff;
67
68        if ( $fixedStart ) {
69            $end = self::roundTimestampToCutoff( $opts->getValue( 'scale' ), $start + $period, 'later' ) - 1;
70        } else {
71            $end = null;
72        }
73
74        $tables = [];
75        $fields = [];
76        $conds = [];
77        $type = __METHOD__;
78        $options = [];
79        $joins = [];
80
81        $so->preQuery(
82            $dbr,
83            $tables,
84            $fields,
85            $conds,
86            $type,
87            $options,
88            $joins,
89            wfTimestamp( TS_MW, $start ),
90            wfTimestampOrNull( TS_MW, $end )
91        );
92        $res = $dbr->newSelectQueryBuilder()
93            ->tables( $tables )
94            ->fields( $fields )
95            ->conds( $conds )
96            ->caller( $type )
97            ->options( $options )
98            ->joinConds( $joins )
99            ->fetchResultSet();
100        wfDebug( __METHOD__ . "-queryend\n" );
101
102        // Start processing the data
103        $dateFormat = $so->getDateFormat();
104        $increment = self::getIncrement( $opts->getValue( 'scale' ) );
105
106        $labels = $so->labels();
107        $keys = array_keys( $labels );
108        $values = array_pad( [], count( $labels ), 0 );
109        $defaults = array_combine( $keys, $values );
110
111        $data = [];
112        // Allow 10 seconds in the future for processing time
113        $lastValue = $end ?? $now + 10;
114        while ( $cutoff <= $lastValue ) {
115            $date = $language->sprintfDate( $dateFormat, wfTimestamp( TS_MW, $cutoff ) );
116            $cutoff += $increment;
117            $data[$date] = $defaults;
118        }
119        // Ensure $lastValue is within range, in case the loop above jumped over it
120        $data[$language->sprintfDate( $dateFormat, wfTimestamp( TS_MW, $lastValue ) )] = $defaults;
121
122        // Processing
123        $labelToIndex = array_flip( $labels );
124
125        foreach ( $res as $row ) {
126            $indexLabels = $so->indexOf( $row );
127            if ( $indexLabels === false ) {
128                continue;
129            }
130
131            foreach ( $indexLabels as $i ) {
132                if ( !isset( $labelToIndex[$i] ) ) {
133                    continue;
134                }
135                $date = $language->sprintfDate( $dateFormat, $so->getTimestamp( $row ) );
136                // Ignore values outside range
137                if ( !isset( $data[$date] ) ) {
138                    continue;
139                }
140
141                $data[$date][$labelToIndex[$i]]++;
142            }
143        }
144
145        // Don't display dummy label
146        if ( count( $labels ) === 1 && $labels[0] === 'all' ) {
147            $labels = [];
148        }
149
150        foreach ( $labels as &$label ) {
151            if ( !str_contains( $label, '@' ) ) {
152                continue;
153            }
154            [ $groupId, $code ] = explode( '@', $label, 2 );
155            if ( $code && $groupId ) {
156                $code = Utilities::getLanguageName( $code, $language->getCode() ) . " ($code)";
157                $group = MessageGroups::getGroup( $groupId );
158                $group = $group ? $group->getLabel() : $groupId;
159                $label = "$group @ $code";
160            } elseif ( $code ) {
161                $label = Utilities::getLanguageName( $code, $language->getCode() ) . " ($code)";
162            } elseif ( $groupId ) {
163                $group = MessageGroups::getGroup( $groupId );
164                $label = $group ? $group->getLabel() : $groupId;
165            }
166        }
167
168        // Indicator that the last value is not full
169        if ( $end === null ) {
170            // Warning: do not user array_splice, which does not preserve numerical keys
171            $last = end( $data );
172            $key = key( $data );
173            unset( $data[$key] );
174            $data[ "$key*" ] = $last;
175        }
176
177        return [ $labels, $data ];
178    }
179
180    /** @noinspection PhpIncompatibleReturnTypeInspection */
181    private function getStatsProvider( string $type, TranslationStatsGraphOptions $opts ): TranslationStatsInterface {
182        $specs = $this->getGraphSpecifications();
183        return $this->objectFactory->createObject(
184            $specs[$type],
185            [
186                'allowClassName' => true,
187                'extraArgs' => [ $opts ],
188            ]
189        );
190    }
191
192    /**
193     * Gets the closest earliest timestamp that corresponds to start of a
194     * period in given scale, like, midnight, monday or first day of the month.
195     */
196    private static function roundTimestampToCutoff(
197        string $scale, int $cutoff, string $direction = 'earlier'
198    ): int {
199        $dir = $direction === 'earlier' ? -1 : 1;
200
201        /* Ensure that the first item in the graph has full data even
202        * if it doesn't align with the given 'days' boundary */
203        if ( $scale === 'hours' ) {
204            $cutoff += self::roundingAddition( $cutoff, 3600, $dir );
205        } elseif ( $scale === 'days' ) {
206            $cutoff += self::roundingAddition( $cutoff, 86400, $dir );
207        } elseif ( $scale === 'weeks' ) {
208            /* Here we assume that week starts on monday, which does not
209            * always hold true. Go Xwards day by day until we are on monday */
210            while ( date( 'D', $cutoff ) !== 'Mon' ) {
211                $cutoff += $dir * 86400;
212            }
213            // Round to nearest day
214            $cutoff -= ( $cutoff % 86400 );
215        } elseif ( $scale === 'months' ) {
216            // Go Xwards/ day by day until we are on the first day of the month
217            while ( date( 'j', $cutoff ) !== '1' ) {
218                $cutoff += $dir * 86400;
219            }
220            // Round to nearest day
221            $cutoff -= ( $cutoff % 86400 );
222        } elseif ( $scale === 'years' ) {
223            // Go Xwards/ day by day until we are on the first day of the year
224            while ( date( 'z', $cutoff ) !== '0' ) {
225                $cutoff += $dir * 86400;
226            }
227            // Round to nearest day
228            $cutoff -= ( $cutoff % 86400 );
229        }
230
231        return $cutoff;
232    }
233
234    private static function roundingAddition( int $ts, int $amount, int $dir ): int {
235        if ( $dir === -1 ) {
236            return -1 * ( $ts % $amount );
237        } else {
238            return $amount - ( $ts % $amount );
239        }
240    }
241
242    /**
243     * Returns an increment in seconds for a given scale.
244     * The increment must be small enough that we will hit every item in the
245     * scale when using different multiples of the increment. It should be
246     * large enough to avoid hitting the same item multiple times.
247     */
248    private static function getIncrement( string $scale ): int {
249        $increment = 3600 * 24;
250        if ( $scale === 'years' ) {
251            $increment = 3600 * 24 * 350;
252        } elseif ( $scale === 'months' ) {
253            /* We use increment to fill up the values. Use number small enough
254             * to ensure we hit each month */
255            $increment = 3600 * 24 * 15;
256        } elseif ( $scale === 'weeks' ) {
257            $increment = 3600 * 24 * 7;
258        } elseif ( $scale === 'hours' ) {
259            $increment = 3600;
260        }
261
262        return $increment;
263    }
264}