3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
22 namespace MediaWiki\Pager
;
25 use MediaWiki\Cache\LinkBatchFactory
;
26 use MediaWiki\CommentFormatter\CommentFormatter
;
27 use MediaWiki\CommentStore\CommentStore
;
28 use MediaWiki\Context\IContextSource
;
29 use MediaWiki\Html\Html
;
30 use MediaWiki\HTMLForm\HTMLForm
;
31 use MediaWiki\Linker\Linker
;
32 use MediaWiki\Linker\LinkRenderer
;
33 use MediaWiki\MainConfigNames
;
34 use MediaWiki\SpecialPage\SpecialPage
;
35 use MediaWiki\Title\Title
;
36 use MediaWiki\User\User
;
37 use MediaWiki\User\UserNameUtils
;
38 use MediaWiki\Xml\Xml
;
40 use UnexpectedValueException
;
41 use Wikimedia\Rdbms\FakeResultWrapper
;
42 use Wikimedia\Rdbms\IConnectionProvider
;
43 use Wikimedia\Rdbms\IResultWrapper
;
44 use Wikimedia\Rdbms\Subquery
;
49 class ImageListPager
extends TablePager
{
51 /** @var string[]|null */
52 protected $mFieldNames = null;
54 * @deprecated Subclasses should override {@see buildQueryConds} instead
57 protected $mQueryConds = [];
58 /** @var string|null */
59 protected $mUserName = null;
60 /** @var User|null The relevant user */
61 protected $mUser = null;
63 protected $mIncluding = false;
65 protected $mShowAll = false;
67 protected $mTableName = 'image';
69 private CommentStore
$commentStore;
70 private LocalRepo
$localRepo;
71 private CommentFormatter
$commentFormatter;
72 private LinkBatchFactory
$linkBatchFactory;
75 * The unique sort fields for the sort options for unique paginate
77 private const INDEX_FIELDS
= [
78 'img_timestamp' => [ 'img_timestamp', 'img_name' ],
79 'img_name' => [ 'img_name' ],
80 'img_size' => [ 'img_size', 'img_name' ],
84 * @param IContextSource $context
85 * @param CommentStore $commentStore
86 * @param LinkRenderer $linkRenderer
87 * @param IConnectionProvider $dbProvider
88 * @param RepoGroup $repoGroup
89 * @param UserNameUtils $userNameUtils
90 * @param CommentFormatter $commentFormatter
91 * @param LinkBatchFactory $linkBatchFactory
92 * @param string $userName
93 * @param string $search
94 * @param bool $including
95 * @param bool $showAll
97 public function __construct(
98 IContextSource
$context,
99 CommentStore
$commentStore,
100 LinkRenderer
$linkRenderer,
101 IConnectionProvider
$dbProvider,
102 RepoGroup
$repoGroup,
103 UserNameUtils
$userNameUtils,
104 CommentFormatter
$commentFormatter,
105 LinkBatchFactory
$linkBatchFactory,
111 $this->setContext( $context );
113 $this->mIncluding
= $including;
114 $this->mShowAll
= $showAll;
116 if ( $userName !== null && $userName !== '' ) {
117 $nt = Title
::makeTitleSafe( NS_USER
, $userName );
118 if ( $nt === null ) {
119 $this->outputUserDoesNotExist( $userName );
121 $this->mUserName
= $nt->getText();
122 $user = User
::newFromName( $this->mUserName
, false );
124 $this->mUser
= $user;
126 if ( !$user ||
( $user->isAnon() && !$userNameUtils->isIP( $user->getName() ) ) ) {
127 $this->outputUserDoesNotExist( $userName );
133 $this->getRequest()->getText( 'sort', 'img_date' ) === 'img_date'
135 $this->mDefaultDirection
= IndexPager
::DIR_DESCENDING
;
137 $this->mDefaultDirection
= IndexPager
::DIR_ASCENDING
;
139 // Set database before parent constructor to avoid setting it there
140 $this->mDb
= $dbProvider->getReplicaDatabase();
142 parent
::__construct( $context, $linkRenderer );
143 $this->commentStore
= $commentStore;
144 $this->localRepo
= $repoGroup->getLocalRepo();
145 $this->commentFormatter
= $commentFormatter;
146 $this->linkBatchFactory
= $linkBatchFactory;
150 * Get the user relevant to the ImageList
154 public function getRelevantUser() {
159 * Add a message to the output stating that the user doesn't exist
161 * @param string $userName Unescaped user name
163 protected function outputUserDoesNotExist( $userName ) {
164 $this->getOutput()->addHTML( Html
::warningBox(
165 $this->getOutput()->msg( 'listfiles-userdoesnotexist', wfEscapeWikiText( $userName ) )->parse(),
166 'mw-userpage-userdoesnotexist'
171 * Build the where clause of the query.
173 * Replaces the older mQueryConds member variable.
174 * @param string $table Either "image" or "oldimage"
175 * @return array The query conditions.
177 protected function buildQueryConds( $table ) {
180 if ( $this->mUserName
!== null ) {
181 // getQueryInfoReal() should have handled the tables and joins.
182 $conds['actor_name'] = $this->mUserName
;
185 if ( $table === 'oldimage' ) {
186 // Don't want to deal with revdel.
187 // Future fixme: Show partial information as appropriate.
188 // Would have to be careful about filtering by username when username is deleted.
189 $conds['oi_deleted'] = 0;
192 // Add mQueryConds in case anyone was subclassing and using the old variable.
193 return $conds +
$this->mQueryConds
;
196 protected function getFieldNames() {
197 if ( !$this->mFieldNames
) {
198 $this->mFieldNames
= [
199 'img_timestamp' => $this->msg( 'listfiles_date' )->text(),
200 'img_name' => $this->msg( 'listfiles_name' )->text(),
201 'thumb' => $this->msg( 'listfiles_thumb' )->text(),
202 'img_size' => $this->msg( 'listfiles_size' )->text(),
204 if ( $this->mUserName
=== null ) {
205 // Do not show username if filtering by username
206 $this->mFieldNames
['img_actor'] = $this->msg( 'listfiles_user' )->text();
208 // img_description down here, in order so that its still after the username field.
209 $this->mFieldNames
['img_description'] = $this->msg( 'listfiles_description' )->text();
211 if ( $this->mShowAll
) {
212 $this->mFieldNames
['top'] = $this->msg( 'listfiles-latestversion' )->text();
213 } elseif ( !$this->getConfig()->get( MainConfigNames
::MiserMode
) ) {
214 $this->mFieldNames
['count'] = $this->msg( 'listfiles_count' )->text();
218 return $this->mFieldNames
;
221 protected function isFieldSortable( $field ) {
222 if ( $this->mIncluding
) {
225 /* For reference, the indices we can use for sorting are:
226 * On the image table: img_actor_timestamp, img_size, img_timestamp
227 * On oldimage: oi_actor_timestamp, oi_name_timestamp
229 * In particular that means we cannot sort by timestamp when not filtering
230 * by user and including old images in the results. Which is sad. (T279982)
232 if ( $this->getConfig()->get( MainConfigNames
::MiserMode
) ) {
233 if ( $this->mUserName
!== null ) {
234 // If we're sorting by user, the index only supports sorting by time.
235 return $field === 'img_timestamp';
236 } elseif ( $this->mShowAll
) {
237 // no oi_timestamp index, so only alphabetical sorting in this case.
238 return $field === 'img_name';
242 return isset( self
::INDEX_FIELDS
[$field] );
245 public function getQueryInfo() {
246 // Hacky Hacky Hacky - I want to get query info
247 // for two different tables, without reimplementing
249 return $this->getQueryInfoReal( $this->mTableName
);
253 * Actually get the query info.
255 * This is to allow displaying both stuff from image and oldimage table.
257 * This is a bit hacky.
259 * @param string $table Either 'image' or 'oldimage'
260 * @return array Query info
262 protected function getQueryInfoReal( $table ) {
263 $dbr = $this->getDatabase();
264 $prefix = $table === 'oldimage' ?
'oi' : 'img';
266 $tables = [ $table, 'actor' ];
269 if ( $table === 'oldimage' ) {
271 'img_timestamp' => 'oi_timestamp',
272 'img_name' => 'oi_name',
273 'img_size' => 'oi_size',
274 'top' => $dbr->addQuotes( 'no' )
276 $join_conds['actor'] = [ 'JOIN', 'actor_id=oi_actor' ];
282 'top' => $dbr->addQuotes( 'yes' )
284 $join_conds['actor'] = [ 'JOIN', 'actor_id=img_actor' ];
288 $commentQuery = $this->commentStore
->getJoin( $prefix . '_description' );
289 $tables +
= $commentQuery['tables'];
290 $fields +
= $commentQuery['fields'];
291 $join_conds +
= $commentQuery['joins'];
292 $fields['description_field'] = $dbr->addQuotes( "{$prefix}_description" );
295 $fields[] = 'actor_user';
296 $fields[] = 'actor_name';
298 # Depends on $wgMiserMode
299 # Will also not happen if mShowAll is true.
300 if ( array_key_exists( 'count', $this->getFieldNames() ) ) {
301 $fields['count'] = new Subquery( $dbr->newSelectQueryBuilder()
302 ->select( 'COUNT(oi_archive_name)' )
304 ->where( 'oi_name = img_name' )
305 ->caller( __METHOD__
)
313 'conds' => $this->buildQueryConds( $table ),
315 'join_conds' => $join_conds
320 * Override reallyDoQuery to mix together two queries.
322 * @param string $offset
324 * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
325 * @return IResultWrapper
327 public function reallyDoQuery( $offset, $limit, $order ) {
328 $dbr = $this->getDatabase();
329 $prevTableName = $this->mTableName
;
330 $this->mTableName
= 'image';
331 [ $tables, $fields, $conds, $fname, $options, $join_conds ] =
332 $this->buildQueryInfo( $offset, $limit, $order );
333 $imageRes = $dbr->newSelectQueryBuilder()
334 ->tables( is_array( $tables ) ?
$tables : [ $tables ] )
338 ->options( $options )
339 ->joinConds( $join_conds )
341 $this->mTableName
= $prevTableName;
343 if ( !$this->mShowAll
) {
347 $this->mTableName
= 'oldimage';
350 $oldIndex = $this->mIndexField
;
351 foreach ( $this->mIndexField
as &$index ) {
352 if ( !str_starts_with( $index, 'img_' ) ) {
353 throw new UnexpectedValueException( "Expected to be sorting on an image table field" );
355 $index = 'oi_' . substr( $index, 4 );
359 [ $tables, $fields, $conds, $fname, $options, $join_conds ] =
360 $this->buildQueryInfo( $offset, $limit, $order );
361 $oldimageRes = $dbr->newSelectQueryBuilder()
362 ->tables( is_array( $tables ) ?
$tables : [ $tables ] )
366 ->options( $options )
367 ->joinConds( $join_conds )
370 $this->mTableName
= $prevTableName;
371 $this->mIndexField
= $oldIndex;
373 return $this->combineResult( $imageRes, $oldimageRes, $limit, $order );
377 * Combine results from 2 tables.
379 * Note: This will throw away some results
381 * @param IResultWrapper $res1
382 * @param IResultWrapper $res2
384 * @param bool $order IndexPager::QUERY_ASCENDING or IndexPager::QUERY_DESCENDING
385 * @return IResultWrapper $res1 and $res2 combined
387 protected function combineResult( $res1, $res2, $limit, $order ) {
390 $topRes1 = $res1->fetchObject();
391 $topRes2 = $res2->fetchObject();
393 for ( $i = 0; $i < $limit && $topRes1 && $topRes2; $i++
) {
394 if ( strcmp( $topRes1->{$this->mIndexField
[0]}, $topRes2->{$this->mIndexField
[0]} ) > 0 ) {
395 if ( $order !== IndexPager
::QUERY_ASCENDING
) {
396 $resultArray[] = $topRes1;
397 $topRes1 = $res1->fetchObject();
399 $resultArray[] = $topRes2;
400 $topRes2 = $res2->fetchObject();
402 } elseif ( $order !== IndexPager
::QUERY_ASCENDING
) {
403 $resultArray[] = $topRes2;
404 $topRes2 = $res2->fetchObject();
406 $resultArray[] = $topRes1;
407 $topRes1 = $res1->fetchObject();
411 for ( ; $i < $limit && $topRes1; $i++
) {
412 $resultArray[] = $topRes1;
413 $topRes1 = $res1->fetchObject();
416 for ( ; $i < $limit && $topRes2; $i++
) {
417 $resultArray[] = $topRes2;
418 $topRes2 = $res2->fetchObject();
421 return new FakeResultWrapper( $resultArray );
424 public function getIndexField() {
425 return [ self
::INDEX_FIELDS
[$this->mSort
] ];
428 public function getDefaultSort() {
429 if ( $this->mShowAll
&&
430 $this->getConfig()->get( MainConfigNames
::MiserMode
) &&
431 $this->mUserName
=== null
433 // Unfortunately no index on oi_timestamp.
436 return 'img_timestamp';
440 protected function doBatchLookups() {
441 $this->mResult
->seek( 0 );
442 $batch = $this->linkBatchFactory
->newLinkBatch();
443 foreach ( $this->mResult
as $row ) {
444 $batch->add( NS_USER
, $row->actor_name
);
445 $batch->add( NS_USER_TALK
, $row->actor_name
);
446 $batch->add( NS_FILE
, $row->img_name
);
452 * @param string $field
453 * @param string|null $value
456 public function formatValue( $field, $value ) {
457 $linkRenderer = $this->getLinkRenderer();
460 $opt = [ 'time' => wfTimestamp( TS_MW
, $this->mCurrentRow
->img_timestamp
) ];
461 $file = $this->localRepo
->findFile( $this->getCurrentRow()->img_name
, $opt );
462 // If statement for paranoia
464 $thumb = $file->transform( [ 'width' => 180, 'height' => 360 ] );
466 return $thumb->toHtml( [ 'desc-link' => true, 'loading' => 'lazy' ] );
468 return $this->msg( 'thumbnail_error', '' )->escaped();
470 return htmlspecialchars( $this->getCurrentRow()->img_name
);
472 case 'img_timestamp':
473 // We may want to make this a link to the "old" version when displaying old files
474 return htmlspecialchars( $this->getLanguage()->userTimeAndDate( $value, $this->getUser() ) );
476 static $imgfile = null;
477 $imgfile ??
= $this->msg( 'imgfile' )->text();
479 // Weird files can maybe exist? T24227
480 $filePage = Title
::makeTitleSafe( NS_FILE
, $value );
482 $html = $linkRenderer->makeKnownLink(
486 $opt = [ 'time' => wfTimestamp( TS_MW
, $this->mCurrentRow
->img_timestamp
) ];
487 $file = $this->localRepo
->findFile( $value, $opt );
489 $download = Xml
::element(
491 [ 'href' => $file->getUrl() ],
494 $html .= ' ' . $this->msg( 'parentheses' )->rawParams( $download )->escaped();
497 // Add delete links if allowed
498 // From https://github.com/Wikia/app/pull/3859
499 if ( $this->getAuthority()->probablyCan( 'delete', $filePage ) ) {
500 $deleteMsg = $this->msg( 'listfiles-delete' )->text();
502 $delete = $linkRenderer->makeKnownLink(
503 $filePage, $deleteMsg, [], [ 'action' => 'delete' ]
505 $html .= ' ' . $this->msg( 'parentheses' )->rawParams( $delete )->escaped();
510 return htmlspecialchars( $value );
513 $userId = (int)$this->mCurrentRow
->actor_user
;
514 $userName = $this->mCurrentRow
->actor_name
;
515 return Linker
::userLink( $userId, $userName )
516 . Linker
::userToolLinks( $userId, $userName );
518 return htmlspecialchars( $this->getLanguage()->formatSize( (int)$value ) );
519 case 'img_description':
520 $field = $this->mCurrentRow
->description_field
;
521 $value = $this->commentStore
->getComment( $field, $this->mCurrentRow
)->text
;
522 return $this->commentFormatter
->format( $value );
524 return htmlspecialchars( $this->getLanguage()->formatNum( intval( $value ) +
1 ) );
526 // Messages: listfiles-latestversion-yes, listfiles-latestversion-no
527 return $this->msg( 'listfiles-latestversion-' . $value )->escaped();
529 throw new UnexpectedValueException( "Unknown field '$field'" );
534 * Escape the options list
537 private function getEscapedLimitSelectList(): array {
538 $list = $this->getLimitSelectList();
540 foreach ( $list as $key => $value ) {
541 $result[htmlspecialchars( $key )] = $value;
546 public function getForm() {
547 $formDescriptor = [];
548 $formDescriptor['limit'] = [
551 'label-message' => 'table_pager_limit_label',
552 'options' => $this->getEscapedLimitSelectList(),
554 'default' => $this->mLimit
557 $formDescriptor['user'] = [
560 'id' => 'mw-listfiles-user',
561 'label-message' => 'username',
562 'default' => $this->mUserName
,
564 'maxlength' => '255',
567 $formDescriptor['ilshowall'] = [
569 'name' => 'ilshowall',
570 'id' => 'mw-listfiles-show-all',
571 'label-message' => 'listfiles-show-all',
572 'default' => $this->mShowAll
,
575 $query = $this->getRequest()->getQueryValues();
576 unset( $query['title'] );
577 unset( $query['limit'] );
578 unset( $query['ilsearch'] );
579 unset( $query['ilshowall'] );
580 unset( $query['user'] );
582 HTMLForm
::factory( 'ooui', $formDescriptor, $this->getContext() )
584 ->setId( 'mw-listfiles-form' )
585 ->setTitle( $this->getTitle() )
586 ->setSubmitTextMsg( 'listfiles-pager-submit' )
587 ->setWrapperLegendMsg( 'listfiles' )
588 ->addHiddenFields( $query )
593 protected function getTableClass() {
594 return parent
::getTableClass() . ' listfiles';
597 protected function getNavClass() {
598 return parent
::getNavClass() . ' listfiles_nav';
601 protected function getSortHeaderClass() {
602 return parent
::getSortHeaderClass() . ' listfiles_sort';
605 public function getPagingQueries() {
606 $queries = parent
::getPagingQueries();
607 if ( $this->mUserName
!== null ) {
608 # Append the username to the query string
609 foreach ( $queries as &$query ) {
610 if ( $query !== false ) {
611 $query['user'] = $this->mUserName
;
619 public function getDefaultQuery() {
620 $queries = parent
::getDefaultQuery();
621 if ( !isset( $queries['user'] ) && $this->mUserName
!== null ) {
622 $queries['user'] = $this->mUserName
;
628 public function getTitle() {
629 return SpecialPage
::getTitleFor( 'Listfiles' );
634 * Retain the old class name for backwards compatibility.
635 * @deprecated since 1.41
637 class_alias( ImageListPager
::class, 'ImageListPager' );