Repository: sissbruecker/linkding
Pull request #656 (2024-03-17 10:49:22)

Add RSS feeds for shared bookmarks
Closes https://github.com/sissbruecker/linkding/issues/452


Patch:
diff --git a/bookmarks/feeds.py b/bookmarks/feeds.py
index 7062818..6c8d9f4 100644
--- a/bookmarks/feeds.py
+++ b/bookmarks/feeds.py
@@ -6,12 +6,12 @@ from django.db.models import QuerySet
 from django.urls import reverse
 
 from bookmarks import queries
-from bookmarks.models import Bookmark, BookmarkSearch, FeedToken
+from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
 
 
 @dataclass
 class FeedContext:
-    feed_token: FeedToken
+    feed_token: FeedToken | None
     query_set: QuerySet[Bookmark]
 
 
@@ -67,3 +67,39 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
 
     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(
+            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)
+
+    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 8128fc8..c2781de 100644
--- a/bookmarks/templates/settings/integrations.html
+++ b/bookmarks/templates/settings/integrations.html
@@ -49,9 +49,11 @@
         <section class="content-area">
             <h2>RSS Feeds</h2>
             <p>The following URLs provide RSS feeds for your bookmarks:</p>
-            <ul>
+            <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.
diff --git a/bookmarks/urls.py b/bookmarks/urls.py
index 24e29e4..3b8c953 100644
--- a/bookmarks/urls.py
+++ b/bookmarks/urls.py
@@ -4,7 +4,12 @@ from django.views.generic import RedirectView
 
 from bookmarks import views
 from bookmarks.api.routes import router
-from bookmarks.feeds import AllBookmarksFeed, UnreadBookmarksFeed
+from bookmarks.feeds import (
+    AllBookmarksFeed,
+    UnreadBookmarksFeed,
+    SharedBookmarksFeed,
+    PublicSharedBookmarksFeed,
+)
 from bookmarks.views import partials
 
 app_name = "bookmarks"
@@ -77,6 +82,8 @@ urlpatterns = [
     # Feeds
     path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"),
     path("feeds/<str:feed_key>/unread", UnreadBookmarksFeed(), name="feeds.unread"),
+    path("feeds/<str:feed_key>/shared", SharedBookmarksFeed(), name="feeds.shared"),
+    path("feeds/shared", PublicSharedBookmarksFeed(), name="feeds.public_shared"),
     # Health check
     path("health", views.health, name="health"),
     # Manifest
diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py
index 0c98f1b..33432a4 100644
--- a/bookmarks/views/settings.py
+++ b/bookmarks/views/settings.py
@@ -114,6 +114,12 @@ def integrations(request):
     unread_feed_url = request.build_absolute_uri(
         reverse("bookmarks:feeds.unread", args=[feed_token.key])
     )
+    shared_feed_url = request.build_absolute_uri(
+        reverse("bookmarks:feeds.shared", args=[feed_token.key])
+    )
+    public_shared_feed_url = request.build_absolute_uri(
+        reverse("bookmarks:feeds.public_shared")
+    )
     return render(
         request,
         "settings/integrations.html",
@@ -122,6 +128,8 @@ def integrations(request):
             "api_token": api_token.key,
             "all_feed_url": all_feed_url,
             "unread_feed_url": unread_feed_url,
+            "shared_feed_url": shared_feed_url,
+            "public_shared_feed_url": public_shared_feed_url,
         },
     )
 


Test patch:
diff --git a/bookmarks/tests/test_feeds.py b/bookmarks/tests/test_feeds.py
index 2123eab..82577f3 100644
--- a/bookmarks/tests/test_feeds.py
+++ b/bookmarks/tests/test_feeds.py
@@ -194,7 +194,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
         self.setup_bookmark(unread=True)
         self.setup_bookmark(unread=True)
 
-        feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
+        feed_url = reverse("bookmarks:feeds.unread", args=[self.token.key])
 
         url = feed_url + f"?q={bookmark1.title}"
         response = self.client.get(url)
@@ -229,3 +229,172 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
         self.assertEqual(response.status_code, 200)
 
         self.assertContains(response, "<item>", count=0)
+
+    def test_shared_returns_404_for_unknown_feed_token(self):
+        response = self.client.get(reverse("bookmarks:feeds.shared", args=["foo"]))
+
+        self.assertEqual(response.status_code, 404)
+
+    def test_shared_metadata(self):
+        feed_url = reverse("bookmarks:feeds.shared", args=[self.token.key])
+        response = self.client.get(feed_url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(response, "<title>Shared bookmarks</title>")
+        self.assertContains(response, "<description>All shared bookmarks</description>")
+        self.assertContains(response, f"<link>http://testserver{feed_url}</link>")
+        self.assertContains(
+            response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>'
+        )
+
+    def test_shared_returns_shared_bookmarks_only(self):
+        user1 = self.setup_user(enable_sharing=True)
+        user2 = self.setup_user(enable_sharing=False)
+
+        self.setup_bookmark()
+        self.setup_bookmark(shared=False, user=user1)
+        self.setup_bookmark(shared=True, user=user2)
+
+        shared_bookmarks = [
+            self.setup_bookmark(shared=True, user=user1, description="test"),
+            self.setup_bookmark(shared=True, user=user1, description="test"),
+            self.setup_bookmark(shared=True, user=user1, description="test"),
+        ]
+
+        response = self.client.get(
+            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)
+
+    def test_public_shared_does_not_require_auth(self):
+        response = self.client.get(reverse("bookmarks:feeds.public_shared"))
+
+        self.assertEqual(response.status_code, 200)
+
+    def test_public_shared_metadata(self):
+        feed_url = reverse("bookmarks:feeds.public_shared")
+        response = self.client.get(feed_url)
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(response, "<title>Public shared bookmarks</title>")
+        self.assertContains(
+            response, "<description>All public shared bookmarks</description>"
+        )
+        self.assertContains(response, f"<link>http://testserver{feed_url}</link>")
+        self.assertContains(
+            response, f'<atom:link href="http://testserver{feed_url}" rel="self"/>'
+        )
+
+    def test_public_shared_returns_publicly_shared_bookmarks_only(self):
+        user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
+        user2 = self.setup_user(enable_sharing=True)
+        user3 = self.setup_user(enable_sharing=False)
+
+        self.setup_bookmark()
+        self.setup_bookmark(shared=False, user=user1)
+        self.setup_bookmark(shared=False, user=user2)
+        self.setup_bookmark(shared=True, user=user2)
+        self.setup_bookmark(shared=True, user=user3)
+
+        public_shared_bookmarks = [
+            self.setup_bookmark(shared=True, user=user1, description="test"),
+            self.setup_bookmark(shared=True, user=user1, description="test"),
+            self.setup_bookmark(shared=True, user=user1, description="test"),
+        ]
+
+        response = self.client.get(reverse("bookmarks:feeds.public_shared"))
+        self.assertEqual(response.status_code, 200)
+
+        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)
+
+        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)
diff --git a/bookmarks/tests/test_settings_integrations_view.py b/bookmarks/tests/test_settings_integrations_view.py
index 44222ec..8098a96 100644
--- a/bookmarks/tests/test_settings_integrations_view.py
+++ b/bookmarks/tests/test_settings_integrations_view.py
@@ -74,3 +74,11 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
             f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>',
             html,
         )
+        self.assertInHTML(
+            f'<a href="http://testserver/feeds/{token.key}/shared">Shared bookmarks</a>',
+            html,
+        )
+        self.assertInHTML(
+            f'<a href="http://testserver/feeds/shared">Public shared bookmarks</a>',
+            html,
+        )