Browse Source

Some work on categories

rpm
J. King 3 years ago
parent
commit
3ebb46f48e
  1. 15
      docs/en/030_Supported_Protocols/005_Miniflux.md
  2. 95
      lib/REST/Miniflux/V1.php
  3. 4
      locale/en.php
  4. 2
      tests/cases/REST/Miniflux/TestErrorResponse.php
  5. 53
      tests/cases/REST/Miniflux/TestV1.php

15
docs/en/030_Supported_Protocols/005_Miniflux.md

@ -13,9 +13,9 @@
<dd><a href="https://miniflux.app/docs/api.html">API Reference</a></dd>
</dl>
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.

95
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)));

4
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',

2
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());
}
}

53
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<extended> */
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)],
];
}
}

Loading…
Cancel
Save