Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.80% covered (danger)
4.80%
19 / 396
0.00% covered (danger)
0.00%
0 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
ImageListPager
4.80% covered (danger)
4.80%
19 / 396
0.00% covered (danger)
0.00%
0 / 26
11928.64
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
110
 getRelevantUser
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 outputUserDoesNotExist
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 buildQueryCondsOld
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 buildQueryConds
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getFieldNames
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 initializeFieldNamesOld
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 initializeFieldNamesNew
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 isFieldSortable
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
72
 getQueryInfo
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
20
 getQueryInfoReal
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
20
 reallyDoQuery
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 reallyDoQueryOld
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
42
 combineResult
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
132
 getIndexField
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getDefaultSort
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
42
 doBatchLookups
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 formatValue
27.94% covered (danger)
27.94%
19 / 68
0.00% covered (danger)
0.00%
0 / 1
239.52
 getEscapedLimitSelectList
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 getForm
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
2
 getTableClass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getNavClass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSortHeaderClass
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getPagingQueries
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 getDefaultQuery
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getTitle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 * @ingroup Pager
6 */
7
8namespace MediaWiki\Specials\Pager;
9
10use MediaWiki\CommentFormatter\RowCommentFormatter;
11use MediaWiki\CommentStore\CommentStore;
12use MediaWiki\Context\IContextSource;
13use MediaWiki\FileRepo\LocalRepo;
14use MediaWiki\FileRepo\RepoGroup;
15use MediaWiki\Html\Html;
16use MediaWiki\HTMLForm\HTMLForm;
17use MediaWiki\Linker\Linker;
18use MediaWiki\Linker\LinkRenderer;
19use MediaWiki\MainConfigNames;
20use MediaWiki\Page\LinkBatchFactory;
21use MediaWiki\Pager\IndexPager;
22use MediaWiki\Pager\TablePager;
23use MediaWiki\SpecialPage\SpecialPage;
24use MediaWiki\Title\Title;
25use MediaWiki\User\User;
26use MediaWiki\User\UserIdentityValue;
27use MediaWiki\User\UserNameUtils;
28use UnexpectedValueException;
29use Wikimedia\Rdbms\FakeResultWrapper;
30use Wikimedia\Rdbms\IConnectionProvider;
31use Wikimedia\Rdbms\IResultWrapper;
32use Wikimedia\Rdbms\Subquery;
33use Wikimedia\Timestamp\TimestampFormat as TS;
34
35/**
36 * @ingroup Pager
37 */
38class ImageListPager extends TablePager {
39
40    /** @var string[]|null */
41    protected ?array $mFieldNames = null;
42    /**
43     * @deprecated Subclasses should override {@see buildQueryConds} instead
44     * @var array
45     */
46    protected $mQueryConds = [];
47    protected ?string $mUserName = null;
48    /** The relevant user */
49    protected ?User $mUser = null;
50    protected ?bool $mIncluding = false;
51    protected bool $mShowAll = false;
52    protected string $mTableName = 'image';
53
54    private CommentStore $commentStore;
55    private LocalRepo $localRepo;
56    private RowCommentFormatter $rowCommentFormatter;
57    private LinkBatchFactory $linkBatchFactory;
58
59    /** @var string[] */
60    private array $formattedComments = [];
61
62    private int $migrationStage;
63
64    /**
65     * The unique sort fields for the sort options for unique paginate
66     */
67    private const INDEX_FIELDS = [
68        'img_timestamp' => [ 'img_timestamp', 'img_name' ],
69        'img_name' => [ 'img_name' ],
70        'img_size' => [ 'img_size', 'img_name' ],
71    ];
72
73    private const INDEX_FIELDS_NEW = [
74        'fr_timestamp' => [ 'fr_timestamp', 'fr_id' ],
75        'file_name' => [ 'file_name' ],
76        'fr_size' => [ 'fr_size', 'fr_id' ],
77    ];
78
79    public function __construct(
80        IContextSource $context,
81        CommentStore $commentStore,
82        LinkRenderer $linkRenderer,
83        IConnectionProvider $dbProvider,
84        RepoGroup $repoGroup,
85        UserNameUtils $userNameUtils,
86        RowCommentFormatter $rowCommentFormatter,
87        LinkBatchFactory $linkBatchFactory,
88        ?string $userName,
89        string $search,
90        ?bool $including,
91        bool $showAll,
92    ) {
93        $this->setContext( $context );
94
95        $this->mIncluding = $including;
96        $this->mShowAll = $showAll;
97
98        if ( $userName !== null && $userName !== '' ) {
99            $nt = Title::makeTitleSafe( NS_USER, $userName );
100            if ( $nt === null ) {
101                $this->outputUserDoesNotExist( $userName );
102            } else {
103                $this->mUserName = $nt->getText();
104                $user = User::newFromName( $this->mUserName, false );
105                if ( $user ) {
106                    $this->mUser = $user;
107                }
108                if ( !$user || ( $user->isAnon() && !$userNameUtils->isIP( $user->getName() ) ) ) {
109                    $this->outputUserDoesNotExist( $userName );
110                }
111            }
112        }
113
114        if ( $including ||
115            $this->getRequest()->getText( 'sort', 'img_date' ) === 'img_date'
116        ) {
117            $this->mDefaultDirection = IndexPager::DIR_DESCENDING;
118        } else {
119            $this->mDefaultDirection = IndexPager::DIR_ASCENDING;
120        }
121        // Set database before parent constructor to avoid setting it there
122        $this->mDb = $dbProvider->getReplicaDatabase();
123        $this->migrationStage = $this->getConfig()->get(
124            MainConfigNames::FileSchemaMigrationStage
125        );
126
127        parent::__construct( $context, $linkRenderer );
128        $this->commentStore = $commentStore;
129        $this->localRepo = $repoGroup->getLocalRepo();
130        $this->rowCommentFormatter = $rowCommentFormatter;
131        $this->linkBatchFactory = $linkBatchFactory;
132    }
133
134    /**
135     * Get the user relevant to the ImageList
136     *
137     * @return User|null
138     */
139    public function getRelevantUser() {
140        return $this->mUser;
141    }
142
143    /**
144     * Add a message to the output stating that the user doesn't exist
145     *
146     * @param string $userName Unescaped user name
147     */
148    protected function outputUserDoesNotExist( $userName ) {
149        $out = $this->getOutput();
150        $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
151        $out->addHTML(
152            Html::warningBox(
153                $out->msg(
154                    'listfiles-userdoesnotexist', wfEscapeWikiText( $userName )
155                )->parse(),
156                'mw-userpage-userdoesnotexist'
157            )
158        );
159    }
160
161    /**
162     * Build the where clause of the query.
163     *
164     * Replaces the older mQueryConds member variable.
165     * @param string $table Either "image" or "oldimage"
166     * @return array The query conditions.
167     */
168    protected function buildQueryCondsOld( $table ) {
169        $conds = [];
170
171        if ( $this->mUserName !== null ) {
172            // getQueryInfoReal() should have handled the tables and joins.
173            $conds['actor_name'] = $this->mUserName;
174        }
175
176        if ( $table === 'oldimage' ) {
177            // Don't want to deal with revdel.
178            // Future fixme: Show partial information as appropriate.
179            // Would have to be careful about filtering by username when username is deleted.
180            $conds['oi_deleted'] = 0;
181        }
182
183        // Add mQueryConds in case anyone was subclassing and using the old variable.
184        return $conds + $this->mQueryConds;
185    }
186
187    private function buildQueryConds(): array {
188        $conds = [
189            'file_deleted' => 0,
190            'fr_deleted' => 0,
191        ];
192
193        if ( $this->mUserName !== null ) {
194            // getQueryInfoReal() should have handled the tables and joins.
195            $conds['actor_name'] = $this->mUserName;
196        }
197
198        if ( !$this->mShowAll ) {
199            $conds[] = 'file_latest = fr_id';
200        }
201        return $conds;
202    }
203
204    /** @inheritDoc */
205    protected function getFieldNames() {
206        if ( $this->migrationStage & SCHEMA_COMPAT_READ_NEW ) {
207            $this->initializeFieldNamesNew();
208        } else {
209            $this->initializeFieldNamesOld();
210        }
211        return $this->mFieldNames;
212    }
213
214    private function initializeFieldNamesOld() {
215        if ( !$this->mFieldNames ) {
216            $this->mFieldNames = [
217                'img_timestamp' => $this->msg( 'listfiles_date' )->text(),
218                'img_name' => $this->msg( 'listfiles_name' )->text(),
219                'thumb' => $this->msg( 'listfiles_thumb' )->text(),
220                'img_size' => $this->msg( 'listfiles_size' )->text(),
221            ];
222            if ( $this->mUserName === null ) {
223                // Do not show username if filtering by username
224                $this->mFieldNames['img_actor'] = $this->msg( 'listfiles_user' )->text();
225            }
226            // img_description down here, in order so that its still after the username field.
227            $this->mFieldNames['img_description'] = $this->msg( 'listfiles_description' )->text();
228
229            if ( $this->mShowAll ) {
230                $this->mFieldNames['top'] = $this->msg( 'listfiles-latestversion' )->text();
231            } elseif ( !$this->getConfig()->get( MainConfigNames::MiserMode ) ) {
232                $this->mFieldNames['count'] = $this->msg( 'listfiles_count' )->text();
233            }
234        }
235    }
236
237    private function initializeFieldNamesNew() {
238        if ( !$this->mFieldNames ) {
239            $this->mFieldNames = [
240                'fr_timestamp' => $this->msg( 'listfiles_date' )->text(),
241                'file_name' => $this->msg( 'listfiles_name' )->text(),
242                'thumb' => $this->msg( 'listfiles_thumb' )->text(),
243                'fr_size' => $this->msg( 'listfiles_size' )->text(),
244            ];
245            if ( $this->mUserName === null ) {
246                // Do not show username if filtering by username
247                $this->mFieldNames['actor_name'] = $this->msg( 'listfiles_user' )->text();
248            }
249            // fr_description down here, in order so that its still after the username field.
250            $this->mFieldNames['fr_description'] = $this->msg( 'listfiles_description' )->text();
251
252            if ( $this->mShowAll ) {
253                $this->mFieldNames['top'] = $this->msg( 'listfiles-latestversion' )->text();
254            } elseif ( !$this->getConfig()->get( MainConfigNames::MiserMode ) ) {
255                $this->mFieldNames['count'] = $this->msg( 'listfiles_count' )->text();
256            }
257        }
258    }
259
260    /** @inheritDoc */
261    protected function isFieldSortable( $field ) {
262        if ( $this->mIncluding ) {
263            return false;
264        }
265
266        /**
267         * For reference, the indices we can use for sorting are:
268         * On the image table: img_actor_timestamp, img_size, img_timestamp
269         * On oldimage: oi_actor_timestamp, oi_name_timestamp
270         *
271         * In particular that means we cannot sort by timestamp when not filtering
272         * by user and including old images in the results. Which is sad. (T279982)
273         */
274        if ( $this->getConfig()->get( MainConfigNames::MiserMode ) ) {
275            if ( $this->mUserName !== null ) {
276                // If we're sorting by user, the index only supports sorting by time.
277                return $field === ( $this->migrationStage & SCHEMA_COMPAT_READ_NEW ? 'fr_timestamp' : 'img_timestamp' );
278            } elseif ( $this->mShowAll && ( $this->migrationStage & SCHEMA_COMPAT_READ_OLD ) ) {
279                // no oi_timestamp index, so only alphabetical sorting in this case.
280                return $field === 'img_name';
281            }
282        }
283
284        $indexFields = $this->migrationStage & SCHEMA_COMPAT_READ_NEW ? self::INDEX_FIELDS_NEW : self::INDEX_FIELDS;
285        return isset( $indexFields[$field] );
286    }
287
288    /** @inheritDoc */
289    public function getQueryInfo() {
290        if ( $this->migrationStage & SCHEMA_COMPAT_READ_OLD ) {
291            // Hacky Hacky Hacky - I want to get query info
292            // for two different tables, without reimplementing
293            // the pager class.
294            return $this->getQueryInfoReal( $this->mTableName );
295        }
296
297        $dbr = $this->getDatabase();
298        $tables = [ 'filerevision', 'file', 'actor' ];
299        $fields = [
300            'fr_timestamp',
301            'file_name',
302            'fr_id',
303            'fr_size',
304            'top' => 'CASE WHEN file_latest = fr_id THEN \'yes\' ELSE \'no\' END',
305        ];
306        $join_conds = [
307            'file' => [ 'JOIN', 'fr_file=file_id' ],
308            'actor' => [ 'JOIN', 'actor_id=fr_actor' ]
309        ];
310
311        // Use STRAIGHT_JOIN for file table when sorting by timestamp to ensure we query filerevision
312        // first in order to use the fr_timestamp index (T423654)
313        if ( $this->mSort !== 'file_name' ) {
314            $join_conds['file'][0] = 'STRAIGHT_JOIN';
315        }
316
317        # Description field
318        $commentQuery = $this->commentStore->getJoin( 'fr_description' );
319        $tables += $commentQuery['tables'];
320        $fields += $commentQuery['fields'];
321        $join_conds += $commentQuery['joins'];
322        $fields['description_field'] = $dbr->addQuotes( "fr_description" );
323
324        # Actor fields
325        $fields[] = 'actor_user';
326        $fields[] = 'actor_name';
327
328        # Depends on $wgMiserMode
329        # Will also not happen if mShowAll is true.
330        if ( array_key_exists( 'count', $this->getFieldNames() ) ) {
331            $fields['count'] = new Subquery( $dbr->newSelectQueryBuilder()
332                ->select( 'COUNT(fr_archive_name)' )
333                ->from( 'filerevision' )
334                ->where( 'fr_file = file_id' )
335                ->caller( __METHOD__ )
336                ->getSQL()
337            );
338        }
339
340        return [
341            'tables' => $tables,
342            'fields' => $fields,
343            'conds' => $this->buildQueryConds(),
344            'options' => [],
345            'join_conds' => $join_conds
346        ];
347    }
348
349    /**
350     * Actually get the query info.
351     *
352     * This is to allow displaying both stuff from image and oldimage table.
353     *
354     * This is a bit hacky.
355     *
356     * @param string $table Either 'image' or 'oldimage'
357     * @return array Query info
358     */
359    protected function getQueryInfoReal( $table ) {
360        $dbr = $this->getDatabase();
361        $prefix = $table === 'oldimage' ? 'oi' : 'img';
362
363        $tables = [ $table, 'actor' ];
364        $join_conds = [];
365
366        if ( $table === 'oldimage' ) {
367            $fields = [
368                'img_timestamp' => 'oi_timestamp',
369                'img_name' => 'oi_name',
370                'img_size' => 'oi_size',
371                'top' => $dbr->addQuotes( 'no' )
372            ];
373            $join_conds['actor'] = [ 'JOIN', 'actor_id=oi_actor' ];
374        } else {
375            $fields = [
376                'img_timestamp',
377                'img_name',
378                'img_size',
379                'top' => $dbr->addQuotes( 'yes' )
380            ];
381            $join_conds['actor'] = [ 'JOIN', 'actor_id=img_actor' ];
382        }
383
384        # Description field
385        $commentQuery = $this->commentStore->getJoin( $prefix . '_description' );
386        $tables += $commentQuery['tables'];
387        $fields += $commentQuery['fields'];
388        $join_conds += $commentQuery['joins'];
389        $fields['description_field'] = $dbr->addQuotes( "{$prefix}_description" );
390
391        # Actor fields
392        $fields[] = 'actor_user';
393        $fields[] = 'actor_name';
394
395        # Depends on $wgMiserMode
396        # Will also not happen if mShowAll is true.
397        if ( array_key_exists( 'count', $this->getFieldNames() ) ) {
398            $fields['count'] = new Subquery( $dbr->newSelectQueryBuilder()
399                ->select( 'COUNT(oi_archive_name)' )
400                ->from( 'oldimage' )
401                ->where( 'oi_name = img_name' )
402                ->caller( __METHOD__ )
403                ->getSQL()
404            );
405        }
406
407        return [
408            'tables' => $tables,
409            'fields' => $fields,
410            'conds' => $this->buildQueryCondsOld( $table ),
411            'options' => [],
412            'join_conds' => $join_conds
413        ];
414    }
415
416    /** @inheritDoc */
417    public function reallyDoQuery( $offset, $limit, $order ) {
418        if ( $this->migrationStage & SCHEMA_COMPAT_READ_OLD ) {
419            return $this->reallyDoQueryOld( $offset, $limit, $order );
420        } else {
421            return parent::reallyDoQuery( $offset, $limit, $order );
422        }
423    }
424
425    /**
426     * Override reallyDoQuery to mix together two queries.
427     *
428     * @param string $offset
429     * @param int $limit
430     * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
431     * @return IResultWrapper
432     */
433    public function reallyDoQueryOld( $offset, $limit, $order ) {
434        $dbr = $this->getDatabase();
435        $prevTableName = $this->mTableName;
436        $this->mTableName = 'image';
437        [ $tables, $fields, $conds, $fname, $options, $join_conds ] =
438            $this->buildQueryInfo( $offset, $limit, $order );
439        $imageRes = $dbr->newSelectQueryBuilder()
440            ->tables( is_array( $tables ) ? $tables : [ $tables ] )
441            ->fields( $fields )
442            ->conds( $conds )
443            ->caller( $fname )
444            ->options( $options )
445            ->joinConds( $join_conds )
446            ->fetchResultSet();
447        $this->mTableName = $prevTableName;
448
449        if ( !$this->mShowAll ) {
450            return $imageRes;
451        }
452
453        $this->mTableName = 'oldimage';
454
455        # Hacky...
456        $oldIndex = $this->mIndexField;
457        foreach ( $this->mIndexField as &$index ) {
458            if ( !str_starts_with( $index, 'img_' ) ) {
459                throw new UnexpectedValueException( "Expected to be sorting on an image table field" );
460            }
461            $index = 'oi_' . substr( $index, 4 );
462        }
463        unset( $index );
464
465        [ $tables, $fields, $conds, $fname, $options, $join_conds ] =
466            $this->buildQueryInfo( $offset, $limit, $order );
467        $oldimageRes = $dbr->newSelectQueryBuilder()
468            ->tables( is_array( $tables ) ? $tables : [ $tables ] )
469            ->fields( $fields )
470            ->conds( $conds )
471            ->caller( $fname )
472            ->options( $options )
473            ->joinConds( $join_conds )
474            ->fetchResultSet();
475
476        $this->mTableName = $prevTableName;
477        $this->mIndexField = $oldIndex;
478
479        return $this->combineResult( $imageRes, $oldimageRes, $limit, $order );
480    }
481
482    /**
483     * Combine results from 2 tables.
484     *
485     * Note: This will throw away some results
486     *
487     * @param IResultWrapper $res1
488     * @param IResultWrapper $res2
489     * @param int $limit
490     * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
491     * @return IResultWrapper $res1 and $res2 combined
492     */
493    protected function combineResult( $res1, $res2, $limit, $order ) {
494        $res1->rewind();
495        $res2->rewind();
496        $topRes1 = $res1->fetchObject();
497        $topRes2 = $res2->fetchObject();
498        $resultArray = [];
499        for ( $i = 0; $i < $limit && $topRes1 && $topRes2; $i++ ) {
500            if ( strcmp( $topRes1->{$this->mIndexField[0]}, $topRes2->{$this->mIndexField[0]} ) > 0 ) {
501                if ( $order !== IndexPager::QUERY_ASCENDING ) {
502                    $resultArray[] = $topRes1;
503                    $topRes1 = $res1->fetchObject();
504                } else {
505                    $resultArray[] = $topRes2;
506                    $topRes2 = $res2->fetchObject();
507                }
508            } elseif ( $order !== IndexPager::QUERY_ASCENDING ) {
509                $resultArray[] = $topRes2;
510                $topRes2 = $res2->fetchObject();
511            } else {
512                $resultArray[] = $topRes1;
513                $topRes1 = $res1->fetchObject();
514            }
515        }
516
517        for ( ; $i < $limit && $topRes1; $i++ ) {
518            $resultArray[] = $topRes1;
519            $topRes1 = $res1->fetchObject();
520        }
521
522        for ( ; $i < $limit && $topRes2; $i++ ) {
523            $resultArray[] = $topRes2;
524            $topRes2 = $res2->fetchObject();
525        }
526
527        return new FakeResultWrapper( $resultArray );
528    }
529
530    /** @inheritDoc */
531    public function getIndexField() {
532        if ( $this->migrationStage & SCHEMA_COMPAT_READ_NEW ) {
533            return [ self::INDEX_FIELDS_NEW[$this->mSort] ];
534        }
535
536        return [ self::INDEX_FIELDS[$this->mSort] ];
537    }
538
539    /** @inheritDoc */
540    public function getDefaultSort() {
541        if ( $this->mShowAll &&
542            $this->getConfig()->get( MainConfigNames::MiserMode ) &&
543            $this->mUserName === null &&
544            $this->migrationStage & SCHEMA_COMPAT_READ_OLD
545        ) {
546            // Unfortunately no index on oi_timestamp.
547            return 'img_name';
548        } else {
549            return $this->migrationStage & SCHEMA_COMPAT_READ_NEW ? 'fr_timestamp' : 'img_timestamp';
550        }
551    }
552
553    protected function doBatchLookups() {
554        $this->mResult->seek( 0 );
555        $batch = $this->linkBatchFactory->newLinkBatch()->setCaller( __METHOD__ );
556        $rowsWithComments = [ 'img_description' => [], 'oi_description' => [], 'fr_description' => [] ];
557        $fileNameField = $this->migrationStage & SCHEMA_COMPAT_READ_NEW ? 'file_name' : 'img_name';
558        foreach ( $this->mResult as $i => $row ) {
559            $batch->addUser( new UserIdentityValue( $row->actor_user ?? 0, $row->actor_name ) );
560            $batch->add( NS_FILE, $row->$fileNameField );
561            $rowsWithComments[$row->description_field][$i] = $row;
562        }
563        $batch->execute();
564
565        // Format the comments
566        if ( $rowsWithComments['img_description'] ) {
567            $this->formattedComments += $this->rowCommentFormatter->formatRows(
568                $rowsWithComments['img_description'],
569                'img_description'
570            );
571        }
572        if ( $rowsWithComments['oi_description'] ) {
573            $this->formattedComments += $this->rowCommentFormatter->formatRows(
574                $rowsWithComments['oi_description'],
575                'oi_description'
576            );
577        }
578        if ( $rowsWithComments['fr_description'] ) {
579            $this->formattedComments += $this->rowCommentFormatter->formatRows(
580                $rowsWithComments['fr_description'],
581                'fr_description'
582            );
583        }
584    }
585
586    /**
587     * @param string $field
588     * @param string|null $value
589     * @return string
590     */
591    public function formatValue( $field, $value ) {
592        $linkRenderer = $this->getLinkRenderer();
593        switch ( $field ) {
594            case 'thumb':
595                $timestamp = $this->migrationStage & SCHEMA_COMPAT_READ_NEW
596                    ? $this->mCurrentRow->fr_timestamp
597                    : $this->mCurrentRow->img_timestamp;
598                $fileName = $this->migrationStage & SCHEMA_COMPAT_READ_NEW
599                    ? $this->mCurrentRow->file_name
600                    : $this->mCurrentRow->img_name;
601
602                $opt = [ 'time' => wfTimestamp( TS::MW, $timestamp ) ];
603                $file = $this->localRepo->findFile( $fileName, $opt );
604                // If statement for paranoia
605                if ( $file ) {
606                    $thumb = $file->transform( [ 'width' => 180, 'height' => 360 ] );
607                    if ( $thumb ) {
608                        return $thumb->toHtml( [ 'desc-link' => true, 'loading' => 'lazy' ] );
609                    }
610                    return $this->msg( 'thumbnail_error', '' )->escaped();
611                } else {
612                    return htmlspecialchars( $fileName );
613                }
614            case 'img_timestamp':
615            case 'fr_timestamp':
616                // We may want to make this a link to the "old" version when displaying old files
617                return htmlspecialchars( $this->getLanguage()->userTimeAndDate( $value, $this->getUser() ) );
618            case 'img_name':
619            case 'file_name':
620                static $imgfile = null;
621                $imgfile ??= $this->msg( 'imgfile' )->text();
622
623                // Weird files can maybe exist? T24227
624                $filePage = Title::makeTitleSafe( NS_FILE, $value );
625                if ( $filePage ) {
626                    $html = $linkRenderer->makeKnownLink(
627                        $filePage,
628                        $filePage->getText()
629                    );
630                    $timestamp = $this->migrationStage & SCHEMA_COMPAT_READ_NEW
631                        ? $this->mCurrentRow->fr_timestamp
632                        : $this->mCurrentRow->img_timestamp;
633                    $opt = [ 'time' => wfTimestamp( TS::MW, $timestamp ) ];
634                    $file = $this->localRepo->findFile( $value, $opt );
635                    if ( $file ) {
636                        $download = Html::element(
637                            'a',
638                            [ 'href' => $file->getUrl() ],
639                            $imgfile
640                        );
641                        $html .= ' ' . $this->msg( 'parentheses' )->rawParams( $download )->escaped();
642                    }
643
644                    // Add delete links if allowed
645                    // From https://github.com/Wikia/app/pull/3859
646                    if ( $this->getAuthority()->probablyCan( 'delete', $filePage ) ) {
647                        $deleteMsg = $this->msg( 'listfiles-delete' )->text();
648
649                        $delete = $linkRenderer->makeKnownLink(
650                            $filePage, $deleteMsg, [], [ 'action' => 'delete' ]
651                        );
652                        $html .= ' ' . $this->msg( 'parentheses' )->rawParams( $delete )->escaped();
653                    }
654
655                    return $html;
656                } else {
657                    return htmlspecialchars( $value );
658                }
659            case 'img_actor':
660            case 'actor_name':
661                $userId = (int)$this->mCurrentRow->actor_user;
662                $userName = $this->mCurrentRow->actor_name;
663                return Linker::userLink( $userId, $userName )
664                    . Linker::userToolLinks( $userId, $userName );
665            case 'img_size':
666            case 'fr_size':
667                return htmlspecialchars( $this->getLanguage()->formatSize( (int)$value ) );
668            case 'img_description':
669            case 'fr_description':
670                return $this->formattedComments[$this->getResultOffset()];
671            case 'count':
672                if ( $this->migrationStage & SCHEMA_COMPAT_READ_OLD ) {
673                    return htmlspecialchars( $this->getLanguage()->formatNum( intval( $value ) + 1 ) );
674                } else {
675                    return htmlspecialchars( $this->getLanguage()->formatNum( intval( $value ) ) );
676                }
677            case 'top':
678                // Messages: listfiles-latestversion-yes, listfiles-latestversion-no
679                return $this->msg( 'listfiles-latestversion-' . $value )->escaped();
680            default:
681                throw new UnexpectedValueException( "Unknown field '$field'" );
682        }
683    }
684
685    /**
686     * Escape the options list
687     */
688    private function getEscapedLimitSelectList(): array {
689        $list = $this->getLimitSelectList();
690        $result = [];
691        foreach ( $list as $key => $value ) {
692            $result[htmlspecialchars( $key )] = $value;
693        }
694        return $result;
695    }
696
697    public function getForm() {
698        $formDescriptor = [];
699        $formDescriptor['limit'] = [
700            'type' => 'radio',
701            'name' => 'limit',
702            'label-message' => 'table_pager_limit_label',
703            'options' => $this->getEscapedLimitSelectList(),
704            'flatlist' => true,
705            'default' => $this->mLimit
706        ];
707
708        $formDescriptor['user'] = [
709            'type' => 'user',
710            'name' => 'user',
711            'id' => 'mw-listfiles-user',
712            'label-message' => 'username',
713            'default' => $this->mUserName,
714            'size' => '40',
715            'maxlength' => '255',
716        ];
717
718        $formDescriptor['ilshowall'] = [
719            'type' => 'check',
720            'name' => 'ilshowall',
721            'id' => 'mw-listfiles-show-all',
722            'label-message' => 'listfiles-show-all',
723            'default' => $this->mShowAll,
724        ];
725
726        $query = $this->getRequest()->getQueryValues();
727        unset( $query['title'] );
728        unset( $query['limit'] );
729        unset( $query['ilsearch'] );
730        unset( $query['ilshowall'] );
731        unset( $query['user'] );
732
733        HTMLForm::factory( 'ooui', $formDescriptor, $this->getContext() )
734            ->setMethod( 'get' )
735            ->setId( 'mw-listfiles-form' )
736            ->setTitle( $this->getTitle() )
737            ->setSubmitTextMsg( 'listfiles-pager-submit' )
738            ->setWrapperLegendMsg( 'listfiles' )
739            ->addHiddenFields( $query )
740            ->prepareForm()
741            ->displayForm( '' );
742    }
743
744    /** @inheritDoc */
745    protected function getTableClass() {
746        return parent::getTableClass() . ' listfiles';
747    }
748
749    /** @inheritDoc */
750    protected function getNavClass() {
751        return parent::getNavClass() . ' listfiles_nav';
752    }
753
754    /** @inheritDoc */
755    protected function getSortHeaderClass() {
756        return parent::getSortHeaderClass() . ' listfiles_sort';
757    }
758
759    /** @inheritDoc */
760    public function getPagingQueries() {
761        $queries = parent::getPagingQueries();
762        if ( $this->mUserName !== null ) {
763            # Append the username to the query string
764            foreach ( $queries as &$query ) {
765                if ( $query !== false ) {
766                    $query['user'] = $this->mUserName;
767                }
768            }
769        }
770
771        return $queries;
772    }
773
774    /** @inheritDoc */
775    public function getDefaultQuery() {
776        $queries = parent::getDefaultQuery();
777        if ( !isset( $queries['user'] ) && $this->mUserName !== null ) {
778            $queries['user'] = $this->mUserName;
779        }
780
781        return $queries;
782    }
783
784    public function getTitle(): Title {
785        return SpecialPage::getTitleFor( 'Listfiles' );
786    }
787}
788
789// @codeCoverageIgnoreStart
790/**
791 * Retain the old class name for backwards compatibility.
792 * @deprecated since 1.41
793 */
794class_alias( ImageListPager::class, 'ImageListPager' );
795
796/** @deprecated class alias since 1.46 */
797class_alias( ImageListPager::class, 'MediaWiki\\Pager\\ImageListPager' );
798// @codeCoverageIgnoreEnd