From 5c5b20c83bcff06dc336be4c2bc038dbc413b376 Mon Sep 17 00:00:00 2001 From: Srijith Nair Date: Wed, 2 Jul 2025 22:24:29 +0400 Subject: [PATCH] Add annotations filter to entries API endpoint Implement a new filter parameter 'annotations' for the GET /api/entries endpoint that allows filtering entries based on whether they have annotations. When annotations=1, only entries with one or more annotations are returned. When annotations=0, only entries without annotations are returned. This feature enables users to easily find annotated content through the API. --- .../Controller/EntryRestController.php | 15 +++- .../CoreBundle/Repository/EntryRepository.php | 15 +++- .../Controller/EntryRestControllerTest.php | 83 +++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/src/Wallabag/ApiBundle/Controller/EntryRestController.php b/src/Wallabag/ApiBundle/Controller/EntryRestController.php index 748e5b0cc..4b318ff37 100644 --- a/src/Wallabag/ApiBundle/Controller/EntryRestController.php +++ b/src/Wallabag/ApiBundle/Controller/EntryRestController.php @@ -269,6 +269,16 @@ class EntryRestController extends WallabagRestController * example="example.com", * ) * ), + * @OA\Parameter( + * name="annotations", + * in="query", + * description="filter by entries with annotations. Use 1 for entries with annotations, 0 for entries without annotations. All entries by default", + * required=false, + * @OA\Schema( + * type="integer", + * enum={"1", "0"} + * ) + * ), * @OA\Response( * response="200", * description="Returned when successful" @@ -294,6 +304,7 @@ class EntryRestController extends WallabagRestController $since = $request->query->get('since', 0); $detail = strtolower($request->query->get('detail', 'full')); $domainName = (null === $request->query->get('domain_name')) ? '' : (string) $request->query->get('domain_name'); + $hasAnnotations = (null === $request->query->get('annotations')) ? null : (bool) $request->query->get('annotations'); try { /** @var Pagerfanta $pager */ @@ -307,7 +318,8 @@ class EntryRestController extends WallabagRestController $since, $tags, $detail, - $domainName + $domainName, + $hasAnnotations ); } catch (\Exception $e) { throw new BadRequestHttpException($e->getMessage()); @@ -332,6 +344,7 @@ class EntryRestController extends WallabagRestController 'tags' => $tags, 'since' => $since, 'detail' => $detail, + 'annotations' => $hasAnnotations, ], true ) diff --git a/src/Wallabag/CoreBundle/Repository/EntryRepository.php b/src/Wallabag/CoreBundle/Repository/EntryRepository.php index 2520e0f87..b0a0137a6 100644 --- a/src/Wallabag/CoreBundle/Repository/EntryRepository.php +++ b/src/Wallabag/CoreBundle/Repository/EntryRepository.php @@ -266,14 +266,15 @@ class EntryRepository extends ServiceEntityRepository * @param string $order * @param int $since * @param string $tags - * @param string $detail 'metadata' or 'full'. Include content field if 'full' + * @param string $detail 'metadata' or 'full'. Include content field if 'full' * @param string $domainName + * @param bool $hasAnnotations * * @todo Breaking change: replace default detail=full by detail=metadata in a future version * * @return Pagerfanta */ - public function findEntries($userId, $isArchived = null, $isStarred = null, $isPublic = null, $sort = 'created', $order = 'asc', $since = 0, $tags = '', $detail = 'full', $domainName = '') + public function findEntries($userId, $isArchived = null, $isStarred = null, $isPublic = null, $sort = 'created', $order = 'asc', $since = 0, $tags = '', $detail = 'full', $domainName = '', $hasAnnotations = null) { if (!\in_array(strtolower($detail), ['full', 'metadata'], true)) { throw new \Exception('Detail "' . $detail . '" parameter is wrong, allowed: full or metadata'); @@ -332,6 +333,16 @@ class EntryRepository extends ServiceEntityRepository $qb->andWhere('e.domainName = :domainName')->setParameter('domainName', $domainName); } + if (null !== $hasAnnotations) { + if ($hasAnnotations) { + $qb->leftJoin('e.annotations', 'a') + ->andWhere('a.id IS NOT NULL'); + } else { + $qb->leftJoin('e.annotations', 'a') + ->andWhere('a.id IS NULL'); + } + } + if (!\in_array(strtolower($order), ['asc', 'desc'], true)) { throw new \Exception('Order "' . $order . '" parameter is wrong, allowed: asc or desc'); } diff --git a/tests/Wallabag/ApiBundle/Controller/EntryRestControllerTest.php b/tests/Wallabag/ApiBundle/Controller/EntryRestControllerTest.php index f2cd1f873..d6f219233 100644 --- a/tests/Wallabag/ApiBundle/Controller/EntryRestControllerTest.php +++ b/tests/Wallabag/ApiBundle/Controller/EntryRestControllerTest.php @@ -190,6 +190,7 @@ class EntryRestControllerTest extends WallabagApiTestCase 'tags' => 'foo', 'since' => 1443274283, 'public' => 0, + 'annotations' => 1, ]); $this->assertSame(200, $this->client->getResponse()->getStatusCode()); @@ -217,6 +218,7 @@ class EntryRestControllerTest extends WallabagApiTestCase $this->assertStringContainsString('tags=foo', $content['_links'][$link]['href']); $this->assertStringContainsString('since=1443274283', $content['_links'][$link]['href']); $this->assertStringContainsString('public=0', $content['_links'][$link]['href']); + $this->assertStringContainsString('annotations=1', $content['_links'][$link]['href']); } $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); @@ -1383,4 +1385,85 @@ class EntryRestControllerTest extends WallabagApiTestCase $this->assertGreaterThan(0, $content['id']); $this->assertSame('https://www.lemonde.fr/m-perso/article/2017/06/25/antoine-de-caunes-je-veux-avoir-le-droit-de-tatonner_5150728_4497916.html', $content['url']); } + + public function testGetEntriesWithAnnotationsFilter() + { + // Test filter for entries WITH annotations + // From fixtures: entry1, entry2 have annotations (for admin-user), entry3 has annotations (for bob-user) + // entry4, entry5, entry6 don't have annotations + $this->client->request('GET', '/api/entries', [ + 'annotations' => 1, + ]); + + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + + $content = json_decode($this->client->getResponse()->getContent(), true); + + $this->assertArrayHasKey('items', $content['_embedded']); + + // Check that only entries with annotations are returned + $entriesWithAnnotations = ['http://0.0.0.0/entry1', 'http://0.0.0.0/entry2']; + $entriesWithoutAnnotations = ['http://0.0.0.0/entry4', 'http://0.0.0.0/entry5', 'http://0.0.0.0/entry6']; + + foreach ($content['_embedded']['items'] as $item) { + if (\in_array($item['url'], $entriesWithAnnotations, true)) { + $this->assertNotEmpty($item['annotations'], 'Entry with URL ' . $item['url'] . ' should have annotations'); + } + $this->assertNotContains($item['url'], $entriesWithoutAnnotations, 'Entry without annotations should NOT be in the results'); + } + + // Ensure we have at least the entries with annotations for admin-user + $foundUrls = array_column($content['_embedded']['items'], 'url'); + $this->assertContains('http://0.0.0.0/entry1', $foundUrls, 'entry1 with annotations should be in the results'); + $this->assertContains('http://0.0.0.0/entry2', $foundUrls, 'entry2 with annotations should be in the results'); + + // Check pagination links contain the filter + $this->assertArrayHasKey('_links', $content); + foreach (['self', 'first', 'last'] as $link) { + $this->assertArrayHasKey('href', $content['_links'][$link]); + $this->assertStringContainsString('annotations=1', $content['_links'][$link]['href']); + } + + $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); + } + + public function testGetEntriesWithoutAnnotationsFilter() + { + // Test filter for entries WITHOUT annotations + $this->client->request('GET', '/api/entries', [ + 'annotations' => 0, + ]); + + $this->assertSame(200, $this->client->getResponse()->getStatusCode()); + + $content = json_decode($this->client->getResponse()->getContent(), true); + + $this->assertArrayHasKey('items', $content['_embedded']); + + // Check that only entries without annotations are returned + $entriesWithoutAnnotations = ['http://0.0.0.0/entry4', 'http://0.0.0.0/entry5', 'http://0.0.0.0/entry6']; + $entriesWithAnnotations = ['http://0.0.0.0/entry1', 'http://0.0.0.0/entry2']; + + foreach ($content['_embedded']['items'] as $item) { + if (\in_array($item['url'], $entriesWithoutAnnotations, true)) { + $this->assertEmpty($item['annotations'], 'Entry with URL ' . $item['url'] . ' should NOT have annotations'); + } + $this->assertNotContains($item['url'], $entriesWithAnnotations, 'Entry with annotations should NOT be in the results'); + } + + // Ensure we have the entries without annotations for admin-user + $foundUrls = array_column($content['_embedded']['items'], 'url'); + $this->assertContains('http://0.0.0.0/entry4', $foundUrls, 'entry4 without annotations should be in the results'); + $this->assertContains('http://0.0.0.0/entry5', $foundUrls, 'entry5 without annotations should be in the results'); + $this->assertContains('http://0.0.0.0/entry6', $foundUrls, 'entry6 without annotations should be in the results'); + + // Check pagination links contain the filter + $this->assertArrayHasKey('_links', $content); + foreach (['self', 'first', 'last'] as $link) { + $this->assertArrayHasKey('href', $content['_links'][$link]); + $this->assertStringContainsString('annotations=0', $content['_links'][$link]['href']); + } + + $this->assertSame('application/json', $this->client->getResponse()->headers->get('Content-Type')); + } }