Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
57.14% |
192 / 336 |
|
31.82% |
14 / 44 |
CRAP | |
0.00% |
0 / 1 |
MessageCollection | |
57.14% |
192 / 336 |
|
31.82% |
14 / 44 |
1481.87 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
newFromDefinitions | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getLanguage | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
setInFile | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setTags | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
keys | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTitles | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getMessageKeys | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getTags | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getAuthors | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
42 | |||
addCollectionAuthors | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
20 | |||
loadTranslations | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
resetForNewLanguage | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
slice | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
filter | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
getAvailableFilters | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
applyFilter | |
30.43% |
7 / 23 |
|
0.00% |
0 / 1 |
43.66 | |||
filterUntranslatedOptional | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
filterOnCondition | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
30 | |||
filterFuzzy | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
30 | |||
filterHastranslation | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
30 | |||
filterChanged | |
92.00% |
23 / 25 |
|
0.00% |
0 / 1 |
9.04 | |||
filterReviewer | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
filterLastTranslator | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
fixKeys | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
loadInfo | |
86.36% |
19 / 22 |
|
0.00% |
0 / 1 |
4.04 | |||
loadReviewInfo | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
4.07 | |||
loadData | |
90.48% |
19 / 21 |
|
0.00% |
0 / 1 |
4.01 | |||
getTitleConds | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
4 | |||
rowToKey | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
2.26 | |||
getReverseMap | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
initMessages | |
72.34% |
34 / 47 |
|
0.00% |
0 / 1 |
30.33 | |||
offsetExists | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
offsetGet | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
offsetSet | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
offsetUnset | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
__get | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
__set | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
rewind | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
current | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
key | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
next | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
valid | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
count | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\Translate\MessageLoading; |
5 | |
6 | use AppendIterator; |
7 | use ArrayAccess; |
8 | use Countable; |
9 | use EmptyIterator; |
10 | use IDBAccessObject; |
11 | use InvalidArgumentException; |
12 | use Iterator; |
13 | use LogicException; |
14 | use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore; |
15 | use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot; |
16 | use MediaWiki\Extension\Translate\Utilities\Utilities; |
17 | use MediaWiki\MediaWikiServices; |
18 | use MediaWiki\Revision\RevisionRecord; |
19 | use MediaWiki\Revision\SlotRecord; |
20 | use RuntimeException; |
21 | use stdClass; |
22 | use TextContent; |
23 | use TitleValue; |
24 | use Traversable; |
25 | use Wikimedia\Rdbms\IDatabase; |
26 | |
27 | /** |
28 | * This file contains the class for core message collections implementation. |
29 | * |
30 | * Message collection is collection of messages of one message group in one |
31 | * language. It handles loading of the messages in one huge batch, and also |
32 | * stores information that can be used to filter the collection in different |
33 | * ways. |
34 | * |
35 | * @author Niklas Laxström |
36 | * @copyright Copyright © 2007-2011, Niklas Laxström |
37 | * @license GPL-2.0-or-later |
38 | */ |
39 | class MessageCollection implements ArrayAccess, Iterator, Countable { |
40 | /** |
41 | * The queries can get very large because each message title is specified |
42 | * individually. Very large queries can confuse the database query planner. |
43 | * Queries are split into multiple separate queries having at most this many |
44 | * items. |
45 | */ |
46 | private const MAX_ITEMS_PER_QUERY = 2000; |
47 | |
48 | /** Language code. */ |
49 | public string $code; |
50 | private MessageDefinitions $definitions; |
51 | /** array( %Message key => translation, ... ) */ |
52 | private array $infile = []; |
53 | // Keys and messages. |
54 | |
55 | /** @var array<string, TitleValue> Key is message display key */ |
56 | protected array $keys = []; |
57 | /** array( %Message String => Message, ... ) */ |
58 | protected ?array $messages = []; |
59 | private ?array $reverseMap; |
60 | // Database resources |
61 | |
62 | /** Stored message existence and fuzzy state. */ |
63 | private Traversable $dbInfo; |
64 | /** Stored translations in database. */ |
65 | private Traversable $dbData; |
66 | /** Stored reviews in database. */ |
67 | private Traversable $dbReviewData; |
68 | /** |
69 | * Tags, copied to thin messages |
70 | * tagtype => keys |
71 | * @var array[] |
72 | */ |
73 | protected array $tags = []; |
74 | /** @var string[] Authors. */ |
75 | private array $authors = []; |
76 | |
77 | /** |
78 | * Constructors. Use newFromDefinitions() instead. |
79 | * @param string $code Language code. |
80 | */ |
81 | public function __construct( string $code ) { |
82 | $this->code = $code; |
83 | } |
84 | |
85 | /** |
86 | * Construct a new message collection from definitions. |
87 | * @param MessageDefinitions $definitions |
88 | * @param string $code Language code. |
89 | */ |
90 | public static function newFromDefinitions( MessageDefinitions $definitions, string $code ): self { |
91 | $collection = new self( $code ); |
92 | $collection->definitions = $definitions; |
93 | $collection->resetForNewLanguage( $code ); |
94 | |
95 | return $collection; |
96 | } |
97 | |
98 | public function getLanguage(): string { |
99 | return $this->code; |
100 | } |
101 | |
102 | // Data setters |
103 | |
104 | /** |
105 | * Set translation from file, as opposed to translation which only exists |
106 | * in the wiki because they are not exported and committed yet. |
107 | * @param string[] $messages Array of translations indexed by display key. |
108 | */ |
109 | public function setInFile( array $messages ): void { |
110 | $this->infile = $messages; |
111 | } |
112 | |
113 | /** |
114 | * Set message tags. |
115 | * @param string $type Tag type, usually ignored or optional. |
116 | * @param string[] $keys List of display keys. |
117 | */ |
118 | public function setTags( string $type, array $keys ): void { |
119 | $this->tags[$type] = $keys; |
120 | } |
121 | |
122 | /** |
123 | * Returns list of available message keys. This is affected by filtering. |
124 | * @return array<string, TitleValue> List of database keys indexed by display keys. |
125 | */ |
126 | public function keys(): array { |
127 | return $this->keys; |
128 | } |
129 | |
130 | /** |
131 | * Returns list of TitleValues of messages that are used in this collection after filtering. |
132 | * @return TitleValue[] |
133 | */ |
134 | private function getTitles(): array { |
135 | return array_values( $this->keys ); |
136 | } |
137 | |
138 | /** |
139 | * Returns list of message keys that are used in this collection after filtering. |
140 | * @return string[] |
141 | */ |
142 | public function getMessageKeys(): array { |
143 | return array_keys( $this->keys ); |
144 | } |
145 | |
146 | /** |
147 | * Returns stored message tags. |
148 | * @param string $type Tag type, usually optional or ignored. |
149 | * @return string[] List of keys with given tag. |
150 | */ |
151 | public function getTags( string $type ): array { |
152 | return $this->tags[$type] ?? []; |
153 | } |
154 | |
155 | /** |
156 | * Lists all translators that have contributed to the latest revisions of |
157 | * each translation. Causes translations to be loaded from the database. |
158 | * Is not affected by filters. |
159 | * @return string[] List of usernames. |
160 | */ |
161 | public function getAuthors(): array { |
162 | $this->loadTranslations(); |
163 | |
164 | $authors = array_flip( $this->authors ); |
165 | |
166 | foreach ( $this->messages as $m ) { |
167 | // Check if there are authors |
168 | /** @var Message $m */ |
169 | $author = $m->getProperty( 'last-translator-text' ); |
170 | |
171 | if ( $author === null ) { |
172 | continue; |
173 | } |
174 | |
175 | if ( !isset( $authors[$author] ) ) { |
176 | $authors[$author] = 1; |
177 | } else { |
178 | $authors[$author]++; |
179 | } |
180 | } |
181 | |
182 | # arsort( $authors, SORT_NUMERIC ); |
183 | ksort( $authors ); |
184 | $fuzzyBot = FuzzyBot::getName(); |
185 | $filteredAuthors = []; |
186 | foreach ( $authors as $author => $edits ) { |
187 | if ( $author !== $fuzzyBot ) { |
188 | $filteredAuthors[] = $author; |
189 | } |
190 | } |
191 | |
192 | return $filteredAuthors; |
193 | } |
194 | |
195 | /** |
196 | * Add external authors (usually from the file). |
197 | * @param string[] $authors List of authors. |
198 | * @param string $mode Either append or set authors. |
199 | */ |
200 | public function addCollectionAuthors( array $authors, string $mode = 'append' ): void { |
201 | switch ( $mode ) { |
202 | case 'append': |
203 | $authors = array_merge( $this->authors, $authors ); |
204 | break; |
205 | case 'set': |
206 | break; |
207 | default: |
208 | throw new InvalidArgumentException( "Invalid mode $mode" ); |
209 | } |
210 | |
211 | $this->authors = array_unique( $authors ); |
212 | } |
213 | |
214 | // Data modifiers |
215 | |
216 | /** |
217 | * Loads all message data. Must be called before accessing the messages |
218 | * with ArrayAccess or iteration. |
219 | */ |
220 | public function loadTranslations(): void { |
221 | // Performance optimization: Instead of building conditions based on key in every |
222 | // method, build them once and pass it on to each of them. |
223 | $dbr = Utilities::getSafeReadDB(); |
224 | $titleConds = $this->getTitleConds( $dbr ); |
225 | |
226 | $this->loadData( $this->keys, $titleConds ); |
227 | $this->loadInfo( $this->keys, $titleConds ); |
228 | $this->loadReviewInfo( $this->keys, $titleConds ); |
229 | $this->initMessages(); |
230 | } |
231 | |
232 | /** |
233 | * Some statistics scripts for example loop the same collection over every |
234 | * language. This is a shortcut which keeps tags and definitions. |
235 | */ |
236 | public function resetForNewLanguage( string $code ): void { |
237 | $this->code = $code; |
238 | $this->keys = $this->fixKeys(); |
239 | $this->dbInfo = new EmptyIterator(); |
240 | $this->dbData = new EmptyIterator(); |
241 | $this->dbReviewData = new EmptyIterator(); |
242 | $this->messages = null; |
243 | $this->infile = []; |
244 | $this->authors = []; |
245 | |
246 | unset( $this->tags['fuzzy'] ); |
247 | $this->reverseMap = null; |
248 | } |
249 | |
250 | /** |
251 | * For paging messages. One can count messages before and after slice. |
252 | * @param string $offset |
253 | * @param int $limit |
254 | * @return array Offsets that can be used for paging backwards and forwards |
255 | * @since String offests and return value since 2013-01-10 |
256 | */ |
257 | public function slice( $offset, $limit ) { |
258 | $indexes = array_keys( $this->keys ); |
259 | |
260 | if ( $offset === '' ) { |
261 | $offset = 0; |
262 | } |
263 | |
264 | // Handle string offsets |
265 | if ( !ctype_digit( (string)$offset ) ) { |
266 | $pos = array_search( $offset, array_keys( $this->keys ), true ); |
267 | // Now offset is always an integer, suitable for array_slice |
268 | $offset = $pos !== false ? $pos : count( $this->keys ); |
269 | } else { |
270 | $offset = (int)$offset; |
271 | } |
272 | |
273 | // False means that cannot go back or forward |
274 | $backwardsOffset = $forwardsOffset = false; |
275 | // Backwards paging uses numerical indexes, see below |
276 | |
277 | // Can only skip this if no offset has been provided or the |
278 | // offset is zero. (offset - limit ) > 1 does not work, because |
279 | // users can end in offest=2, limit=5 and can't see the first |
280 | // two messages. That's also why it is capped into zero with |
281 | // max(). And finally make the offsets to be strings even if |
282 | // they are numbers in this case. |
283 | if ( $offset > 0 ) { |
284 | $backwardsOffset = (string)( max( 0, $offset - $limit ) ); |
285 | } |
286 | |
287 | // Forwards paging uses keys. If user opens view Untranslated, |
288 | // translates some messages and then clicks next, the first |
289 | // message visible in the page is the first message not shown |
290 | // in the previous page (unless someone else translated it at |
291 | // the same time). If we used integer offsets, we would skip |
292 | // same number of messages that were translated, because they |
293 | // are no longer in the list. For backwards paging this is not |
294 | // such a big issue, so it still uses integer offsets, because |
295 | // we would need to also implement "direction" to have it work |
296 | // correctly. |
297 | if ( isset( $indexes[$offset + $limit] ) ) { |
298 | $forwardsOffset = $indexes[$offset + $limit]; |
299 | } |
300 | |
301 | $this->keys = array_slice( $this->keys, $offset, $limit, true ); |
302 | |
303 | return [ $backwardsOffset, $forwardsOffset, $offset ]; |
304 | } |
305 | |
306 | /** |
307 | * Filters messages based on some condition. Some filters cause data to be |
308 | * loaded from the database. PAGEINFO: existence and fuzzy tags. |
309 | * TRANSLATIONS: translations for every message. It is recommended to first |
310 | * filter with messages that do not need those. It is recommended to add |
311 | * translations from file with addInfile, and it is needed for changed |
312 | * filter to work. |
313 | * |
314 | * @param string $type |
315 | * - fuzzy: messages with fuzzy tag (PAGEINFO) |
316 | * - optional: messages marked for optional. |
317 | * - ignored: messages which are not for translation. |
318 | * - hastranslation: messages which have translation (be if fuzzy or not) |
319 | * (PAGEINFO, *INFILE). |
320 | * - translated: messages which have translation which is not fuzzy |
321 | * (PAGEINFO, *INFILE). |
322 | * - changed: translation in database differs from infile. |
323 | * (INFILE, TRANSLATIONS) |
324 | * @param bool $condition Whether to return messages which do not satisfy |
325 | * the given filter condition (true), or only which do (false). |
326 | * @param int|null $value Value for properties filtering. |
327 | * @throws InvalidFilterException If given invalid filter name. |
328 | */ |
329 | public function filter( string $type, bool $condition = true, ?int $value = null ): void { |
330 | if ( !in_array( $type, self::getAvailableFilters(), true ) ) { |
331 | throw new InvalidFilterException( $type ); |
332 | } |
333 | $this->applyFilter( $type, $condition, $value ); |
334 | } |
335 | |
336 | private static function getAvailableFilters(): array { |
337 | return [ |
338 | 'fuzzy', |
339 | 'optional', |
340 | 'ignored', |
341 | 'hastranslation', |
342 | 'changed', |
343 | 'translated', |
344 | 'reviewer', |
345 | 'last-translator', |
346 | ]; |
347 | } |
348 | |
349 | /** |
350 | * Really apply a filter. Some filters need multiple conditions. |
351 | * @param string $filter Filter name. |
352 | * @param bool $condition Whether to return messages which do not satisfy |
353 | * @param int|null $value Value for properties filtering. |
354 | * the given filter condition (true), or only which do (false). |
355 | */ |
356 | private function applyFilter( string $filter, bool $condition, ?int $value ): void { |
357 | $keys = $this->keys; |
358 | if ( $filter === 'fuzzy' ) { |
359 | $keys = $this->filterFuzzy( $keys, $condition ); |
360 | } elseif ( $filter === 'hastranslation' ) { |
361 | $keys = $this->filterHastranslation( $keys, $condition ); |
362 | } elseif ( $filter === 'translated' ) { |
363 | $fuzzy = $this->filterFuzzy( $keys, false ); |
364 | $hastranslation = $this->filterHastranslation( $keys, false ); |
365 | // Fuzzy messages are not counted as translated messages |
366 | $translated = $this->filterOnCondition( $hastranslation, $fuzzy ); |
367 | $keys = $this->filterOnCondition( $keys, $translated, $condition ); |
368 | } elseif ( $filter === 'changed' ) { |
369 | $keys = $this->filterChanged( $keys, $condition ); |
370 | } elseif ( $filter === 'reviewer' ) { |
371 | $keys = $this->filterReviewer( $keys, $condition, $value ); |
372 | } elseif ( $filter === 'last-translator' ) { |
373 | $keys = $this->filterLastTranslator( $keys, $condition, $value ); |
374 | } else { |
375 | // Filter based on tags. |
376 | if ( !isset( $this->tags[$filter] ) ) { |
377 | if ( $filter !== 'optional' && $filter !== 'ignored' ) { |
378 | throw new RuntimeException( "No tagged messages for custom filter $filter" ); |
379 | } |
380 | $keys = $this->filterOnCondition( $keys, [], $condition ); |
381 | } else { |
382 | $taggedKeys = array_flip( $this->tags[$filter] ); |
383 | $keys = $this->filterOnCondition( $keys, $taggedKeys, $condition ); |
384 | } |
385 | } |
386 | |
387 | $this->keys = $keys; |
388 | } |
389 | |
390 | /** @internal For MessageGroupStats */ |
391 | public function filterUntranslatedOptional(): void { |
392 | $optionalKeys = array_flip( $this->tags['optional'] ?? [] ); |
393 | // Convert plain message keys to array<string,TitleValue> |
394 | $optional = $this->filterOnCondition( $this->keys, $optionalKeys, false ); |
395 | // Then get reduce that list to those which have no translation. Ensure we don't |
396 | // accidentally populate the info cache with too few keys. |
397 | $this->loadInfo( $this->keys ); |
398 | $untranslatedOptional = $this->filterHastranslation( $optional, true ); |
399 | // Now remove that list from the full list |
400 | $this->keys = $this->filterOnCondition( $this->keys, $untranslatedOptional ); |
401 | } |
402 | |
403 | /** |
404 | * Filters list of keys with other list of keys according to the condition. |
405 | * In other words, you have a list of keys, and you have determined list of |
406 | * keys that have some feature. Now you can either take messages that are |
407 | * both in the first list and the second list OR are in the first list but |
408 | * are not in the second list (conditition = false and true respectively). |
409 | * What makes this more complex is that second list of keys might not be a |
410 | * subset of the first list of keys. |
411 | * @param string[] $keys List of keys to filter. |
412 | * @param string[] $condKeys Second list of keys for filtering. |
413 | * @param bool $condition True (default) to return keys which are on first |
414 | * but not on the second list, false to return keys which are on both. |
415 | * second. |
416 | * @return string[] Filtered keys. |
417 | */ |
418 | private function filterOnCondition( array $keys, array $condKeys, bool $condition = true ): array { |
419 | if ( $condition ) { |
420 | // Delete $condKeys from $keys |
421 | foreach ( array_keys( $condKeys ) as $key ) { |
422 | unset( $keys[$key] ); |
423 | } |
424 | } else { |
425 | // Keep the keys which are in $condKeys |
426 | foreach ( array_keys( $keys ) as $key ) { |
427 | if ( !isset( $condKeys[$key] ) ) { |
428 | unset( $keys[$key] ); |
429 | } |
430 | } |
431 | } |
432 | |
433 | return $keys; |
434 | } |
435 | |
436 | /** |
437 | * Filters list of keys according to whether the translation is fuzzy. |
438 | * @param string[] $keys List of keys to filter. |
439 | * @param bool $condition True to filter away fuzzy translations, false |
440 | * to filter non-fuzzy translations. |
441 | * @return string[] Filtered keys. |
442 | */ |
443 | private function filterFuzzy( array $keys, bool $condition ): array { |
444 | $this->loadInfo( $keys ); |
445 | |
446 | $origKeys = []; |
447 | if ( !$condition ) { |
448 | $origKeys = $keys; |
449 | } |
450 | |
451 | foreach ( $this->dbInfo as $row ) { |
452 | if ( $row->rt_type !== null ) { |
453 | unset( $keys[$this->rowToKey( $row )] ); |
454 | } |
455 | } |
456 | |
457 | if ( !$condition ) { |
458 | $keys = array_diff( $origKeys, $keys ); |
459 | } |
460 | |
461 | return $keys; |
462 | } |
463 | |
464 | /** |
465 | * Filters list of keys according to whether they have a translation. |
466 | * @param string[] $keys List of keys to filter. |
467 | * @param bool $condition True to filter away translated, false |
468 | * to filter untranslated. |
469 | * @return string[] Filtered keys. |
470 | */ |
471 | private function filterHastranslation( array $keys, bool $condition ): array { |
472 | $this->loadInfo( $keys ); |
473 | |
474 | $origKeys = []; |
475 | if ( !$condition ) { |
476 | $origKeys = $keys; |
477 | } |
478 | |
479 | foreach ( $this->dbInfo as $row ) { |
480 | unset( $keys[$this->rowToKey( $row )] ); |
481 | } |
482 | |
483 | // Check also if there is something in the file that is not yet in the database |
484 | foreach ( array_keys( $this->infile ) as $inf ) { |
485 | unset( $keys[$inf] ); |
486 | } |
487 | |
488 | // Remove the messages which do not have a translation from the list |
489 | if ( !$condition ) { |
490 | $keys = array_diff( $origKeys, $keys ); |
491 | } |
492 | |
493 | return $keys; |
494 | } |
495 | |
496 | /** |
497 | * Filters list of keys according to whether the current translation |
498 | * differs from the commited translation. |
499 | * @param string[] $keys List of keys to filter. |
500 | * @param bool $condition True to filter changed translations, false |
501 | * to filter unchanged translations. |
502 | * @return string[] Filtered keys. |
503 | */ |
504 | private function filterChanged( array $keys, bool $condition ): array { |
505 | $this->loadData( $keys ); |
506 | |
507 | $origKeys = []; |
508 | if ( !$condition ) { |
509 | $origKeys = $keys; |
510 | } |
511 | |
512 | $revStore = MediaWikiServices::getInstance()->getRevisionStore(); |
513 | $infileRows = []; |
514 | foreach ( $this->dbData as $row ) { |
515 | $mkey = $this->rowToKey( $row ); |
516 | if ( isset( $this->infile[$mkey] ) ) { |
517 | $infileRows[] = $row; |
518 | } |
519 | } |
520 | |
521 | $revisions = $revStore->newRevisionsFromBatch( $infileRows, [ |
522 | 'slots' => [ SlotRecord::MAIN ], |
523 | 'content' => true |
524 | ] )->getValue(); |
525 | foreach ( $infileRows as $row ) { |
526 | /** @var RevisionRecord|null $rev */ |
527 | $rev = $revisions[$row->rev_id]; |
528 | if ( $rev ) { |
529 | /** @var TextContent $content */ |
530 | $content = $rev->getContent( SlotRecord::MAIN ); |
531 | if ( $content ) { |
532 | $mkey = $this->rowToKey( $row ); |
533 | if ( $this->infile[$mkey] === $content->getText() ) { |
534 | // Remove unchanged messages from the list |
535 | unset( $keys[$mkey] ); |
536 | } |
537 | } |
538 | } |
539 | } |
540 | |
541 | // Remove the messages which have changed from the original list |
542 | if ( !$condition ) { |
543 | $keys = $this->filterOnCondition( $origKeys, $keys ); |
544 | } |
545 | |
546 | return $keys; |
547 | } |
548 | |
549 | /** |
550 | * Filters list of keys according to whether the user has accepted them. |
551 | * @param string[] $keys List of keys to filter. |
552 | * @param bool $condition True to remove translatations $user has accepted, |
553 | * false to get only translations accepted by $user. |
554 | * @param ?int $userId |
555 | * @return string[] Filtered keys. |
556 | */ |
557 | private function filterReviewer( array $keys, bool $condition, ?int $userId ): array { |
558 | $this->loadReviewInfo( $keys ); |
559 | $origKeys = $keys; |
560 | |
561 | /* This removes messages from the list which have certain |
562 | * reviewer (among others) */ |
563 | foreach ( $this->dbReviewData as $row ) { |
564 | if ( $userId === null || (int)$row->trr_user === $userId ) { |
565 | unset( $keys[$this->rowToKey( $row )] ); |
566 | } |
567 | } |
568 | |
569 | if ( !$condition ) { |
570 | $keys = array_diff( $origKeys, $keys ); |
571 | } |
572 | |
573 | return $keys; |
574 | } |
575 | |
576 | /** |
577 | * @param string[] $keys List of keys to filter. |
578 | * @param bool $condition True to remove translatations where last translator is $user |
579 | * false to get only last translations done by others. |
580 | * @return string[] Filtered keys. |
581 | */ |
582 | private function filterLastTranslator( array $keys, bool $condition, ?int $userId ): array { |
583 | $this->loadData( $keys ); |
584 | $origKeys = $keys; |
585 | |
586 | $userId ??= 0; |
587 | foreach ( $this->dbData as $row ) { |
588 | if ( (int)$row->rev_user === $userId ) { |
589 | unset( $keys[$this->rowToKey( $row )] ); |
590 | } |
591 | } |
592 | |
593 | if ( !$condition ) { |
594 | $keys = array_diff( $origKeys, $keys ); |
595 | } |
596 | |
597 | return $keys; |
598 | } |
599 | |
600 | /** |
601 | * Takes list of keys and converts them into database format. |
602 | * @return array ( string => string ) Array of keys in database format indexed by display format. |
603 | */ |
604 | private function fixKeys(): array { |
605 | $newkeys = []; |
606 | |
607 | $pages = $this->definitions->getPages(); |
608 | foreach ( $pages as $key => $baseTitle ) { |
609 | $newkeys[$key] = new TitleValue( |
610 | $baseTitle->getNamespace(), |
611 | $baseTitle->getDBkey() . '/' . $this->code |
612 | ); |
613 | } |
614 | |
615 | return $newkeys; |
616 | } |
617 | |
618 | /** |
619 | * Loads existence and fuzzy state for given list of keys. |
620 | * @param string[] $keys List of keys in database format. |
621 | * @param string[]|null $titleConds Database query condition based on current keys. |
622 | */ |
623 | private function loadInfo( array $keys, ?array $titleConds = null ): void { |
624 | if ( !$this->dbInfo instanceof EmptyIterator ) { |
625 | return; |
626 | } |
627 | |
628 | if ( !count( $keys ) ) { |
629 | $this->dbInfo = new EmptyIterator(); |
630 | return; |
631 | } |
632 | |
633 | $dbr = Utilities::getSafeReadDB(); |
634 | |
635 | $titleConds ??= $this->getTitleConds( $dbr ); |
636 | $iterator = new AppendIterator(); |
637 | foreach ( $titleConds as $conds ) { |
638 | $iterator->append( $dbr->newSelectQueryBuilder() |
639 | ->select( [ 'page_namespace', 'page_title', 'rt_type' ] ) |
640 | ->from( 'page' ) |
641 | ->leftJoin( 'revtag', null, [ |
642 | 'page_id=rt_page', |
643 | 'page_latest=rt_revision', |
644 | 'rt_type' => RevTagStore::FUZZY_TAG, |
645 | ] ) |
646 | ->where( $conds ) |
647 | ->caller( __METHOD__ ) |
648 | ->fetchResultSet() ); |
649 | } |
650 | |
651 | $this->dbInfo = $iterator; |
652 | |
653 | // Populate and cache reverse map now, since if call to initMesages is delayed (e.g. a |
654 | // filter that calls loadData() is used, or ::slice is used) the reverse map will not |
655 | // contain all the entries that are present in our $iterator and will throw notices. |
656 | $this->getReverseMap(); |
657 | } |
658 | |
659 | /** |
660 | * Loads reviewers for given messages. |
661 | * @param string[] $keys List of keys in database format. |
662 | * @param string[]|null $titleConds Database query condition based on current keys. |
663 | */ |
664 | private function loadReviewInfo( array $keys, ?array $titleConds = null ): void { |
665 | if ( !$this->dbReviewData instanceof EmptyIterator ) { |
666 | return; |
667 | } |
668 | |
669 | if ( !count( $keys ) ) { |
670 | $this->dbReviewData = new EmptyIterator(); |
671 | return; |
672 | } |
673 | |
674 | $dbr = Utilities::getSafeReadDB(); |
675 | |
676 | $titleConds ??= $this->getTitleConds( $dbr ); |
677 | $iterator = new AppendIterator(); |
678 | foreach ( $titleConds as $conds ) { |
679 | $iterator->append( $dbr->newSelectQueryBuilder() |
680 | ->select( [ 'page_namespace', 'page_title', 'trr_user' ] ) |
681 | ->from( 'page' ) |
682 | ->join( 'translate_reviews', null, [ 'page_id=trr_page', 'page_latest=trr_revision' ] ) |
683 | ->where( $conds ) |
684 | ->caller( __METHOD__ ) |
685 | ->fetchResultSet() ); |
686 | } |
687 | |
688 | $this->dbReviewData = $iterator; |
689 | |
690 | // Populate and cache reverse map now, since if call to initMesages is delayed (e.g. a |
691 | // filter that calls loadData() is used, or ::slice is used) the reverse map will not |
692 | // contain all the entries that are present in our $iterator and will throw notices. |
693 | $this->getReverseMap(); |
694 | } |
695 | |
696 | /** |
697 | * Loads translation for given list of keys. |
698 | * @param string[] $keys List of keys in database format. |
699 | * @param string[]|null $titleConds Database query condition based on current keys. |
700 | */ |
701 | private function loadData( array $keys, ?array $titleConds = null ): void { |
702 | if ( !$this->dbData instanceof EmptyIterator ) { |
703 | return; |
704 | } |
705 | |
706 | if ( !count( $keys ) ) { |
707 | $this->dbData = new EmptyIterator(); |
708 | return; |
709 | } |
710 | |
711 | $dbr = Utilities::getSafeReadDB(); |
712 | $revisionStore = MediaWikiServices::getInstance()->getRevisionStore(); |
713 | $revQuery = $revisionStore->getQueryInfo( [ 'page' ] ); |
714 | |
715 | $titleConds ??= $this->getTitleConds( $dbr ); |
716 | $iterator = new AppendIterator(); |
717 | foreach ( $titleConds as $conds ) { |
718 | $iterator->append( $dbr->newSelectQueryBuilder() |
719 | ->tables( $revQuery['tables'] ) |
720 | ->fields( $revQuery['fields'] ) |
721 | ->where( $conds ) |
722 | ->andWhere( [ 'page_latest = rev_id' ] ) |
723 | ->joinConds( $revQuery['joins'] ) |
724 | ->caller( __METHOD__ ) |
725 | ->fetchResultSet() ); |
726 | } |
727 | |
728 | $this->dbData = $iterator; |
729 | |
730 | // Populate and cache reverse map now, since if call to initMesages is delayed (e.g. a |
731 | // filter that calls loadData() is used, or ::slice is used) the reverse map will not |
732 | // contain all the entries that are present in our $iterator and will throw notices. |
733 | $this->getReverseMap(); |
734 | } |
735 | |
736 | /** |
737 | * Of the current set of keys, construct database query conditions. |
738 | * @return string[] |
739 | */ |
740 | private function getTitleConds( IDatabase $db ): array { |
741 | $titles = $this->getTitles(); |
742 | $chunks = array_chunk( $titles, self::MAX_ITEMS_PER_QUERY ); |
743 | $results = []; |
744 | |
745 | foreach ( $chunks as $titles ) { |
746 | // Array of array( namespace, pagename ) |
747 | $byNamespace = []; |
748 | foreach ( $titles as $title ) { |
749 | $namespace = $title->getNamespace(); |
750 | $pagename = $title->getDBkey(); |
751 | $byNamespace[$namespace][] = $pagename; |
752 | } |
753 | |
754 | $conds = []; |
755 | foreach ( $byNamespace as $namespaces => $pagenames ) { |
756 | $cond = [ |
757 | 'page_namespace' => $namespaces, |
758 | 'page_title' => $pagenames, |
759 | ]; |
760 | |
761 | $conds[] = $db->makeList( $cond, LIST_AND ); |
762 | } |
763 | |
764 | $results[] = $db->makeList( $conds, LIST_OR ); |
765 | } |
766 | |
767 | return $results; |
768 | } |
769 | |
770 | /** |
771 | * Given two-dimensional map of namespace and pagenames, this uses |
772 | * database fields page_namespace and page_title as keys and returns |
773 | * the value for those indexes. |
774 | */ |
775 | private function rowToKey( stdClass $row ): ?string { |
776 | $map = $this->getReverseMap(); |
777 | if ( isset( $map[$row->page_namespace][$row->page_title] ) ) { |
778 | return $map[$row->page_namespace][$row->page_title]; |
779 | } else { |
780 | wfWarn( "Got unknown title from the database: {$row->page_namespace}:{$row->page_title}" ); |
781 | |
782 | return null; |
783 | } |
784 | } |
785 | |
786 | /** Creates a two-dimensional map of namespace and pagenames. */ |
787 | private function getReverseMap(): array { |
788 | if ( isset( $this->reverseMap ) ) { |
789 | return $this->reverseMap; |
790 | } |
791 | |
792 | $map = []; |
793 | /** @var TitleValue $title */ |
794 | foreach ( $this->keys as $mkey => $title ) { |
795 | $map[$title->getNamespace()][$title->getDBkey()] = $mkey; |
796 | } |
797 | |
798 | $this->reverseMap = $map; |
799 | return $this->reverseMap; |
800 | } |
801 | |
802 | /** |
803 | * Constructs all Messages (ThinMessage) from the data accumulated so far. |
804 | * Usually there is no need to call this method directly. |
805 | */ |
806 | public function initMessages(): void { |
807 | if ( $this->messages !== null ) { |
808 | return; |
809 | } |
810 | |
811 | $messages = []; |
812 | $definitions = $this->definitions->getDefinitions(); |
813 | $revStore = MediaWikiServices::getInstance()->getRevisionStore(); |
814 | $queryFlags = Utilities::shouldReadFromPrimary() ? IDBAccessObject::READ_LATEST : 0; |
815 | foreach ( array_keys( $this->keys ) as $mkey ) { |
816 | $messages[$mkey] = new ThinMessage( $mkey, $definitions[$mkey] ); |
817 | } |
818 | |
819 | if ( !$this->dbData instanceof EmptyIterator ) { |
820 | $slotRows = $revStore->getContentBlobsForBatch( |
821 | $this->dbData, [ SlotRecord::MAIN ], $queryFlags |
822 | )->getValue(); |
823 | |
824 | foreach ( $this->dbData as $row ) { |
825 | $mkey = $this->rowToKey( $row ); |
826 | if ( !isset( $messages[$mkey] ) ) { |
827 | continue; |
828 | } |
829 | $messages[$mkey]->setRow( $row ); |
830 | $messages[$mkey]->setProperty( 'revision', $row->page_latest ); |
831 | |
832 | if ( isset( $slotRows[$row->rev_id][SlotRecord::MAIN] ) ) { |
833 | $slot = $slotRows[$row->rev_id][SlotRecord::MAIN]; |
834 | $messages[$mkey]->setTranslation( $slot->blob_data ); |
835 | } |
836 | } |
837 | } |
838 | |
839 | $fuzzy = []; |
840 | foreach ( $this->dbInfo as $row ) { |
841 | if ( $row->rt_type !== null ) { |
842 | $fuzzy[] = $this->rowToKey( $row ); |
843 | } |
844 | } |
845 | |
846 | $this->setTags( 'fuzzy', $fuzzy ); |
847 | |
848 | // Copy tags if any. |
849 | foreach ( $this->tags as $type => $keys ) { |
850 | foreach ( $keys as $mkey ) { |
851 | if ( isset( $messages[$mkey] ) ) { |
852 | $messages[$mkey]->addTag( $type ); |
853 | } |
854 | } |
855 | } |
856 | |
857 | // Copy infile if any. |
858 | foreach ( $this->infile as $mkey => $value ) { |
859 | if ( isset( $messages[$mkey] ) ) { |
860 | $messages[$mkey]->setInfile( $value ); |
861 | } |
862 | } |
863 | |
864 | foreach ( $this->dbReviewData as $row ) { |
865 | $mkey = $this->rowToKey( $row ); |
866 | if ( !isset( $messages[$mkey] ) ) { |
867 | continue; |
868 | } |
869 | $messages[$mkey]->appendProperty( 'reviewers', $row->trr_user ); |
870 | } |
871 | |
872 | // Set the status property |
873 | foreach ( $messages as $obj ) { |
874 | if ( $obj->hasTag( 'fuzzy' ) ) { |
875 | $obj->setProperty( 'status', 'fuzzy' ); |
876 | } elseif ( is_array( $obj->getProperty( 'reviewers' ) ) ) { |
877 | $obj->setProperty( 'status', 'proofread' ); |
878 | } elseif ( $obj->translation() !== null ) { |
879 | $obj->setProperty( 'status', 'translated' ); |
880 | } else { |
881 | $obj->setProperty( 'status', 'untranslated' ); |
882 | } |
883 | } |
884 | |
885 | $this->messages = $messages; |
886 | } |
887 | |
888 | /** |
889 | * ArrayAccess methods. @{ |
890 | * @param mixed $offset |
891 | */ |
892 | public function offsetExists( $offset ): bool { |
893 | return isset( $this->keys[$offset] ); |
894 | } |
895 | |
896 | /** @param mixed $offset */ |
897 | public function offsetGet( $offset ): ?Message { |
898 | return $this->messages[$offset] ?? null; |
899 | } |
900 | |
901 | /** |
902 | * @param mixed $offset |
903 | * @param mixed $value |
904 | */ |
905 | public function offsetSet( $offset, $value ): void { |
906 | $this->messages[$offset] = $value; |
907 | } |
908 | |
909 | /** @param mixed $offset */ |
910 | public function offsetUnset( $offset ): void { |
911 | unset( $this->keys[$offset] ); |
912 | } |
913 | |
914 | /** @} */ |
915 | |
916 | /** |
917 | * Fail fast if trying to access unknown properties. @{ |
918 | * @return never |
919 | */ |
920 | public function __get( string $name ): void { |
921 | throw new LogicException( __METHOD__ . ": Trying to access unknown property $name" ); |
922 | } |
923 | |
924 | /** |
925 | * Fail fast if trying to access unknown properties. |
926 | * @param mixed $value |
927 | * @return never |
928 | */ |
929 | public function __set( string $name, $value ): void { |
930 | throw new LogicException( __METHOD__ . ": Trying to modify unknown property $name" ); |
931 | } |
932 | |
933 | /** @} */ |
934 | |
935 | /** |
936 | * Iterator method. @{ |
937 | */ |
938 | public function rewind(): void { |
939 | reset( $this->keys ); |
940 | } |
941 | |
942 | #[\ReturnTypeWillChange] |
943 | public function current() { |
944 | if ( !count( $this->keys ) ) { |
945 | return false; |
946 | } |
947 | |
948 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable |
949 | return $this->messages[key( $this->keys )]; |
950 | } |
951 | |
952 | public function key(): ?string { |
953 | return key( $this->keys ); |
954 | } |
955 | |
956 | public function next(): void { |
957 | next( $this->keys ); |
958 | } |
959 | |
960 | public function valid(): bool { |
961 | return isset( $this->messages[key( $this->keys )] ); |
962 | } |
963 | |
964 | public function count(): int { |
965 | return count( $this->keys() ); |
966 | } |
967 | |
968 | /** @} */ |
969 | } |