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:
+
+
+ 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.
+
+ A limit
parameter for specifying the maximum number of bookmarks to include in the feed. By
+ default, only the latest 100 matching bookmarks are included.
+
+
+
+ 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))