Repository: sissbruecker/linkding
Pull request #810 (2024-08-31 20:34:39)

Return bookmark tags in RSS feeds
Closes https://github.com/sissbruecker/linkding/issues/784


Patch:
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 %}
-    <div class="settings-page">
+  <div class="settings-page">
 
-        {% include 'settings/nav.html' %}
+    {% include 'settings/nav.html' %}
 
-        <section class="content-area">
-            <h2>Browser Extension</h2>
-            <p>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:</p>
-            <ul>
-                <li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
-                <li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
-            </ul>
-            <p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a> as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
-            <h2>Bookmarklet</h2>
-            <p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application
-                first. Here's how it works:</p>
-            <ul>
-                <li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
-                <li>Open the website that you want to bookmark</li>
-                <li>Click the bookmarklet in your browsers toolbar</li>
-                <li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
-                <li>After saving the bookmark the linkding window closes and you are back on your website</li>
-            </ul>
-            <p>Drag the following bookmarklet to your browsers toolbar:</p>
-            <a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
-               class="btn btn-primary">📎 Add bookmark</a>
-        </section>
+    <section class="content-area">
+      <h2>Browser Extension</h2>
+      <p>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:</p>
+      <ul>
+        <li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
+        <li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
+               target="_blank">Chrome</a></li>
+      </ul>
+      <p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a>
+        as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
+      <h2>Bookmarklet</h2>
+      <p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
+        application first. Here's how it works:</p>
+      <ul>
+        <li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
+        <li>Open the website that you want to bookmark</li>
+        <li>Click the bookmarklet in your browsers toolbar</li>
+        <li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
+        <li>After saving the bookmark the linkding window closes and you are back on your website</li>
+      </ul>
+      <p>Drag the following bookmarklet to your browser's toolbar:</p>
+      <a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
+         class="btn btn-primary">📎 Add bookmark</a>
+    </section>
 
-        <section class="content-area">
-            <h2>REST API</h2>
-            <p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
-            <div class="form-group">
-                <div class="columns">
-                    <div class="column width-50 width-md-100">
-                        <input class="form-input" value="{{ api_token }}" readonly>
-                    </div>
-                </div>
-            </div>
-            <p>
-              <strong>Please treat this token as you would any other credential.</strong>
-              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 <a href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
-              After deleting the token, a new one will be generated when you reload this settings page.
-            </p>
-        </section>
+    <section class="content-area">
+      <h2>REST API</h2>
+      <p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
+      <div class="form-group">
+        <div class="columns">
+          <div class="column width-50 width-md-100">
+            <input class="form-input" value="{{ api_token }}" readonly>
+          </div>
+        </div>
+      </div>
+      <p>
+        <strong>Please treat this token as you would any other credential.</strong>
+        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 <a
+          href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
+        After deleting the token, a new one will be generated when you reload this settings page.
+      </p>
+    </section>
 
-        <section class="content-area">
-            <h2>RSS Feeds</h2>
-            <p>The following URLs provide RSS feeds for your bookmarks:</p>
-            <ul style="list-style-position: outside;">
-              <li><a href="{{ all_feed_url }}">All bookmarks</a></li>
-              <li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
-              <li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
-              <li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span></li>
-            </ul>
-            <p>
-              All URLs support appending a <code>q</code> 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.
-            </p>
-            <p>
-              <strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong>
-              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 <a href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
-              After deleting the feed token, new URLs will be generated when you reload this settings page.
-            </p>
-        </section>
-    </div>
+    <section class="content-area">
+      <h2>RSS Feeds</h2>
+      <p>The following URLs provide RSS feeds for your bookmarks:</p>
+      <ul style="list-style-position: outside;">
+        <li><a href="{{ all_feed_url }}">All bookmarks</a></li>
+        <li><a href="{{ unread_feed_url }}">Unread bookmarks</a></li>
+        <li><a href="{{ shared_feed_url }}">Shared bookmarks</a></li>
+        <li><a href="{{ public_shared_feed_url }}">Public shared bookmarks</a><br><span class="text-small text-gray">The public shared feed does not contain an authentication token and can be shared with other people. Only shows shared bookmarks from users who have explicitly enabled public sharing.</span>
+        </li>
+      </ul>
+      <p>
+        All URLs support the following URL parameters:
+      </p>
+      <ul style="list-style-position: outside;">
+        <li>A <code>q</code> 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.
+        </li>
+        <li>A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By
+          default, only the latest 100 matching bookmarks are included.
+        </li>
+      </ul>
+      <p>
+        <strong>Please note that these URLs include an authentication token that should be treated like any other
+          credential.</strong>
+        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 <a
+          href="{% url 'admin:bookmarks_feedtoken_changelist' %}">admin panel</a>.
+        After deleting the feed token, new URLs will be generated when you reload this settings page.
+      </p>
+    </section>
+  </div>
 {% endblock %}


Test patch:
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, "<item>", count=len(bookmarks))
+
+        for bookmark in bookmarks:
+            categories = []
+            for tag in bookmark.tag_names:
+                categories.append(f"<category>{tag}</category>")
+
+            expected_item = (
+                "<item>"
+                f"<title>{bookmark.resolved_title}</title>"
+                f"<link>{bookmark.url}</link>"
+                f"<description>{bookmark.resolved_description}</description>"
+                f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
+                f"<guid>{bookmark.url}</guid>"
+                f"{''.join(categories)}"
+                "</item>"
+            )
+            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, "<item>", count=len(bookmarks))
-
-        for bookmark in bookmarks:
-            expected_item = (
-                "<item>"
-                f"<title>{bookmark.resolved_title}</title>"
-                f"<link>{bookmark.url}</link>"
-                f"<description>{bookmark.resolved_description}</description>"
-                f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
-                f"<guid>{bookmark.url}</guid>"
-                "</item>"
-            )
-            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, "<item>", count=1)
-        self.assertContains(response, f"<guid>{bookmark1.url}</guid>", 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, "<item>", count=2)
-        self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
-        self.assertContains(response, f"<guid>{bookmark3.url}</guid>", 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, "<item>", count=1)
-        self.assertContains(response, f"<guid>{bookmark2.url}</guid>", 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, "<item>", 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, "<item>", count=1)
-        self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
-        self.assertContains(
-            response, f"<description>test\n\r\tdescription</description>", 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, "<item>", count=len(unread_bookmarks))
-
-        for bookmark in unread_bookmarks:
-            expected_item = (
-                "<item>"
-                f"<title>{bookmark.resolved_title}</title>"
-                f"<link>{bookmark.url}</link>"
-                f"<description>{bookmark.resolved_description}</description>"
-                f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
-                f"<guid>{bookmark.url}</guid>"
-                "</item>"
-            )
-            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, "<item>", count=1)
-        self.assertContains(response, f"<guid>{bookmark1.url}</guid>", 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, "<item>", count=2)
-        self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
-        self.assertContains(response, f"<guid>{bookmark3.url}</guid>", 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, "<item>", count=1)
-        self.assertContains(response, f"<guid>{bookmark2.url}</guid>", 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, "<item>", count=len(shared_bookmarks))
-
-        for bookmark in shared_bookmarks:
-            expected_item = (
-                "<item>"
-                f"<title>{bookmark.resolved_title}</title>"
-                f"<link>{bookmark.url}</link>"
-                f"<description>{bookmark.resolved_description}</description>"
-                f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
-                f"<guid>{bookmark.url}</guid>"
-                "</item>"
-            )
-            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, "<item>", count=1)
-        self.assertContains(response, f"<guid>{bookmark1.url}</guid>", 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, "<item>", count=2)
-        self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
-        self.assertContains(response, f"<guid>{bookmark3.url}</guid>", 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, "<item>", count=1)
-        self.assertContains(response, f"<guid>{bookmark2.url}</guid>", 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, "<item>", count=len(public_shared_bookmarks))
-
-        for bookmark in public_shared_bookmarks:
-            expected_item = (
-                "<item>"
-                f"<title>{bookmark.resolved_title}</title>"
-                f"<link>{bookmark.url}</link>"
-                f"<description>{bookmark.resolved_description}</description>"
-                f"<pubDate>{rfc2822_date(bookmark.date_added)}</pubDate>"
-                f"<guid>{bookmark.url}</guid>"
-                "</item>"
-            )
-            self.assertContains(response, expected_item, count=1)
-
-    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)
+    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])
 
-        self.setup_bookmark(shared=True, user=user)
-        self.setup_bookmark(shared=True, user=user)
-        self.setup_bookmark(shared=True, user=user)
+        self.setup_bookmark()
+        self.setup_bookmark()
+        self.setup_bookmark()
 
-        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, "<item>", count=1)
         self.assertContains(response, f"<guid>{bookmark2.url}</guid>", 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, "<item>", 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, "<item>", 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, "<item>", 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, "<item>", count=1)
+        self.assertContains(response, f"<title>test\n\r\ttitle</title>", count=1)
+        self.assertContains(
+            response, f"<description>test\n\r\tdescription</description>", count=1
+        )
+
+    def test_sanitize_with_none_text(self):
+        self.assertEqual("", sanitize(None))