[ Index ] |
PHP Cross Reference of MediaWiki-1.24.0 |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * 4 * 5 * Created on Sep 7, 2006 6 * 7 * Copyright © 2006 Yuri Astrakhan "<Firstname><Lastname>@gmail.com" 8 * 9 * This program is free software; you can redistribute it and/or modify 10 * it under the terms of the GNU General Public License as published by 11 * the Free Software Foundation; either version 2 of the License, or 12 * (at your option) any later version. 13 * 14 * This program is distributed in the hope that it will be useful, 15 * but WITHOUT ANY WARRANTY; without even the implied warranty of 16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 * GNU General Public License for more details. 18 * 19 * You should have received a copy of the GNU General Public License along 20 * with this program; if not, write to the Free Software Foundation, Inc., 21 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 22 * http://www.gnu.org/copyleft/gpl.html 23 * 24 * @file 25 */ 26 27 /** 28 * This is the main query class. It behaves similar to ApiMain: based on the 29 * parameters given, it will create a list of titles to work on (an ApiPageSet 30 * object), instantiate and execute various property/list/meta modules, and 31 * assemble all resulting data into a single ApiResult object. 32 * 33 * In generator mode, a generator will be executed first to populate a second 34 * ApiPageSet object, and that object will be used for all subsequent modules. 35 * 36 * @ingroup API 37 */ 38 class ApiQuery extends ApiBase { 39 40 /** 41 * List of Api Query prop modules 42 * @var array 43 */ 44 private static $QueryPropModules = array( 45 'categories' => 'ApiQueryCategories', 46 'categoryinfo' => 'ApiQueryCategoryInfo', 47 'contributors' => 'ApiQueryContributors', 48 'duplicatefiles' => 'ApiQueryDuplicateFiles', 49 'extlinks' => 'ApiQueryExternalLinks', 50 'fileusage' => 'ApiQueryBacklinksprop', 51 'images' => 'ApiQueryImages', 52 'imageinfo' => 'ApiQueryImageInfo', 53 'info' => 'ApiQueryInfo', 54 'links' => 'ApiQueryLinks', 55 'linkshere' => 'ApiQueryBacklinksprop', 56 'iwlinks' => 'ApiQueryIWLinks', 57 'langlinks' => 'ApiQueryLangLinks', 58 'pageprops' => 'ApiQueryPageProps', 59 'redirects' => 'ApiQueryBacklinksprop', 60 'revisions' => 'ApiQueryRevisions', 61 'stashimageinfo' => 'ApiQueryStashImageInfo', 62 'templates' => 'ApiQueryLinks', 63 'transcludedin' => 'ApiQueryBacklinksprop', 64 ); 65 66 /** 67 * List of Api Query list modules 68 * @var array 69 */ 70 private static $QueryListModules = array( 71 'allcategories' => 'ApiQueryAllCategories', 72 'allfileusages' => 'ApiQueryAllLinks', 73 'allimages' => 'ApiQueryAllImages', 74 'alllinks' => 'ApiQueryAllLinks', 75 'allpages' => 'ApiQueryAllPages', 76 'allredirects' => 'ApiQueryAllLinks', 77 'alltransclusions' => 'ApiQueryAllLinks', 78 'allusers' => 'ApiQueryAllUsers', 79 'backlinks' => 'ApiQueryBacklinks', 80 'blocks' => 'ApiQueryBlocks', 81 'categorymembers' => 'ApiQueryCategoryMembers', 82 'deletedrevs' => 'ApiQueryDeletedrevs', 83 'embeddedin' => 'ApiQueryBacklinks', 84 'exturlusage' => 'ApiQueryExtLinksUsage', 85 'filearchive' => 'ApiQueryFilearchive', 86 'imageusage' => 'ApiQueryBacklinks', 87 'iwbacklinks' => 'ApiQueryIWBacklinks', 88 'langbacklinks' => 'ApiQueryLangBacklinks', 89 'logevents' => 'ApiQueryLogEvents', 90 'pageswithprop' => 'ApiQueryPagesWithProp', 91 'pagepropnames' => 'ApiQueryPagePropNames', 92 'prefixsearch' => 'ApiQueryPrefixSearch', 93 'protectedtitles' => 'ApiQueryProtectedTitles', 94 'querypage' => 'ApiQueryQueryPage', 95 'random' => 'ApiQueryRandom', 96 'recentchanges' => 'ApiQueryRecentChanges', 97 'search' => 'ApiQuerySearch', 98 'tags' => 'ApiQueryTags', 99 'usercontribs' => 'ApiQueryContributions', 100 'users' => 'ApiQueryUsers', 101 'watchlist' => 'ApiQueryWatchlist', 102 'watchlistraw' => 'ApiQueryWatchlistRaw', 103 ); 104 105 /** 106 * List of Api Query meta modules 107 * @var array 108 */ 109 private static $QueryMetaModules = array( 110 'allmessages' => 'ApiQueryAllMessages', 111 'siteinfo' => 'ApiQuerySiteinfo', 112 'userinfo' => 'ApiQueryUserInfo', 113 'filerepoinfo' => 'ApiQueryFileRepoInfo', 114 'tokens' => 'ApiQueryTokens', 115 ); 116 117 /** 118 * @var ApiPageSet 119 */ 120 private $mPageSet; 121 122 private $mParams; 123 private $mNamedDB = array(); 124 private $mModuleMgr; 125 126 /** 127 * @param ApiMain $main 128 * @param string $action 129 */ 130 public function __construct( ApiMain $main, $action ) { 131 parent::__construct( $main, $action ); 132 133 $this->mModuleMgr = new ApiModuleManager( $this ); 134 135 // Allow custom modules to be added in LocalSettings.php 136 $config = $this->getConfig(); 137 $this->mModuleMgr->addModules( self::$QueryPropModules, 'prop' ); 138 $this->mModuleMgr->addModules( $config->get( 'APIPropModules' ), 'prop' ); 139 $this->mModuleMgr->addModules( self::$QueryListModules, 'list' ); 140 $this->mModuleMgr->addModules( $config->get( 'APIListModules' ), 'list' ); 141 $this->mModuleMgr->addModules( self::$QueryMetaModules, 'meta' ); 142 $this->mModuleMgr->addModules( $config->get( 'APIMetaModules' ), 'meta' ); 143 144 // Create PageSet that will process titles/pageids/revids/generator 145 $this->mPageSet = new ApiPageSet( $this ); 146 } 147 148 /** 149 * Overrides to return this instance's module manager. 150 * @return ApiModuleManager 151 */ 152 public function getModuleManager() { 153 return $this->mModuleMgr; 154 } 155 156 /** 157 * Get the query database connection with the given name. 158 * If no such connection has been requested before, it will be created. 159 * Subsequent calls with the same $name will return the same connection 160 * as the first, regardless of the values of $db and $groups 161 * @param string $name Name to assign to the database connection 162 * @param int $db One of the DB_* constants 163 * @param array $groups Query groups 164 * @return DatabaseBase 165 */ 166 public function getNamedDB( $name, $db, $groups ) { 167 if ( !array_key_exists( $name, $this->mNamedDB ) ) { 168 $this->profileDBIn(); 169 $this->mNamedDB[$name] = wfGetDB( $db, $groups ); 170 $this->profileDBOut(); 171 } 172 173 return $this->mNamedDB[$name]; 174 } 175 176 /** 177 * Gets the set of pages the user has requested (or generated) 178 * @return ApiPageSet 179 */ 180 public function getPageSet() { 181 return $this->mPageSet; 182 } 183 184 /** 185 * Get the array mapping module names to class names 186 * @deprecated since 1.21, use getModuleManager()'s methods instead 187 * @return array Array(modulename => classname) 188 */ 189 public function getModules() { 190 wfDeprecated( __METHOD__, '1.21' ); 191 192 return $this->getModuleManager()->getNamesWithClasses(); 193 } 194 195 /** 196 * Get the generators array mapping module names to class names 197 * @deprecated since 1.21, list of generators is maintained by ApiPageSet 198 * @return array Array(modulename => classname) 199 */ 200 public function getGenerators() { 201 wfDeprecated( __METHOD__, '1.21' ); 202 $gens = array(); 203 foreach ( $this->mModuleMgr->getNamesWithClasses() as $name => $class ) { 204 if ( is_subclass_of( $class, 'ApiQueryGeneratorBase' ) ) { 205 $gens[$name] = $class; 206 } 207 } 208 209 return $gens; 210 } 211 212 /** 213 * Get whether the specified module is a prop, list or a meta query module 214 * @deprecated since 1.21, use getModuleManager()->getModuleGroup() 215 * @param string $moduleName Name of the module to find type for 216 * @return string|null 217 */ 218 function getModuleType( $moduleName ) { 219 return $this->getModuleManager()->getModuleGroup( $moduleName ); 220 } 221 222 /** 223 * @return ApiFormatRaw|null 224 */ 225 public function getCustomPrinter() { 226 // If &exportnowrap is set, use the raw formatter 227 if ( $this->getParameter( 'export' ) && 228 $this->getParameter( 'exportnowrap' ) 229 ) { 230 return new ApiFormatRaw( $this->getMain(), 231 $this->getMain()->createPrinterByName( 'xml' ) ); 232 } else { 233 return null; 234 } 235 } 236 237 /** 238 * Query execution happens in the following steps: 239 * #1 Create a PageSet object with any pages requested by the user 240 * #2 If using a generator, execute it to get a new ApiPageSet object 241 * #3 Instantiate all requested modules. 242 * This way the PageSet object will know what shared data is required, 243 * and minimize DB calls. 244 * #4 Output all normalization and redirect resolution information 245 * #5 Execute all requested modules 246 */ 247 public function execute() { 248 $this->mParams = $this->extractRequestParams(); 249 250 // Instantiate requested modules 251 $allModules = array(); 252 $this->instantiateModules( $allModules, 'prop' ); 253 $propModules = array_keys( $allModules ); 254 $this->instantiateModules( $allModules, 'list' ); 255 $this->instantiateModules( $allModules, 'meta' ); 256 257 // Filter modules based on continue parameter 258 list( $generatorDone, $modules ) = $this->getResult()->beginContinuation( 259 $this->mParams['continue'], $allModules, $propModules 260 ); 261 262 if ( !$generatorDone ) { 263 // Query modules may optimize data requests through the $this->getPageSet() 264 // object by adding extra fields from the page table. 265 foreach ( $modules as $module ) { 266 $module->requestExtraData( $this->mPageSet ); 267 } 268 // Populate page/revision information 269 $this->mPageSet->execute(); 270 // Record page information (title, namespace, if exists, etc) 271 $this->outputGeneralPageInfo(); 272 } else { 273 $this->mPageSet->executeDryRun(); 274 } 275 276 $cacheMode = $this->mPageSet->getCacheMode(); 277 278 // Execute all unfinished modules 279 /** @var $module ApiQueryBase */ 280 foreach ( $modules as $module ) { 281 $params = $module->extractRequestParams(); 282 $cacheMode = $this->mergeCacheMode( 283 $cacheMode, $module->getCacheMode( $params ) ); 284 $module->profileIn(); 285 $module->execute(); 286 wfRunHooks( 'APIQueryAfterExecute', array( &$module ) ); 287 $module->profileOut(); 288 } 289 290 // Set the cache mode 291 $this->getMain()->setCacheMode( $cacheMode ); 292 293 // Write the continuation data into the result 294 $this->getResult()->endContinuation( 295 $this->mParams['continue'] === null ? 'raw' : 'standard' 296 ); 297 } 298 299 /** 300 * Update a cache mode string, applying the cache mode of a new module to it. 301 * The cache mode may increase in the level of privacy, but public modules 302 * added to private data do not decrease the level of privacy. 303 * 304 * @param string $cacheMode 305 * @param string $modCacheMode 306 * @return string 307 */ 308 protected function mergeCacheMode( $cacheMode, $modCacheMode ) { 309 if ( $modCacheMode === 'anon-public-user-private' ) { 310 if ( $cacheMode !== 'private' ) { 311 $cacheMode = 'anon-public-user-private'; 312 } 313 } elseif ( $modCacheMode === 'public' ) { 314 // do nothing, if it's public already it will stay public 315 } else { // private 316 $cacheMode = 'private'; 317 } 318 319 return $cacheMode; 320 } 321 322 /** 323 * Create instances of all modules requested by the client 324 * @param array $modules To append instantiated modules to 325 * @param string $param Parameter name to read modules from 326 */ 327 private function instantiateModules( &$modules, $param ) { 328 $wasPosted = $this->getRequest()->wasPosted(); 329 if ( isset( $this->mParams[$param] ) ) { 330 foreach ( $this->mParams[$param] as $moduleName ) { 331 $instance = $this->mModuleMgr->getModule( $moduleName, $param ); 332 if ( $instance === null ) { 333 ApiBase::dieDebug( __METHOD__, 'Error instantiating module' ); 334 } 335 if ( !$wasPosted && $instance->mustBePosted() ) { 336 $this->dieUsageMsgOrDebug( array( 'mustbeposted', $moduleName ) ); 337 } 338 // Ignore duplicates. TODO 2.0: die()? 339 if ( !array_key_exists( $moduleName, $modules ) ) { 340 $modules[$moduleName] = $instance; 341 } 342 } 343 } 344 } 345 346 /** 347 * Appends an element for each page in the current pageSet with the 348 * most general information (id, title), plus any title normalizations 349 * and missing or invalid title/pageids/revids. 350 */ 351 private function outputGeneralPageInfo() { 352 $pageSet = $this->getPageSet(); 353 $result = $this->getResult(); 354 355 // We can't really handle max-result-size failure here, but we need to 356 // check anyway in case someone set the limit stupidly low. 357 $fit = true; 358 359 $values = $pageSet->getNormalizedTitlesAsResult( $result ); 360 if ( $values ) { 361 $fit = $fit && $result->addValue( 'query', 'normalized', $values ); 362 } 363 $values = $pageSet->getConvertedTitlesAsResult( $result ); 364 if ( $values ) { 365 $fit = $fit && $result->addValue( 'query', 'converted', $values ); 366 } 367 $values = $pageSet->getInterwikiTitlesAsResult( $result, $this->mParams['iwurl'] ); 368 if ( $values ) { 369 $fit = $fit && $result->addValue( 'query', 'interwiki', $values ); 370 } 371 $values = $pageSet->getRedirectTitlesAsResult( $result ); 372 if ( $values ) { 373 $fit = $fit && $result->addValue( 'query', 'redirects', $values ); 374 } 375 $values = $pageSet->getMissingRevisionIDsAsResult( $result ); 376 if ( $values ) { 377 $fit = $fit && $result->addValue( 'query', 'badrevids', $values ); 378 } 379 380 // Page elements 381 $pages = array(); 382 383 // Report any missing titles 384 foreach ( $pageSet->getMissingTitles() as $fakeId => $title ) { 385 $vals = array(); 386 ApiQueryBase::addTitleInfo( $vals, $title ); 387 $vals['missing'] = ''; 388 $pages[$fakeId] = $vals; 389 } 390 // Report any invalid titles 391 foreach ( $pageSet->getInvalidTitles() as $fakeId => $title ) { 392 $pages[$fakeId] = array( 'title' => $title, 'invalid' => '' ); 393 } 394 // Report any missing page ids 395 foreach ( $pageSet->getMissingPageIDs() as $pageid ) { 396 $pages[$pageid] = array( 397 'pageid' => $pageid, 398 'missing' => '' 399 ); 400 } 401 // Report special pages 402 /** @var $title Title */ 403 foreach ( $pageSet->getSpecialTitles() as $fakeId => $title ) { 404 $vals = array(); 405 ApiQueryBase::addTitleInfo( $vals, $title ); 406 $vals['special'] = ''; 407 if ( $title->isSpecialPage() && 408 !SpecialPageFactory::exists( $title->getDBkey() ) 409 ) { 410 $vals['missing'] = ''; 411 } elseif ( $title->getNamespace() == NS_MEDIA && 412 !wfFindFile( $title ) 413 ) { 414 $vals['missing'] = ''; 415 } 416 $pages[$fakeId] = $vals; 417 } 418 419 // Output general page information for found titles 420 foreach ( $pageSet->getGoodTitles() as $pageid => $title ) { 421 $vals = array(); 422 $vals['pageid'] = $pageid; 423 ApiQueryBase::addTitleInfo( $vals, $title ); 424 $pages[$pageid] = $vals; 425 } 426 427 if ( count( $pages ) ) { 428 if ( $this->mParams['indexpageids'] ) { 429 $pageIDs = array_keys( $pages ); 430 // json treats all map keys as strings - converting to match 431 $pageIDs = array_map( 'strval', $pageIDs ); 432 $result->setIndexedTagName( $pageIDs, 'id' ); 433 $fit = $fit && $result->addValue( 'query', 'pageids', $pageIDs ); 434 } 435 436 $result->setIndexedTagName( $pages, 'page' ); 437 $fit = $fit && $result->addValue( 'query', 'pages', $pages ); 438 } 439 440 if ( !$fit ) { 441 $this->dieUsage( 442 'The value of $wgAPIMaxResultSize on this wiki is ' . 443 'too small to hold basic result information', 444 'badconfig' 445 ); 446 } 447 448 if ( $this->mParams['export'] ) { 449 $this->doExport( $pageSet, $result ); 450 } 451 } 452 453 /** 454 * This method is called by the generator base when generator in the smart-continue 455 * mode tries to set 'query-continue' value. ApiQuery stores those values separately 456 * until the post-processing when it is known if the generation should continue or repeat. 457 * @deprecated since 1.24 458 * @param ApiQueryGeneratorBase $module Generator module 459 * @param string $paramName 460 * @param mixed $paramValue 461 * @return bool True if processed, false if this is a legacy continue 462 */ 463 public function setGeneratorContinue( $module, $paramName, $paramValue ) { 464 wfDeprecated( __METHOD__, '1.24' ); 465 $this->getResult()->setGeneratorContinueParam( $module, $paramName, $paramValue ); 466 return $this->getParameter( 'continue' ) !== null; 467 } 468 469 /** 470 * @param ApiPageSet $pageSet Pages to be exported 471 * @param ApiResult $result Result to output to 472 */ 473 private function doExport( $pageSet, $result ) { 474 $exportTitles = array(); 475 $titles = $pageSet->getGoodTitles(); 476 if ( count( $titles ) ) { 477 $user = $this->getUser(); 478 /** @var $title Title */ 479 foreach ( $titles as $title ) { 480 if ( $title->userCan( 'read', $user ) ) { 481 $exportTitles[] = $title; 482 } 483 } 484 } 485 486 $exporter = new WikiExporter( $this->getDB() ); 487 // WikiExporter writes to stdout, so catch its 488 // output with an ob 489 ob_start(); 490 $exporter->openStream(); 491 foreach ( $exportTitles as $title ) { 492 $exporter->pageByTitle( $title ); 493 } 494 $exporter->closeStream(); 495 $exportxml = ob_get_contents(); 496 ob_end_clean(); 497 498 // Don't check the size of exported stuff 499 // It's not continuable, so it would cause more 500 // problems than it'd solve 501 if ( $this->mParams['exportnowrap'] ) { 502 $result->reset(); 503 // Raw formatter will handle this 504 $result->addValue( null, 'text', $exportxml, ApiResult::NO_SIZE_CHECK ); 505 $result->addValue( null, 'mime', 'text/xml', ApiResult::NO_SIZE_CHECK ); 506 } else { 507 $r = array(); 508 ApiResult::setContent( $r, $exportxml ); 509 $result->addValue( 'query', 'export', $r, ApiResult::NO_SIZE_CHECK ); 510 } 511 } 512 513 public function getAllowedParams( $flags = 0 ) { 514 $result = array( 515 'prop' => array( 516 ApiBase::PARAM_ISMULTI => true, 517 ApiBase::PARAM_TYPE => 'submodule', 518 ), 519 'list' => array( 520 ApiBase::PARAM_ISMULTI => true, 521 ApiBase::PARAM_TYPE => 'submodule', 522 ), 523 'meta' => array( 524 ApiBase::PARAM_ISMULTI => true, 525 ApiBase::PARAM_TYPE => 'submodule', 526 ), 527 'indexpageids' => false, 528 'export' => false, 529 'exportnowrap' => false, 530 'iwurl' => false, 531 'continue' => null, 532 'rawcontinue' => false, 533 ); 534 if ( $flags ) { 535 $result += $this->getPageSet()->getFinalParams( $flags ); 536 } 537 538 return $result; 539 } 540 541 /** 542 * Override the parent to generate help messages for all available query modules. 543 * @return string 544 */ 545 public function makeHelpMsg() { 546 547 // Use parent to make default message for the query module 548 $msg = parent::makeHelpMsg(); 549 550 $querySeparator = str_repeat( '--- ', 12 ); 551 $moduleSeparator = str_repeat( '*** ', 14 ); 552 $msg .= "\n$querySeparator Query: Prop $querySeparator\n\n"; 553 $msg .= $this->makeHelpMsgHelper( 'prop' ); 554 $msg .= "\n$querySeparator Query: List $querySeparator\n\n"; 555 $msg .= $this->makeHelpMsgHelper( 'list' ); 556 $msg .= "\n$querySeparator Query: Meta $querySeparator\n\n"; 557 $msg .= $this->makeHelpMsgHelper( 'meta' ); 558 $msg .= "\n\n$moduleSeparator Modules: continuation $moduleSeparator\n\n"; 559 560 return $msg; 561 } 562 563 /** 564 * For all modules of a given group, generate help messages and join them together 565 * @param string $group Module group 566 * @return string 567 */ 568 private function makeHelpMsgHelper( $group ) { 569 $moduleDescriptions = array(); 570 571 $moduleNames = $this->mModuleMgr->getNames( $group ); 572 sort( $moduleNames ); 573 foreach ( $moduleNames as $name ) { 574 /** 575 * @var $module ApiQueryBase 576 */ 577 $module = $this->mModuleMgr->getModule( $name ); 578 579 $msg = ApiMain::makeHelpMsgHeader( $module, $group ); 580 $msg2 = $module->makeHelpMsg(); 581 if ( $msg2 !== false ) { 582 $msg .= $msg2; 583 } 584 if ( $module instanceof ApiQueryGeneratorBase ) { 585 $msg .= "Generator:\n This module may be used as a generator\n"; 586 } 587 $moduleDescriptions[] = $msg; 588 } 589 590 return implode( "\n", $moduleDescriptions ); 591 } 592 593 public function shouldCheckMaxlag() { 594 return true; 595 } 596 597 public function getParamDescription() { 598 return $this->getPageSet()->getFinalParamDescription() + array( 599 'prop' => 'Which properties to get for the titles/revisions/pageids. ' . 600 'Module help is available below', 601 'list' => 'Which lists to get. Module help is available below', 602 'meta' => 'Which metadata to get about the site. Module help is available below', 603 'indexpageids' => 'Include an additional pageids section listing all returned page IDs', 604 'export' => 'Export the current revisions of all given or generated pages', 605 'exportnowrap' => 'Return the export XML without wrapping it in an ' . 606 'XML result (same format as Special:Export). Can only be used with export', 607 'iwurl' => 'Whether to get the full URL if the title is an interwiki link', 608 'continue' => array( 609 'When present, formats query-continue as key-value pairs that ' . 610 'should simply be merged into the original request.', 611 'This parameter must be set to an empty string in the initial query.', 612 'This parameter is recommended for all new development, and ' . 613 'will be made default in the next API version.' 614 ), 615 'rawcontinue' => 'Currently ignored. In the future, \'continue=\' will become the ' . 616 'default and this will be needed to receive the raw query-continue data.', 617 ); 618 } 619 620 public function getDescription() { 621 return array( 622 'Query API module allows applications to get needed pieces of data ' . 623 'from the MediaWiki databases,', 624 'and is loosely based on the old query.php interface.', 625 'All data modifications will first have to use query to acquire a ' . 626 'token to prevent abuse from malicious sites.' 627 ); 628 } 629 630 public function getExamples() { 631 return array( 632 'api.php?action=query&prop=revisions&meta=siteinfo&' . 633 'titles=Main%20Page&rvprop=user|comment&continue=', 634 'api.php?action=query&generator=allpages&gapprefix=API/&prop=revisions&continue=', 635 ); 636 } 637 638 public function getHelpUrls() { 639 return array( 640 'https://www.mediawiki.org/wiki/API:Query', 641 'https://www.mediawiki.org/wiki/API:Meta', 642 'https://www.mediawiki.org/wiki/API:Properties', 643 'https://www.mediawiki.org/wiki/API:Lists', 644 ); 645 } 646 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
Generated: Fri Nov 28 14:03:12 2014 | Cross-referenced by PHPXref 0.7.1 |