From 5c140aedc48811cf97180f8e60fa7ae0af621273 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Mon, 20 Nov 2017 00:09:20 -0500 Subject: [PATCH] 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 --- lib/REST/TinyTinyRSS/API.php | 209 +++++++++++++++++---- tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 85 +++++++++ 2 files changed, 258 insertions(+), 36 deletions(-) diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 04bc8196..cbc5bf85 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/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; + } + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index 9d4f30e2..7bbda179 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/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"); + } + } }