From c426593d8a9d802727d24abc84a31e15a01393c7 Mon Sep 17 00:00:00 2001 From: Roan Kattouw Date: Fri, 13 Feb 2009 14:44:19 +0000 Subject: [PATCH] --- includes/api/ApiQueryInfo.php | 606 +++++++++++++++------------------- 1 file changed, 263 insertions(+), 343 deletions(-) diff --git a/includes/api/ApiQueryInfo.php b/includes/api/ApiQueryInfo.php index 9c87b77943..86a6af71a6 100644 --- a/includes/api/ApiQueryInfo.php +++ b/includes/api/ApiQueryInfo.php @@ -34,6 +34,10 @@ if (!defined('MEDIAWIKI')) { * @ingroup API */ class ApiQueryInfo extends ApiQueryBase { + + private $fld_protection = false, $fld_talkid = false, + $fld_subjectid = false, $fld_url = false, + $fld_readable = false; public function __construct($query, $moduleName) { parent :: __construct($query, $moduleName, 'in'); @@ -49,11 +53,13 @@ class ApiQueryInfo extends ApiQueryBase { $pageSet->requestField('page_len'); } + /** + * Get an array mapping token names to their handler functions. + * The prototype for a token function is func($pageid, $title) + * it should return a token or false (permission denied) + * @return array(tokenname => function) + */ protected function getTokenFunctions() { - // tokenname => function - // function prototype is func($pageid, $title) - // should return token or false - // Don't call the hooks twice if(isset($this->tokenFunctions)) return $this->tokenFunctions; @@ -80,6 +86,7 @@ class ApiQueryInfo extends ApiQueryBase { { // We could check for $title->userCan('edit') here, // but that's too expensive for this purpose + // and would break caching global $wgUser; if(!$wgUser->isAllowed('edit')) return false; @@ -184,374 +191,288 @@ class ApiQueryInfo extends ApiQueryBase { } public function execute() { - - global $wgUser; - - $params = $this->extractRequestParams(); - $fld_protection = $fld_talkid = $fld_subjectid = $fld_url = $fld_readable = false; - if(!is_null($params['prop'])) { - $prop = array_flip($params['prop']); - $fld_protection = isset($prop['protection']); - $fld_talkid = isset($prop['talkid']); - $fld_subjectid = isset($prop['subjectid']); - $fld_url = isset($prop['url']); - $fld_readable = isset($prop['readable']); + $this->params = $this->extractRequestParams(); + if(!is_null($this->params['prop'])) { + $prop = array_flip($this->params['prop']); + $this->fld_protection = isset($prop['protection']); + $this->fld_talkid = isset($prop['talkid']); + $this->fld_subjectid = isset($prop['subjectid']); + $this->fld_url = isset($prop['url']); + $this->fld_readable = isset($prop['readable']); } $pageSet = $this->getPageSet(); - $titles = $pageSet->getGoodTitles(); - $missing = $pageSet->getMissingTitles(); + $this->titles = $pageSet->getGoodTitles(); + $this->missing = $pageSet->getMissingTitles(); + $this->everything = array_merge($this->titles, $this->missing); $result = $this->getResult(); - $pageRestrictions = $pageSet->getCustomField('page_restrictions'); - $pageIsRedir = $pageSet->getCustomField('page_is_redirect'); - $pageIsNew = $pageSet->getCustomField('page_is_new'); - $pageCounter = $pageSet->getCustomField('page_counter'); - $pageTouched = $pageSet->getCustomField('page_touched'); - $pageLatest = $pageSet->getCustomField('page_latest'); - $pageLength = $pageSet->getCustomField('page_len'); + $this->pageRestrictions = $pageSet->getCustomField('page_restrictions'); + $this->pageIsRedir = $pageSet->getCustomField('page_is_redirect'); + $this->pageIsNew = $pageSet->getCustomField('page_is_new'); + $this->pageCounter = $pageSet->getCustomField('page_counter'); + $this->pageTouched = $pageSet->getCustomField('page_touched'); + $this->pageLatest = $pageSet->getCustomField('page_latest'); + $this->pageLength = $pageSet->getCustomField('page_len'); $db = $this->getDB(); - if ($fld_protection && count($titles)) { - $this->addTables('page_restrictions'); - $this->addFields(array('pr_page', 'pr_type', 'pr_level', 'pr_expiry', 'pr_cascade')); - $this->addWhereFld('pr_page', array_keys($titles)); + // Get protection info if requested + if ($this->fld_protection) + $this->getProtectionInfo(); - $res = $this->select(__METHOD__); - while($row = $db->fetchObject($res)) { - $a = array( - 'type' => $row->pr_type, - 'level' => $row->pr_level, - 'expiry' => Block::decodeExpiry( $row->pr_expiry, TS_ISO_8601 ) - ); - if($row->pr_cascade) - $a['cascade'] = ''; - $protections[$row->pr_page][] = $a; - - # Also check old restrictions - if($pageRestrictions[$row->pr_page]) { - foreach(explode(':', trim($pageRestrictions[$pageid])) as $restrict) { - $temp = explode('=', trim($restrict)); - if(count($temp) == 1) { - // old old format should be treated as edit/move restriction - $restriction = trim( $temp[0] ); - if($restriction == '') - continue; - $protections[$row->pr_page][] = array( - 'type' => 'edit', - 'level' => $restriction, - 'expiry' => 'infinity', - ); - $protections[$row->pr_page][] = array( - 'type' => 'move', - 'level' => $restriction, - 'expiry' => 'infinity', - ); - } else { - $restriction = trim( $temp[1] ); - if($restriction == '') - continue; - $protections[$row->pr_page][] = array( - 'type' => $temp[0], - 'level' => $restriction, - 'expiry' => 'infinity', - ); - } - } - } - } - $db->freeResult($res); - - $imageIds = array(); - foreach ($titles as $id => $title) - if ($title->getNamespace() == NS_FILE) - $imageIds[] = $id; - // To avoid code duplication - $cascadeTypes = array( - array( - 'prefix' => 'tl', - 'table' => 'templatelinks', - 'ns' => 'tl_namespace', - 'title' => 'tl_title', - 'ids' => array_diff(array_keys($titles), $imageIds) - ), - array( - 'prefix' => 'il', - 'table' => 'imagelinks', - 'ns' => NS_FILE, - 'title' => 'il_to', - 'ids' => $imageIds - ) - ); - - foreach ($cascadeTypes as $type) + // Run the talkid/subjectid query if requested + if($this->fld_talkid || $this->fld_subjectid) + $this->getTSIDs(); + + $count = 0; + ksort($this->everything); // Ensure they're always in the same order + foreach($this->everything as $pageid => $title) { + $count++; + if(!is_null($params['continue'])) + if($count < $params['continue']) + continue; + $pageInfo = $this->extractPageInfo($pageid, $title); + $fit = $result->addValue(array ( + 'query', + 'pages' + ), $pageid, $pageInfo); + if(!$fit) { - if (count($type['ids']) != 0) { - $this->resetQueryParams(); - $this->addTables(array('page_restrictions', $type['table'])); - $this->addTables('page', 'page_source'); - $this->addTables('page', 'page_target'); - $this->addFields(array('pr_type', 'pr_level', 'pr_expiry', - 'page_target.page_id AS page_target_id', - 'page_source.page_namespace AS page_source_namespace', - 'page_source.page_title AS page_source_title')); - $this->addWhere(array("{$type['prefix']}_from = pr_page", - 'page_target.page_namespace = '.$type['ns'], - 'page_target.page_title = '.$type['title'], - 'page_source.page_id = pr_page' - )); - $this->addWhereFld('pr_cascade', 1); - $this->addWhereFld('page_target.page_id', $type['ids']); - - $res = $this->select(__METHOD__); - while($row = $db->fetchObject($res)) { - $source = Title::makeTitle($row->page_source_namespace, $row->page_source_title); - $a = array( - 'type' => $row->pr_type, - 'level' => $row->pr_level, - 'expiry' => Block::decodeExpiry( $row->pr_expiry, TS_ISO_8601 ), - 'source' => $source->getPrefixedText() - ); - $protections[$row->page_target_id][] = $a; - } - $db->freeResult($res); - } + $this->setContinueEnumParameter('continue', $count); + break; } } + } - // We don't need to check for pt stuff if there are no nonexistent titles - if($fld_protection && count($missing)) + /** + * Get a result array with information about a title + * @param $pageid int Page ID (negative for missing titles) + * @param $title Title object + * @return array + */ + private function extractPageInfo($pageid, $title) + { + $pageInfo = array(); + if($title->exists()) { - $this->resetQueryParams(); - // Construct a custom WHERE clause that matches all titles in $missing - $lb = new LinkBatch($missing); - $this->addTables('protected_titles'); - $this->addFields(array('pt_title', 'pt_namespace', 'pt_create_perm', 'pt_expiry')); - $this->addWhere($lb->constructSet('pt', $db)); - $res = $this->select(__METHOD__); - $prottitles = array(); - while($row = $db->fetchObject($res)) { - $prottitles[$row->pt_namespace][$row->pt_title][] = array( - 'type' => 'create', - 'level' => $row->pt_create_perm, - 'expiry' => Block::decodeExpiry($row->pt_expiry, TS_ISO_8601) - ); - } - $db->freeResult($res); - - $images = array(); - $others = array(); - foreach ($missing as $title) - if ($title->getNamespace() == NS_FILE) - $images[] = $title->getDBKey(); - else - $others[] = $title; - - if (count($others) != 0) { - $lb = new LinkBatch($others); - $this->resetQueryParams(); - $this->addTables(array('page_restrictions', 'page', 'templatelinks')); - $this->addFields(array('pr_type', 'pr_level', 'pr_expiry', - 'page_title', 'page_namespace', - 'tl_title', 'tl_namespace')); - $this->addWhere($lb->constructSet('tl', $db)); - $this->addWhere('pr_page = page_id'); - $this->addWhere('pr_page = tl_from'); - $this->addWhereFld('pr_cascade', 1); - - $res = $this->select(__METHOD__); - while($row = $db->fetchObject($res)) { - $source = Title::makeTitle($row->page_namespace, $row->page_title); - $a = array( - 'type' => $row->pr_type, - 'level' => $row->pr_level, - 'expiry' => Block::decodeExpiry( $row->pr_expiry, TS_ISO_8601 ), - 'source' => $source->getPrefixedText() - ); - $prottitles[$row->tl_namespace][$row->tl_title][] = $a; - } - $db->freeResult($res); - } - - if (count($images) != 0) { - $this->resetQueryParams(); - $this->addTables(array('page_restrictions', 'page', 'imagelinks')); - $this->addFields(array('pr_type', 'pr_level', 'pr_expiry', - 'page_title', 'page_namespace', 'il_to')); - $this->addWhere('pr_page = page_id'); - $this->addWhere('pr_page = il_from'); - $this->addWhereFld('pr_cascade', 1); - $this->addWhereFld('il_to', $images); - - $res = $this->select(__METHOD__); - while($row = $db->fetchObject($res)) { - $source = Title::makeTitle($row->page_namespace, $row->page_title); - $a = array( - 'type' => $row->pr_type, - 'level' => $row->pr_level, - 'expiry' => Block::decodeExpiry( $row->pr_expiry, TS_ISO_8601 ), - 'source' => $source->getPrefixedText() - ); - $prottitles[NS_FILE][$row->il_to][] = $a; - } - $db->freeResult($res); - } + $pageInfo['touched'] = wfTimestamp(TS_ISO_8601, $this->pageTouched[$pageid]); + $pageInfo['lastrevid'] = intval($this->pageLatest[$pageid]); + $pageInfo['counter'] = intval($this->pageCounter[$pageid]); + $pageInfo['length'] = intval($this->pageLength[$pageid]); + if ($this->pageIsRedir[$pageid]) + $pageInfo['redirect'] = ''; + if ($this->pageIsNew[$pageid]) + $pageInfo['new'] = ''; } - // Run the talkid/subjectid query - if($fld_talkid || $fld_subjectid) - { - $talktitles = $subjecttitles = - $talkids = $subjectids = array(); - $everything = array_merge($titles, $missing); - foreach($everything as $t) + if (!is_null($this->params['token'])) { + $tokenFunctions = $this->getTokenFunctions(); + $pageInfo['starttimestamp'] = wfTimestamp(TS_ISO_8601, time()); + foreach($this->params['token'] as $t) { - if(MWNamespace::isTalk($t->getNamespace())) - { - if($fld_subjectid) - $subjecttitles[] = $t->getSubjectPage(); - } - else if($fld_talkid) - $talktitles[] = $t->getTalkPage(); - } - if(count($talktitles) || count($subjecttitles)) - { - // Construct a custom WHERE clause that matches - // all titles in $talktitles and $subjecttitles - $lb = new LinkBatch(array_merge($talktitles, $subjecttitles)); - $this->resetQueryParams(); - $this->addTables('page'); - $this->addFields(array('page_title', 'page_namespace', 'page_id')); - $this->addWhere($lb->constructSet('page', $db)); - $res = $this->select(__METHOD__); - while($row = $db->fetchObject($res)) - { - if(MWNamespace::isTalk($row->page_namespace)) - $talkids[MWNamespace::getSubject($row->page_namespace)][$row->page_title] = $row->page_id; - else - $subjectids[MWNamespace::getTalk($row->page_namespace)][$row->page_title] = $row->page_id; - } + $val = call_user_func($tokenFunctions[$t], $pageid, $title); + if($val === false) + $this->setWarning("Action '$t' is not allowed for the current user"); + else + $pageInfo[$t . 'token'] = $val; } } - $count = 0; - $fit = true; - ksort($titles); // Ensure they're always in the same order - foreach ( $titles as $pageid => $title ) { - $count++; - if(!is_null($params['continue'])) - if($count < $params['continue']) - continue; - $pageInfo = array ( - 'touched' => wfTimestamp(TS_ISO_8601, $pageTouched[$pageid]), - 'lastrevid' => intval($pageLatest[$pageid]), - 'counter' => intval($pageCounter[$pageid]), - 'length' => intval($pageLength[$pageid]), - ); - - if ($pageIsRedir[$pageid]) - $pageInfo['redirect'] = ''; + if($this->fld_protection) { + $pageInfo['protection'] = array(); + if (isset($this->protections[$title->getNamespace()][$title->getDBkey()])) + $pageInfo['protection'] = + $this->protections[$title->getNamespace()][$title->getDBkey()]; + $result->setIndexedTagName($pageInfo['protection'], 'pr'); + } + if($this->fld_talkid && isset($this->talkids[$title->getNamespace()][$title->getDBKey()])) + $pageInfo['talkid'] = $this->talkids[$title->getNamespace()][$title->getDBKey()]; + if($this->fld_subjectid && isset($this->subjectids[$title->getNamespace()][$title->getDBKey()])) + $pageInfo['subjectid'] = $this->subjectids[$title->getNamespace()][$title->getDBKey()]; + if($this->fld_url) { + $pageInfo['fullurl'] = $title->getFullURL(); + $pageInfo['editurl'] = $title->getFullURL('action=edit'); + } + if($this->fld_readable) + if($title->userCanRead()) + $pageInfo['readable'] = ''; + return $pageInfo; + } - if ($pageIsNew[$pageid]) - $pageInfo['new'] = ''; + /** + * Get information about protections and put it in $protections + */ + private function getProtectionInfo() + { + $this->protections = array(); + $db = $this->getDB(); - if (!is_null($params['token'])) { - $tokenFunctions = $this->getTokenFunctions(); - $pageInfo['starttimestamp'] = wfTimestamp(TS_ISO_8601, time()); - foreach($params['token'] as $t) - { - $val = call_user_func($tokenFunctions[$t], $pageid, $title); - if($val === false) - $this->setWarning("Action '$t' is not allowed for the current user"); - else - $pageInfo[$t . 'token'] = $val; + // Get normal protections for existing titles + $this->addTables('page_restrictions', 'page'); + $this->addWhere('page_id=pr_page'); + $this->addFields(array('pr_page', 'pr_type', 'pr_level', + 'pr_expiry', 'pr_cascade', 'page_namespace', + 'page_title')); + $this->addWhereFld('pr_page', array_keys($this->titles)); + + $res = $this->select(__METHOD__); + while($row = $db->fetchObject($res)) { + $a = array( + 'type' => $row->pr_type, + 'level' => $row->pr_level, + 'expiry' => Block::decodeExpiry($row->pr_expiry, TS_ISO_8601) + ); + if($row->pr_cascade) + $a['cascade'] = ''; + $this->protections[$row->page_namespace][$row->page_title][] = $a; + + # Also check old restrictions + if($pageRestrictions[$row->pr_page]) { + foreach(explode(':', trim($this->pageRestrictions[$row->pr_page])) as $restrict) { + $temp = explode('=', trim($restrict)); + if(count($temp) == 1) { + // old old format should be treated as edit/move restriction + $restriction = trim( $temp[0] ); + + if($restriction == '') + continue; + $this->protections[$row->page_namespace][$row->page_title][] = array( + 'type' => 'edit', + 'level' => $restriction, + 'expiry' => 'infinity', + ); + $this->protections[$row->page_namespace][$row->page_title][] = array( + 'type' => 'move', + 'level' => $restriction, + 'expiry' => 'infinity', + ); + } else { + $restriction = trim($temp[1]); + if($restriction == '') + continue; + $this->protections[$row->page_namespace][$row->page_title][] = array( + 'type' => $temp[0], + 'level' => $restriction, + 'expiry' => 'infinity', + ); + } } } + } + $db->freeResult($res); + + // Get protections for missing titles + $this->resetQueryParams(); + $lb = new LinkBatch($this->missing); + $this->addTables('protected_titles'); + $this->addFields(array('pt_title', 'pt_namespace', 'pt_create_perm', 'pt_expiry')); + $this->addWhere($lb->constructSet('pt', $db)); + $res = $this->select(__METHOD__); + while($row = $db->fetchObject($res)) { + $this->protections[$row->pt_namespace][$row->pt_title][] = array( + 'type' => 'create', + 'level' => $row->pt_create_perm, + 'expiry' => Block::decodeExpiry($row->pt_expiry, TS_ISO_8601) + ); + } + $db->freeResult($res); + + // Cascading protections + $images = $others = array(); + foreach ($this->everything as $title) + if ($title->getNamespace() == NS_FILE) + $images[] = $title->getDBKey(); + else + $others[] = $title; + + if (count($others)) { + // Non-images: check templatelinks + $lb = new LinkBatch($others); + $this->resetQueryParams(); + $this->addTables(array('page_restrictions', 'page', 'templatelinks')); + $this->addFields(array('pr_type', 'pr_level', 'pr_expiry', + 'page_title', 'page_namespace', + 'tl_title', 'tl_namespace')); + $this->addWhere($lb->constructSet('tl', $db)); + $this->addWhere('pr_page = page_id'); + $this->addWhere('pr_page = tl_from'); + $this->addWhereFld('pr_cascade', 1); - if($fld_protection) { - $pageInfo['protection'] = array(); - if (isset($protections[$pageid])) { - $pageInfo['protection'] = $protections[$pageid]; - $result->setIndexedTagName($pageInfo['protection'], 'pr'); - } - } - if($fld_talkid && isset($talkids[$title->getNamespace()][$title->getDBKey()])) - $pageInfo['talkid'] = $talkids[$title->getNamespace()][$title->getDBKey()]; - if($fld_subjectid && isset($subjectids[$title->getNamespace()][$title->getDBKey()])) - $pageInfo['subjectid'] = $subjectids[$title->getNamespace()][$title->getDBKey()]; - if($fld_url) { - $pageInfo['fullurl'] = $title->getFullURL(); - $pageInfo['editurl'] = $title->getFullURL('action=edit'); + $res = $this->select(__METHOD__); + while($row = $db->fetchObject($res)) { + $source = Title::makeTitle($row->page_namespace, $row->page_title); + $this->protections[$row->pt_namespace][$row->pt_title][] = array( + 'type' => $row->pr_type, + 'level' => $row->pr_level, + 'expiry' => Block::decodeExpiry($row->pr_expiry, TS_ISO_8601), + 'source' => $source->getPrefixedText() + ); } - if($fld_readable) - if($title->userCanRead()) - $pageInfo['readable'] = ''; + $db->freeResult($res); + } - $fit = $result->addValue(array ( - 'query', - 'pages' - ), $pageid, $pageInfo); - if(!$fit) - { - $this->setContinueEnumParameter('continue', $count); - break; + if (count($images)) { + // Images: check imagelinks + $this->resetQueryParams(); + $this->addTables(array('page_restrictions', 'page', 'imagelinks')); + $this->addFields(array('pr_type', 'pr_level', 'pr_expiry', + 'page_title', 'page_namespace', 'il_to')); + $this->addWhere('pr_page = page_id'); + $this->addWhere('pr_page = il_from'); + $this->addWhereFld('pr_cascade', 1); + $this->addWhereFld('il_to', $images); + + $res = $this->select(__METHOD__); + while($row = $db->fetchObject($res)) { + $source = Title::makeTitle($row->page_namespace, $row->page_title); + $this->protections[NS_FILE][$row->il_to][] = array( + 'type' => $row->pr_type, + 'level' => $row->pr_level, + 'expiry' => Block::decodeExpiry($row->pr_expiry, TS_ISO_8601), + 'source' => $source->getPrefixedText() + ); } + $db->freeResult($res); } + } - // Get properties for missing titles if requested - if($fit && (!is_null($params['token']) || $fld_protection || - $fld_talkid || $fld_subjectid || $fld_url || - $fld_readable)) + /** + * Get talk page IDs (if requested) and subject page IDs (if requested) + * and put them in $talkids and $subjectids + */ + private function getTSIDs() + { + $getTitles = $this->talkids = $this->subjectids = array(); + $db = $this->getDB(); + foreach($this->everything as $t) { - foreach($missing as $pageid => $title) { - $count++; - if(!is_null($params['continue'])) - if($count < $params['continue']) - continue; - $r = array(); - if(!is_null($params['token'])) - { - $tokenFunctions = $this->getTokenFunctions(); - $res['query']['pages'][$pageid]['starttimestamp'] = wfTimestamp(TS_ISO_8601, time()); - foreach($params['token'] as $t) - { - $val = call_user_func($tokenFunctions[$t], $pageid, $title); - if($val === false) - $this->setWarning("Action '$t' is not allowed for the current user"); - else - $r[$t . 'token'] = $val; - } - } - if($fld_protection) - { - // Apparently the XML formatting code doesn't like array(null) - // This is painful to fix, so we'll just work around it - if(isset($prottitles[$title->getNamespace()][$title->getDBkey()])) - $val = $prottitles[$title->getNamespace()][$title->getDBkey()]; - else - $val = array(); - $result->setIndexedTagName($val, 'pr'); - $r['protection'] = $val; - } - if($fld_talkid && isset($talkids[$title->getNamespace()][$title->getDbKey()])) - $r['talkid'] = $talkids[$title->getNamespace()][$title->getDbKey()]; - if($fld_subjectid && isset($subjectids[$title->getNamespace()][$title->getDbKey()])) - $r['subjectid'] = $subjectids[$title->getNamespace()][$title->getDbKey()]; - if($fld_url) - { - $r['fullurl'] = $title->getFullURL(); - $r['editurl'] = $title->getFullURL('action=edit'); - } - if($fld_readable) - if($title->userCanRead()) - $r['readable'] = ''; - $fit = $result->addValue(array('query', 'pages'), $pageid, $r); - if(!$fit) - { - $this->setContinueEnumParameter('continue', $count); - break; - } + if(MWNamespace::isTalk($t->getNamespace())) + { + if($this->fld_subjectid) + $getTitles[] = $t->getSubjectPage(); } + else if($this->fld_talkid) + $getTitles[] = $t->getTalkPage(); + } + if(!count($getTitles)) + return; + + // Construct a custom WHERE clause that matches + // all titles in $getTitles + $lb = new LinkBatch($getTitles); + $this->resetQueryParams(); + $this->addTables('page'); + $this->addFields(array('page_title', 'page_namespace', 'page_id')); + $this->addWhere($lb->constructSet('page', $db)); + $res = $this->select(__METHOD__); + while($row = $db->fetchObject($res)) + { + if(MWNamespace::isTalk($row->page_namespace)) + $this->talkids[MWNamespace::getSubject($row->page_namespace)][$row->page_title] = + $row->page_id; + else + $this->subjectids[MWNamespace::getTalk($row->page_namespace)][$row->page_title] = + $row->page_id; } } @@ -582,14 +503,13 @@ class ApiQueryInfo extends ApiQueryBase { 'Which additional properties to get:', ' protection - List the protection level of each page', ' talkid - The page ID of the talk page for each non-talk page', - ' subjectid - The page ID of the parent page for each talk page' + ' subjectid - The page ID of the parent page for each talk page' ), 'token' => 'Request a token to perform a data-modifying action on a page', 'continue' => 'When more results are available, use this to continue', ); } - public function getDescription() { return 'Get basic page information such as namespace, title, last touched date, ...'; } -- 2.20.1