Unread pages API

The query shouldn't be too expensive: it'll use an index to narrow
down the resultset for 1 user. After that, it'll be sorted based on
a grouped by value, but that should fit in memory: it'll never be
on more than 2000 entries, which is the max amount of notifications
per user.

Change-Id: I271ea7f7a6e010284739bfce02c4ec8a077148fc
This commit is contained in:
Matthias Mullie 2016-05-27 14:10:04 -07:00
parent 6b0081fd78
commit f751e96839
7 changed files with 197 additions and 13 deletions

View file

@ -57,6 +57,7 @@ $wgJobClasses['EchoNotificationDeleteJob'] = 'EchoNotificationDeleteJob';
// API
$wgAPIMetaModules['notifications'] = 'ApiEchoNotifications';
$wgAPIMetaModules['unreadnotificationpages'] = 'ApiEchoUnreadNotificationPages';
$wgAPIModules['echomarkread'] = 'ApiEchoMarkRead';
$wgAPIModules['echomarkseen'] = 'ApiEchoMarkSeen';

View file

@ -10,6 +10,7 @@ $wgAutoloadClasses += [
'ApiEchoMarkSeen' => __DIR__ . '/includes/api/ApiEchoMarkSeen.php',
'ApiEchoNotifications' => __DIR__ . '/includes/api/ApiEchoNotifications.php',
'ApiEchoNotificationsTest' => __DIR__ . '/tests/phpunit/api/ApiEchoNotificationsTest.php',
'ApiEchoUnreadNotificationPages' => __DIR__ . '/includes/api/ApiEchoUnreadNotificationPages.php',
'ContainmentSetTest' => __DIR__ . '/tests/phpunit/ContainmentSetTest.php',
'EchoAbstractEntity' => __DIR__ . '/includes/model/AbstractEntity.php',
'EchoAbstractMapper' => __DIR__ . '/includes/mapper/AbstractMapper.php',

View file

@ -259,5 +259,9 @@
"apihelp-query+notifications-param-messagecontinue": "When more message results are available, use this to continue.",
"apihelp-query+notifications-param-messageunreadfirst": "Whether to show unread alert notifications first (only used if groupbysection is set).",
"apihelp-query+notifications-example-1": "List notifications",
"apihelp-query+notifications-example-2": "List notifications, grouped by section, with counts"
"apihelp-query+notifications-example-2": "List notifications, grouped by section, with counts",
"apihelp-query+unreadnotificationpages-description": "Get pages for which there are unread notifications for the current user.",
"apihelp-query+unreadnotificationpages-param-limit": "The maximum number of pages to return.",
"apihelp-query+unreadnotificationpages-param-wikis": "List of wikis to fetch pages with unread notifications from (defaults to only current wiki).",
"apihelp-query+unreadnotificationpages-example-1": "List pages with (their amount of) unread notifications"
}

View file

@ -250,5 +250,9 @@
"apihelp-query+notifications-param-messagecontinue": "{{doc-apihelp-param|query+notifications|messagecontinue}}",
"apihelp-query+notifications-param-messageunreadfirst": "{{doc-apihelp-param|query+notifications|messageunreadfirst}}",
"apihelp-query+notifications-example-1": "{{doc-apihelp-example|query+notifications}}",
"apihelp-query+notifications-example-2": "{{doc-apihelp-example|query+notifications}}"
"apihelp-query+notifications-example-2": "{{doc-apihelp-example|query+notifications}}",
"apihelp-query+unreadnotificationpages-description": "{{doc-apihelp-description|query+unreadnotificationpages}}",
"apihelp-query+unreadnotificationpages-param-limit": "{{doc-apihelp-param|query+unreadnotificationpages|limit}}",
"apihelp-query+unreadnotificationpages-param-wikis": "{{doc-apihelp-param|query+unreadnotificationpages|wikis}}",
"apihelp-query+unreadnotificationpages-example-1": "{{doc-apihelp-example|query+unreadnotificationpages}}"
}

View file

@ -27,7 +27,7 @@ abstract class ApiCrossWikiBase extends ApiQueryBase {
* @throws Exception
*/
protected function getFromForeign() {
$reqs = $this->getForeignRequestParams( $this->getForeignWikis() );
$reqs = $this->getForeignRequestParams( $this->getRequestedForeignWikis() );
return $this->foreignRequests( $reqs );
}
@ -41,15 +41,47 @@ abstract class ApiCrossWikiBase extends ApiQueryBase {
}
/**
* @return array Wiki names
* This is basically equivalent to $params['wikis'], but some added checks:
* - `*` will expand to "all wikis with unread notifications"
* - if `$wgEchoCrossWikiNotifications` is off, foreign wikis will be excluded
*
* @return array
*/
protected function getForeignWikis() {
if ( !$this->allowCrossWikiNotifications() ) {
return array();
protected function getRequestedWikis() {
$params = $this->extractRequestParams();
// if wiki is omitted from params, that's because crosswiki is/was not
// available, and it'll default to current wiki
$wikis = isset( $params['wikis'] ) ? $params['wikis'] : array( wfWikiID() );
if ( array_search( '*', $wikis ) !== false ) {
// expand `*` to all foreign wikis with unread notifications + local
$wikis = array_merge(
array( wfWikiID() ),
$this->getForeignWikisWithUnreadNotifications()
);
}
$params = $this->extractRequestParams();
return array_diff( $params['wikis'], array( wfWikiId() ) );
if ( !$this->allowCrossWikiNotifications() ) {
// exclude foreign wikis if x-wiki is not enabled
$wikis = array_intersect_key( array( wfWikiID() ), $wikis );
}
return $wikis;
}
/**
* @return array Wiki names
*/
protected function getRequestedForeignWikis() {
return array_diff( $this->getRequestedWikis(), array( wfWikiId() ) );
}
/**
* @return array Wiki names
*/
protected function getForeignWikisWithUnreadNotifications() {
return $this->foreignNotifications->getWikis();
}
/**
@ -133,8 +165,9 @@ abstract class ApiCrossWikiBase extends ApiQueryBase {
if ( !isset( $results[$wiki] ) ) {
LoggerFactory::getInstance( 'Echo' )->warning(
'Failed to fetch notifications from {wiki}. Response: {code} {response}',
'Failed to fetch {module} from {wiki}. Response: {code} {response}',
array(
'module' => $this->getModuleName(),
'wiki' => $wiki,
'code' => $response['response']['code'],
'response' => $response['response']['body'],
@ -160,7 +193,9 @@ abstract class ApiCrossWikiBase extends ApiQueryBase {
'wikis' => array(
ApiBase::PARAM_ISMULTI => true,
ApiBase::PARAM_DFLT => wfWikiId(),
ApiBase::PARAM_TYPE => array_unique( array_merge( $wgConf->wikis, array( wfWikiId() ) ) ),
// `*` will let you immediately fetch from all wikis that have
// unread notifications, without having to look them up first
ApiBase::PARAM_TYPE => array_unique( array_merge( $wgConf->wikis, array( wfWikiId(), '*' ) ) ),
),
);
}

View file

@ -39,11 +39,11 @@ class ApiEchoNotifications extends ApiCrossWikiBase {
}
$results = array();
if ( !$this->allowCrossWikiNotifications() || in_array( wfWikiId(), $params['wikis'] ) ) {
if ( in_array( wfWikiId(), $this->getRequestedWikis() ) ) {
$results[wfWikiId()] = $this->getLocalNotifications( $params );
}
if ( $this->getForeignWikis() ) {
if ( $this->getRequestedForeignWikis() ) {
$foreignResults = $this->getFromForeign();
foreach ( $foreignResults as $wiki => $result ) {
if ( isset( $result['query']['notifications'] ) ) {

View file

@ -0,0 +1,139 @@
<?php
class ApiEchoUnreadNotificationPages extends ApiCrossWikiBase {
/**
* @var bool
*/
protected $crossWikiSummary = false;
/**
* @param ApiQuery $query
* @param string $moduleName
*/
public function __construct( $query, $moduleName ) {
parent::__construct( $query, $moduleName, 'unp' );
}
/**
* @throws UsageException
*/
public function execute() {
// To avoid API warning, register the parameter used to bust browser cache
$this->getMain()->getVal( '_' );
if ( $this->getUser()->isAnon() ) {
$this->dieUsage( 'Login is required', 'login-required' );
}
$params = $this->extractRequestParams();
$result = array();
if ( in_array( wfWikiId(), $this->getRequestedWikis() ) ) {
$result[wfWikiID()] = $this->getFromLocal( $params['limit'] );
}
if ( $this->getRequestedForeignWikis() ) {
$result += $this->getFromForeign();
}
$apis = $this->foreignNotifications->getApiEndpoints( $this->getRequestedWikis() );
foreach ( $result as $wiki => $data ) {
$result[$wiki]['source'] = $apis[$wiki];
// StdClass to ensure empty data is json_encoded to `{}` instead of `[]`
$result[$wiki]['pages'] = $data['pages'] ?: new StdClass;
}
$this->getResult()->addValue( 'query', $this->getModuleName(), $result );
}
/**
* @param int $limit
* @return array
*/
protected function getFromLocal( $limit ) {
$dbr = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_SLAVE );
$rows = $dbr->select(
array( 'echo_event', 'echo_notification' ),
array( 'event_page_id', 'count' => 'COUNT(*)' ),
array(
'notification_user' => $this->getUser()->getId(),
'notification_read_timestamp' => null,
'event_page_id IS NOT NULL',
),
__METHOD__,
array(
'GROUP BY' => 'event_page_id',
'ORDER BY' => 'count DESC',
'LIMIT' => $limit,
),
array( 'echo_notification' => array( 'INNER JOIN', 'notification_event = event_id' ) )
);
if ( $rows === false ) {
return array();
}
$pages = array();
foreach ( $rows as $row ) {
$pages[$row->event_page_id] = $row->count;
}
$result = array();
$titles = Title::newFromIDs( array_keys( $pages ) );
foreach ( $titles as $title ) {
$result[$title->getArticleID()] = array(
'title' => $title->getPrefixedText(),
'count' => $pages[$title->getArticleID()],
);
}
return array( 'pages' => $result );
}
/**
* @return array
*/
protected function getFromForeign() {
$result = array();
foreach ( parent::getFromForeign() as $wiki => $data ) {
$result[$wiki] = $data['query'][$this->getModuleName()][$wiki];
}
return $result;
}
/**
* @return array
*/
public function getAllowedParams() {
global $wgEchoMaxUpdateCount;
return parent::getAllowedParams() + array(
'limit' => array(
ApiBase::PARAM_TYPE => 'limit',
ApiBase::PARAM_DFLT => 20,
ApiBase::PARAM_MIN => 1,
ApiBase::PARAM_MAX => $wgEchoMaxUpdateCount,
ApiBase::PARAM_MAX2 => $wgEchoMaxUpdateCount,
),
// there is no `offset` or `continue` value: the set of possible
// notifications is small enough to allow fetching all of them at
// once, and any sort of fetching would be unreliable because
// they're sorted based on count of notifications, which could
// change in between requests
);
}
/**
* @see ApiBase::getExamplesMessages()
*/
protected function getExamplesMessages() {
return array(
'action=query&meta=unreadnotificationpages' => 'apihelp-query+unreadnotificationpages-example-1',
);
}
public function getHelpUrls() {
return 'https://www.mediawiki.org/wiki/Echo_(Notifications)/API';
}
}