mirror of
https://code.mensbeam.com/MensBeam/Arsse.git
synced 2024-12-22 13:12:41 +00:00
Implement article marking
This commit is contained in:
parent
334a585cb8
commit
ab1cf7447b
2 changed files with 178 additions and 48 deletions
|
@ -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)));
|
||||
|
|
|
@ -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…
Reference in a new issue