Completed
Branch master (2064dd)
by
unknown
30:17
created

CategoryViewer::getImageSection()   B

Complexity

Conditions 4
Paths 6

Size

Total Lines 25
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 20
c 0
b 0
f 0
nc 6
nop 0
dl 0
loc 25
rs 8.5806
1
<?php
2
/**
3
 * List and paging of category members.
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 */
22
23
class CategoryViewer extends ContextSource {
24
	/** @var int */
25
	public $limit;
26
27
	/** @var array */
28
	public $from;
29
30
	/** @var array */
31
	public $until;
32
33
	/** @var string[] */
34
	public $articles;
35
36
	/** @var array */
37
	public $articles_start_char;
38
39
	/** @var array */
40
	public $children;
41
42
	/** @var array */
43
	public $children_start_char;
44
45
	/** @var bool */
46
	public $showGallery;
47
48
	/** @var array */
49
	public $imgsNoGallery_start_char;
50
51
	/** @var array */
52
	public $imgsNoGallery;
53
54
	/** @var array */
55
	public $nextPage;
56
57
	/** @var array */
58
	protected $prevPage;
59
60
	/** @var array */
61
	public $flip;
62
63
	/** @var Title */
64
	public $title;
65
66
	/** @var Collation */
67
	public $collation;
68
69
	/** @var ImageGallery */
70
	public $gallery;
71
72
	/** @var Category Category object for this page. */
73
	private $cat;
74
75
	/** @var array The original query array, to be used in generating paging links. */
76
	private $query;
77
78
	/**
79
	 * @since 1.19 $context is a second, required parameter
80
	 * @param Title $title
81
	 * @param IContextSource $context
82
	 * @param array $from An array with keys page, subcat,
83
	 *        and file for offset of results of each section (since 1.17)
84
	 * @param array $until An array with 3 keys for until of each section (since 1.17)
85
	 * @param array $query
86
	 */
87
	function __construct( $title, IContextSource $context, $from = [],
88
		$until = [], $query = []
89
	) {
90
		$this->title = $title;
91
		$this->setContext( $context );
92
		$this->getOutput()->addModuleStyles( [
93
			'mediawiki.action.view.categoryPage.styles'
94
		] );
95
		$this->from = $from;
96
		$this->until = $until;
97
		$this->limit = $context->getConfig()->get( 'CategoryPagingLimit' );
98
		$this->cat = Category::newFromTitle( $title );
0 ignored issues
show
Documentation Bug introduced by
It seems like \Category::newFromTitle($title) can also be of type boolean. However, the property $cat is declared as type object<Category>. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
99
		$this->query = $query;
100
		$this->collation = Collation::singleton();
101
		unset( $this->query['title'] );
102
	}
103
104
	/**
105
	 * Format the category data list.
106
	 *
107
	 * @return string HTML output
108
	 */
109
	public function getHTML() {
110
111
		$this->showGallery = $this->getConfig()->get( 'CategoryMagicGallery' )
112
			&& !$this->getOutput()->mNoGallery;
113
114
		$this->clearCategoryState();
115
		$this->doCategoryQuery();
116
		$this->finaliseCategoryState();
117
118
		$r = $this->getSubcategorySection() .
119
			$this->getPagesSection() .
120
			$this->getImageSection();
121
122
		if ( $r == '' ) {
123
			// If there is no category content to display, only
124
			// show the top part of the navigation links.
125
			// @todo FIXME: Cannot be completely suppressed because it
126
			//        is unknown if 'until' or 'from' makes this
127
			//        give 0 results.
128
			$r = $r . $this->getCategoryTop();
129
		} else {
130
			$r = $this->getCategoryTop() .
131
				$r .
132
				$this->getCategoryBottom();
133
		}
134
135
		// Give a proper message if category is empty
136
		if ( $r == '' ) {
137
			$r = $this->msg( 'category-empty' )->parseAsBlock();
138
		}
139
140
		$lang = $this->getLanguage();
141
		$attribs = [
142
			'class' => 'mw-category-generated',
143
			'lang' => $lang->getHtmlCode(),
144
			'dir' => $lang->getDir()
145
		];
146
		# put a div around the headings which are in the user language
147
		$r = Html::openElement( 'div', $attribs ) . $r . '</div>';
148
149
		return $r;
150
	}
151
152
	function clearCategoryState() {
153
		$this->articles = [];
154
		$this->articles_start_char = [];
155
		$this->children = [];
156
		$this->children_start_char = [];
157
		if ( $this->showGallery ) {
158
			// Note that null for mode is taken to mean use default.
159
			$mode = $this->getRequest()->getVal( 'gallerymode', null );
160
			try {
161
				$this->gallery = ImageGalleryBase::factory( $mode, $this->getContext() );
162
			} catch ( Exception $e ) {
163
				// User specified something invalid, fallback to default.
164
				$this->gallery = ImageGalleryBase::factory( false, $this->getContext() );
165
			}
166
167
			$this->gallery->setHideBadImages();
168
		} else {
169
			$this->imgsNoGallery = [];
170
			$this->imgsNoGallery_start_char = [];
171
		}
172
	}
173
174
	/**
175
	 * Add a subcategory to the internal lists, using a Category object
176
	 * @param Category $cat
177
	 * @param string $sortkey
178
	 * @param int $pageLength
179
	 */
180
	function addSubcategoryObject( Category $cat, $sortkey, $pageLength ) {
181
		// Subcategory; strip the 'Category' namespace from the link text.
182
		$title = $cat->getTitle();
183
184
		$this->children[] = $this->generateLink(
185
			'subcat',
186
			$title,
0 ignored issues
show
Bug introduced by
It seems like $title defined by $cat->getTitle() on line 182 can also be of type false or null; however, CategoryViewer::generateLink() does only seem to accept object<Title>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
187
			$title->isRedirect(),
188
			htmlspecialchars( $title->getText() )
189
		);
190
191
		$this->children_start_char[] =
192
			$this->getSubcategorySortChar( $cat->getTitle(), $sortkey );
0 ignored issues
show
Bug introduced by
It seems like $cat->getTitle() targeting Category::getTitle() can also be of type false or null; however, CategoryViewer::getSubcategorySortChar() does only seem to accept object<Title>, maybe add an additional type check?

This check looks at variables that are passed out again to other methods.

If the outgoing method call has stricter type requirements than the method itself, an issue is raised.

An additional type check may prevent trouble.

Loading history...
193
	}
194
195
	function generateLink( $type, Title $title, $isRedirect, $html = null ) {
196
		$link = null;
197
		Hooks::run( 'CategoryViewer::generateLink', [ $type, $title, $html, &$link ] );
198
		if ( $link === null ) {
199
			$link = Linker::link( $title, $html );
200
		}
201
		if ( $isRedirect ) {
202
			$link = '<span class="redirect-in-category">' . $link . '</span>';
203
		}
204
205
		return $link;
206
	}
207
208
	/**
209
	 * Get the character to be used for sorting subcategories.
210
	 * If there's a link from Category:A to Category:B, the sortkey of the resulting
211
	 * entry in the categorylinks table is Category:A, not A, which it SHOULD be.
212
	 * Workaround: If sortkey == "Category:".$title, than use $title for sorting,
213
	 * else use sortkey...
214
	 *
215
	 * @param Title $title
216
	 * @param string $sortkey The human-readable sortkey (before transforming to icu or whatever).
217
	 * @return string
218
	 */
219
	function getSubcategorySortChar( $title, $sortkey ) {
220
		global $wgContLang;
221
222
		if ( $title->getPrefixedText() == $sortkey ) {
223
			$word = $title->getDBkey();
224
		} else {
225
			$word = $sortkey;
226
		}
227
228
		$firstChar = $this->collation->getFirstLetter( $word );
229
230
		return $wgContLang->convert( $firstChar );
231
	}
232
233
	/**
234
	 * Add a page in the image namespace
235
	 * @param Title $title
236
	 * @param string $sortkey
237
	 * @param int $pageLength
238
	 * @param bool $isRedirect
239
	 */
240
	function addImage( Title $title, $sortkey, $pageLength, $isRedirect = false ) {
241
		global $wgContLang;
242
		if ( $this->showGallery ) {
243
			$flip = $this->flip['file'];
244
			if ( $flip ) {
245
				$this->gallery->insert( $title );
246
			} else {
247
				$this->gallery->add( $title );
248
			}
249
		} else {
250
			$this->imgsNoGallery[] = $this->generateLink( 'image', $title, $isRedirect );
251
252
			$this->imgsNoGallery_start_char[] = $wgContLang->convert(
253
				$this->collation->getFirstLetter( $sortkey ) );
254
		}
255
	}
256
257
	/**
258
	 * Add a miscellaneous page
259
	 * @param Title $title
260
	 * @param string $sortkey
261
	 * @param int $pageLength
262
	 * @param bool $isRedirect
263
	 */
264
	function addPage( $title, $sortkey, $pageLength, $isRedirect = false ) {
265
		global $wgContLang;
266
267
		$this->articles[] = $this->generateLink( 'page', $title, $isRedirect );
268
269
		$this->articles_start_char[] = $wgContLang->convert(
270
			$this->collation->getFirstLetter( $sortkey ) );
271
	}
272
273
	function finaliseCategoryState() {
274
		if ( $this->flip['subcat'] ) {
275
			$this->children = array_reverse( $this->children );
276
			$this->children_start_char = array_reverse( $this->children_start_char );
277
		}
278
		if ( $this->flip['page'] ) {
279
			$this->articles = array_reverse( $this->articles );
280
			$this->articles_start_char = array_reverse( $this->articles_start_char );
281
		}
282
		if ( !$this->showGallery && $this->flip['file'] ) {
283
			$this->imgsNoGallery = array_reverse( $this->imgsNoGallery );
284
			$this->imgsNoGallery_start_char = array_reverse( $this->imgsNoGallery_start_char );
285
		}
286
	}
287
288
	function doCategoryQuery() {
289
		$dbr = wfGetDB( DB_SLAVE, 'category' );
290
291
		$this->nextPage = [
292
			'page' => null,
293
			'subcat' => null,
294
			'file' => null,
295
		];
296
		$this->prevPage = [
297
			'page' => null,
298
			'subcat' => null,
299
			'file' => null,
300
		];
301
302
		$this->flip = [ 'page' => false, 'subcat' => false, 'file' => false ];
303
304
		foreach ( [ 'page', 'subcat', 'file' ] as $type ) {
305
			# Get the sortkeys for start/end, if applicable.  Note that if
306
			# the collation in the database differs from the one
307
			# set in $wgCategoryCollation, pagination might go totally haywire.
308
			$extraConds = [ 'cl_type' => $type ];
309
			if ( isset( $this->from[$type] ) && $this->from[$type] !== null ) {
310
				$extraConds[] = 'cl_sortkey >= '
311
					. $dbr->addQuotes( $this->collation->getSortKey( $this->from[$type] ) );
312
			} elseif ( isset( $this->until[$type] ) && $this->until[$type] !== null ) {
313
				$extraConds[] = 'cl_sortkey < '
314
					. $dbr->addQuotes( $this->collation->getSortKey( $this->until[$type] ) );
315
				$this->flip[$type] = true;
316
			}
317
318
			$res = $dbr->select(
319
				[ 'page', 'categorylinks', 'category' ],
320
				[ 'page_id', 'page_title', 'page_namespace', 'page_len',
321
					'page_is_redirect', 'cl_sortkey', 'cat_id', 'cat_title',
322
					'cat_subcats', 'cat_pages', 'cat_files',
323
					'cl_sortkey_prefix', 'cl_collation' ],
324
				array_merge( [ 'cl_to' => $this->title->getDBkey() ], $extraConds ),
325
				__METHOD__,
326
				[
327
					'USE INDEX' => [ 'categorylinks' => 'cl_sortkey' ],
328
					'LIMIT' => $this->limit + 1,
329
					'ORDER BY' => $this->flip[$type] ? 'cl_sortkey DESC' : 'cl_sortkey',
330
				],
331
				[
332
					'categorylinks' => [ 'INNER JOIN', 'cl_from = page_id' ],
333
					'category' => [ 'LEFT JOIN', [
334
						'cat_title = page_title',
335
						'page_namespace' => NS_CATEGORY
336
					] ]
337
				]
338
			);
339
340
			Hooks::run( 'CategoryViewer::doCategoryQuery', [ $type, $res ] );
341
342
			$count = 0;
343
			foreach ( $res as $row ) {
0 ignored issues
show
Bug introduced by
The expression $res of type boolean|object<ResultWrapper> is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
344
				$title = Title::newFromRow( $row );
345
				if ( $row->cl_collation === '' ) {
346
					// Hack to make sure that while updating from 1.16 schema
347
					// and db is inconsistent, that the sky doesn't fall.
348
					// See r83544. Could perhaps be removed in a couple decades...
349
					$humanSortkey = $row->cl_sortkey;
350
				} else {
351
					$humanSortkey = $title->getCategorySortkey( $row->cl_sortkey_prefix );
352
				}
353
354
				if ( ++$count > $this->limit ) {
355
					# We've reached the one extra which shows that there
356
					# are additional pages to be had. Stop here...
357
					$this->nextPage[$type] = $humanSortkey;
358
					break;
359
				}
360
				if ( $count == $this->limit ) {
361
					$this->prevPage[$type] = $humanSortkey;
362
				}
363
364
				if ( $title->getNamespace() == NS_CATEGORY ) {
365
					$cat = Category::newFromRow( $row, $title );
366
					$this->addSubcategoryObject( $cat, $humanSortkey, $row->page_len );
367
				} elseif ( $title->getNamespace() == NS_FILE ) {
368
					$this->addImage( $title, $humanSortkey, $row->page_len, $row->page_is_redirect );
369
				} else {
370
					$this->addPage( $title, $humanSortkey, $row->page_len, $row->page_is_redirect );
371
				}
372
			}
373
		}
374
	}
375
376
	/**
377
	 * @return string
378
	 */
379
	function getCategoryTop() {
380
		$r = $this->getCategoryBottom();
381
		return $r === ''
382
			? $r
383
			: "<br style=\"clear:both;\"/>\n" . $r;
384
	}
385
386
	/**
387
	 * @return string
388
	 */
389
	function getSubcategorySection() {
390
		# Don't show subcategories section if there are none.
391
		$r = '';
392
		$rescnt = count( $this->children );
393
		$dbcnt = $this->cat->getSubcatCount();
394
		// This function should be called even if the result isn't used, it has side-effects
395
		$countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'subcat' );
396
397 View Code Duplication
		if ( $rescnt > 0 ) {
398
			# Showing subcategories
399
			$r .= "<div id=\"mw-subcategories\">\n";
400
			$r .= '<h2>' . $this->msg( 'subcategories' )->parse() . "</h2>\n";
401
			$r .= $countmsg;
402
			$r .= $this->getSectionPagingLinks( 'subcat' );
403
			$r .= $this->formatList( $this->children, $this->children_start_char );
404
			$r .= $this->getSectionPagingLinks( 'subcat' );
405
			$r .= "\n</div>";
406
		}
407
		return $r;
408
	}
409
410
	/**
411
	 * Return pretty name which is display name if given and different from prefix text or
412
	 * the unprefixed page name.
413
	 *
414
	 * @return string HTML safe name.
415
	 */
416
	function getPrettyPageNameHtml() {
417
		$displayTitle = $this->getOutput()->getPageTitle();
418
		if ( $displayTitle === $this->getTitle()->getPrefixedText() ) {
419
			return htmlspecialchars( $this->getTitle()->getText() );
420
		} else {
421
			return $displayTitle;
422
		}
423
	}
424
425
	/**
426
	 * @return string
427
	 */
428
	function getPagesSection() {
429
		$name = $this->getPrettyPageNameHtml();
430
		# Don't show articles section if there are none.
431
		$r = '';
432
433
		# @todo FIXME: Here and in the other two sections: we don't need to bother
434
		# with this rigmarole if the entire category contents fit on one page
435
		# and have already been retrieved.  We can just use $rescnt in that
436
		# case and save a query and some logic.
437
		$dbcnt = $this->cat->getPageCount() - $this->cat->getSubcatCount()
438
			- $this->cat->getFileCount();
439
		$rescnt = count( $this->articles );
440
		// This function should be called even if the result isn't used, it has side-effects
441
		$countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'article' );
442
443 View Code Duplication
		if ( $rescnt > 0 ) {
444
			$r = "<div id=\"mw-pages\">\n";
445
			$r .= '<h2>' . $this->msg( 'category_header' )->rawParams( $name )->parse() . "</h2>\n";
446
			$r .= $countmsg;
447
			$r .= $this->getSectionPagingLinks( 'page' );
448
			$r .= $this->formatList( $this->articles, $this->articles_start_char );
449
			$r .= $this->getSectionPagingLinks( 'page' );
450
			$r .= "\n</div>";
451
		}
452
		return $r;
453
	}
454
455
	/**
456
	 * @return string
457
	 */
458
	function getImageSection() {
459
		$name = $this->getPrettyPageNameHtml();
460
		$r = '';
461
		$rescnt = $this->showGallery ? $this->gallery->count() : count( $this->imgsNoGallery );
462
		$dbcnt = $this->cat->getFileCount();
463
		// This function should be called even if the result isn't used, it has side-effects
464
		$countmsg = $this->getCountMessage( $rescnt, $dbcnt, 'file' );
465
466
		if ( $rescnt > 0 ) {
467
			$r .= "<div id=\"mw-category-media\">\n";
468
			$r .= '<h2>' .
469
				$this->msg( 'category-media-header' )->rawParams( $name )->parse() .
470
				"</h2>\n";
471
			$r .= $countmsg;
472
			$r .= $this->getSectionPagingLinks( 'file' );
473
			if ( $this->showGallery ) {
474
				$r .= $this->gallery->toHTML();
475
			} else {
476
				$r .= $this->formatList( $this->imgsNoGallery, $this->imgsNoGallery_start_char );
477
			}
478
			$r .= $this->getSectionPagingLinks( 'file' );
479
			$r .= "\n</div>";
480
		}
481
		return $r;
482
	}
483
484
	/**
485
	 * Get the paging links for a section (subcats/pages/files), to go at the top and bottom
486
	 * of the output.
487
	 *
488
	 * @param string $type 'page', 'subcat', or 'file'
489
	 * @return string HTML output, possibly empty if there are no other pages
490
	 */
491
	private function getSectionPagingLinks( $type ) {
492
		if ( isset( $this->until[$type] ) && $this->until[$type] !== null ) {
493
			// The new value for the until parameter should be pointing to the first
494
			// result displayed on the page which is the second last result retrieved
495
			// from the database.The next link should have a from parameter pointing
496
			// to the until parameter of the current page.
497
			if ( $this->nextPage[$type] !== null ) {
498
				return $this->pagingLinks( $this->prevPage[$type], $this->until[$type], $type );
499
			} else {
500
				// If the nextPage variable is null, it means that we have reached the first page
501
				// and therefore the previous link should be disabled.
502
				return $this->pagingLinks( null, $this->until[$type], $type );
503
			}
504
		} elseif ( $this->nextPage[$type] !== null
505
			|| ( isset( $this->from[$type] ) && $this->from[$type] !== null )
506
		) {
507
			return $this->pagingLinks( $this->from[$type], $this->nextPage[$type], $type );
508
		} else {
509
			return '';
510
		}
511
	}
512
513
	/**
514
	 * @return string
515
	 */
516
	function getCategoryBottom() {
517
		return '';
518
	}
519
520
	/**
521
	 * Format a list of articles chunked by letter, either as a
522
	 * bullet list or a columnar format, depending on the length.
523
	 *
524
	 * @param array $articles
525
	 * @param array $articles_start_char
526
	 * @param int $cutoff
527
	 * @return string
528
	 * @private
529
	 */
530
	function formatList( $articles, $articles_start_char, $cutoff = 6 ) {
531
		$list = '';
532
		if ( count( $articles ) > $cutoff ) {
533
			$list = self::columnList( $articles, $articles_start_char );
534
		} elseif ( count( $articles ) > 0 ) {
535
			// for short lists of articles in categories.
536
			$list = self::shortList( $articles, $articles_start_char );
537
		}
538
539
		$pageLang = $this->title->getPageLanguage();
540
		$attribs = [ 'lang' => $pageLang->getHtmlCode(), 'dir' => $pageLang->getDir(),
541
			'class' => 'mw-content-' . $pageLang->getDir() ];
542
		$list = Html::rawElement( 'div', $attribs, $list );
543
544
		return $list;
545
	}
546
547
	/**
548
	 * Format a list of articles chunked by letter in a three-column list, ordered
549
	 * vertically. This is used for categories with a significant number of pages.
550
	 *
551
	 * TODO: Take the headers into account when creating columns, so they're
552
	 * more visually equal.
553
	 *
554
	 * TODO: shortList and columnList are similar, need merging
555
	 *
556
	 * @param string[] $articles HTML links to each article
557
	 * @param string[] $articles_start_char The header characters for each article
558
	 * @return string HTML to output
559
	 * @private
560
	 */
561
	static function columnList( $articles, $articles_start_char ) {
562
		$columns = array_combine( $articles, $articles_start_char );
563
564
		$ret = Html::openElement( 'div', [ 'class' => 'mw-category' ] );
565
566
		$colContents = [];
567
568
		# Kind of like array_flip() here, but we keep duplicates in an
569
		# array instead of dropping them.
570
		foreach ( $columns as $article => $char ) {
571
			if ( !isset( $colContents[$char] ) ) {
572
				$colContents[$char] = [];
573
			}
574
			$colContents[$char][] = $article;
575
		}
576
577
		foreach ( $colContents as $char => $articles ) {
578
			# Change space to non-breaking space to keep headers aligned
579
			$h3char = $char === ' ' ? '&#160;' : htmlspecialchars( $char );
580
581
			$ret .= '<div class="mw-category-group"><h3>' . $h3char;
582
			$ret .= "</h3>\n";
583
584
			$ret .= '<ul><li>';
585
			$ret .= implode( "</li>\n<li>", $articles );
586
			$ret .= '</li></ul></div>';
587
588
		}
589
590
		$ret .= Html::closeElement( 'div' );
591
		return $ret;
592
	}
593
594
	/**
595
	 * Format a list of articles chunked by letter in a bullet list. This is used
596
	 * for categories with a small number of pages (when columns aren't needed).
597
	 * @param string[] $articles HTML links to each article
598
	 * @param string[] $articles_start_char The header characters for each article
599
	 * @return string HTML to output
600
	 * @private
601
	 */
602
	static function shortList( $articles, $articles_start_char ) {
603
		$r = '<h3>' . htmlspecialchars( $articles_start_char[0] ) . "</h3>\n";
604
		$r .= '<ul><li>' . $articles[0] . '</li>';
605
		$articleCount = count( $articles );
606
		for ( $index = 1; $index < $articleCount; $index++ ) {
607
			if ( $articles_start_char[$index] != $articles_start_char[$index - 1] ) {
608
				$r .= "</ul><h3>" . htmlspecialchars( $articles_start_char[$index] ) . "</h3>\n<ul>";
609
			}
610
611
			$r .= "<li>{$articles[$index]}</li>";
612
		}
613
		$r .= '</ul>';
614
		return $r;
615
	}
616
617
	/**
618
	 * Create paging links, as a helper method to getSectionPagingLinks().
619
	 *
620
	 * @param string $first The 'until' parameter for the generated URL
621
	 * @param string $last The 'from' parameter for the generated URL
622
	 * @param string $type A prefix for parameters, 'page' or 'subcat' or
623
	 *     'file'
624
	 * @return string HTML
625
	 */
626
	private function pagingLinks( $first, $last, $type = '' ) {
627
		$prevLink = $this->msg( 'prev-page' )->text();
628
629 View Code Duplication
		if ( $first != '' ) {
630
			$prevQuery = $this->query;
631
			$prevQuery["{$type}until"] = $first;
632
			unset( $prevQuery["{$type}from"] );
633
			$prevLink = Linker::linkKnown(
634
				$this->addFragmentToTitle( $this->title, $type ),
635
				$prevLink,
636
				[],
637
				$prevQuery
638
			);
639
		}
640
641
		$nextLink = $this->msg( 'next-page' )->text();
642
643 View Code Duplication
		if ( $last != '' ) {
644
			$lastQuery = $this->query;
645
			$lastQuery["{$type}from"] = $last;
646
			unset( $lastQuery["{$type}until"] );
647
			$nextLink = Linker::linkKnown(
648
				$this->addFragmentToTitle( $this->title, $type ),
649
				$nextLink,
650
				[],
651
				$lastQuery
652
			);
653
		}
654
655
		return $this->msg( 'categoryviewer-pagedlinks' )->rawParams( $prevLink, $nextLink )->escaped();
656
	}
657
658
	/**
659
	 * Takes a title, and adds the fragment identifier that
660
	 * corresponds to the correct segment of the category.
661
	 *
662
	 * @param Title $title The title (usually $this->title)
663
	 * @param string $section Which section
664
	 * @throws MWException
665
	 * @return Title
666
	 */
667
	private function addFragmentToTitle( $title, $section ) {
668
		switch ( $section ) {
669
			case 'page':
670
				$fragment = 'mw-pages';
671
				break;
672
			case 'subcat':
673
				$fragment = 'mw-subcategories';
674
				break;
675
			case 'file':
676
				$fragment = 'mw-category-media';
677
				break;
678
			default:
679
				throw new MWException( __METHOD__ .
680
					" Invalid section $section." );
681
		}
682
683
		return Title::makeTitle( $title->getNamespace(),
684
			$title->getDBkey(), $fragment );
685
	}
686
687
	/**
688
	 * What to do if the category table conflicts with the number of results
689
	 * returned?  This function says what. Each type is considered independently
690
	 * of the other types.
691
	 *
692
	 * @param int $rescnt The number of items returned by our database query.
693
	 * @param int $dbcnt The number of items according to the category table.
694
	 * @param string $type 'subcat', 'article', or 'file'
695
	 * @return string A message giving the number of items, to output to HTML.
696
	 */
697
	private function getCountMessage( $rescnt, $dbcnt, $type ) {
698
		// There are three cases:
699
		//   1) The category table figure seems sane.  It might be wrong, but
700
		//      we can't do anything about it if we don't recalculate it on ev-
701
		//      ery category view.
702
		//   2) The category table figure isn't sane, like it's smaller than the
703
		//      number of actual results, *but* the number of results is less
704
		//      than $this->limit and there's no offset.  In this case we still
705
		//      know the right figure.
706
		//   3) We have no idea.
707
708
		// Check if there's a "from" or "until" for anything
709
710
		// This is a little ugly, but we seem to use different names
711
		// for the paging types then for the messages.
712
		if ( $type === 'article' ) {
713
			$pagingType = 'page';
714
		} else {
715
			$pagingType = $type;
716
		}
717
718
		$fromOrUntil = false;
719
		if ( ( isset( $this->from[$pagingType] ) && $this->from[$pagingType] !== null ) ||
720
			( isset( $this->until[$pagingType] ) && $this->until[$pagingType] !== null )
721
		) {
722
			$fromOrUntil = true;
723
		}
724
725
		if ( $dbcnt == $rescnt ||
726
			( ( $rescnt == $this->limit || $fromOrUntil ) && $dbcnt > $rescnt )
727
		) {
728
			// Case 1: seems sane.
729
			$totalcnt = $dbcnt;
730
		} elseif ( $rescnt < $this->limit && !$fromOrUntil ) {
731
			// Case 2: not sane, but salvageable.  Use the number of results.
732
			// Since there are fewer than 200, we can also take this opportunity
733
			// to refresh the incorrect category table entry -- which should be
734
			// quick due to the small number of entries.
735
			$totalcnt = $rescnt;
736
			$category = $this->cat;
737
			DeferredUpdates::addCallableUpdate( function () use ( $category ) {
738
				$category->refreshCounts();
739
			} );
740
		} else {
741
			// Case 3: hopeless.  Don't give a total count at all.
742
			// Messages: category-subcat-count-limited, category-article-count-limited,
743
			// category-file-count-limited
744
			return $this->msg( "category-$type-count-limited" )->numParams( $rescnt )->parseAsBlock();
745
		}
746
		// Messages: category-subcat-count, category-article-count, category-file-count
747
		return $this->msg( "category-$type-count" )->numParams( $rescnt, $totalcnt )->parseAsBlock();
748
	}
749
}
750