mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2024-12-22 13:12:41 +00:00
Some work on categories
This commit is contained in:
parent
2e6c5d2ad2
commit
3ebb46f48e
5 changed files with 135 additions and 34 deletions
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
@ -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)));
|
||||
|
|
|
@ -17,6 +17,8 @@ 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',
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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…
Reference in a new issue