diff --git a/bookmarks/feeds.py b/bookmarks/feeds.py index 6c8d9f4..bb3472b 100644 --- a/bookmarks/feeds.py +++ b/bookmarks/feeds.py @@ -2,7 +2,8 @@ import unicodedata from dataclasses import dataclass from django.contrib.syndication.views import Feed -from django.db.models import QuerySet +from django.db.models import QuerySet, prefetch_related_objects +from django.http import HttpRequest from django.urls import reverse from bookmarks import queries @@ -11,6 +12,7 @@ from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile @dataclass class FeedContext: + request: HttpRequest feed_token: FeedToken | None query_set: QuerySet[Bookmark] @@ -26,13 +28,23 @@ def sanitize(text: str): class BaseBookmarksFeed(Feed): - def get_object(self, request, feed_key: str): - feed_token = FeedToken.objects.get(key__exact=feed_key) + def get_object(self, request, feed_key: str | None): + feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None search = BookmarkSearch(q=request.GET.get("q", "")) - query_set = queries.query_bookmarks( - feed_token.user, feed_token.user.profile, search - ) - return FeedContext(feed_token, query_set) + query_set = self.get_query_set(feed_token, search) + return FeedContext(request, feed_token, query_set) + + def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch): + raise NotImplementedError + + def items(self, context: FeedContext): + limit = context.request.GET.get("limit", 100) + if limit: + data = context.query_set[: int(limit)] + else: + data = list(context.query_set) + prefetch_related_objects(data, "tags") + return data def item_title(self, item: Bookmark): return sanitize(item.resolved_title) @@ -46,60 +58,56 @@ class BaseBookmarksFeed(Feed): def item_pubdate(self, item: Bookmark): return item.date_added + def item_categories(self, item: Bookmark): + return item.tag_names + class AllBookmarksFeed(BaseBookmarksFeed): title = "All bookmarks" description = "All bookmarks" + def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch): + return queries.query_bookmarks(feed_token.user, feed_token.user.profile, search) + def link(self, context: FeedContext): return reverse("bookmarks:feeds.all", args=[context.feed_token.key]) - def items(self, context: FeedContext): - return context.query_set - class UnreadBookmarksFeed(BaseBookmarksFeed): title = "Unread bookmarks" description = "All unread bookmarks" + def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch): + return queries.query_bookmarks( + feed_token.user, feed_token.user.profile, search + ).filter(unread=True) + def link(self, context: FeedContext): return reverse("bookmarks:feeds.unread", args=[context.feed_token.key]) - def items(self, context: FeedContext): - return context.query_set.filter(unread=True) - class SharedBookmarksFeed(BaseBookmarksFeed): title = "Shared bookmarks" description = "All shared bookmarks" - def get_object(self, request, feed_key: str): - feed_token = FeedToken.objects.get(key__exact=feed_key) - search = BookmarkSearch(q=request.GET.get("q", "")) - query_set = queries.query_shared_bookmarks( + def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch): + return queries.query_shared_bookmarks( None, feed_token.user.profile, search, False ) - return FeedContext(feed_token, query_set) def link(self, context: FeedContext): return reverse("bookmarks:feeds.shared", args=[context.feed_token.key]) - def items(self, context: FeedContext): - return context.query_set - class PublicSharedBookmarksFeed(BaseBookmarksFeed): title = "Public shared bookmarks" description = "All public shared bookmarks" def get_object(self, request): - search = BookmarkSearch(q=request.GET.get("q", "")) - default_profile = UserProfile() - query_set = queries.query_shared_bookmarks(None, default_profile, search, True) - return FeedContext(None, query_set) + return super().get_object(request, None) + + def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch): + return queries.query_shared_bookmarks(None, UserProfile(), search, True) def link(self, context: FeedContext): return reverse("bookmarks:feeds.public_shared") - - def items(self, context: FeedContext): - return context.query_set diff --git a/bookmarks/templates/settings/integrations.html b/bookmarks/templates/settings/integrations.html index c2781de..02d3d40 100644 --- a/bookmarks/templates/settings/integrations.html +++ b/bookmarks/templates/settings/integrations.html @@ -1,70 +1,84 @@ {% extends "bookmarks/layout.html" %} {% block content %} -
+
- {% include 'settings/nav.html' %} + {% include 'settings/nav.html' %} -
-

Browser Extension

-

The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The extension is available in the official extension stores for:

- -

The extension is open source as well, which enables you to build and manually load it into any browser that supports Chrome extensions.

-

Bookmarklet

-

The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application - first. Here's how it works:

-
    -
  • Drag the bookmarklet below into your browsers bookmark bar / toolbar
  • -
  • Open the website that you want to bookmark
  • -
  • Click the bookmarklet in your browsers toolbar
  • -
  • linkding opens in a new window or tab and allows you to add a bookmark for the site
  • -
  • After saving the bookmark the linkding window closes and you are back on your website
  • -
-

Drag the following bookmarklet to your browsers toolbar:

- 📎 Add bookmark -
+
+

Browser Extension

+

The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The + extension is available in the official extension stores for:

+ +

The extension is open source + as well, which enables you to build and manually load it into any browser that supports Chrome extensions.

+

Bookmarklet

+

The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding + application first. Here's how it works:

+
    +
  • Drag the bookmarklet below into your browsers bookmark bar / toolbar
  • +
  • Open the website that you want to bookmark
  • +
  • Click the bookmarklet in your browsers toolbar
  • +
  • linkding opens in a new window or tab and allows you to add a bookmark for the site
  • +
  • After saving the bookmark the linkding window closes and you are back on your website
  • +
+

Drag the following bookmarklet to your browser's toolbar:

+ 📎 Add bookmark +
-
-

REST API

-

The following token can be used to authenticate 3rd-party applications against the REST API:

-
-
-
- -
-
-
-

- Please treat this token as you would any other credential. - Any party with access to this token can access and manage all your bookmarks. - If you think that a token was compromised you can revoke (delete) it in the admin panel. - After deleting the token, a new one will be generated when you reload this settings page. -

-
+
+

REST API

+

The following token can be used to authenticate 3rd-party applications against the REST API:

+
+
+
+ +
+
+
+

+ Please treat this token as you would any other credential. + Any party with access to this token can access and manage all your bookmarks. + If you think that a token was compromised you can revoke (delete) it in the admin panel. + After deleting the token, a new one will be generated when you reload this settings page. +

+
-
-

RSS Feeds

-

The following URLs provide RSS feeds for your bookmarks:

- -

- All URLs support appending a q URL parameter for specifying a search query. - You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL. -

-

- Please note that these URLs include an authentication token that should be treated like any other credential. - Any party with access to these URLs can read all your bookmarks. - If you think that a URL was compromised you can delete the feed token for your user in the admin panel. - After deleting the feed token, new URLs will be generated when you reload this settings page. -

-
-
+
+

RSS Feeds

+

The following URLs provide RSS feeds for your bookmarks:

+ +

+ All URLs support the following URL parameters: +

+ +

+ Please note that these URLs include an authentication token that should be treated like any other + credential. + Any party with access to these URLs can read all your bookmarks. + If you think that a URL was compromised you can delete the feed token for your user in the admin panel. + After deleting the feed token, new URLs will be generated when you reload this settings page. +

+
+
{% endblock %} diff --git a/bookmarks/tests/test_feeds.py b/bookmarks/tests/test_feeds.py index 82577f3..1dba4d6 100644 --- a/bookmarks/tests/test_feeds.py +++ b/bookmarks/tests/test_feeds.py @@ -23,6 +23,26 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin): self.client.force_login(user) self.token = FeedToken.objects.get_or_create(user=user)[0] + def assertFeedItems(self, response, bookmarks): + self.assertContains(response, "", count=len(bookmarks)) + + for bookmark in bookmarks: + categories = [] + for tag in bookmark.tag_names: + categories.append(f"{tag}") + + expected_item = ( + "" + f"{bookmark.resolved_title}" + f"{bookmark.url}" + f"{bookmark.resolved_description}" + f"{rfc2822_date(bookmark.date_added)}" + f"{bookmark.url}" + f"{''.join(categories)}" + "" + ) + self.assertContains(response, expected_item, count=1) + def test_all_returns_404_for_unknown_feed_token(self): response = self.client.get(reverse("bookmarks:feeds.all", args=["foo"])) @@ -54,51 +74,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin): reverse("bookmarks:feeds.all", args=[self.token.key]) ) self.assertEqual(response.status_code, 200) - - self.assertContains(response, "", count=len(bookmarks)) - - for bookmark in bookmarks: - expected_item = ( - "" - f"{bookmark.resolved_title}" - f"{bookmark.url}" - f"{bookmark.resolved_description}" - f"{rfc2822_date(bookmark.date_added)}" - f"{bookmark.url}" - "" - ) - self.assertContains(response, expected_item, count=1) - - def test_all_with_query(self): - tag1 = self.setup_tag() - bookmark1 = self.setup_bookmark() - bookmark2 = self.setup_bookmark(tags=[tag1]) - bookmark3 = self.setup_bookmark(tags=[tag1]) - - self.setup_bookmark() - self.setup_bookmark() - self.setup_bookmark() - - feed_url = reverse("bookmarks:feeds.all", args=[self.token.key]) - - url = feed_url + f"?q={bookmark1.title}" - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=1) - self.assertContains(response, f"{bookmark1.url}", count=1) - - url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=2) - self.assertContains(response, f"{bookmark2.url}", count=1) - self.assertContains(response, f"{bookmark3.url}", count=1) - - url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}") - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=1) - self.assertContains(response, f"{bookmark2.url}", count=1) + self.assertFeedItems(response, bookmarks) def test_all_returns_only_user_owned_bookmarks(self): other_user = User.objects.create_user( @@ -115,23 +91,6 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin): self.assertContains(response, "", count=0) - def test_strip_control_characters(self): - self.setup_bookmark( - title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description" - ) - response = self.client.get( - reverse("bookmarks:feeds.all", args=[self.token.key]) - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=1) - self.assertContains(response, f"test\n\r\ttitle", count=1) - self.assertContains( - response, f"test\n\r\tdescription", count=1 - ) - - def test_sanitize_with_none_text(self): - self.assertEqual("", sanitize(None)) - def test_unread_returns_404_for_unknown_feed_token(self): response = self.client.get(reverse("bookmarks:feeds.unread", args=["foo"])) @@ -169,51 +128,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin): reverse("bookmarks:feeds.unread", args=[self.token.key]) ) self.assertEqual(response.status_code, 200) - - self.assertContains(response, "", count=len(unread_bookmarks)) - - for bookmark in unread_bookmarks: - expected_item = ( - "" - f"{bookmark.resolved_title}" - f"{bookmark.url}" - f"{bookmark.resolved_description}" - f"{rfc2822_date(bookmark.date_added)}" - f"{bookmark.url}" - "" - ) - self.assertContains(response, expected_item, count=1) - - def test_unread_with_query(self): - tag1 = self.setup_tag() - bookmark1 = self.setup_bookmark(unread=True) - bookmark2 = self.setup_bookmark(unread=True, tags=[tag1]) - bookmark3 = self.setup_bookmark(unread=True, tags=[tag1]) - - self.setup_bookmark(unread=True) - self.setup_bookmark(unread=True) - self.setup_bookmark(unread=True) - - feed_url = reverse("bookmarks:feeds.unread", args=[self.token.key]) - - url = feed_url + f"?q={bookmark1.title}" - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=1) - self.assertContains(response, f"{bookmark1.url}", count=1) - - url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=2) - self.assertContains(response, f"{bookmark2.url}", count=1) - self.assertContains(response, f"{bookmark3.url}", count=1) - - url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}") - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=1) - self.assertContains(response, f"{bookmark2.url}", count=1) + self.assertFeedItems(response, unread_bookmarks) def test_unread_returns_only_user_owned_bookmarks(self): other_user = User.objects.create_user( @@ -265,53 +180,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin): reverse("bookmarks:feeds.shared", args=[self.token.key]) ) self.assertEqual(response.status_code, 200) - - self.assertContains(response, "", count=len(shared_bookmarks)) - - for bookmark in shared_bookmarks: - expected_item = ( - "" - f"{bookmark.resolved_title}" - f"{bookmark.url}" - f"{bookmark.resolved_description}" - f"{rfc2822_date(bookmark.date_added)}" - f"{bookmark.url}" - "" - ) - self.assertContains(response, expected_item, count=1) - - def test_shared_with_query(self): - user = self.setup_user(enable_sharing=True) - - tag1 = self.setup_tag(user=user) - bookmark1 = self.setup_bookmark(shared=True, user=user) - bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user) - bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user) - - self.setup_bookmark(shared=True, user=user) - self.setup_bookmark(shared=True, user=user) - self.setup_bookmark(shared=True, user=user) - - feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key]) - - url = feed_url + f"?q={bookmark1.title}" - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=1) - self.assertContains(response, f"{bookmark1.url}", count=1) - - url = feed_url + "?q=" + urllib.parse.quote("#" + tag1.name) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=2) - self.assertContains(response, f"{bookmark2.url}", count=1) - self.assertContains(response, f"{bookmark3.url}", count=1) - - url = feed_url + "?q=" + urllib.parse.quote(f"#{tag1.name} {bookmark2.title}") - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "", count=1) - self.assertContains(response, f"{bookmark2.url}", count=1) + self.assertFeedItems(response, shared_bookmarks) def test_public_shared_does_not_require_auth(self): response = self.client.get(reverse("bookmarks:feeds.public_shared")) @@ -351,34 +220,19 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin): response = self.client.get(reverse("bookmarks:feeds.public_shared")) self.assertEqual(response.status_code, 200) + self.assertFeedItems(response, public_shared_bookmarks) - self.assertContains(response, "", count=len(public_shared_bookmarks)) + def test_with_query(self): + tag1 = self.setup_tag() + bookmark1 = self.setup_bookmark() + bookmark2 = self.setup_bookmark(tags=[tag1]) + bookmark3 = self.setup_bookmark(tags=[tag1]) - for bookmark in public_shared_bookmarks: - expected_item = ( - "" - f"{bookmark.resolved_title}" - f"{bookmark.url}" - f"{bookmark.resolved_description}" - f"{rfc2822_date(bookmark.date_added)}" - f"{bookmark.url}" - "" - ) - self.assertContains(response, expected_item, count=1) + self.setup_bookmark() + self.setup_bookmark() + self.setup_bookmark() - def test_public_shared_with_query(self): - user = self.setup_user(enable_sharing=True, enable_public_sharing=True) - - tag1 = self.setup_tag(user=user) - bookmark1 = self.setup_bookmark(shared=True, user=user) - bookmark2 = self.setup_bookmark(shared=True, tags=[tag1], user=user) - bookmark3 = self.setup_bookmark(shared=True, tags=[tag1], user=user) - - self.setup_bookmark(shared=True, user=user) - self.setup_bookmark(shared=True, user=user) - self.setup_bookmark(shared=True, user=user) - - feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key]) + feed_url = reverse("bookmarks:feeds.all", args=[self.token.key]) url = feed_url + f"?q={bookmark1.title}" response = self.client.get(url) @@ -398,3 +252,59 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin): self.assertEqual(response.status_code, 200) self.assertContains(response, "", count=1) self.assertContains(response, f"{bookmark2.url}", count=1) + + def test_with_tags(self): + bookmarks = [ + self.setup_bookmark(description="test description"), + self.setup_bookmark( + description="test description", + tags=[self.setup_tag(), self.setup_tag()], + ), + ] + + response = self.client.get( + reverse("bookmarks:feeds.all", args=[self.token.key]) + ) + self.assertEqual(response.status_code, 200) + self.assertFeedItems(response, bookmarks) + + def test_with_limit(self): + self.setup_numbered_bookmarks(200) + + # without limit - defaults to 100 + response = self.client.get( + reverse("bookmarks:feeds.all", args=[self.token.key]) + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "", count=100) + + # with increased limit + response = self.client.get( + reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=200" + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "", count=200) + + # with decreased limit + response = self.client.get( + reverse("bookmarks:feeds.all", args=[self.token.key]) + "?limit=5" + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "", count=5) + + def test_strip_control_characters(self): + self.setup_bookmark( + title="test\n\r\t\0\x08title", description="test\n\r\t\0\x08description" + ) + response = self.client.get( + reverse("bookmarks:feeds.all", args=[self.token.key]) + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "", count=1) + self.assertContains(response, f"test\n\r\ttitle", count=1) + self.assertContains( + response, f"test\n\r\tdescription", count=1 + ) + + def test_sanitize_with_none_text(self): + self.assertEqual("", sanitize(None))