Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.14% covered (warning)
57.14%
192 / 336
31.82% covered (danger)
31.82%
14 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageCollection
57.14% covered (warning)
57.14%
192 / 336
31.82% covered (danger)
31.82%
14 / 44
1481.87
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 newFromDefinitions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setInFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 keys
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMessageKeys
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTags
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthors
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 addCollectionAuthors
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 loadTranslations
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 resetForNewLanguage
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 slice
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 filter
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 getAvailableFilters
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 applyFilter
30.43% covered (danger)
30.43%
7 / 23
0.00% covered (danger)
0.00%
0 / 1
43.66
 filterUntranslatedOptional
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 filterOnCondition
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 filterFuzzy
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 filterHastranslation
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 filterChanged
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
9.04
 filterReviewer
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 filterLastTranslator
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 fixKeys
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 loadInfo
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
4.04
 loadReviewInfo
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
4.07
 loadData
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
4.01
 getTitleConds
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 rowToKey
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 getReverseMap
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 initMessages
72.34% covered (warning)
72.34%
34 / 47
0.00% covered (danger)
0.00%
0 / 1
30.33
 offsetExists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 offsetGet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 offsetSet
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 offsetUnset
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __get
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __set
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 rewind
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 current
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 next
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 valid
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 count
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageLoading;
5
6use AppendIterator;
7use ArrayAccess;
8use Countable;
9use EmptyIterator;
10use IDBAccessObject;
11use InvalidArgumentException;
12use Iterator;
13use LogicException;
14use MediaWiki\Extension\Translate\MessageGroupProcessing\RevTagStore;
15use MediaWiki\Extension\Translate\SystemUsers\FuzzyBot;
16use MediaWiki\Extension\Translate\Utilities\Utilities;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Revision\RevisionRecord;
19use MediaWiki\Revision\SlotRecord;
20use RuntimeException;
21use stdClass;
22use TextContent;
23use TitleValue;
24use Traversable;
25use 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 */
39class 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}