From de92fb514bce817d62d25058d86d0ede2f1ffd67 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Wed, 15 Nov 2017 15:38:49 -0500 Subject: [PATCH] Implement TTRSS opera getArticle; fixes #84 --- lib/Database.php | 5 +- lib/REST/TinyTinyRSS/API.php | 67 +++++++++++ tests/REST/TinyTinyRSS/TestTinyTinyAPI.php | 132 ++++++++++++++++++++- tests/lib/Database/SeriesArticle.php | 72 ++++++----- 4 files changed, 244 insertions(+), 32 deletions(-) diff --git a/lib/Database.php b/lib/Database.php index 59f4a423..82a09e26 100644 --- a/lib/Database.php +++ b/lib/Database.php @@ -962,8 +962,10 @@ class Database { return new Db\ResultAggregate(...$out); } else { $columns = [ + // (id, subscription, feed, modified, unread, starred, edition): always included "arsse_articles.url as url", - "title", + "arsse_articles.title as title", + "(select coalesce(arsse_subscriptions.title,arsse_feeds.title) from arsse_feeds join arsse_subscriptions on arsse_subscriptions.feed is arsse_feeds.id where arsse_feeds.id is arsse_articles.feed) as subscription_title", "author", "content", "guid", @@ -972,6 +974,7 @@ class Database { "url_title_hash||':'||url_content_hash||':'||title_content_hash as fingerprint", "arsse_enclosures.url as media_url", "arsse_enclosures.type as media_type", + "(select note from arsse_marks where article is arsse_articles.id and subscription in (select sub from subscribed_feeds)) as note" ]; $q = $this->articleQuery($user, $context, $columns); $q->setJoin("left join arsse_enclosures on arsse_enclosures.article is arsse_articles.id"); diff --git a/lib/REST/TinyTinyRSS/API.php b/lib/REST/TinyTinyRSS/API.php index 8390c504..78eddccf 100644 --- a/lib/REST/TinyTinyRSS/API.php +++ b/lib/REST/TinyTinyRSS/API.php @@ -29,6 +29,9 @@ Protocol difference so far: - 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 - 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 */ @@ -1169,4 +1172,68 @@ class API extends \JKingWeb\Arsse\REST\AbstractHandler { $tr->commit(); return ['status' => "OK", 'updated' => $out]; } + + public function opGetArticle(array $data): array { + // normalize input + $articles = array_filter(ValueInfo::normalize(explode(",", (string) $data['article_id']), ValueInfo::T_INT | ValueInfo::M_ARRAY), [ValueInfo::class, "id"]); + if (!$articles) { + // if there are no valid articles this is an error + throw new Exception("INCORRECT_USAGE"); + } + $tr = Arsse::$db->begin(); + // retrieve the list of label names for the user + $labels = []; + foreach (Arsse::$db->labelList(Arsse::$user->id, false) as $label) { + $labels[$label['id']] = $label['name']; + } + // retrieve the requested articles + $out = []; + foreach (Arsse::$db->articleList(Arsse::$user->id, (new Context)->articles($articles)) as $article) { + $out[] = [ + 'id' => $article['id'], + 'guid' => $article['guid'] ? "SHA256:".$article['guid'] : null, + 'title' => $article['title'], + 'link' => $article['url'], + 'labels' => $this->articleLabelList($labels, $article['id']), + 'unread' => (bool) $article['unread'], + 'marked' => (bool) $article['starred'], + 'published' => false, // TODO: if the Published feed is implemented, the getArticle operation should be amended accordingly + 'comments' => "", // FIXME: What is this? + 'author' => $article['author'], + 'updated' => Date::transform($article['edited_date'], "unix", "sql"), + 'feed_id' => $article['subscription'], + 'feed_title' => $article['subscription_title'], + 'attachments' => $article['media_url'] ? [[ + 'content_url' => $article['media_url'], + 'content_type' => $article['media_type'], + 'title' => "", + 'duration' => "", + 'width' => "", + 'height' => "", + 'post_id' => $article['id'], + ]] : [], // TODO: We need to support multiple enclosures + 'score' => 0, // score is not implemented as it is not modifiable from the TTRSS API + 'note' => strlen($article['note']) ? $article['note'] : null, + 'lang' => "", // FIXME: picoFeed should be able to retrieve this information + 'content' => $article['content'], + ]; + } + return $out; + } + + protected function articleLabelList(array $labels, int $id): array { + $out = []; + if (!$labels) { + return $out; + } + foreach (Arsse::$db->articleLabelsGet(Arsse::$user->id, $id) as $label) { + $out[] = [ + $this->labelOut($label), // ID + $labels[$label], // name + "", // foreground colour + "", // background colour + ]; + } + return $out; + } } diff --git a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php index c66c727a..1900bc72 100644 --- a/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php +++ b/tests/REST/TinyTinyRSS/TestTinyTinyAPI.php @@ -47,6 +47,48 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['id' => 1, 'articles' => 2, 'read' => 2, 'unread' => 0, 'name' => "Logical"], ]; protected $starred = ['total' => 10, 'unread' => 4, 'read' => 6]; + protected $articles = [ + [ + 'id' => 101, + 'url' => 'http://example.com/1', + 'title' => 'Article title 1', + 'subscription_title' => "Feed 11", + 'author' => '', + 'content' => '

Article content 1

', + 'guid' => '', + 'published_date' => '2000-01-01 00:00:00', + 'edited_date' => '2000-01-01 00:00:01', + 'modified_date' => '2000-01-01 01:00:00', + 'unread' => 1, + 'starred' => 0, + 'edition' => 101, + 'subscription' => 8, + 'fingerprint' => 'f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6:fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4:18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207', + 'media_url' => null, + 'media_type' => null, + 'note' => "", + ], + [ + 'id' => 102, + 'url' => 'http://example.com/2', + 'title' => 'Article title 2', + 'subscription_title' => "Feed 11", + 'author' => 'J. King', + 'content' => '

Article content 2

', + 'guid' => '5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7', + 'published_date' => '2000-01-02 00:00:00', + 'edited_date' => '2000-01-02 00:00:02', + 'modified_date' => '2000-01-02 02:00:00', + 'unread' => 0, + 'starred' => 0, + 'edition' => 202, + 'subscription' => 8, + 'fingerprint' => '0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153:13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9:2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e', + 'media_url' => "http://example.com/text", + 'media_type' => "text/plain", + 'note' => "Note 2", + ], + ]; protected function respGood($content = null, $seq = 0): Response { return new Response(200, [ @@ -1133,7 +1175,7 @@ class TestTinyTinyAPI extends Test\AbstractTest { ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 3, 'data' => "eh"], ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "42, 2112, -1", 'field' => 4], // invalid field - ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "0, -1", 'field' => 4], // no valid IDs + ['op' => "updateArticle", 'sid' => "PriestsOfSyrinx", 'article_ids' => "0, -1", 'field' => 3], // no valid IDs ]; Phake::when(Arsse::$db)->articleMark->thenReturn(1); Phake::when(Arsse::$db)->articleMark($this->anything(), ['starred' => false], (new Context)->articles([42, 2112]))->thenReturn(2); @@ -1182,4 +1224,92 @@ class TestTinyTinyAPI extends Test\AbstractTest { $this->assertEquals($out[$a], $this->h->dispatch(new Request("POST", "", json_encode($in[$a]))), "Test $a failed"); } } + + public function testListArticles() { + $in = [ + // error conditions + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx"], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => 0], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => -1], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "0,-1"], + // acceptable input + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "101,102"], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "101"], + ['op' => "getArticle", 'sid' => "PriestsOfSyrinx", 'article_id' => "102"], + ]; + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result($this->labels)); + Phake::when(Arsse::$db)->labelList($this->anything(), false)->thenReturn(new Result($this->usedLabels)); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 101)->thenReturn([]); + Phake::when(Arsse::$db)->articleLabelsGet($this->anything(), 102)->thenReturn([1,3]); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101, 102]))->thenReturn(new Result($this->articles)); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([101]))->thenReturn(new Result([$this->articles[0]])); + Phake::when(Arsse::$db)->articleList($this->anything(), (new Context)->articles([102]))->thenReturn(new Result([$this->articles[1]])); + $exp = $this->respErr("INCORRECT_USAGE"); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[0])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[1])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[2])))); + $this->assertEquals($exp, $this->h->dispatch(new Request("POST", "", json_encode($in[3])))); + $exp = [ + [ + 'id' => 101, + 'guid' => null, + 'title' => 'Article title 1', + 'link' => 'http://example.com/1', + 'labels' => [], + 'unread' => true, + 'marked' => false, + 'published' => false, + 'comments' => "", + 'author' => '', + 'updated' => strtotime('2000-01-01 00:00:01'), + 'feed_id' => 8, + 'feed_title' => "Feed 11", + 'attachments' => [], + 'score' => 0, + 'note' => null, + 'lang' => "", + 'content' => '

Article content 1

', + ], + [ + 'id' => 102, + 'guid' => "SHA256:5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7", + 'title' => 'Article title 2', + 'link' => 'http://example.com/2', + 'labels' => [ + [-1025, "Logical", "", ""], + [-1027, "Fascinating", "", ""], + ], + 'unread' => false, + 'marked' => false, + 'published' => false, + 'comments' => "", + 'author' => "J. King", + 'updated' => strtotime('2000-01-02 00:00:02'), + 'feed_id' => 8, + 'feed_title' => "Feed 11", + 'attachments' => [ + [ + 'content_url' => "http://example.com/text", + 'content_type' => "text/plain", + 'title' => "", + 'duration' => "", + 'width' => "", + 'height' => "", + 'post_id' => 102, + ], + ], + 'score' => 0, + 'note' => "Note 2", + 'lang' => "", + 'content' => '

Article content 2

', + ], + ]; + $this->assertEquals($this->respGood($exp), $this->h->dispatch(new Request("POST", "", json_encode($in[4])))); + $this->assertEquals($this->respGood([$exp[0]]), $this->h->dispatch(new Request("POST", "", json_encode($in[5])))); + $this->assertEquals($this->respGood([$exp[1]]), $this->h->dispatch(new Request("POST", "", json_encode($in[6])))); + // test the special case when labels are not used + Phake::when(Arsse::$db)->labelList($this->anything())->thenReturn(new Result([])); + 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])))); + } } diff --git a/tests/lib/Database/SeriesArticle.php b/tests/lib/Database/SeriesArticle.php index ab67768c..90c36ea2 100644 --- a/tests/lib/Database/SeriesArticle.php +++ b/tests/lib/Database/SeriesArticle.php @@ -46,21 +46,22 @@ trait SeriesArticle { 'columns' => [ 'id' => "int", 'url' => "str", + 'title' => "str", ], 'rows' => [ - [1,"http://example.com/1"], - [2,"http://example.com/2"], - [3,"http://example.com/3"], - [4,"http://example.com/4"], - [5,"http://example.com/5"], - [6,"http://example.com/6"], - [7,"http://example.com/7"], - [8,"http://example.com/8"], - [9,"http://example.com/9"], - [10,"http://example.com/10"], - [11,"http://example.com/11"], - [12,"http://example.com/12"], - [13,"http://example.com/13"], + [1,"http://example.com/1", "Feed 1"], + [2,"http://example.com/2", "Feed 2"], + [3,"http://example.com/3", "Feed 3"], + [4,"http://example.com/4", "Feed 4"], + [5,"http://example.com/5", "Feed 5"], + [6,"http://example.com/6", "Feed 6"], + [7,"http://example.com/7", "Feed 7"], + [8,"http://example.com/8", "Feed 8"], + [9,"http://example.com/9", "Feed 9"], + [10,"http://example.com/10", "Feed 10"], + [11,"http://example.com/11", "Feed 11"], + [12,"http://example.com/12", "Feed 12"], + [13,"http://example.com/13", "Feed 13"], ] ], 'arsse_subscriptions' => [ @@ -69,22 +70,23 @@ trait SeriesArticle { 'owner' => "str", 'feed' => "int", 'folder' => "int", + 'title' => "str", ], 'rows' => [ - [1,"john.doe@example.com",1,null], - [2,"john.doe@example.com",2,null], - [3,"john.doe@example.com",3,1], - [4,"john.doe@example.com",4,6], - [5,"john.doe@example.com",10,5], - [6,"jane.doe@example.com",1,null], - [7,"jane.doe@example.com",10,null], - [8,"john.doe@example.org",11,null], - [9,"john.doe@example.org",12,null], - [10,"john.doe@example.org",13,null], - [11,"john.doe@example.net",10,null], - [12,"john.doe@example.net",2,9], - [13,"john.doe@example.net",3,8], - [14,"john.doe@example.net",4,7], + [1, "john.doe@example.com",1, null,"Subscription 1"], + [2, "john.doe@example.com",2, null,null], + [3, "john.doe@example.com",3, 1,"Subscription 3"], + [4, "john.doe@example.com",4, 6,null], + [5, "john.doe@example.com",10, 5,"Subscription 5"], + [6, "jane.doe@example.com",1, null,null], + [7, "jane.doe@example.com",10,null,"Subscription 7"], + [8, "john.doe@example.org",11,null,null], + [9, "john.doe@example.org",12,null,"Subscription 9"], + [10,"john.doe@example.org",13,null,null], + [11,"john.doe@example.net",10,null,"Subscription 11"], + [12,"john.doe@example.net",2, 9,null], + [13,"john.doe@example.net",3, 8,"Subscription 13"], + [14,"john.doe@example.net",4, 7,null], ] ], 'arsse_articles' => [ @@ -198,9 +200,9 @@ trait SeriesArticle { [5, 19,1,0,'2000-01-01 00:00:00',''], [5, 20,0,1,'2010-01-01 00:00:00',''], [7, 20,1,0,'2010-01-01 00:00:00',''], - [8, 102,1,0,'2000-01-02 02:00:00',''], - [9, 103,0,1,'2000-01-03 03:00:00',''], - [9, 104,1,1,'2000-01-04 04:00:00',''], + [8, 102,1,0,'2000-01-02 02:00:00','Note 2'], + [9, 103,0,1,'2000-01-03 03:00:00','Note 3'], + [9, 104,1,1,'2000-01-04 04:00:00','Note 4'], [10,105,0,0,'2000-01-05 05:00:00',''], [11, 19,0,0,'2017-01-01 00:00:00','ook'], [11, 20,1,0,'2017-01-01 00:00:00','eek'], @@ -243,6 +245,7 @@ trait SeriesArticle { 'id' => 101, 'url' => 'http://example.com/1', 'title' => 'Article title 1', + 'subscription_title' => "Feed 11", 'author' => '', 'content' => '

Article content 1

', 'guid' => 'e433653cef2e572eee4215fa299a4a5af9137b2cefd6283c85bd69a32915beda', @@ -256,11 +259,13 @@ trait SeriesArticle { 'fingerprint' => 'f5cb8bfc1c7396dc9816af212a3e2ac5221585c2a00bf7ccb6aabd95dcfcd6a6:fb0bc8f8cb08913dc5a497db700e327f1d34e4987402687d494a5891f24714d4:18fdd4fa93d693128c43b004399e5c9cea6c261ddfa002518d3669f55d8c2207', 'media_url' => null, 'media_type' => null, + 'note' => "", ], [ 'id' => 102, 'url' => 'http://example.com/2', 'title' => 'Article title 2', + 'subscription_title' => "Feed 11", 'author' => '', 'content' => '

Article content 2

', 'guid' => '5be8a5a46ecd52ed132191c8d27fb1af6b3d4edc00234c5d9f8f0e10562ed3b7', @@ -274,11 +279,13 @@ trait SeriesArticle { 'fingerprint' => '0e86d2de822a174fe3c44a466953e63ca1f1a58a19cbf475fce0855d4e3d5153:13075894189c47ffcfafd1dfe7fbb539f7c74a69d35a399b3abf8518952714f9:2abd0a8cba83b8214a66c8f0293ba63e467d720540e29ff8ddcdab069d4f1c9e', 'media_url' => "http://example.com/text", 'media_type' => "text/plain", + 'note' => "Note 2", ], [ 'id' => 103, 'url' => 'http://example.com/3', 'title' => 'Article title 3', + 'subscription_title' => "Subscription 9", 'author' => '', 'content' => '

Article content 3

', 'guid' => '31a6594500a48b59fcc8a075ce82b946c9c3c782460d088bd7b8ef3ede97ad92', @@ -292,11 +299,13 @@ trait SeriesArticle { 'fingerprint' => 'f74b06b240bd08abf4d3fdfc20dba6a6f6eb8b4f1a00e9a617efd63a87180a4b:b278380e984cefe63f0e412b88ffc9cb0befdfa06fdc00bace1da99a8daff406:ad622b31e739cd3a3f3c788991082cf4d2f7a8773773008e75f0572e58cd373b', 'media_url' => "http://example.com/video", 'media_type' => "video/webm", + 'note' => "Note 3", ], [ 'id' => 104, 'url' => 'http://example.com/4', 'title' => 'Article title 4', + 'subscription_title' => "Subscription 9", 'author' => '', 'content' => '

Article content 4

', 'guid' => '804e517d623390e71497982c77cf6823180342ebcd2e7d5e32da1e55b09dd180', @@ -310,11 +319,13 @@ trait SeriesArticle { 'fingerprint' => 'f3615c7f16336d3ea242d35cf3fc17dbc4ee3afb78376bf49da2dd7a5a25dec8:f11c2b4046f207579aeb9c69a8c20ca5461cef49756ccfa5ba5e2344266da3b3:ab2da63276acce431250b18d3d49b988b226a99c7faadf275c90b751aee05be9', 'media_url' => "http://example.com/image", 'media_type' => "image/svg+xml", + 'note' => "Note 4", ], [ 'id' => 105, 'url' => 'http://example.com/5', 'title' => 'Article title 5', + 'subscription_title' => "Feed 13", 'author' => '', 'content' => '

Article content 5

', 'guid' => 'db3e736c2c492f5def5c5da33ddcbea1824040e9ced2142069276b0a6e291a41', @@ -328,6 +339,7 @@ trait SeriesArticle { 'fingerprint' => 'd40da96e39eea6c55948ccbe9b3d275b5f931298288dbe953990c5f496097022:834240f84501b5341d375414718204ec421561f3825d34c22bf9182203e42900:43b970ac6ec5f8a9647b2c7e4eed8b1d7f62e154a95eed748b0294c1256764ba', 'media_url' => "http://example.com/audio", 'media_type' => "audio/ogg", + 'note' => "", ], ];