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