Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 130 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
TranslationStatsDataProvider | |
0.00% |
0 / 130 |
|
0.00% |
0 / 8 |
1722 | |
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 / 86 |
|
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 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\Statistics; |
5 | |
6 | use Language; |
7 | use MediaWiki\Config\ServiceOptions; |
8 | use MediaWiki\Extension\Translate\MessageGroupProcessing\MessageGroups; |
9 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
10 | use Wikimedia\ObjectFactory\ObjectFactory; |
11 | use Wikimedia\Rdbms\ILoadBalancer; |
12 | use 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 | */ |
20 | class 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 | } |