Browse Source

Implement article marking

rpm
J. King 3 years ago
parent
commit
ab1cf7447b
  1. 113
      lib/REST/Miniflux/V1.php
  2. 113
      tests/cases/REST/Miniflux/TestV1.php

113
lib/REST/Miniflux/V1.php

@ -71,6 +71,8 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
'disabled' => "boolean",
'ignore_http_cache' => "boolean",
'fetch_via_proxy' => "boolean",
'entry_ids' => "array", // this is a special case: it is an array of integers
'status' => "string",
];
protected const USER_META_MAP = [
// Miniflux ID // Arsse ID Default value
@ -146,7 +148,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
],
'/entries' => [
'GET' => ["getEntries", false, false, false, true, []],
'PUT' => ["updateEntries", false, false, true, false, []],
'PUT' => ["updateEntries", false, false, true, false, ["entry_ids", "status"]],
],
'/entries/1' => [
'GET' => ["getEntry", false, true, false, false, []],
@ -349,8 +351,17 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
(in_array($k, ["keeplist_rules", "blocklist_rules"]) && !Rule::validate($body[$k]))
|| (in_array($k, ["url", "feed_url"]) && !URL::absolute($body[$k]))
|| ($k === "category_id" && $body[$k] < 1)
|| ($k === "status" && !in_array($body[$k], ["read", "unread", "removed"]))
) {
return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
} elseif ($k === "entry_ids") {
foreach ($body[$k] as $v) {
if (gettype($v) !== "integer") {
return new ErrorResponse(["InvalidInputType", 'field' => $k, 'expected' => "integer", 'actual' => gettype($v)], 422);
} elseif ($v < 1) {
return new ErrorResponse(["InvalidInputValue", 'field' => $k], 422);
}
}
}
}
//normalize user-specific input
@ -368,7 +379,7 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
// check for any missing required values
foreach ($req as $k) {
if (!isset($body[$k])) {
if (!isset($body[$k]) || (is_array($body[$k]) && !$body[$k])) {
return new ErrorResponse(["MissingInputValue", 'field' => $k], 422);
}
}
@ -629,16 +640,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
protected function markUserByNum(array $path): ResponseInterface {
// this function is restricted to the logged-in user
$user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
if (((int) $path[1]) !== $user['num']) {
return new ErrorResponse("403", 403);
}
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], (new Context)->hidden(false));
return new EmptyResponse(204);
}
/** Returns a useful subset of user metadata
*
* The following keys are included:
@ -729,23 +730,6 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
return new EmptyResponse(204);
}
protected function markCategory(array $path): ResponseInterface {
$folder = $path[1] - 1;
$c = new Context;
if ($folder === 0) {
// if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders
$c = $c->folderShallow($folder);
} else {
$c = $c->folder($folder);
}
try {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c);
} catch (ExceptionInput $e) {
return new ErrorResponse("404", 404);
}
return new EmptyResponse(204);
}
protected function transformFeed(array $sub, int $uid, string $rootName, \DateTimeZone $tz): array {
$url = new Uri($sub['url']);
return [
@ -1106,6 +1090,77 @@ class V1 extends \JKingWeb\Arsse\REST\AbstractHandler {
}
}
protected function updateEntries(array $data): ResponseInterface {
if ($data['status'] === "read") {
$in = ['read' => true, 'hidden' => false];
} elseif ($data['status'] === "unread") {
$in = ['read' => false, 'hidden' => false];
} elseif ($data['status'] === "removed") {
$in = ['read' => true, 'hidden' => true];
}
assert(isset($in), new \Exception("Unknown status specified"));
Arsse::$db->articleMark(Arsse::$user->id, $in, (new Context)->articles($data['entry_ids']));
return new EmptyResponse(204);
}
protected function massRead(Context $c): void {
Arsse::$db->articleMark(Arsse::$user->id, ['read' => true], $c->hidden(false));
}
protected function markUserByNum(array $path): ResponseInterface {
// this function is restricted to the logged-in user
$user = Arsse::$user->propertiesGet(Arsse::$user->id, false);
if (((int) $path[1]) !== $user['num']) {
return new ErrorResponse("403", 403);
}
$this->massRead(new Context);
return new EmptyResponse(204);
}
protected function markFeed(array $path): ResponseInterface {
try {
$this->massRead((new Context)->subscription((int) $path[1]));
} catch (ExceptionInput $e) {
return new ErrorResponse("404", 404);
}
return new EmptyResponse(204);
}
protected function markCategory(array $path): ResponseInterface {
$folder = $path[1] - 1;
$c = new Context;
if ($folder === 0) {
// if we're marking the root folder don't also mark its child folders, since Miniflux organizes it as a peer of other folders
$c->folderShallow($folder);
} else {
$c->folder($folder);
}
try {
$this->massRead($c);
} catch (ExceptionInput $e) {
return new ErrorResponse("404", 404);
}
return new EmptyResponse(204);
}
protected function toggleEntryBookmark(array $path): ResponseInterface {
// NOTE: A toggle is bad design, but we have no choice but to implement what Miniflux does
$id = (int) $path[1];
$c = (new Context)->article($id);
try {
$tr = Arsse::$db->begin();
if (Arsse::$db->articleCount(Arsse::$user->id, (clone $c)->starred(false))) {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => true], $c);
} else {
Arsse::$db->articleMark(Arsse::$user->id, ['starred' => false], $c);
}
$tr->commit();
} catch (ExceptionInput $e) {
return new ErrorResponse("404", 404);
}
return new EmptyResponse(204);
}
public static function tokenGenerate(string $user, string $label): string {
// Miniflux produces tokenss in base64url alphabet
$t = str_replace(["+", "/"], ["-", "_"], base64_encode(random_bytes(self::TOKEN_LENGTH)));

113
tests/cases/REST/Miniflux/TestV1.php

@ -398,13 +398,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
$this->assertMessage(new ErrorResponse("403", 403), $this->req("DELETE", "/users/2112"));
}
public function testMarkAllArticlesAsRead(): void {
\Phake::when(Arsse::$db)->articleMark->thenReturn(true);
$this->assertMessage(new ErrorResponse("403", 403), $this->req("PUT", "/users/1/mark-all-as-read"));
$this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/users/42/mark-all-as-read"));
\Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->hidden(false));
}
public function testListCategories(): void {
\Phake::when(Arsse::$db)->folderList->thenReturn(new Result($this->v([
['id' => 1, 'name' => "Science"],
@ -512,18 +505,6 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
);
}
public function testMarkACategoryAsRead(): void {
\Phake::when(Arsse::$db)->articleMark->thenReturn(1)->thenReturn(1)->thenThrow(new ExceptionInput("idMissing"));
$this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/categories/2/mark-all-as-read"));
$this->assertMessage(new EmptyResponse(204), $this->req("PUT", "/categories/1/mark-all-as-read"));
$this->assertMessage(new ErrorResponse("404", 404), $this->req("PUT", "/categories/2112/mark-all-as-read"));
\Phake::inOrder(
\Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folder(1)),
\Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folderShallow(0)),
\Phake::verify(Arsse::$db)->articleMark("john.doe@example.com", ['read' => true], (new Context)->folder(2111))
);
}
public function testListFeeds(): void {
\Phake::when(Arsse::$db)->subscriptionList->thenReturn(new Result($this->v(self::FEEDS)));
$exp = new Response(self::FEEDS_OUT);
@ -831,6 +812,7 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
}
public function provideSingleEntryQueries(): iterable {
self::clearData();
$c = new Context;
return [
["/entries/42", (clone $c)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
@ -846,4 +828,97 @@ class TestV1 extends \JKingWeb\Arsse\Test\AbstractTest {
["/categories/1/entries/42", (clone $c)->folderShallow(0)->article(42), [self::ENTRIES[1]], new Response(self::ENTRIES_OUT[1])],
];
}
/** @dataProvider provideEntryMarkings */
public function testMarkEntries(array $in, ?array $data, ResponseInterface $exp): void {
\Phake::when(Arsse::$db)->articleMark->thenReturn(0);
$this->assertMessage($exp, $this->req("PUT", "/entries", $in));
if ($data) {
\Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, $data, (new Context)->articles($in['entry_ids']));
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
}
}
public function provideEntryMarkings(): iterable {
self::clearData();
return [
[['status' => "read"], null, new ErrorResponse(["MissingInputValue", 'field' => "entry_ids"], 422)],
[['entry_ids' => [1]], null, new ErrorResponse(["MissingInputValue", 'field' => "status"], 422)],
[['entry_ids' => [], 'status' => "read"], null, new ErrorResponse(["MissingInputValue", 'field' => "entry_ids"], 422)],
[['entry_ids' => 1, 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "array", 'actual' => "integer"], 422)],
[['entry_ids' => ["1"], 'status' => "read"], null, new ErrorResponse(["InvalidInputType", 'field' => "entry_ids", 'expected' => "integer", 'actual' => "string"], 422)],
[['entry_ids' => [1], 'status' => 1], null, new ErrorResponse(["InvalidInputType", 'field' => "status", 'expected' => "string", 'actual' => "integer"], 422)],
[['entry_ids' => [0], 'status' => "read"], null, new ErrorResponse(["InvalidInputValue", 'field' => "entry_ids",], 422)],
[['entry_ids' => [1], 'status' => "reread"], null, new ErrorResponse(["InvalidInputValue", 'field' => "status",], 422)],
[['entry_ids' => [1, 2], 'status' => "read"], ['read' => true, 'hidden' => false], new EmptyResponse(204)],
[['entry_ids' => [1, 2], 'status' => "unread"], ['read' => false, 'hidden' => false], new EmptyResponse(204)],
[['entry_ids' => [1, 2], 'status' => "removed"], ['read' => true, 'hidden' => true], new EmptyResponse(204)],
];
}
/** @dataProvider provideMassMarkings */
public function testMassMarkEntries(string $url, Context $c, $out, ResponseInterface $exp): void {
if ($out instanceof \Exception) {
\Phake::when(Arsse::$db)->articleMark->thenThrow($out);
} else {
\Phake::when(Arsse::$db)->articleMark->thenReturn($out);
}
$this->assertMessage($exp, $this->req("PUT", $url));
if ($out !== null) {
\Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['read' => true], $c);
} else {
\Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
}
}
public function provideMassMarkings(): iterable {
self::clearData();
$c = (new Context)->hidden(false);
return [
["/users/42/mark-all-as-read", $c, 1123, new EmptyResponse(204)],
["/users/2112/mark-all-as-read", $c, null, new ErrorResponse("403", 403)],
["/feeds/47/mark-all-as-read", (clone $c)->subscription(47), 2112, new EmptyResponse(204)],
["/feeds/2112/mark-all-as-read", (clone $c)->subscription(2112), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
["/categories/47/mark-all-as-read", (clone $c)->folder(46), 1337, new EmptyResponse(204)],
["/categories/2112/mark-all-as-read", (clone $c)->folder(2111), new ExceptionInput("idMissing"), new ErrorResponse("404", 404)],
["/categories/1/mark-all-as-read", (clone $c)->folderShallow(0), 6666, new EmptyResponse(204)],
];
}
/** @dataProvider provideBookmarkTogglings */
public function testToggleABookmark($before, ?bool $after, ResponseInterface $exp): void {
$c = (new Context)->article(2112);
\Phake::when(Arsse::$db)->articleMark->thenReturn(1);
if ($before instanceof \Exception) {
\Phake::when(Arsse::$db)->articleCount->thenThrow($before);
} else {
\Phake::when(Arsse::$db)->articleCount->thenReturn($before);
}
$this->assertMessage($exp, $this->req("PUT", "/entries/2112/bookmark"));
if ($after !== null) {
\Phake::inOrder(
\Phake::verify(Arsse::$db)->begin(),
\Phake::verify(Arsse::$db)->articleCount(Arsse::$user->id, (clone $c)->starred(false)),
\Phake::verify(Arsse::$db)->articleMark(Arsse::$user->id, ['starred' => $after], $c),
\Phake::verify($this->transaction)->commit()
);
} else {
\Phake::inOrder(
\Phake::verify(Arsse::$db)->begin(),
\Phake::verify(Arsse::$db)->articleCount(Arsse::$user->id, (clone $c)->starred(false))
);
\Phake::verify(Arsse::$db, \Phake::times(0))->articleMark;
\Phake::verifyNoInteraction($this->transaction);
}
}
public function provideBookmarkTogglings(): iterable {
self::clearData();
return [
[1, true, new EmptyResponse(204)],
[0, false, new EmptyResponse(204)],
[new ExceptionInput("subjectMissing"), null, new ErrorResponse("404", 404)],
];
}
}

Loading…
Cancel
Save