From 3ebb46f48e79fadfc4e0c70bb76a35c1d5f9aecf Mon Sep 17 00:00:00 2001 From: "J. King" Date: Fri, 11 Dec 2020 23:47:13 -0500 Subject: [PATCH] Some work on categories --- .../030_Supported_Protocols/005_Miniflux.md | 15 ++- lib/REST/Miniflux/V1.php | 95 ++++++++++++++----- locale/en.php | 4 +- .../cases/REST/Miniflux/TestErrorResponse.php | 2 +- tests/cases/REST/Miniflux/TestV1.php | 53 ++++++++++- 5 files changed, 135 insertions(+), 34 deletions(-) diff --git a/docs/en/030_Supported_Protocols/005_Miniflux.md b/docs/en/030_Supported_Protocols/005_Miniflux.md index 04e53e2..0d5792e 100644 --- a/docs/en/030_Supported_Protocols/005_Miniflux.md +++ b/docs/en/030_Supported_Protocols/005_Miniflux.md @@ -13,9 +13,9 @@
API Reference
-The Miniflux protocol is a well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient. +The Miniflux protocol is a fairly well-designed protocol supporting a wide variety of operations on newsfeeds, folders (termed "categories"), and articles; it also allows for user administration, and native OPML importing and exporting. Architecturally it is similar to the Nextcloud News protocol, but is generally more efficient and has more capabilities. -Miniflux version 2.0.25 is emulated, though not all features are implemented +Miniflux version 2.0.26 is emulated, though not all features are implemented # Missing features @@ -28,8 +28,15 @@ Miniflux version 2.0.25 is emulated, though not all features are implemented # Differences +- Various error messages differ due to significant implementation differences - Only the URL should be considered reliable in feed discovery results +- The "All" category is treated specially (see below for details) +- Category names consisting only of whitespace are rejected along with the empty string -# Interaction with nested folders +# Special handling of the "All" category -Tiny Tiny RSS is unique in allowing newsfeeds to be grouped into folders nested to arbitrary depth. When newsfeeds are placed into nested folders, they simply appear in the top-level folder when accessed via the Miniflux protocol. This does not affect OPML exports, where full nesting is preserved. +Nextcloud News' root folder and Tiny Tiny RSS' "Uncategorized" catgory are mapped to Miniflux's initial "All" category. This Miniflux category can be renamed, but it cannot be deleted. Attempting to do so will delete the child feeds it contains, but not the category itself. + +# Interaction with nested categories + +Tiny Tiny RSS is unique in allowing newsfeeds to be grouped into categories nested to arbitrary depth. When newsfeeds are placed into nested categories, they simply appear in the top-level category when accessed via the Miniflux protocol. This does not affect OPML exports, where full nesting is preserved. diff --git a/lib/REST/Miniflux/V1.php b/lib/REST/Miniflux/V1.php index ec82dca..fd6baa9 100644 --- a/lib/REST/Miniflux/V1.php +++ b/lib/REST/Miniflux/V1.php @@ -32,27 +32,31 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { 'username' => "string", 'password' => "string", 'user_agent' => "string", + 'title' => "string", ]; protected const PATHS = [ - '/categories' => ['GET' => "getCategories", 'POST' => "createCategory"], - '/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"], - '/discover' => ['POST' => "discoverSubscriptions"], - '/entries' => ['GET' => "getEntries", 'PUT' => "updateEntries"], - '/entries/1' => ['GET' => "getEntry"], - '/entries/1/bookmark' => ['PUT' => "toggleEntryBookmark"], - '/export' => ['GET' => "opmlExport"], - '/feeds' => ['GET' => "getFeeds", 'POST' => "createFeed"], - '/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"], - '/feeds/1/entries/1' => ['GET' => "getFeedEntry"], - '/feeds/1/entries' => ['GET' => "getFeedEntries"], - '/feeds/1/icon' => ['GET' => "getFeedIcon"], - '/feeds/1/refresh' => ['PUT' => "refreshFeed"], - '/feeds/refresh' => ['PUT' => "refreshAllFeeds"], - '/import' => ['POST' => "opmlImport"], - '/me' => ['GET' => "getCurrentUser"], - '/users' => ['GET' => "getUsers", 'POST' => "createUser"], - '/users/1' => ['GET' => "getUserByNum", 'PUT' => "updateUserByNum", 'DELETE' => "deleteUser"], - '/users/*' => ['GET' => "getUserById"], + '/categories' => ['GET' => "getCategories", 'POST' => "createCategory"], + '/categories/1' => ['PUT' => "updateCategory", 'DELETE' => "deleteCategory"], + '/categories/1/mark-all-as-read' => ['PUT' => "markCategory"], + '/discover' => ['POST' => "discoverSubscriptions"], + '/entries' => ['GET' => "getEntries", 'PUT' => "updateEntries"], + '/entries/1' => ['GET' => "getEntry"], + '/entries/1/bookmark' => ['PUT' => "toggleEntryBookmark"], + '/export' => ['GET' => "opmlExport"], + '/feeds' => ['GET' => "getFeeds", 'POST' => "createFeed"], + '/feeds/1' => ['GET' => "getFeed", 'PUT' => "updateFeed", 'DELETE' => "removeFeed"], + '/feeds/1/mark-all-as-read' => ['PUT' => "markFeed"], + '/feeds/1/entries/1' => ['GET' => "getFeedEntry"], + '/feeds/1/entries' => ['GET' => "getFeedEntries"], + '/feeds/1/icon' => ['GET' => "getFeedIcon"], + '/feeds/1/refresh' => ['PUT' => "refreshFeed"], + '/feeds/refresh' => ['PUT' => "refreshAllFeeds"], + '/import' => ['POST' => "opmlImport"], + '/me' => ['GET' => "getCurrentUser"], + '/users' => ['GET' => "getUsers", 'POST' => "createUser"], + '/users/1' => ['GET' => "getUserByNum", 'PUT' => "updateUserByNum", 'DELETE' => "deleteUser"], + '/users/1/mark-all-as-read' => ['PUT' => "markAll"], + '/users/*' => ['GET' => "getUserById"], ]; protected const ADMIN_FUNCTIONS = [ 'getUsers' => true, @@ -85,7 +89,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { return true; } } - // next check HTTP auth + // next check HTTP auth if ($req->getAttribute("authenticated", false)) { Arsse::$user->id = $req->getAttribute("authenticatedUser"); return true; @@ -255,7 +259,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { return $out; } - protected function discoverSubscriptions(array $path, array $query, array $data) { + protected function discoverSubscriptions(array $path, array $query, array $data): ResponseInterface { try { $list = Feed::discoverAll((string) $data['url'], (string) $data['username'], (string) $data['password']); } catch (FeedException $e) { @@ -274,11 +278,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { return new Response($out); } - protected function getUsers(array $path, array $query, array $data) { + protected function getUsers(array $path, array $query, array $data): ResponseInterface { return new Response($this->listUsers(Arsse::$user->list(), false)); } - protected function getUserById(array $path, array $query, array $data) { + protected function getUserById(array $path, array $query, array $data): ResponseInterface { try { return new Response($this->listUsers([$path[1]], true)[0] ?? new \stdClass); } catch (UserException $e) { @@ -286,7 +290,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { } } - protected function getUserByNum(array $path, array $query, array $data) { + protected function getUserByNum(array $path, array $query, array $data): ResponseInterface { try { $user = Arsse::$user->lookup((int) $path[1]); return new Response($this->listUsers([$user], true)[0] ?? new \stdClass); @@ -295,11 +299,11 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { } } - protected function getCurrentUser(array $path, array $query, array $data) { + protected function getCurrentUser(array $path, array $query, array $data): ResponseInterface { return new Response($this->listUsers([Arsse::$user->id], false)[0] ?? new \stdClass); } - protected function getCategories(array $path, array $query, array $data) { + protected function getCategories(array $path, array $query, array $data): ResponseInterface { $out = []; $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false); // add the root folder as a category @@ -312,6 +316,45 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler { return new Response($out); } + protected function createCategory(array $path, array $query, array $data): ResponseInterface { + try { + $id = Arsse::$db->folderAdd(Arsse::$user->id, ['name' => (string) $data['title']]); + } catch (ExceptionInput $e) { + if ($e->getCode() === 10236) { + return new ErrorResponse(["DuplicateCategory", 'title' => $data['title']], 500); + } else { + return new ErrorResponse(["InvalidCategory", 'title' => $data['title']], 500); + } + } + $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false); + return new Response(['id' => $id + 1, 'title' => $data['title'], 'user_id' => $meta['num']]); + } + + protected function updateCategory(array $path, array $query, array $data): ResponseInterface { + $folder = $path[1] - 1; + $title = $data['title'] ?? ""; + try { + if ($folder === 0) { + if (!strlen(trim($title))) { + throw new ExceptionInput("whitespace"); + } + $title = Arsse::$user->propertiesSet(Arsse::$user->id, ['root_folder_name' => $title])['root_folder_name']; + } else { + Arsse::$db->folderPropertiesSet(Arsse::$user->id, $folder, ['name' => $title]); + } + } catch (ExceptionInput $e) { + if ($e->getCode() === 10236) { + return new ErrorResponse(["DuplicateCategory", 'title' => $title], 500); + } elseif ($e->getCode === 10239) { + return new ErrorResponse("404", 404); + } else { + return new ErrorResponse(["InvalidCategory", 'title' => $title], 500); + } + } + $meta = Arsse::$user->propertiesGet(Arsse::$user->id, false); + return new Response(['id' => (int) $path[1], 'title' => $title, 'user_id' => $meta['num']]); + } + public static function tokenGenerate(string $user, string $label): string { // Miniflux produces tokens in base64url alphabet $t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH))); diff --git a/locale/en.php b/locale/en.php index 0e03fd9..acdaa60 100644 --- a/locale/en.php +++ b/locale/en.php @@ -17,7 +17,9 @@ return [ 'API.Miniflux.Error.Fetch401' => 'You are not authorized to access this resource (invalid username/password)', 'API.Miniflux.Error.Fetch403' => 'Unable to fetch this resource (Status Code = 403)', 'API.Miniflux.Error.FetchOther' => 'Unable to fetch this resource', - + 'API.Miniflux.Error.DuplicateCategory' => 'Category "{title}" already exists', + 'API.Miniflux.Error.InvalidCategory' => 'Invalid category title "{title}"', + 'API.TTRSS.Category.Uncategorized' => 'Uncategorized', 'API.TTRSS.Category.Special' => 'Special', 'API.TTRSS.Category.Labels' => 'Labels', diff --git a/tests/cases/REST/Miniflux/TestErrorResponse.php b/tests/cases/REST/Miniflux/TestErrorResponse.php index 23d6e28..5852b4d 100644 --- a/tests/cases/REST/Miniflux/TestErrorResponse.php +++ b/tests/cases/REST/Miniflux/TestErrorResponse.php @@ -16,7 +16,7 @@ class TestErrorResponse extends \JKingWeb\Arsse\Test\AbstractTest { } public function testCreateVariableResponse(): void { - $act = new ErrorResponse(["invalidBodyJSON", "Doh!"], 401); + $act = new ErrorResponse(["InvalidBodyJSON", "Doh!"], 401); $this->assertSame('{"error_message":"Invalid JSON payload: Doh!"}', (string) $act->getBody()); } } diff --git a/tests/cases/REST/Miniflux/TestV1.php b/tests/cases/REST/Miniflux/TestV1.php index 4ac2734..0259948 100644 --- a/tests/cases/REST/Miniflux/TestV1.php +++ b/tests/cases/REST/Miniflux/TestV1.php @@ -18,6 +18,7 @@ use JKingWeb\Arsse\User\ExceptionConflict; use Psr\Http\Message\ResponseInterface; use Laminas\Diactoros\Response\JsonResponse as Response; use Laminas\Diactoros\Response\EmptyResponse; +use JKingWeb\Arsse\Test\Result; /** @covers \JKingWeb\Arsse\REST\Miniflux\V1 */ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { @@ -79,8 +80,9 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { public function setUp(): void { self::clearData(); self::setConf(); - // create a mock user manager - Arsse::$user = \Phake::mock(User::class); + // create a mock user manager; we use a PHPUnitmock because Phake for reasons unknown is unable to mock the User class correctly, sometimes + Arsse::$user = $this->createMock(User::class); + Arsse::$user->method("propertiesGet")->willReturn(['num' => 42, 'admin' => false, 'root_folder_name' => null]); // create a mock database interface Arsse::$db = \Phake::mock(Database::class); $this->transaction = \Phake::mock(Transaction::class); @@ -234,4 +236,51 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest { [false, "/users/47", new ErrorResponse("403", 403)], ]; } + + public function testListCategories(): void { + \Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([ + ['id' => 1, 'name' => "Science"], + ['id' => 20, 'name' => "Technology"], + ]))); + $exp = new Response([ + ['id' => 1, 'title' => "All", 'user_id' => 42], + ['id' => 2, 'title' => "Science", 'user_id' => 42], + ['id' => 21, 'title' => "Technology", 'user_id' => 42], + ]); + $this->assertMessage($exp, $this->req("GET", "/categories")); + \Phake::verify(Arsse::$db)->folderList("john.doe@example.com", null, false); + // run test again with a renamed root folder + Arsse::$user = $this->createMock(User::class); + Arsse::$user->method("propertiesGet")->willReturn(['num' => 47, 'admin' => false, 'root_folder_name' => "Uncategorized"]); + $exp = new Response([ + ['id' => 1, 'title' => "Uncategorized", 'user_id' => 47], + ['id' => 2, 'title' => "Science", 'user_id' => 47], + ['id' => 21, 'title' => "Technology", 'user_id' => 47], + ]); + $this->assertMessage($exp, $this->req("GET", "/categories")); + } + + /** @dataProvider provideCategoryAdditions */ + public function testAddACategory($title, ResponseInterface $exp): void { + if (!strlen((string) $title)) { + \Phake::when(Arsse::$db)->folderAdd->thenThrow(new ExceptionInput("missing")); + } elseif (!strlen(trim((string) $title))) { + \Phake::when(Arsse::$db)->folderAdd->thenThrow(new ExceptionInput("whitespace")); + } elseif ($title === "Duplicate") { + \Phake::when(Arsse::$db)->folderAdd->thenThrow(new ExceptionInput("constraintViolation")); + } else { + \Phake::when(Arsse::$db)->folderAdd->thenReturn(2111); + } + $this->assertMessage($exp, $this->req("POST", "/categories", ['title' => $title])); + } + + public function provideCategoryAdditions(): iterable { + return [ + ["New", new Response(['id' => 2112, 'title' => "New", 'user_id' => 42])], + ["Duplicate", new ErrorResponse(["DuplicateCategory", 'title' => "Duplicate"], 500)], + ["", new ErrorResponse(["InvalidCategory", 'title' => ""], 500)], + [" ", new ErrorResponse(["InvalidCategory", 'title' => " "], 500)], + [false, new ErrorResponse(["InvalidInputType", 'field' => "title", 'actual' => "boolean", 'expected' => "string"], 400)], + ]; + } }