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