Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 122 |
|
0.00% |
0 / 9 |
CRAP | |
0.00% |
0 / 1 |
TranslationStatsDataProvider | |
0.00% |
0 / 122 |
|
0.00% |
0 / 9 |
1980 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getGraphSpecifications | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGraphTypes | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getGraphData | |
0.00% |
0 / 72 |
|
0.00% |
0 / 1 |
420 | |||
getStatsProvider | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
2 | |||
roundTimestampToCutoff | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
110 | |||
roundingAddition | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
getIncrement | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
makeTimeCondition | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\Statistics; |
5 | |
6 | use MediaWiki\Config\ServiceOptions; |
7 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups; |
8 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
9 | use MediaWiki\Language\Language; |
10 | use Wikimedia\ObjectFactory\ObjectFactory; |
11 | use Wikimedia\Rdbms\IConnectionProvider; |
12 | use Wikimedia\Rdbms\IReadableDatabase; |
13 | use 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 | */ |
21 | class 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 | } |