1<?php
2/**
3 * Copyright © 2006, 2013 Yuri Astrakhan "<Firstname><Lastname>@gmail.com"
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
23use MediaWiki\Linker\LinkTarget;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Page\PageIdentity;
26use MediaWiki\Page\PageReference;
27use Wikimedia\ParamValidator\ParamValidator;
28use Wikimedia\Rdbms\IDatabase;
29use Wikimedia\Rdbms\IResultWrapper;
30
31/**
32 * This class contains a list of pages that the client has requested.
33 * Initially, when the client passes in titles=, pageids=, or revisions=
34 * parameter, an instance of the ApiPageSet class will normalize titles,
35 * determine if the pages/revisions exist, and prefetch any additional page
36 * data requested.
37 *
38 * When a generator is used, the result of the generator will become the input
39 * for the second instance of this class, and all subsequent actions will use
40 * the second instance for all their work.
41 *
42 * @ingroup API
43 * @since 1.21 derives from ApiBase instead of ApiQueryBase
44 */
45class ApiPageSet extends ApiBase {
46	/**
47	 * Constructor flag: The new instance of ApiPageSet will ignore the 'generator=' parameter
48	 * @since 1.21
49	 */
50	private const DISABLE_GENERATORS = 1;
51
52	/** @var ApiBase used for getDb() call */
53	private $mDbSource;
54
55	/** @var array */
56	private $mParams;
57
58	/** @var bool */
59	private $mResolveRedirects;
60
61	/** @var bool */
62	private $mConvertTitles;
63
64	/** @var bool */
65	private $mAllowGenerator;
66
67	/** @var int[][] [ns][dbkey] => page_id or negative when missing */
68	private $mAllPages = [];
69
70	/** @var Title[] */
71	private $mTitles = [];
72
73	/** @var int[][] [ns][dbkey] => page_id or negative when missing */
74	private $mGoodAndMissingPages = [];
75
76	/** @var int[][] [ns][dbkey] => page_id */
77	private $mGoodPages = [];
78
79	/** @var Title[] */
80	private $mGoodTitles = [];
81
82	/** @var int[][] [ns][dbkey] => fake page_id */
83	private $mMissingPages = [];
84
85	/** @var Title[] */
86	private $mMissingTitles = [];
87
88	/** @var array[] [fake_page_id] => [ 'title' => $title, 'invalidreason' => $reason ] */
89	private $mInvalidTitles = [];
90
91	/** @var int[] */
92	private $mMissingPageIDs = [];
93
94	/** @var Title[] */
95	private $mRedirectTitles = [];
96
97	/** @var Title[] */
98	private $mSpecialTitles = [];
99
100	/** @var int[][] separate from mAllPages to avoid breaking getAllTitlesByNamespace() */
101	private $mAllSpecials = [];
102
103	/** @var string[] */
104	private $mNormalizedTitles = [];
105
106	/** @var string[] */
107	private $mInterwikiTitles = [];
108
109	/** @var Title[] */
110	private $mPendingRedirectIDs = [];
111
112	/** @var Title[][] [dbkey] => [ Title $from, Title $to ] */
113	private $mPendingRedirectSpecialPages = [];
114
115	/** @var Title[] */
116	private $mResolvedRedirectTitles = [];
117
118	/** @var string[] */
119	private $mConvertedTitles = [];
120
121	/** @var int[] Array of revID (int) => pageID (int) */
122	private $mGoodRevIDs = [];
123
124	/** @var int[] Array of revID (int) => pageID (int) */
125	private $mLiveRevIDs = [];
126
127	/** @var int[] Array of revID (int) => pageID (int) */
128	private $mDeletedRevIDs = [];
129
130	/** @var int[] */
131	private $mMissingRevIDs = [];
132
133	/** @var array[][] [ns][dbkey] => data array */
134	private $mGeneratorData = [];
135
136	/** @var int */
137	private $mFakePageId = -1;
138
139	/** @var string */
140	private $mCacheMode = 'public';
141
142	/** @var array */
143	private $mRequestedPageFields = [];
144
145	/** @var int */
146	private $mDefaultNamespace;
147
148	/** @var callable|null */
149	private $mRedirectMergePolicy;
150
151	/** @var string[]|null see getGenerators() */
152	private static $generators = null;
153
154	/**
155	 * Add all items from $values into the result
156	 * @param array &$result Output
157	 * @param array $values Values to add
158	 * @param string[] $flags The names of boolean flags to mark this element
159	 * @param string|null $name If given, name of the value
160	 */
161	private static function addValues( array &$result, $values, $flags = [], $name = null ) {
162		foreach ( $values as $val ) {
163			if ( $val instanceof Title ) {
164				$v = [];
165				ApiQueryBase::addTitleInfo( $v, $val );
166			} elseif ( $name !== null ) {
167				$v = [ $name => $val ];
168			} else {
169				$v = $val;
170			}
171			foreach ( $flags as $flag ) {
172				$v[$flag] = true;
173			}
174			$result[] = $v;
175		}
176	}
177
178	/**
179	 * @param ApiBase $dbSource Module implementing getDB().
180	 *        Allows PageSet to reuse existing db connection from the shared state like ApiQuery.
181	 * @param int $flags Zero or more flags like DISABLE_GENERATORS
182	 * @param int $defaultNamespace The namespace to use if none is specified by a prefix.
183	 * @since 1.21 accepts $flags instead of two boolean values
184	 */
185	public function __construct( ApiBase $dbSource, $flags = 0, $defaultNamespace = NS_MAIN ) {
186		parent::__construct( $dbSource->getMain(), $dbSource->getModuleName() );
187		$this->mDbSource = $dbSource;
188		$this->mAllowGenerator = ( $flags & self::DISABLE_GENERATORS ) == 0;
189		$this->mDefaultNamespace = $defaultNamespace;
190
191		$this->mParams = $this->extractRequestParams();
192		$this->mResolveRedirects = $this->mParams['redirects'];
193		$this->mConvertTitles = $this->mParams['converttitles'];
194	}
195
196	/**
197	 * In case execute() is not called, call this method to mark all relevant parameters as used
198	 * This prevents unused parameters from being reported as warnings
199	 */
200	public function executeDryRun() {
201		$this->executeInternal( true );
202	}
203
204	/**
205	 * Populate the PageSet from the request parameters.
206	 */
207	public function execute() {
208		$this->executeInternal( false );
209	}
210
211	/**
212	 * Populate the PageSet from the request parameters.
213	 * @param bool $isDryRun If true, instantiates generator, but only to mark
214	 *    relevant parameters as used
215	 */
216	private function executeInternal( $isDryRun ) {
217		$generatorName = $this->mAllowGenerator ? $this->mParams['generator'] : null;
218		if ( isset( $generatorName ) ) {
219			$dbSource = $this->mDbSource;
220			if ( !$dbSource instanceof ApiQuery ) {
221				// If the parent container of this pageset is not ApiQuery, we must create it to run generator
222				$dbSource = $this->getMain()->getModuleManager()->getModule( 'query' );
223			}
224			$generator = $dbSource->getModuleManager()->getModule( $generatorName, null, true );
225			if ( $generator === null ) {
226				$this->dieWithError( [ 'apierror-badgenerator-unknown', $generatorName ], 'badgenerator' );
227			}
228			if ( !$generator instanceof ApiQueryGeneratorBase ) {
229				$this->dieWithError( [ 'apierror-badgenerator-notgenerator', $generatorName ], 'badgenerator' );
230			}
231			// Create a temporary pageset to store generator's output,
232			// add any additional fields generator may need, and execute pageset to populate titles/pageids
233			$tmpPageSet = new ApiPageSet( $dbSource, self::DISABLE_GENERATORS );
234			$generator->setGeneratorMode( $tmpPageSet );
235			$this->mCacheMode = $generator->getCacheMode( $generator->extractRequestParams() );
236
237			if ( !$isDryRun ) {
238				$generator->requestExtraData( $tmpPageSet );
239			}
240			$tmpPageSet->executeInternal( $isDryRun );
241
242			// populate this pageset with the generator output
243			if ( !$isDryRun ) {
244				$generator->executeGenerator( $this );
245
246				$this->getHookRunner()->onAPIQueryGeneratorAfterExecute( $generator, $this );
247			} else {
248				// Prevent warnings from being reported on these parameters
249				$main = $this->getMain();
250				foreach ( $generator->extractRequestParams() as $paramName => $param ) {
251					$main->markParamsUsed( $generator->encodeParamName( $paramName ) );
252				}
253			}
254
255			if ( !$isDryRun ) {
256				$this->resolvePendingRedirects();
257			}
258		} else {
259			// Only one of the titles/pageids/revids is allowed at the same time
260			$dataSource = null;
261			if ( isset( $this->mParams['titles'] ) ) {
262				$dataSource = 'titles';
263			}
264			if ( isset( $this->mParams['pageids'] ) ) {
265				if ( isset( $dataSource ) ) {
266					$this->dieWithError(
267						[
268							'apierror-invalidparammix-cannotusewith',
269							$this->encodeParamName( 'pageids' ),
270							$this->encodeParamName( $dataSource )
271						],
272						'multisource'
273					);
274				}
275				$dataSource = 'pageids';
276			}
277			if ( isset( $this->mParams['revids'] ) ) {
278				if ( isset( $dataSource ) ) {
279					$this->dieWithError(
280						[
281							'apierror-invalidparammix-cannotusewith',
282							$this->encodeParamName( 'revids' ),
283							$this->encodeParamName( $dataSource )
284						],
285						'multisource'
286					);
287				}
288				$dataSource = 'revids';
289			}
290
291			if ( !$isDryRun ) {
292				// Populate page information with the original user input
293				switch ( $dataSource ) {
294					case 'titles':
295						$this->initFromTitles( $this->mParams['titles'] );
296						break;
297					case 'pageids':
298						$this->initFromPageIds( $this->mParams['pageids'] );
299						break;
300					case 'revids':
301						if ( $this->mResolveRedirects ) {
302							$this->addWarning( 'apiwarn-redirectsandrevids' );
303						}
304						$this->mResolveRedirects = false;
305						$this->initFromRevIDs( $this->mParams['revids'] );
306						break;
307					default:
308						// Do nothing - some queries do not need any of the data sources.
309						break;
310				}
311			}
312		}
313	}
314
315	/**
316	 * Check whether this PageSet is resolving redirects
317	 * @return bool
318	 */
319	public function isResolvingRedirects() {
320		return $this->mResolveRedirects;
321	}
322
323	/**
324	 * Return the parameter name that is the source of data for this PageSet
325	 *
326	 * If multiple source parameters are specified (e.g. titles and pageids),
327	 * one will be named arbitrarily.
328	 *
329	 * @return string|null
330	 */
331	public function getDataSource() {
332		if ( $this->mAllowGenerator && isset( $this->mParams['generator'] ) ) {
333			return 'generator';
334		}
335		if ( isset( $this->mParams['titles'] ) ) {
336			return 'titles';
337		}
338		if ( isset( $this->mParams['pageids'] ) ) {
339			return 'pageids';
340		}
341		if ( isset( $this->mParams['revids'] ) ) {
342			return 'revids';
343		}
344
345		return null;
346	}
347
348	/**
349	 * Request an additional field from the page table.
350	 * Must be called before execute()
351	 * @param string $fieldName Field name
352	 */
353	public function requestField( $fieldName ) {
354		$this->mRequestedPageFields[$fieldName] = null;
355	}
356
357	/**
358	 * Get the value of a custom field previously requested through
359	 * requestField()
360	 * @param string $fieldName Field name
361	 * @return mixed Field value
362	 */
363	public function getCustomField( $fieldName ) {
364		return $this->mRequestedPageFields[$fieldName];
365	}
366
367	/**
368	 * Get the fields that have to be queried from the page table:
369	 * the ones requested through requestField() and a few basic ones
370	 * we always need
371	 * @return string[] Array of field names
372	 */
373	public function getPageTableFields() {
374		// Ensure we get minimum required fields
375		// DON'T change this order
376		$pageFlds = [
377			'page_namespace' => null,
378			'page_title' => null,
379			'page_id' => null,
380		];
381
382		if ( $this->mResolveRedirects ) {
383			$pageFlds['page_is_redirect'] = null;
384		}
385
386		$pageFlds['page_content_model'] = null;
387
388		if ( $this->getConfig()->get( 'PageLanguageUseDB' ) ) {
389			$pageFlds['page_lang'] = null;
390		}
391
392		foreach ( LinkCache::getSelectFields() as $field ) {
393			$pageFlds[$field] = null;
394		}
395
396		$pageFlds = array_merge( $pageFlds, $this->mRequestedPageFields );
397
398		return array_keys( $pageFlds );
399	}
400
401	/**
402	 * Returns an array [ns][dbkey] => page_id for all requested titles.
403	 * page_id is a unique negative number in case title was not found.
404	 * Invalid titles will also have negative page IDs and will be in namespace 0
405	 * @return array
406	 */
407	public function getAllTitlesByNamespace() {
408		return $this->mAllPages;
409	}
410
411	/**
412	 * All existing and missing pages including redirects.
413	 * Does not include special pages, interwiki links, and invalid titles.
414	 * If redirects are resolved, both the redirect and the target will be included here.
415	 *
416	 * @deprecated since 1.37, use getPages() instead.
417	 * @return Title[]
418	 */
419	public function getTitles() {
420		return $this->mTitles;
421	}
422
423	/**
424	 * All existing and missing pages including redirects.
425	 * Does not include special pages, interwiki links, and invalid titles.
426	 * If redirects are resolved, both the redirect and the target will be included here.
427	 *
428	 * @since 1.37
429	 * @return PageIdentity[]
430	 */
431	public function getPages(): array {
432		return $this->mTitles;
433	}
434
435	/**
436	 * Returns the number of unique pages (not revisions) in the set.
437	 * @return int
438	 */
439	public function getTitleCount() {
440		return count( $this->mTitles );
441	}
442
443	/**
444	 * Returns an array [ns][dbkey] => page_id for all good titles.
445	 * @return array
446	 */
447	public function getGoodTitlesByNamespace() {
448		return $this->mGoodPages;
449	}
450
451	/**
452	 * Title objects that were found in the database, including redirects.
453	 * If redirects are resolved, this will include existing redirect targets.
454	 * @deprecated since 1.37, use getGoodPages() instead.
455	 * @return Title[] Array page_id (int) => Title (obj)
456	 */
457	public function getGoodTitles() {
458		return $this->mGoodTitles;
459	}
460
461	/**
462	 * Pages that were found in the database, including redirects.
463	 * If redirects are resolved, this will include existing redirect targets.
464	 * @since 1.37
465	 * @return PageIdentity[] Array page_id (int) => PageIdentity (obj)
466	 */
467	public function getGoodPages(): array {
468		return $this->mGoodTitles;
469	}
470
471	/**
472	 * Returns the number of found unique pages (not revisions) in the set.
473	 * @return int
474	 */
475	public function getGoodTitleCount() {
476		return count( $this->mGoodTitles );
477	}
478
479	/**
480	 * Returns an array [ns][dbkey] => fake_page_id for all missing titles.
481	 * fake_page_id is a unique negative number.
482	 * @return array
483	 */
484	public function getMissingTitlesByNamespace() {
485		return $this->mMissingPages;
486	}
487
488	/**
489	 * Title objects that were NOT found in the database.
490	 * The array's index will be negative for each item.
491	 * If redirects are resolved, this will include missing redirect targets.
492	 * @deprecated since 1.37, use getMissingPages instead.
493	 * @return Title[]
494	 */
495	public function getMissingTitles() {
496		return $this->mMissingTitles;
497	}
498
499	/**
500	 * Pages that were NOT found in the database.
501	 * The array's index will be negative for each item.
502	 * If redirects are resolved, this will include missing redirect targets.
503	 * @since 1.37
504	 * @return PageIdentity[]
505	 */
506	public function getMissingPages(): array {
507		return $this->mMissingTitles;
508	}
509
510	/**
511	 * Returns an array [ns][dbkey] => page_id for all good and missing titles.
512	 * @return array
513	 */
514	public function getGoodAndMissingTitlesByNamespace() {
515		return $this->mGoodAndMissingPages;
516	}
517
518	/**
519	 * Title objects for good and missing titles.
520	 * @deprecated since 1.37, use getGoodAndMissingPages() instead.
521	 * @return Title[]
522	 */
523	public function getGoodAndMissingTitles() {
524		return $this->mGoodTitles + $this->mMissingTitles;
525	}
526
527	/**
528	 * Pages for good and missing titles.
529	 * @since 1.37
530	 * @return PageIdentity[]
531	 */
532	public function getGoodAndMissingPages(): array {
533		return $this->mGoodTitles + $this->mMissingTitles;
534	}
535
536	/**
537	 * Titles that were deemed invalid by Title::newFromText()
538	 * The array's index will be unique and negative for each item
539	 * @return array[] Array of arrays with 'title' and 'invalidreason' properties
540	 */
541	public function getInvalidTitlesAndReasons() {
542		return $this->mInvalidTitles;
543	}
544
545	/**
546	 * Page IDs that were not found in the database
547	 * @return int[] Array of page IDs
548	 */
549	public function getMissingPageIDs() {
550		return $this->mMissingPageIDs;
551	}
552
553	/**
554	 * Get a list of redirect resolutions - maps a title to its redirect
555	 * target.
556	 * @deprecated since 1.37, use getRedirectTargets instead.
557	 * @return Title[]
558	 */
559	public function getRedirectTitles() {
560		return $this->mRedirectTitles;
561	}
562
563	/**
564	 * Get a list of redirect resolutions - maps a title to its redirect
565	 * target.
566	 * @since 1.37
567	 * @return LinkTarget[]
568	 */
569	public function getRedirectTargets(): array {
570		return $this->mRedirectTitles;
571	}
572
573	/**
574	 * Get a list of redirect resolutions - maps a title to its redirect
575	 * target. Includes generator data for redirect source when available.
576	 * @param ApiResult|null $result
577	 * @return string[][]
578	 * @since 1.21
579	 */
580	public function getRedirectTitlesAsResult( $result = null ) {
581		$values = [];
582		foreach ( $this->getRedirectTitles() as $titleStrFrom => $titleTo ) {
583			$r = [
584				'from' => strval( $titleStrFrom ),
585				'to' => $titleTo->getPrefixedText(),
586			];
587			if ( $titleTo->hasFragment() ) {
588				$r['tofragment'] = $titleTo->getFragment();
589			}
590			if ( $titleTo->isExternal() ) {
591				$r['tointerwiki'] = $titleTo->getInterwiki();
592			}
593			if ( isset( $this->mResolvedRedirectTitles[$titleStrFrom] ) ) {
594				$titleFrom = $this->mResolvedRedirectTitles[$titleStrFrom];
595				$ns = $titleFrom->getNamespace();
596				$dbkey = $titleFrom->getDBkey();
597				if ( isset( $this->mGeneratorData[$ns][$dbkey] ) ) {
598					$r = array_merge( $this->mGeneratorData[$ns][$dbkey], $r );
599				}
600			}
601
602			$values[] = $r;
603		}
604		if ( !empty( $values ) && $result ) {
605			ApiResult::setIndexedTagName( $values, 'r' );
606		}
607
608		return $values;
609	}
610
611	/**
612	 * Get a list of title normalizations - maps a title to its normalized
613	 * version.
614	 * @return string[] Array of raw_prefixed_title (string) => prefixed_title (string)
615	 */
616	public function getNormalizedTitles() {
617		return $this->mNormalizedTitles;
618	}
619
620	/**
621	 * Get a list of title normalizations - maps a title to its normalized
622	 * version in the form of result array.
623	 * @param ApiResult|null $result
624	 * @return string[][]
625	 * @since 1.21
626	 */
627	public function getNormalizedTitlesAsResult( $result = null ) {
628		$values = [];
629		$contLang = MediaWikiServices::getInstance()->getContentLanguage();
630		foreach ( $this->getNormalizedTitles() as $rawTitleStr => $titleStr ) {
631			$encode = $contLang->normalize( $rawTitleStr ) !== $rawTitleStr;
632			$values[] = [
633				'fromencoded' => $encode,
634				'from' => $encode ? rawurlencode( $rawTitleStr ) : $rawTitleStr,
635				'to' => $titleStr
636			];
637		}
638		if ( !empty( $values ) && $result ) {
639			ApiResult::setIndexedTagName( $values, 'n' );
640		}
641
642		return $values;
643	}
644
645	/**
646	 * Get a list of title conversions - maps a title to its converted
647	 * version.
648	 * @return string[] Array of raw_prefixed_title (string) => prefixed_title (string)
649	 */
650	public function getConvertedTitles() {
651		return $this->mConvertedTitles;
652	}
653
654	/**
655	 * Get a list of title conversions - maps a title to its converted
656	 * version as a result array.
657	 * @param ApiResult|null $result
658	 * @return string[][] Array of (from, to) strings
659	 * @since 1.21
660	 */
661	public function getConvertedTitlesAsResult( $result = null ) {
662		$values = [];
663		foreach ( $this->getConvertedTitles() as $rawTitleStr => $titleStr ) {
664			$values[] = [
665				'from' => $rawTitleStr,
666				'to' => $titleStr
667			];
668		}
669		if ( !empty( $values ) && $result ) {
670			ApiResult::setIndexedTagName( $values, 'c' );
671		}
672
673		return $values;
674	}
675
676	/**
677	 * Get a list of interwiki titles - maps a title to its interwiki
678	 * prefix.
679	 * @return string[] Array of raw_prefixed_title (string) => interwiki_prefix (string)
680	 */
681	public function getInterwikiTitles() {
682		return $this->mInterwikiTitles;
683	}
684
685	/**
686	 * Get a list of interwiki titles - maps a title to its interwiki
687	 * prefix as result.
688	 * @param ApiResult|null $result
689	 * @param bool $iwUrl
690	 * @return string[][]
691	 * @since 1.21
692	 */
693	public function getInterwikiTitlesAsResult( $result = null, $iwUrl = false ) {
694		$values = [];
695		foreach ( $this->getInterwikiTitles() as $rawTitleStr => $interwikiStr ) {
696			$item = [
697				'title' => $rawTitleStr,
698				'iw' => $interwikiStr,
699			];
700			if ( $iwUrl ) {
701				$title = Title::newFromText( $rawTitleStr );
702				$item['url'] = $title->getFullURL( '', false, PROTO_CURRENT );
703			}
704			$values[] = $item;
705		}
706		if ( !empty( $values ) && $result ) {
707			ApiResult::setIndexedTagName( $values, 'i' );
708		}
709
710		return $values;
711	}
712
713	/**
714	 * Get an array of invalid/special/missing titles.
715	 *
716	 * @param string[] $invalidChecks List of types of invalid titles to include.
717	 *   Recognized values are:
718	 *   - invalidTitles: Titles and reasons from $this->getInvalidTitlesAndReasons()
719	 *   - special: Titles from $this->getSpecialTitles()
720	 *   - missingIds: ids from $this->getMissingPageIDs()
721	 *   - missingRevIds: ids from $this->getMissingRevisionIDs()
722	 *   - missingTitles: Titles from $this->getMissingTitles()
723	 *   - interwikiTitles: Titles from $this->getInterwikiTitlesAsResult()
724	 * @return array Array suitable for inclusion in the response
725	 * @since 1.23
726	 */
727	public function getInvalidTitlesAndRevisions( $invalidChecks = [ 'invalidTitles',
728		'special', 'missingIds', 'missingRevIds', 'missingTitles', 'interwikiTitles' ]
729	) {
730		$result = [];
731		if ( in_array( 'invalidTitles', $invalidChecks ) ) {
732			self::addValues( $result, $this->getInvalidTitlesAndReasons(), [ 'invalid' ] );
733		}
734		if ( in_array( 'special', $invalidChecks ) ) {
735			$known = [];
736			$unknown = [];
737			foreach ( $this->getSpecialTitles() as $title ) {
738				if ( $title->isKnown() ) {
739					$known[] = $title;
740				} else {
741					$unknown[] = $title;
742				}
743			}
744			self::addValues( $result, $unknown, [ 'special', 'missing' ] );
745			self::addValues( $result, $known, [ 'special' ] );
746		}
747		if ( in_array( 'missingIds', $invalidChecks ) ) {
748			self::addValues( $result, $this->getMissingPageIDs(), [ 'missing' ], 'pageid' );
749		}
750		if ( in_array( 'missingRevIds', $invalidChecks ) ) {
751			self::addValues( $result, $this->getMissingRevisionIDs(), [ 'missing' ], 'revid' );
752		}
753		if ( in_array( 'missingTitles', $invalidChecks ) ) {
754			$known = [];
755			$unknown = [];
756			foreach ( $this->getMissingTitles() as $title ) {
757				if ( $title->isKnown() ) {
758					$known[] = $title;
759				} else {
760					$unknown[] = $title;
761				}
762			}
763			self::addValues( $result, $unknown, [ 'missing' ] );
764			self::addValues( $result, $known, [ 'missing', 'known' ] );
765		}
766		if ( in_array( 'interwikiTitles', $invalidChecks ) ) {
767			self::addValues( $result, $this->getInterwikiTitlesAsResult() );
768		}
769
770		return $result;
771	}
772
773	/**
774	 * Get the list of valid revision IDs (requested with the revids= parameter)
775	 * @return int[] Array of revID (int) => pageID (int)
776	 */
777	public function getRevisionIDs() {
778		return $this->mGoodRevIDs;
779	}
780
781	/**
782	 * Get the list of non-deleted revision IDs (requested with the revids= parameter)
783	 * @return int[] Array of revID (int) => pageID (int)
784	 */
785	public function getLiveRevisionIDs() {
786		return $this->mLiveRevIDs;
787	}
788
789	/**
790	 * Get the list of revision IDs that were associated with deleted titles.
791	 * @return int[] Array of revID (int) => pageID (int)
792	 */
793	public function getDeletedRevisionIDs() {
794		return $this->mDeletedRevIDs;
795	}
796
797	/**
798	 * Revision IDs that were not found in the database
799	 * @return int[] Array of revision IDs
800	 */
801	public function getMissingRevisionIDs() {
802		return $this->mMissingRevIDs;
803	}
804
805	/**
806	 * Revision IDs that were not found in the database as result array.
807	 * @param ApiResult|null $result
808	 * @return int[][]
809	 * @since 1.21
810	 */
811	public function getMissingRevisionIDsAsResult( $result = null ) {
812		$values = [];
813		foreach ( $this->getMissingRevisionIDs() as $revid ) {
814			$values[$revid] = [
815				'revid' => $revid
816			];
817		}
818		if ( !empty( $values ) && $result ) {
819			ApiResult::setIndexedTagName( $values, 'rev' );
820		}
821
822		return $values;
823	}
824
825	/**
826	 * Get the list of titles with negative namespace
827	 * @deprecated since 1.37, use getSpecialPages() instead.
828	 * @return Title[]
829	 */
830	public function getSpecialTitles() {
831		return $this->mSpecialTitles;
832	}
833
834	/**
835	 * Get the list of pages with negative namespace
836	 * @since 1.37
837	 * @return PageReference[]
838	 */
839	public function getSpecialPages(): array {
840		return $this->mSpecialTitles;
841	}
842
843	/**
844	 * Returns the number of revisions (requested with revids= parameter).
845	 * @return int Number of revisions.
846	 */
847	public function getRevisionCount() {
848		return count( $this->getRevisionIDs() );
849	}
850
851	/**
852	 * Populate this PageSet
853	 * @param string[]|LinkTarget[]|PageReference[] $titles
854	 */
855	public function populateFromTitles( $titles ) {
856		$this->initFromTitles( $titles );
857	}
858
859	/**
860	 * Populate this PageSet from a list of page IDs
861	 * @param int[] $pageIDs
862	 */
863	public function populateFromPageIDs( $pageIDs ) {
864		$this->initFromPageIds( $pageIDs );
865	}
866
867	/**
868	 * Populate this PageSet from a rowset returned from the database
869	 *
870	 * Note that the query result must include the columns returned by
871	 * $this->getPageTableFields().
872	 *
873	 * @param IDatabase $db
874	 * @param IResultWrapper $queryResult
875	 */
876	public function populateFromQueryResult( $db, $queryResult ) {
877		$this->initFromQueryResult( $queryResult );
878	}
879
880	/**
881	 * Populate this PageSet from a list of revision IDs
882	 * @param int[] $revIDs Array of revision IDs
883	 */
884	public function populateFromRevisionIDs( $revIDs ) {
885		$this->initFromRevIDs( $revIDs );
886	}
887
888	/**
889	 * Extract all requested fields from the row received from the database
890	 * @param stdClass $row Result row
891	 */
892	public function processDbRow( $row ) {
893		// Store Title object in various data structures
894		$title = Title::newFromRow( $row );
895
896		$linkCache = MediaWikiServices::getInstance()->getLinkCache();
897		$linkCache->addGoodLinkObjFromRow( $title, $row );
898
899		$pageId = (int)$row->page_id;
900		$this->mAllPages[$row->page_namespace][$row->page_title] = $pageId;
901		$this->mTitles[] = $title;
902
903		if ( $this->mResolveRedirects && $row->page_is_redirect == '1' ) {
904			$this->mPendingRedirectIDs[$pageId] = $title;
905		} else {
906			$this->mGoodPages[$row->page_namespace][$row->page_title] = $pageId;
907			$this->mGoodAndMissingPages[$row->page_namespace][$row->page_title] = $pageId;
908			$this->mGoodTitles[$pageId] = $title;
909		}
910
911		foreach ( $this->mRequestedPageFields as $fieldName => &$fieldValues ) {
912			$fieldValues[$pageId] = $row->$fieldName;
913		}
914	}
915
916	/**
917	 * This method populates internal variables with page information
918	 * based on the given array of title strings.
919	 *
920	 * Steps:
921	 * #1 For each title, get data from `page` table
922	 * #2 If page was not found in the DB, store it as missing
923	 *
924	 * Additionally, when resolving redirects:
925	 * #3 If no more redirects left, stop.
926	 * #4 For each redirect, get its target from the `redirect` table.
927	 * #5 Substitute the original LinkBatch object with the new list
928	 * #6 Repeat from step #1
929	 *
930	 * @param string[]|LinkTarget[]|PageReference[] $titles
931	 */
932	private function initFromTitles( $titles ) {
933		// Get validated and normalized title objects
934		$linkBatch = $this->processTitlesArray( $titles );
935		if ( $linkBatch->isEmpty() ) {
936			// There might be special-page redirects
937			$this->resolvePendingRedirects();
938			return;
939		}
940
941		$db = $this->getDB();
942		$set = $linkBatch->constructSet( 'page', $db );
943
944		// Get pageIDs data from the `page` table
945		$res = $db->select( 'page', $this->getPageTableFields(), $set,
946			__METHOD__ );
947
948		// Hack: get the ns:titles stored in [ ns => [ titles ] ] format
949		$this->initFromQueryResult( $res, $linkBatch->data, true ); // process Titles
950
951		// Resolve any found redirects
952		$this->resolvePendingRedirects();
953	}
954
955	/**
956	 * Does the same as initFromTitles(), but is based on page IDs instead
957	 * @param int[] $pageids
958	 * @param bool $filterIds Whether the IDs need filtering
959	 */
960	private function initFromPageIds( $pageids, $filterIds = true ) {
961		if ( !$pageids ) {
962			return;
963		}
964
965		$pageids = array_map( 'intval', $pageids ); // paranoia
966		$remaining = array_fill_keys( $pageids, true );
967
968		if ( $filterIds ) {
969			$pageids = $this->filterIDs( [ [ 'page', 'page_id' ] ], $pageids );
970		}
971
972		$res = null;
973		if ( !empty( $pageids ) ) {
974			$set = [
975				'page_id' => $pageids
976			];
977			$db = $this->getDB();
978
979			// Get pageIDs data from the `page` table
980			$res = $db->select( 'page', $this->getPageTableFields(), $set,
981				__METHOD__ );
982		}
983
984		$this->initFromQueryResult( $res, $remaining, false ); // process PageIDs
985
986		// Resolve any found redirects
987		$this->resolvePendingRedirects();
988	}
989
990	/**
991	 * Iterate through the result of the query on 'page' table,
992	 * and for each row create and store title object and save any extra fields requested.
993	 * @param IResultWrapper $res DB Query result
994	 * @param array|null &$remaining Array of either pageID or ns/title elements (optional).
995	 *        If given, any missing items will go to $mMissingPageIDs and $mMissingTitles
996	 * @param bool|null $processTitles Must be provided together with $remaining.
997	 *        If true, treat $remaining as an array of [ns][title]
998	 *        If false, treat it as an array of [pageIDs]
999	 */
1000	private function initFromQueryResult( $res, &$remaining = null, $processTitles = null ) {
1001		if ( $remaining !== null && $processTitles === null ) {
1002			ApiBase::dieDebug( __METHOD__, 'Missing $processTitles parameter when $remaining is provided' );
1003		}
1004
1005		$nsInfo = MediaWikiServices::getInstance()->getNamespaceInfo();
1006
1007		$usernames = [];
1008		if ( $res ) {
1009			foreach ( $res as $row ) {
1010				$pageId = (int)$row->page_id;
1011
1012				// Remove found page from the list of remaining items
1013				if ( $remaining ) {
1014					if ( $processTitles ) {
1015						unset( $remaining[$row->page_namespace][$row->page_title] );
1016					} else {
1017						unset( $remaining[$pageId] );
1018					}
1019				}
1020
1021				// Store any extra fields requested by modules
1022				$this->processDbRow( $row );
1023
1024				// Need gender information
1025				if ( $nsInfo->hasGenderDistinction( $row->page_namespace ) ) {
1026					$usernames[] = $row->page_title;
1027				}
1028			}
1029		}
1030
1031		if ( $remaining ) {
1032			// Any items left in the $remaining list are added as missing
1033			if ( $processTitles ) {
1034				// The remaining titles in $remaining are non-existent pages
1035				$linkCache = MediaWikiServices::getInstance()->getLinkCache();
1036				foreach ( $remaining as $ns => $dbkeys ) {
1037					foreach ( array_keys( $dbkeys ) as $dbkey ) {
1038						$title = Title::makeTitle( $ns, $dbkey );
1039						$linkCache->addBadLinkObj( $title );
1040						$this->mAllPages[$ns][$dbkey] = $this->mFakePageId;
1041						$this->mMissingPages[$ns][$dbkey] = $this->mFakePageId;
1042						$this->mGoodAndMissingPages[$ns][$dbkey] = $this->mFakePageId;
1043						$this->mMissingTitles[$this->mFakePageId] = $title;
1044						$this->mFakePageId--;
1045						$this->mTitles[] = $title;
1046
1047						// need gender information
1048						if ( $nsInfo->hasGenderDistinction( $ns ) ) {
1049							$usernames[] = $dbkey;
1050						}
1051					}
1052				}
1053			} else {
1054				// The remaining pageids do not exist
1055				if ( !$this->mMissingPageIDs ) {
1056					$this->mMissingPageIDs = array_keys( $remaining );
1057				} else {
1058					$this->mMissingPageIDs = array_merge( $this->mMissingPageIDs, array_keys( $remaining ) );
1059				}
1060			}
1061		}
1062
1063		// Get gender information
1064		$genderCache = MediaWikiServices::getInstance()->getGenderCache();
1065		$genderCache->doQuery( $usernames, __METHOD__ );
1066	}
1067
1068	/**
1069	 * Does the same as initFromTitles(), but is based on revision IDs
1070	 * instead
1071	 * @param int[] $revids Array of revision IDs
1072	 */
1073	private function initFromRevIDs( $revids ) {
1074		if ( !$revids ) {
1075			return;
1076		}
1077
1078		$revids = array_map( 'intval', $revids ); // paranoia
1079		$db = $this->getDB();
1080		$pageids = [];
1081		$remaining = array_fill_keys( $revids, true );
1082
1083		$revids = $this->filterIDs( [ [ 'revision', 'rev_id' ], [ 'archive', 'ar_rev_id' ] ], $revids );
1084		$goodRemaining = array_fill_keys( $revids, true );
1085
1086		if ( $revids ) {
1087			$tables = [ 'revision', 'page' ];
1088			$fields = [ 'rev_id', 'rev_page' ];
1089			$where = [ 'rev_id' => $revids, 'rev_page = page_id' ];
1090
1091			// Get pageIDs data from the `page` table
1092			$res = $db->select( $tables, $fields, $where, __METHOD__ );
1093			foreach ( $res as $row ) {
1094				$revid = (int)$row->rev_id;
1095				$pageid = (int)$row->rev_page;
1096				$this->mGoodRevIDs[$revid] = $pageid;
1097				$this->mLiveRevIDs[$revid] = $pageid;
1098				$pageids[$pageid] = '';
1099				unset( $remaining[$revid] );
1100				unset( $goodRemaining[$revid] );
1101			}
1102		}
1103
1104		// Populate all the page information
1105		$this->initFromPageIds( array_keys( $pageids ), false );
1106
1107		// If the user can see deleted revisions, pull out the corresponding
1108		// titles from the archive table and include them too. We ignore
1109		// ar_page_id because deleted revisions are tied by title, not page_id.
1110		if ( $goodRemaining &&
1111			$this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
1112			$tables = [ 'archive' ];
1113			$fields = [ 'ar_rev_id', 'ar_namespace', 'ar_title' ];
1114			$where = [ 'ar_rev_id' => array_keys( $goodRemaining ) ];
1115
1116			$res = $db->select( $tables, $fields, $where, __METHOD__ );
1117			$titles = [];
1118			foreach ( $res as $row ) {
1119				$revid = (int)$row->ar_rev_id;
1120				$titles[$revid] = Title::makeTitle( $row->ar_namespace, $row->ar_title );
1121				unset( $remaining[$revid] );
1122			}
1123
1124			$this->initFromTitles( $titles );
1125
1126			foreach ( $titles as $revid => $title ) {
1127				$ns = $title->getNamespace();
1128				$dbkey = $title->getDBkey();
1129
1130				// Handle converted titles
1131				if ( !isset( $this->mAllPages[$ns][$dbkey] ) &&
1132					isset( $this->mConvertedTitles[$title->getPrefixedText()] )
1133				) {
1134					$title = Title::newFromText( $this->mConvertedTitles[$title->getPrefixedText()] );
1135					$ns = $title->getNamespace();
1136					$dbkey = $title->getDBkey();
1137				}
1138
1139				if ( isset( $this->mAllPages[$ns][$dbkey] ) ) {
1140					$this->mGoodRevIDs[$revid] = $this->mAllPages[$ns][$dbkey];
1141					$this->mDeletedRevIDs[$revid] = $this->mAllPages[$ns][$dbkey];
1142				} else {
1143					$remaining[$revid] = true;
1144				}
1145			}
1146		}
1147
1148		$this->mMissingRevIDs = array_keys( $remaining );
1149	}
1150
1151	/**
1152	 * Resolve any redirects in the result if redirect resolution was
1153	 * requested. This function is called repeatedly until all redirects
1154	 * have been resolved.
1155	 */
1156	private function resolvePendingRedirects() {
1157		if ( $this->mResolveRedirects ) {
1158			$db = $this->getDB();
1159			$pageFlds = $this->getPageTableFields();
1160
1161			// Repeat until all redirects have been resolved
1162			// The infinite loop is prevented by keeping all known pages in $this->mAllPages
1163			while ( $this->mPendingRedirectIDs || $this->mPendingRedirectSpecialPages ) {
1164				// Resolve redirects by querying the pagelinks table, and repeat the process
1165				// Create a new linkBatch object for the next pass
1166				$linkBatch = $this->loadRedirectTargets();
1167
1168				if ( $linkBatch->isEmpty() ) {
1169					break;
1170				}
1171
1172				$set = $linkBatch->constructSet( 'page', $db );
1173				if ( $set === false ) {
1174					break;
1175				}
1176
1177				// Get pageIDs data from the `page` table
1178				$res = $db->select( 'page', $pageFlds, $set, __METHOD__ );
1179
1180				// Hack: get the ns:titles stored in [ns => array(titles)] format
1181				$this->initFromQueryResult( $res, $linkBatch->data, true );
1182			}
1183		}
1184	}
1185
1186	/**
1187	 * Get the targets of the pending redirects from the database
1188	 *
1189	 * Also creates entries in the redirect table for redirects that don't
1190	 * have one.
1191	 * @return LinkBatch
1192	 */
1193	private function loadRedirectTargets() {
1194		$titlesToResolve = [];
1195		$db = $this->getDB();
1196
1197		if ( $this->mPendingRedirectIDs ) {
1198			$res = $db->select(
1199				'redirect',
1200				[
1201					'rd_from',
1202					'rd_namespace',
1203					'rd_fragment',
1204					'rd_interwiki',
1205					'rd_title'
1206				], [ 'rd_from' => array_keys( $this->mPendingRedirectIDs ) ],
1207					__METHOD__
1208				);
1209			foreach ( $res as $row ) {
1210				$rdfrom = (int)$row->rd_from;
1211				$from = $this->mPendingRedirectIDs[$rdfrom]->getPrefixedText();
1212				$to = Title::makeTitle(
1213					$row->rd_namespace,
1214					$row->rd_title,
1215					$row->rd_fragment,
1216					$row->rd_interwiki
1217				);
1218				$this->mResolvedRedirectTitles[$from] = $this->mPendingRedirectIDs[$rdfrom];
1219				unset( $this->mPendingRedirectIDs[$rdfrom] );
1220				if ( $to->isExternal() ) {
1221					$this->mInterwikiTitles[$to->getPrefixedText()] = $to->getInterwiki();
1222				} elseif ( !isset( $this->mAllPages[$to->getNamespace()][$to->getDBkey()] ) ) {
1223					$titlesToResolve[] = $to;
1224				}
1225				$this->mRedirectTitles[$from] = $to;
1226			}
1227
1228			if ( $this->mPendingRedirectIDs ) {
1229				// We found pages that aren't in the redirect table
1230				// Add them
1231				foreach ( $this->mPendingRedirectIDs as $id => $title ) {
1232					$page = WikiPage::factory( $title );
1233					$rt = $page->insertRedirect();
1234					if ( !$rt ) {
1235						// What the hell. Let's just ignore this
1236						continue;
1237					}
1238					if ( $rt->isExternal() ) {
1239						$this->mInterwikiTitles[$rt->getPrefixedText()] = $rt->getInterwiki();
1240					} elseif ( !isset( $this->mAllPages[$rt->getNamespace()][$rt->getDBkey()] ) ) {
1241						$titlesToResolve[] = $rt;
1242					}
1243					$from = $title->getPrefixedText();
1244					$this->mResolvedRedirectTitles[$from] = $title;
1245					$this->mRedirectTitles[$from] = $rt;
1246					unset( $this->mPendingRedirectIDs[$id] );
1247				}
1248			}
1249		}
1250
1251		if ( $this->mPendingRedirectSpecialPages ) {
1252			foreach ( $this->mPendingRedirectSpecialPages as $key => list( $from, $to ) ) {
1253				/** @var Title $from */
1254				$fromKey = $from->getPrefixedText();
1255				$this->mResolvedRedirectTitles[$fromKey] = $from;
1256				$this->mRedirectTitles[$fromKey] = $to;
1257				if ( $to->isExternal() ) {
1258					$this->mInterwikiTitles[$to->getPrefixedText()] = $to->getInterwiki();
1259				} elseif ( !isset( $this->mAllPages[$to->getNamespace()][$to->getDBkey()] ) ) {
1260					$titlesToResolve[] = $to;
1261				}
1262			}
1263			$this->mPendingRedirectSpecialPages = [];
1264
1265			// Set private caching since we don't know what criteria the
1266			// special pages used to decide on these redirects.
1267			$this->mCacheMode = 'private';
1268		}
1269
1270		return $this->processTitlesArray( $titlesToResolve );
1271	}
1272
1273	/**
1274	 * Get the cache mode for the data generated by this module.
1275	 * All PageSet users should take into account whether this returns a more-restrictive
1276	 * cache mode than the using module itself. For possible return values and other
1277	 * details about cache modes, see ApiMain::setCacheMode()
1278	 *
1279	 * Public caching will only be allowed if *all* the modules that supply
1280	 * data for a given request return a cache mode of public.
1281	 *
1282	 * @param array|null $params
1283	 * @return string
1284	 * @since 1.21
1285	 */
1286	public function getCacheMode( $params = null ) {
1287		return $this->mCacheMode;
1288	}
1289
1290	/**
1291	 * Given an array of title strings, convert them into Title objects.
1292	 * Alternatively, an array of Title objects may be given.
1293	 * This method validates access rights for the title,
1294	 * and appends normalization values to the output.
1295	 *
1296	 * @param string[]|LinkTarget[]|PageReference[] $titles
1297	 * @return LinkBatch
1298	 */
1299	private function processTitlesArray( $titles ) {
1300		$services = MediaWikiServices::getInstance();
1301		$linkBatchFactory = $services->getLinkBatchFactory();
1302		$linkBatch = $linkBatchFactory->newLinkBatch();
1303		$languageConverter = $services
1304			->getLanguageConverterFactory()
1305			->getLanguageConverter( $services->getContentLanguage() );
1306
1307		$titleFactory = $services->getTitleFactory();
1308
1309		/** @var Title[] $titleObjects */
1310		$titleObjects = [];
1311		foreach ( $titles as $index => $title ) {
1312			if ( is_string( $title ) ) {
1313				try {
1314					/** @var Title $titleObj */
1315					$titleObj = Title::newFromTextThrow( $title, $this->mDefaultNamespace );
1316				} catch ( MalformedTitleException $ex ) {
1317					// Handle invalid titles gracefully
1318					if ( !isset( $this->mAllPages[0][$title] ) ) {
1319						$this->mAllPages[0][$title] = $this->mFakePageId;
1320						$this->mInvalidTitles[$this->mFakePageId] = [
1321							'title' => $title,
1322							'invalidreason' => $this->getErrorFormatter()->formatException( $ex, [ 'bc' => true ] ),
1323						];
1324						$this->mFakePageId--;
1325					}
1326					continue; // There's nothing else we can do
1327				}
1328			} elseif ( $title instanceof LinkTarget ) {
1329				$titleObj = $titleFactory->castFromLinkTarget( $title );
1330			} else {
1331				$titleObj = $titleFactory->castFromPageReference( $title );
1332			}
1333
1334			$titleObjects[$index] = $titleObj;
1335		}
1336
1337		// Get gender information
1338		$genderCache = $services->getGenderCache();
1339		$genderCache->doTitlesArray( $titleObjects, __METHOD__ );
1340
1341		foreach ( $titleObjects as $index => $titleObj ) {
1342			$title = is_string( $titles[$index] ) ? $titles[$index] : false;
1343			$unconvertedTitle = $titleObj->getPrefixedText();
1344			$titleWasConverted = false;
1345			if ( $titleObj->isExternal() ) {
1346				// This title is an interwiki link.
1347				$this->mInterwikiTitles[$unconvertedTitle] = $titleObj->getInterwiki();
1348			} else {
1349				// Variants checking
1350				if (
1351					$this->mConvertTitles
1352					&& $languageConverter->hasVariants()
1353					&& !$titleObj->exists()
1354				) {
1355					// ILanguageConverter::findVariantLink will modify titleText and
1356					// titleObj into the canonical variant if possible
1357					$titleText = $title !== false ? $title : $titleObj->getPrefixedText();
1358					$languageConverter->findVariantLink( $titleText, $titleObj );
1359					$titleWasConverted = $unconvertedTitle !== $titleObj->getPrefixedText();
1360				}
1361
1362				if ( $titleObj->getNamespace() < 0 ) {
1363					// Handle Special and Media pages
1364					$titleObj = $titleObj->fixSpecialName();
1365					$ns = $titleObj->getNamespace();
1366					$dbkey = $titleObj->getDBkey();
1367					if ( !isset( $this->mAllSpecials[$ns][$dbkey] ) ) {
1368						$this->mAllSpecials[$ns][$dbkey] = $this->mFakePageId;
1369						$target = null;
1370						if ( $ns === NS_SPECIAL && $this->mResolveRedirects ) {
1371							$spFactory = $services->getSpecialPageFactory();
1372							$special = $spFactory->getPage( $dbkey );
1373							if ( $special instanceof RedirectSpecialArticle ) {
1374								// Only RedirectSpecialArticle is intended to redirect to an article, other kinds of
1375								// RedirectSpecialPage are probably applying weird URL parameters we don't want to
1376								// handle.
1377								$context = new DerivativeContext( $this );
1378								$context->setTitle( $titleObj );
1379								$context->setRequest( new FauxRequest );
1380								$special->setContext( $context );
1381								list( /* $alias */, $subpage ) = $spFactory->resolveAlias( $dbkey );
1382								$target = $special->getRedirect( $subpage );
1383							}
1384						}
1385						if ( $target ) {
1386							$this->mPendingRedirectSpecialPages[$dbkey] = [ $titleObj, $target ];
1387						} else {
1388							$this->mSpecialTitles[$this->mFakePageId] = $titleObj;
1389							$this->mFakePageId--;
1390						}
1391					}
1392				} else {
1393					// Regular page
1394					$linkBatch->addObj( $titleObj );
1395				}
1396			}
1397
1398			// Make sure we remember the original title that was
1399			// given to us. This way the caller can correlate new
1400			// titles with the originally requested when e.g. the
1401			// namespace is localized or the capitalization is
1402			// different
1403			if ( $titleWasConverted ) {
1404				$this->mConvertedTitles[$unconvertedTitle] = $titleObj->getPrefixedText();
1405				// In this case the page can't be Special.
1406				if ( $title !== false && $title !== $unconvertedTitle ) {
1407					$this->mNormalizedTitles[$title] = $unconvertedTitle;
1408				}
1409			} elseif ( $title !== false && $title !== $titleObj->getPrefixedText() ) {
1410				$this->mNormalizedTitles[$title] = $titleObj->getPrefixedText();
1411			}
1412		}
1413
1414		return $linkBatch;
1415	}
1416
1417	/**
1418	 * Set data for a title.
1419	 *
1420	 * This data may be extracted into an ApiResult using
1421	 * self::populateGeneratorData. This should generally be limited to
1422	 * data that is likely to be particularly useful to end users rather than
1423	 * just being a dump of everything returned in non-generator mode.
1424	 *
1425	 * Redirects here will *not* be followed, even if 'redirects' was
1426	 * specified, since in the case of multiple redirects we can't know which
1427	 * source's data to use on the target.
1428	 *
1429	 * @param PageReference|LinkTarget $title
1430	 * @param array $data
1431	 */
1432	public function setGeneratorData( $title, array $data ) {
1433		$ns = $title->getNamespace();
1434		$dbkey = $title->getDBkey();
1435		$this->mGeneratorData[$ns][$dbkey] = $data;
1436	}
1437
1438	/**
1439	 * Controls how generator data about a redirect source is merged into
1440	 * the generator data for the redirect target. When not set no data
1441	 * is merged. Note that if multiple titles redirect to the same target
1442	 * the order of operations is undefined.
1443	 *
1444	 * Example to include generated data from redirect in target, prefering
1445	 * the data generated for the destination when there is a collision:
1446	 * @code
1447	 *   $pageSet->setRedirectMergePolicy( function( array $current, array $new ) {
1448	 *       return $current + $new;
1449	 *   } );
1450	 * @endcode
1451	 *
1452	 * @param callable|null $callable Recieves two array arguments, first the
1453	 *  generator data for the redirect target and second the generator data
1454	 *  for the redirect source. Returns the resulting generator data to use
1455	 *  for the redirect target.
1456	 */
1457	public function setRedirectMergePolicy( $callable ) {
1458		$this->mRedirectMergePolicy = $callable;
1459	}
1460
1461	/**
1462	 * Resolve the title a redirect points to.
1463	 *
1464	 * Will follow sequential redirects to find the final page. In
1465	 * the case of a redirect cycle the original page will be returned.
1466	 * self::resolvePendingRedirects must be executed before calling
1467	 * this method.
1468	 *
1469	 * @param Title $titleFrom A title from $this->mResolvedRedirectTitles
1470	 * @return Title
1471	 */
1472	private function resolveRedirectTitleDest( Title $titleFrom ): Title {
1473		$seen = [];
1474		$dest = $titleFrom;
1475		while ( isset( $this->mRedirectTitles[$dest->getPrefixedText()] ) ) {
1476			$dest = $this->mRedirectTitles[$dest->getPrefixedText()];
1477			if ( isset( $seen[$dest->getPrefixedText()] ) ) {
1478				return $titleFrom;
1479			}
1480			$seen[$dest->getPrefixedText()] = true;
1481		}
1482		return $dest;
1483	}
1484
1485	/**
1486	 * Populate the generator data for all titles in the result
1487	 *
1488	 * The page data may be inserted into an ApiResult object or into an
1489	 * associative array. The $path parameter specifies the path within the
1490	 * ApiResult or array to find the "pages" node.
1491	 *
1492	 * The "pages" node itself must be an associative array mapping the page ID
1493	 * or fake page ID values returned by this pageset (see
1494	 * self::getAllTitlesByNamespace() and self::getSpecialTitles()) to
1495	 * associative arrays of page data. Each of those subarrays will have the
1496	 * data from self::setGeneratorData() merged in.
1497	 *
1498	 * Data that was set by self::setGeneratorData() for pages not in the
1499	 * "pages" node will be ignored.
1500	 *
1501	 * @param ApiResult|array &$result
1502	 * @param array $path
1503	 * @return bool Whether the data fit
1504	 */
1505	public function populateGeneratorData( &$result, array $path = [] ) {
1506		if ( $result instanceof ApiResult ) {
1507			$data = $result->getResultData( $path );
1508			if ( $data === null ) {
1509				return true;
1510			}
1511		} else {
1512			$data = &$result;
1513			foreach ( $path as $key ) {
1514				if ( !isset( $data[$key] ) ) {
1515					// Path isn't in $result, so nothing to add, so everything
1516					// "fits"
1517					return true;
1518				}
1519				$data = &$data[$key];
1520			}
1521		}
1522		foreach ( $this->mGeneratorData as $ns => $dbkeys ) {
1523			if ( $ns === NS_SPECIAL ) {
1524				$pages = [];
1525				foreach ( $this->mSpecialTitles as $id => $title ) {
1526					$pages[$title->getDBkey()] = $id;
1527				}
1528			} else {
1529				if ( !isset( $this->mAllPages[$ns] ) ) {
1530					// No known titles in the whole namespace. Skip it.
1531					continue;
1532				}
1533				$pages = $this->mAllPages[$ns];
1534			}
1535			foreach ( $dbkeys as $dbkey => $genData ) {
1536				if ( !isset( $pages[$dbkey] ) ) {
1537					// Unknown title. Forget it.
1538					continue;
1539				}
1540				$pageId = $pages[$dbkey];
1541				if ( !isset( $data[$pageId] ) ) {
1542					// $pageId didn't make it into the result. Ignore it.
1543					continue;
1544				}
1545
1546				if ( $result instanceof ApiResult ) {
1547					$path2 = array_merge( $path, [ $pageId ] );
1548					foreach ( $genData as $key => $value ) {
1549						if ( !$result->addValue( $path2, $key, $value ) ) {
1550							return false;
1551						}
1552					}
1553				} else {
1554					$data[$pageId] = array_merge( $data[$pageId], $genData );
1555				}
1556			}
1557		}
1558
1559		// Merge data generated about redirect titles into the redirect destination
1560		if ( $this->mRedirectMergePolicy ) {
1561			foreach ( $this->mResolvedRedirectTitles as $titleFrom ) {
1562				$dest = $this->resolveRedirectTitleDest( $titleFrom );
1563				$fromNs = $titleFrom->getNamespace();
1564				$fromDBkey = $titleFrom->getDBkey();
1565				$toPageId = $dest->getArticleID();
1566				if ( isset( $data[$toPageId] ) &&
1567					isset( $this->mGeneratorData[$fromNs][$fromDBkey] )
1568				) {
1569					// It is necessary to set both $data and add to $result, if an ApiResult,
1570					// to ensure multiple redirects to the same destination are all merged.
1571					$data[$toPageId] = call_user_func(
1572						$this->mRedirectMergePolicy,
1573						$data[$toPageId],
1574						$this->mGeneratorData[$fromNs][$fromDBkey]
1575					);
1576					if ( $result instanceof ApiResult &&
1577						!$result->addValue( $path, $toPageId, $data[$toPageId], ApiResult::OVERRIDE )
1578					) {
1579						return false;
1580					}
1581				}
1582			}
1583		}
1584
1585		return true;
1586	}
1587
1588	/**
1589	 * Get the database connection (read-only)
1590	 * @return IDatabase
1591	 */
1592	protected function getDB() {
1593		return $this->mDbSource->getDB();
1594	}
1595
1596	public function getAllowedParams( $flags = 0 ) {
1597		$result = [
1598			'titles' => [
1599				ApiBase::PARAM_ISMULTI => true,
1600				ApiBase::PARAM_HELP_MSG => 'api-pageset-param-titles',
1601			],
1602			'pageids' => [
1603				ApiBase::PARAM_TYPE => 'integer',
1604				ApiBase::PARAM_ISMULTI => true,
1605				ApiBase::PARAM_HELP_MSG => 'api-pageset-param-pageids',
1606			],
1607			'revids' => [
1608				ApiBase::PARAM_TYPE => 'integer',
1609				ApiBase::PARAM_ISMULTI => true,
1610				ApiBase::PARAM_HELP_MSG => 'api-pageset-param-revids',
1611			],
1612			'generator' => [
1613				ApiBase::PARAM_TYPE => null,
1614				ApiBase::PARAM_HELP_MSG => 'api-pageset-param-generator',
1615				ApiBase::PARAM_SUBMODULE_PARAM_PREFIX => 'g',
1616			],
1617			'redirects' => [
1618				ApiBase::PARAM_DFLT => false,
1619				ApiBase::PARAM_HELP_MSG => $this->mAllowGenerator
1620					? 'api-pageset-param-redirects-generator'
1621					: 'api-pageset-param-redirects-nogenerator',
1622			],
1623			'converttitles' => [
1624				ApiBase::PARAM_DFLT => false,
1625				ApiBase::PARAM_HELP_MSG => [
1626					'api-pageset-param-converttitles',
1627					[ Message::listParam( LanguageConverter::$languagesWithVariants, 'text' ) ],
1628				],
1629			],
1630		];
1631
1632		if ( !$this->mAllowGenerator ) {
1633			unset( $result['generator'] );
1634		} elseif ( $flags & ApiBase::GET_VALUES_FOR_HELP ) {
1635			$result['generator'][ApiBase::PARAM_TYPE] = 'submodule';
1636			$result['generator'][ApiBase::PARAM_SUBMODULE_MAP] = $this->getGenerators();
1637		}
1638
1639		return $result;
1640	}
1641
1642	public function handleParamNormalization( $paramName, $value, $rawValue ) {
1643		parent::handleParamNormalization( $paramName, $value, $rawValue );
1644
1645		if ( $paramName === 'titles' ) {
1646			// For the 'titles' parameter, we want to split it like ApiBase would
1647			// and add any changed titles to $this->mNormalizedTitles
1648			$value = ParamValidator::explodeMultiValue( $value, self::LIMIT_SML2 + 1 );
1649			$l = count( $value );
1650			$rawValue = ParamValidator::explodeMultiValue( $rawValue, $l );
1651			for ( $i = 0; $i < $l; $i++ ) {
1652				if ( $value[$i] !== $rawValue[$i] ) {
1653					$this->mNormalizedTitles[$rawValue[$i]] = $value[$i];
1654				}
1655			}
1656		}
1657	}
1658
1659	/**
1660	 * Get an array of all available generators
1661	 * @return string[]
1662	 */
1663	private function getGenerators() {
1664		if ( self::$generators === null ) {
1665			$query = $this->mDbSource;
1666			if ( !( $query instanceof ApiQuery ) ) {
1667				// If the parent container of this pageset is not ApiQuery,
1668				// we must create it to get module manager
1669				$query = $this->getMain()->getModuleManager()->getModule( 'query' );
1670			}
1671			$gens = [];
1672			$prefix = $query->getModulePath() . '+';
1673			$mgr = $query->getModuleManager();
1674			foreach ( $mgr->getNamesWithClasses() as $name => $class ) {
1675				if ( is_subclass_of( $class, ApiQueryGeneratorBase::class ) ) {
1676					$gens[$name] = $prefix . $name;
1677				}
1678			}
1679			ksort( $gens );
1680			self::$generators = $gens;
1681		}
1682
1683		return self::$generators;
1684	}
1685}
1686