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