Browse Source

Implement TTRSS operation getCompactHeadlines; fixes #95

This commit also implements the back-end for the standard getHeadlines operation and handles all special feeds and categories; fixes #119
microsub
J. King 7 years ago
parent
commit
5c140aedc4
  1. 209
      lib/REST/TinyTinyRSS/API.php
  2. 85
      tests/REST/TinyTinyRSS/TestTinyTinyAPI.php

209
lib/REST/TinyTinyRSS/API.php

@ -8,6 +8,7 @@ namespace JKingWeb\Arsse\REST\TinyTinyRSS;
use JKingWeb\Arsse\Feed;
use JKingWeb\Arsse\Arsse;
use JKingWeb\Arsse\Database;
use JKingWeb\Arsse\User;
use JKingWeb\Arsse\Service;
use JKingWeb\Arsse\Misc\Date;
@ -16,6 +17,7 @@ use JKingWeb\Arsse\Misc\ValueInfo;
use JKingWeb\Arsse\AbstractException;
use JKingWeb\Arsse\ExceptionType;
use JKingWeb\Arsse\Db\ExceptionInput;
use JKingWeb\Arsse\Db\ResultEmpty;
use JKingWeb\Arsse\Feed\Exception as FeedException;
use JKingWeb\Arsse\REST\Response;
@ -32,10 +34,13 @@ Protocol difference so far:
- The "Published" virtual feed is non-functional (this will not be implemented in the near term)
- setArticleLabel responds with errors for invalid labels where TT-RSS simply returns a zero result
- The result of setArticleLabel counts only records which actually changed rather than all entries attempted
- Using both limit/skip and unread_only in getFeeds produces reliable results, unlike in TT-RSS
- Top-level categories in getFeedTree have a 'parent_id' property (set to null); in TT-RSS the property is absent
- Article hashes are SHA-256 rather than SHA-1.
- Articles have at most one attachment (enclosure), whereas TTRSS allows for several; there is also significantly less detail. These are limitations of picoFeed which should be addressed
- IDs for enclosures are ommitted as we don't give them IDs
- Searching in getHeadlines is not yet implemented
- Category -3 (all non-special feeds) is handled correctly in getHeadlines; TT-RSS returns results for feed -3 (Fresh)
*/
@ -59,35 +64,34 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
const CAT_ALL = -4;
// valid input
const VALID_INPUT = [
'op' => ValueInfo::T_STRING,
'sid' => ValueInfo::T_STRING,
'seq' => ValueInfo::T_INT,
'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'parent_id' => ValueInfo::T_INT,
'category_id' => ValueInfo::T_INT,
'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT,
'feed_id' => ValueInfo::T_INT,
'article_id' => ValueInfo::T_MIXED, // single integer or comma-separated list in getArticle
'label_id' => ValueInfo::T_INT,
'article_ids' => ValueInfo::T_STRING,
'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'cat_id' => ValueInfo::T_INT,
'limit' => ValueInfo::T_INT,
'offset' => ValueInfo::T_INT,
'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'skip' => ValueInfo::T_INT,
'filter' => ValueInfo::T_STRING,
'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'op' => ValueInfo::T_STRING, // the function ("operation") to perform
'sid' => ValueInfo::T_STRING, // session ID
'seq' => ValueInfo::T_INT, // request number from client
'user' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // user name for `login`
'password' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // password for `login` and `subscribeToFeed`
'include_empty' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include empty items in `getFeedTree` and `getCategories`
'unread_only' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to exclude items without unread articles in `getCategories` and `getFeeds`
'enable_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to NOT show subcategories in `getCategories
'include_nested' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include subcategories in `getFeeds` and the articles thereof in `getHeadlines`
'caption' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // name for categories, feed, and labels
'parent_id' => ValueInfo::T_INT, // parent category for `addCategory` and `moveCategory`
'category_id' => ValueInfo::T_INT, // parent category for `subscribeToFeed` and `moveFeed`, and subject for category-modification functions
'cat_id' => ValueInfo::T_INT, // parent category for `getFeeds`
'label_id' => ValueInfo::T_INT, // label ID in label-related functions
'feed_url' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // URL of feed in `subscribeToFeed`
'login' => ValueInfo::T_STRING | ValueInfo::M_STRICT, // remote user name in `subscribeToFeed`
'feed_id' => ValueInfo::T_INT, // feed, label, or category ID for various functions
'is_cat' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether 'feed_id' refers to a category
'article_id' => ValueInfo::T_MIXED, // single article ID in `getLabels`; one or more (comma-separated) article IDs in `getArticle`
'article_ids' => ValueInfo::T_STRING, // one or more (comma-separated) article IDs in `updateArticle` and `setArticleLabel`
'assign' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to assign or clear (false) a label in `setArticleLabel`
'limit' => ValueInfo::T_INT, // maximum number of records returned in `getFeeds`, `getHeadlines`, and `getCompactHeadlines`
'offset' => ValueInfo::T_INT, // number of records to skip in `getFeeds`, for pagination
'skip' => ValueInfo::T_INT, // number of records to skip in `getHeadlines` and `getCompactHeadlines`, for pagination
'show_excerpt' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article excerpts in `getHeadlines`
'show_content' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article content in `getHeadlines`
'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP, // whether to include article enclosures in `getHeadlines`
'view_mode' => ValueInfo::T_STRING,
'include_attachments' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'since_id' => ValueInfo::T_INT,
'order_by' => ValueInfo::T_STRING,
'sanitize' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
@ -95,12 +99,10 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
'has_sandbox' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'include_header' => ValueInfo::T_BOOL | ValueInfo::M_DROP,
'search' => ValueInfo::T_STRING,
'search_mode' => ValueInfo::T_STRING,
'match_on' => ValueInfo::T_STRING,
'mode' => ValueInfo::T_INT,
'field' => ValueInfo::T_INT,
'data' => ValueInfo::T_STRING,
'pref_name' => ValueInfo::T_STRING,
'field' => ValueInfo::T_INT, // which state to change in `updateArticle`
'mode' => ValueInfo::T_INT, // whether to set, clear, or toggle the selected state in `updateArticle`
'data' => ValueInfo::T_STRING, // note text in `updateArticle` if setting a note
'pref_name' => ValueInfo::T_STRING, // preference identifier in `getPref`
];
// generic error construct
const FATAL_ERR = [
@ -1033,7 +1035,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
$id = $data['feed_id'] ?? self::FEED_ARCHIVED;
$cat = $data['is_cat'] ?? false;
$out = ['status' => "OK"];
// first prepare the context; unsupported contexts simply return early, whereas some valid contexts are special cases
// first prepare the context; unsupported contexts simply return early
$c = new Context;
if ($cat) { // categories
switch ($id) {
@ -1043,7 +1045,7 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
// not valid
return $out;
case self::CAT_UNCATEGORIZED:
// this requires a shallow context since in TTRSS folder zero/null is apart from the tree rather than at the root
// this requires a shallow context since in TTRSS the zero/null folder ("Uncategorized") is apart from the tree rather than at the root
$c->folderShallow(0);
break;
case self::CAT_LABELS:
@ -1217,4 +1219,139 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler {
}
return $out;
}
public function opGetCompactHeadlines(array $data): array {
// getCompactHeadlines supports fewer features than getHeadlines
$data['is_cat'] = false;
$data['include_nested'] = false;
$data['search'] = null;
$data['order_by'] = null;
$out = [];
foreach ($this->fetchArticles($data, Database::LIST_MINIMAL) as $row) {
$out[] = ['id' => $row['id']];
}
return $out;
}
protected function fetchArticles(array $data, int $fields): \JKingWeb\Arsse\Db\Result {
// normalize input
if (is_null($data['feed_id'])) {
throw new Exception("INCORRECT_USAGE");
}
$id = $data['feed_id'];
$cat = $data['is_cat'] ?? false;
$shallow = !($data['include_nested'] ?? false);
$viewMode = in_array($data['view_mode'], ["all_articles", "adaptive", "unread", "marked", "has_note", "published"]) ? $data['view_mode'] : "all_articles";
// prepare the context; unsupported, invalid, or inherently empty contexts return synthetic empty result sets
$c = new Context;
$tr = Arsse::$db->begin();
// start with the feed or category ID
if ($cat) { // categories
switch ($id) {
case self::CAT_SPECIAL:
// not valid
return new ResultEmpty;
case self::CAT_NOT_SPECIAL:
case self::CAT_ALL:
// no context needed here
break;
case self::CAT_UNCATEGORIZED:
// this requires a shallow context since in TTRSS the zero/null folder ("Uncategorized") is apart from the tree rather than at the root
$c->folderShallow(0);
break;
case self::CAT_LABELS:
$c->labelled(true);
break;
default:
// any actual category
if ($shallow) {
$c->folderShallow($id);
} else {
$c->folder($id);
}
break;
}
} else { // feeds
if ($this->labelIn($id, false)) { // labels
$c->label($this->labelIn($id));
} else {
switch ($id) {
case self::FEED_ARCHIVED:
// not implemented
return new ResultEmpty;
case self::FEED_STARRED:
$c->starred(true);
break;
case self::FEED_PUBLISHED:
// not implemented
// TODO: if the Published feed is implemented, the headline function needs to be modified accordingly
return new ResultEmpty;
case self::FEED_FRESH:
$c->modifiedSince(Date::sub("PT24H"))->unread(true);
break;
case self::FEED_ALL:
// no context needed here
break;
case self::FEED_READ:
$c->markedSince(Date::sub("PT24H"))->unread(false); // FIXME: this selects any recently touched article which is read, not necessarily a recently read one
break;
default:
// any actual feed
$c->subscription($id);
break;
}
}
}
// next handle the view mode
switch ($viewMode) {
case "all_articles":
// no context needed here
break;
case "adaptive":
// adaptive means "return only unread unless there are none, in which case return all articles"
if ($c->unread !== false && Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->unread(true))) {
$c->unread(true);
}
break;
case "unread":
if ($c->unread !== false) {
$c->unread(true);
} else {
// unread mode in the "Recently Read" feed is a no-op
return new ResultEmpty;
}
break;
case "marked":
$c->starred(true);
break;
case "has_note":
$c->annotated(true);
break;
case "published":
// not implemented
// TODO: if the Published feed is implemented, the headline function needs to be modified accordingly
return new ResultEmpty;
default:
throw new \JKingWeb\Arsse\Exception("constantUnknown", $viewMode); // @codeCoverageIgnore
}
// TODO: implement searching
// set the limit and offset
if ($data['limit'] > 0) {
$c->limit($data['limit']);
}
if ($data['skip'] > 0) {
$c->offset($data['skip']);
}
// set the minimum article ID
if ($data['since_id'] > 0) {
$c->oldestArticle($data['since_id'] + 1);
}
// return results
try {
return Arsse::$db->articleList(Arsse::$user->id, $c, $fields);
} catch (ExceptionInput $e) {
// if a category/feed does not exist
return new ResultEmpty;
}
}
}

85
tests/REST/TinyTinyRSS/TestTinyTinyAPI.php

@ -1300,4 +1300,89 @@ class TestTinyTinyAPI extends Test\AbstractTest {
Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result([]));
$this->assertEquals($this->respGood([$exp[0]]), $this->h->dispatch(new Request("POST", "", json_encode($in[5]))));
}
public function testGetCompactHeadlines() {
$in1 = [
// erroneous input
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx"],
// empty results
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 0],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2, 'is_cat' => true], // is_cat is not used in getCompactHeadlines
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 2112],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "published"],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "unread"],
// non-empty results
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -1],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'view_mode' => "adaptive"],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "adaptive"],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -2112, 'view_mode' => "unread"],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "marked"],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => 42, 'view_mode' => "has_note"],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'skip' => 2],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'limit' => 5, 'skip' => 2],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -4, 'since_id' => 47],
];
$in2 = [
// time-based contexts, handled separately
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -6, 'view_mode' => "adaptive"],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3],
['op' => "getCompactHeadlines", 'sid' => "PriestsOfSyrinx", 'feed_id' => -3, 'view_mode' => "marked"],
];
Phake::when(Arsse::$db)->articleList->thenReturn(new Result([['id' => 0]]));
Phake::when(Arsse::$db)->articleCount->thenReturn(0);
Phake::when(Arsse::$db)->articleCount($this->anything(), (new Context)->unread(true))->thenReturn(1);
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->subscription(2112), Database::LIST_MINIMAL)->thenThrow(new ExceptionInput("subjectMissing"));
Phake::when(Arsse::$db)->articleList($this->anything(), new Context, Database::LIST_MINIMAL)->thenReturn(new Result($this->articles));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->label(1088), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 2]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 3]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->label(1088)->unread(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 4]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->subscription(42)->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 5]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->subscription(42)->annotated(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 6]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(5), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 7]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 8]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->limit(5)->offset(2), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 9]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->oldestArticle(48), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 10]]));
$out1 = [
$this->respErr("INCORRECT_USAGE"),
$this->respGood([]),
$this->respGood([]),
$this->respGood([]),
$this->respGood([]),
$this->respGood([]),
$this->respGood([]),
$this->respGood([['id' => 101],['id' => 102]]),
$this->respGood([['id' => 1]]),
$this->respGood([['id' => 2]]),
$this->respGood([['id' => 3]]),
$this->respGood([['id' => 2]]), // the result is 2 rather than 4 because there are no unread, so the unread context is not used
$this->respGood([['id' => 4]]),
$this->respGood([['id' => 5]]),
$this->respGood([['id' => 6]]),
$this->respGood([['id' => 7]]),
$this->respGood([['id' => 8]]),
$this->respGood([['id' => 9]]),
$this->respGood([['id' => 10]]),
];
$out2 = [
$this->respGood([['id' => 1001]]),
$this->respGood([['id' => 1001]]),
$this->respGood([['id' => 1002]]),
$this->respGood([['id' => 1003]]),
];
for ($a = 0; $a < sizeof($in1); $a++) {
$this->assertEquals($out1[$a], $this->h->dispatch(new Request("POST", "", json_encode($in1[$a]))), "Test $a failed");
}
for ($a = 0; $a < sizeof($in2); $a++) {
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(false)->markedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1001]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true)->modifiedSince(Date::sub("PT24H")), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1002]]));
Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->unread(true)->modifiedSince(Date::sub("PT24H"))->starred(true), Database::LIST_MINIMAL)->thenReturn(new Result([['id' => 1003]]));
$this->assertEquals($out2[$a], $this->h->dispatch(new Request("POST", "", json_encode($in2[$a]))), "Test $a failed");
}
}
}

Loading…
Cancel
Save