Add RSS feeds for shared bookmarks (#656)

* Add shared bookmarks feed

* Add public shared bookmarks feed
This commit is contained in:
Sascha Ißbrücker 2024-03-17 11:55:34 +01:00 committed by GitHub
parent afb752765d
commit d0d5c15345
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 235 additions and 5 deletions

View file

@ -6,12 +6,12 @@ from django.db.models import QuerySet
from django.urls import reverse from django.urls import reverse
from bookmarks import queries from bookmarks import queries
from bookmarks.models import Bookmark, BookmarkSearch, FeedToken from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
@dataclass @dataclass
class FeedContext: class FeedContext:
feed_token: FeedToken feed_token: FeedToken | None
query_set: QuerySet[Bookmark] query_set: QuerySet[Bookmark]
@ -67,3 +67,39 @@ class UnreadBookmarksFeed(BaseBookmarksFeed):
def items(self, context: FeedContext): def items(self, context: FeedContext):
return context.query_set.filter(unread=True) 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

View file

@ -49,9 +49,11 @@
<section class="content-area"> <section class="content-area">
<h2>RSS Feeds</h2> <h2>RSS Feeds</h2>
<p>The following URLs provide RSS feeds for your bookmarks:</p> <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="{{ all_feed_url }}">All bookmarks</a></li>
<li><a href="{{ unread_feed_url }}">Unread 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> </ul>
<p> <p>
All URLs support appending a <code>q</code> URL parameter for specifying a search query. All URLs support appending a <code>q</code> URL parameter for specifying a search query.

View file

@ -194,7 +194,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.setup_bookmark(unread=True) self.setup_bookmark(unread=True)
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}" url = feed_url + f"?q={bookmark1.title}"
response = self.client.get(url) response = self.client.get(url)
@ -229,3 +229,172 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "<item>", count=0) 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)

View file

@ -74,3 +74,11 @@ class SettingsIntegrationsViewTestCase(TestCase, BookmarkFactoryMixin):
f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>', f'<a href="http://testserver/feeds/{token.key}/unread">Unread bookmarks</a>',
html, 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,
)

View file

@ -4,7 +4,12 @@ from django.views.generic import RedirectView
from bookmarks import views from bookmarks import views
from bookmarks.api.routes import router 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 from bookmarks.views import partials
app_name = "bookmarks" app_name = "bookmarks"
@ -77,6 +82,8 @@ urlpatterns = [
# Feeds # Feeds
path("feeds/<str:feed_key>/all", AllBookmarksFeed(), name="feeds.all"), 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>/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 # Health check
path("health", views.health, name="health"), path("health", views.health, name="health"),
# Manifest # Manifest

View file

@ -114,6 +114,12 @@ def integrations(request):
unread_feed_url = request.build_absolute_uri( unread_feed_url = request.build_absolute_uri(
reverse("bookmarks:feeds.unread", args=[feed_token.key]) 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( return render(
request, request,
"settings/integrations.html", "settings/integrations.html",
@ -122,6 +128,8 @@ def integrations(request):
"api_token": api_token.key, "api_token": api_token.key,
"all_feed_url": all_feed_url, "all_feed_url": all_feed_url,
"unread_feed_url": unread_feed_url, "unread_feed_url": unread_feed_url,
"shared_feed_url": shared_feed_url,
"public_shared_feed_url": public_shared_feed_url,
}, },
) )