mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-03-07 02:13:12 +08:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b736464f3f | ||
|
|
7572aa5bc9 | ||
|
|
cb0301fd9e | ||
|
|
b30486317d | ||
|
|
1c6e5902db | ||
|
|
20fe88dd57 | ||
|
|
aad62f61c9 | ||
|
|
79bf4b38c6 | ||
|
|
5eadb3ede3 | ||
|
|
36749c398b | ||
|
|
190b5aeeca | ||
|
|
1122d18e18 | ||
|
|
0fe6304328 | ||
|
|
7d4e65976f | ||
|
|
749bc1ef63 | ||
|
|
36a84276a2 | ||
|
|
b72697b819 | ||
|
|
d9362c9b9c | ||
|
|
b0610db406 | ||
|
|
af16a9e727 | ||
|
|
d898c1be4d | ||
|
|
0282220307 | ||
|
|
bb243b382d | ||
|
|
fbc97a3841 | ||
|
|
380f5ed19c | ||
|
|
b28352fb28 |
47
CHANGELOG.md
47
CHANGELOG.md
@@ -1,5 +1,52 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v1.31.1 (30/08/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Include favicons and thumbnails in REST API by @sissbruecker in https://github.com/sissbruecker/linkding/pull/763
|
||||||
|
* Add Pinkt to the Community section by @fibelatti in https://github.com/sissbruecker/linkding/pull/772
|
||||||
|
* removed version line from docker compose yaml by @volumedata21 in https://github.com/sissbruecker/linkding/pull/800
|
||||||
|
* Add resource linkding logo by @QYG2297248353 in https://github.com/sissbruecker/linkding/pull/788
|
||||||
|
* Allow use of standard docker `TZ` env var by @watsonbox in https://github.com/sissbruecker/linkding/pull/765
|
||||||
|
* Add OCI source annotation to link back to source repo by @Ramblurr in https://github.com/sissbruecker/linkding/pull/701
|
||||||
|
* Generate fallback URLs for web archive links by @sissbruecker in https://github.com/sissbruecker/linkding/pull/804
|
||||||
|
* Fix overflow in settings page by @sissbruecker in https://github.com/sissbruecker/linkding/pull/805
|
||||||
|
* Bump django from 5.0.3 to 5.0.8 by @dependabot in https://github.com/sissbruecker/linkding/pull/795
|
||||||
|
* Bump certifi from 2023.11.17 to 2024.7.4 by @dependabot in https://github.com/sissbruecker/linkding/pull/775
|
||||||
|
* Bump djangorestframework from 3.14.0 to 3.15.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/769
|
||||||
|
* Bump urllib3 from 2.1.0 to 2.2.2 by @dependabot in https://github.com/sissbruecker/linkding/pull/762
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @fibelatti made their first contribution in https://github.com/sissbruecker/linkding/pull/772
|
||||||
|
* @volumedata21 made their first contribution in https://github.com/sissbruecker/linkding/pull/800
|
||||||
|
* @QYG2297248353 made their first contribution in https://github.com/sissbruecker/linkding/pull/788
|
||||||
|
* @watsonbox made their first contribution in https://github.com/sissbruecker/linkding/pull/765
|
||||||
|
* @Ramblurr made their first contribution in https://github.com/sissbruecker/linkding/pull/701
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.31.0...v1.31.1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.31.0 (16/06/2024)
|
||||||
|
|
||||||
|
### What's Changed
|
||||||
|
* Add support for bookmark thumbnails by @vslinko in https://github.com/sissbruecker/linkding/pull/721
|
||||||
|
* Automatically add tags to bookmarks based on URL pattern by @vslinko in https://github.com/sissbruecker/linkding/pull/736
|
||||||
|
* Load bookmark thumbnails after import by @vslinko in https://github.com/sissbruecker/linkding/pull/724
|
||||||
|
* Load missing thumbnails after enabling the feature by @sissbruecker in https://github.com/sissbruecker/linkding/pull/725
|
||||||
|
* Thumbnails lazy loading by @vslinko in https://github.com/sissbruecker/linkding/pull/734
|
||||||
|
* Add option for disabling tag grouping by @vslinko in https://github.com/sissbruecker/linkding/pull/735
|
||||||
|
* Preview auto tags in bookmark form by @sissbruecker in https://github.com/sissbruecker/linkding/pull/737
|
||||||
|
* Hide tooltip on mobile by @vslinko in https://github.com/sissbruecker/linkding/pull/733
|
||||||
|
* Bump requests from 2.31.0 to 2.32.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/740
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
* @vslinko made their first contribution in https://github.com/sissbruecker/linkding/pull/721
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.30.0...v1.31.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v1.30.0 (20/04/2024)
|
## v1.30.0 (20/04/2024)
|
||||||
|
|
||||||
### What's Changed
|
### What's Changed
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -7,7 +7,7 @@ tasks:
|
|||||||
python manage.py process_tasks
|
python manage.py process_tasks
|
||||||
|
|
||||||
test:
|
test:
|
||||||
pytest
|
pytest -n auto
|
||||||
|
|
||||||
format:
|
format:
|
||||||
black bookmarks
|
black bookmarks
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ This section lists community projects around using linkding, in alphabetical ord
|
|||||||
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
- [Linkdy](https://github.com/JGeek00/linkdy): An open source mobile and desktop (not yet) client created with Flutter. Available at the [Google Play Store](https://play.google.com/store/apps/details?id=com.jgeek00.linkdy). By [JGeek00](https://github.com/JGeek00).
|
||||||
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
- [LinkThing](https://apps.apple.com/us/app/linkthing/id1666031776) An iOS client for linkding. By [amoscardino](https://github.com/amoscardino)
|
||||||
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
- [Open all links bookmarklet](https://gist.github.com/ukcuddlyguy/336dd7339e6d35fc64a75ccfc9323c66) A browser bookmarklet to open all links on the current Linkding page in new tabs. By [ukcuddlyguy](https://github.com/ukcuddlyguy)
|
||||||
|
- [Pinkt](https://github.com/fibelatti/pinboard-kotlin) An Android client for linkding. By [fibelatti](https://github.com/fibelatti)
|
||||||
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
- [Postman collection](https://gist.github.com/gingerbeardman/f0b42502f3bc9344e92ce63afd4360d3) a group of saved request templates for API testing. By [gingerbeardman](https://github.com/gingerbeardman)
|
||||||
|
|
||||||
## Acknowledgements + Donations
|
## Acknowledgements + Donations
|
||||||
|
|||||||
BIN
assets/logo.png
Normal file
BIN
assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
1
assets/logo.svg
Normal file
1
assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="1.5" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><circle cx="255.0164" cy="254.9236" fill="#5856e0" r="224.78528" stroke-width="1.18"/><g fill="none" stroke="#fff" stroke-width="31.25"><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(.70710678 .70710678 -.70710678 .70710678 284.139117 -1684.198509)"/><path d="m1244.39 1293.95v199.64s-.81 67.89 74.9 68.88c75.98.99 74.88-68.88 74.88-68.88v-199.64" transform="matrix(-.70957074 -.70463421 .70463421 -.70957074 235.113139 2195.434643)"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 688 B |
@@ -1,3 +1,5 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
from rest_framework import viewsets, mixins, status
|
from rest_framework import viewsets, mixins, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
@@ -19,6 +21,8 @@ from bookmarks.services.bookmarks import (
|
|||||||
)
|
)
|
||||||
from bookmarks.services.website_loader import WebsiteMetadata
|
from bookmarks.services.website_loader import WebsiteMetadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkViewSet(
|
class BookmarkViewSet(
|
||||||
viewsets.GenericViewSet,
|
viewsets.GenericViewSet,
|
||||||
@@ -52,7 +56,7 @@ class BookmarkViewSet(
|
|||||||
return Bookmark.objects.all().filter(owner=user)
|
return Bookmark.objects.all().filter(owner=user)
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
return {"user": self.request.user}
|
return {"request": self.request, "user": self.request.user}
|
||||||
|
|
||||||
@action(methods=["get"], detail=False)
|
@action(methods=["get"], detail=False)
|
||||||
def archived(self, request):
|
def archived(self, request):
|
||||||
@@ -60,8 +64,8 @@ class BookmarkViewSet(
|
|||||||
search = BookmarkSearch.from_request(request.GET)
|
search = BookmarkSearch.from_request(request.GET)
|
||||||
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
||||||
page = self.paginate_queryset(query_set)
|
page = self.paginate_queryset(query_set)
|
||||||
serializer = self.get_serializer_class()
|
serializer = self.get_serializer(page, many=True)
|
||||||
data = serializer(page, many=True).data
|
data = serializer.data
|
||||||
return self.get_paginated_response(data)
|
return self.get_paginated_response(data)
|
||||||
|
|
||||||
@action(methods=["get"], detail=False)
|
@action(methods=["get"], detail=False)
|
||||||
@@ -73,8 +77,8 @@ class BookmarkViewSet(
|
|||||||
user, request.user_profile, search, public_only
|
user, request.user_profile, search, public_only
|
||||||
)
|
)
|
||||||
page = self.paginate_queryset(query_set)
|
page = self.paginate_queryset(query_set)
|
||||||
serializer = self.get_serializer_class()
|
serializer = self.get_serializer(page, many=True)
|
||||||
data = serializer(page, many=True).data
|
data = serializer.data
|
||||||
return self.get_paginated_response(data)
|
return self.get_paginated_response(data)
|
||||||
|
|
||||||
@action(methods=["post"], detail=True)
|
@action(methods=["post"], detail=True)
|
||||||
@@ -112,7 +116,13 @@ class BookmarkViewSet(
|
|||||||
profile = request.user.profile
|
profile = request.user.profile
|
||||||
auto_tags = []
|
auto_tags = []
|
||||||
if profile.auto_tagging_rules:
|
if profile.auto_tagging_rules:
|
||||||
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
|
try:
|
||||||
|
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to auto-tag bookmark. url={bookmark.url}",
|
||||||
|
exc_info=e,
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.db.models import prefetch_related_objects
|
from django.db.models import prefetch_related_objects
|
||||||
|
from django.templatetags.static import static
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.serializers import ListSerializer
|
from rest_framework.serializers import ListSerializer
|
||||||
|
|
||||||
@@ -31,6 +32,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
"website_title",
|
"website_title",
|
||||||
"website_description",
|
"website_description",
|
||||||
"web_archive_snapshot_url",
|
"web_archive_snapshot_url",
|
||||||
|
"favicon_url",
|
||||||
|
"preview_image_url",
|
||||||
"is_archived",
|
"is_archived",
|
||||||
"unread",
|
"unread",
|
||||||
"shared",
|
"shared",
|
||||||
@@ -42,6 +45,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
"website_title",
|
"website_title",
|
||||||
"website_description",
|
"website_description",
|
||||||
"web_archive_snapshot_url",
|
"web_archive_snapshot_url",
|
||||||
|
"favicon_url",
|
||||||
|
"preview_image_url",
|
||||||
"date_added",
|
"date_added",
|
||||||
"date_modified",
|
"date_modified",
|
||||||
]
|
]
|
||||||
@@ -56,6 +61,24 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
|||||||
shared = serializers.BooleanField(required=False, default=False)
|
shared = serializers.BooleanField(required=False, default=False)
|
||||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||||
tag_names = TagListField(required=False, default=[])
|
tag_names = TagListField(required=False, default=[])
|
||||||
|
favicon_url = serializers.SerializerMethodField()
|
||||||
|
preview_image_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
def get_favicon_url(self, obj: Bookmark):
|
||||||
|
if not obj.favicon_file:
|
||||||
|
return None
|
||||||
|
request = self.context.get("request")
|
||||||
|
favicon_file_path = static(obj.favicon_file)
|
||||||
|
favicon_url = request.build_absolute_uri(favicon_file_path)
|
||||||
|
return favicon_url
|
||||||
|
|
||||||
|
def get_preview_image_url(self, obj: Bookmark):
|
||||||
|
if not obj.preview_image_file:
|
||||||
|
return None
|
||||||
|
request = self.context.get("request")
|
||||||
|
preview_image_file_path = static(obj.preview_image_file)
|
||||||
|
preview_image_url = request.build_absolute_uri(preview_image_file_path)
|
||||||
|
return preview_image_url
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
bookmark = Bookmark()
|
bookmark = Bookmark()
|
||||||
|
|||||||
@@ -18,19 +18,5 @@ def toasts(request):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def public_shares(request):
|
|
||||||
# Only check for public shares for anonymous users
|
|
||||||
if not request.user.is_authenticated:
|
|
||||||
query_set = queries.query_shared_bookmarks(
|
|
||||||
None, request.user_profile, BookmarkSearch(), True
|
|
||||||
)
|
|
||||||
has_public_shares = query_set.count() > 0
|
|
||||||
return {
|
|
||||||
"has_public_shares": has_public_shares,
|
|
||||||
}
|
|
||||||
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def app_version(request):
|
def app_version(request):
|
||||||
return {"app_version": utils.app_version}
|
return {"app_version": utils.app_version}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import unicodedata
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from django.contrib.syndication.views import Feed
|
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 django.urls import reverse
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
@@ -11,6 +12,7 @@ from bookmarks.models import Bookmark, BookmarkSearch, FeedToken, UserProfile
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class FeedContext:
|
class FeedContext:
|
||||||
|
request: HttpRequest
|
||||||
feed_token: FeedToken | None
|
feed_token: FeedToken | None
|
||||||
query_set: QuerySet[Bookmark]
|
query_set: QuerySet[Bookmark]
|
||||||
|
|
||||||
@@ -26,13 +28,27 @@ def sanitize(text: str):
|
|||||||
|
|
||||||
|
|
||||||
class BaseBookmarksFeed(Feed):
|
class BaseBookmarksFeed(Feed):
|
||||||
def get_object(self, request, feed_key: str):
|
def get_object(self, request, feed_key: str | None):
|
||||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
feed_token = FeedToken.objects.get(key__exact=feed_key) if feed_key else None
|
||||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
search = BookmarkSearch(
|
||||||
query_set = queries.query_bookmarks(
|
q=request.GET.get("q", ""),
|
||||||
feed_token.user, feed_token.user.profile, search
|
unread=request.GET.get("unread", ""),
|
||||||
|
shared=request.GET.get("shared", ""),
|
||||||
)
|
)
|
||||||
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):
|
def item_title(self, item: Bookmark):
|
||||||
return sanitize(item.resolved_title)
|
return sanitize(item.resolved_title)
|
||||||
@@ -46,60 +62,56 @@ class BaseBookmarksFeed(Feed):
|
|||||||
def item_pubdate(self, item: Bookmark):
|
def item_pubdate(self, item: Bookmark):
|
||||||
return item.date_added
|
return item.date_added
|
||||||
|
|
||||||
|
def item_categories(self, item: Bookmark):
|
||||||
|
return item.tag_names
|
||||||
|
|
||||||
|
|
||||||
class AllBookmarksFeed(BaseBookmarksFeed):
|
class AllBookmarksFeed(BaseBookmarksFeed):
|
||||||
title = "All bookmarks"
|
title = "All bookmarks"
|
||||||
description = "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):
|
def link(self, context: FeedContext):
|
||||||
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
return reverse("bookmarks:feeds.all", args=[context.feed_token.key])
|
||||||
|
|
||||||
def items(self, context: FeedContext):
|
|
||||||
return context.query_set
|
|
||||||
|
|
||||||
|
|
||||||
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
class UnreadBookmarksFeed(BaseBookmarksFeed):
|
||||||
title = "Unread bookmarks"
|
title = "Unread bookmarks"
|
||||||
description = "All 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):
|
def link(self, context: FeedContext):
|
||||||
return reverse("bookmarks:feeds.unread", args=[context.feed_token.key])
|
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):
|
class SharedBookmarksFeed(BaseBookmarksFeed):
|
||||||
title = "Shared bookmarks"
|
title = "Shared bookmarks"
|
||||||
description = "All shared bookmarks"
|
description = "All shared bookmarks"
|
||||||
|
|
||||||
def get_object(self, request, feed_key: str):
|
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||||
feed_token = FeedToken.objects.get(key__exact=feed_key)
|
return queries.query_shared_bookmarks(
|
||||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
|
||||||
query_set = queries.query_shared_bookmarks(
|
|
||||||
None, feed_token.user.profile, search, False
|
None, feed_token.user.profile, search, False
|
||||||
)
|
)
|
||||||
return FeedContext(feed_token, query_set)
|
|
||||||
|
|
||||||
def link(self, context: FeedContext):
|
def link(self, context: FeedContext):
|
||||||
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
return reverse("bookmarks:feeds.shared", args=[context.feed_token.key])
|
||||||
|
|
||||||
def items(self, context: FeedContext):
|
|
||||||
return context.query_set
|
|
||||||
|
|
||||||
|
|
||||||
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
class PublicSharedBookmarksFeed(BaseBookmarksFeed):
|
||||||
title = "Public shared bookmarks"
|
title = "Public shared bookmarks"
|
||||||
description = "All public shared bookmarks"
|
description = "All public shared bookmarks"
|
||||||
|
|
||||||
def get_object(self, request):
|
def get_object(self, request):
|
||||||
search = BookmarkSearch(q=request.GET.get("q", ""))
|
return super().get_object(request, None)
|
||||||
default_profile = UserProfile()
|
|
||||||
query_set = queries.query_shared_bookmarks(None, default_profile, search, True)
|
def get_query_set(self, feed_token: FeedToken, search: BookmarkSearch):
|
||||||
return FeedContext(None, query_set)
|
return queries.query_shared_bookmarks(None, UserProfile(), search, True)
|
||||||
|
|
||||||
def link(self, context: FeedContext):
|
def link(self, context: FeedContext):
|
||||||
return reverse("bookmarks:feeds.public_shared")
|
return reverse("bookmarks:feeds.public_shared")
|
||||||
|
|
||||||
def items(self, context: FeedContext):
|
|
||||||
return context.query_set
|
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.middleware import RemoteUserMiddleware
|
from django.contrib.auth.middleware import RemoteUserMiddleware
|
||||||
|
|
||||||
from bookmarks.models import UserProfile
|
from bookmarks.models import UserProfile, GlobalSettings
|
||||||
|
|
||||||
|
|
||||||
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
class CustomRemoteUserMiddleware(RemoteUserMiddleware):
|
||||||
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
header = settings.LD_AUTH_PROXY_USERNAME_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
standard_profile = UserProfile()
|
||||||
|
standard_profile.enable_favicons = True
|
||||||
|
|
||||||
|
|
||||||
class UserProfileMiddleware:
|
class UserProfileMiddleware:
|
||||||
def __init__(self, get_response):
|
def __init__(self, get_response):
|
||||||
self.get_response = get_response
|
self.get_response = get_response
|
||||||
@@ -16,8 +20,16 @@ class UserProfileMiddleware:
|
|||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
request.user_profile = request.user.profile
|
request.user_profile = request.user.profile
|
||||||
else:
|
else:
|
||||||
request.user_profile = UserProfile()
|
# check if a custom profile for guests exists, otherwise use standard profile
|
||||||
request.user_profile.enable_favicons = True
|
guest_profile = None
|
||||||
|
try:
|
||||||
|
global_settings = GlobalSettings.get()
|
||||||
|
if global_settings.guest_profile_user:
|
||||||
|
guest_profile = global_settings.guest_profile_user.profile
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
request.user_profile = guest_profile or standard_profile
|
||||||
|
|
||||||
response = self.get_response(request)
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
|||||||
38
bookmarks/migrations/0037_globalsettings.py
Normal file
38
bookmarks/migrations/0037_globalsettings.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 5.0.8 on 2024-08-31 12:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0036_userprofile_auto_tagging_rules"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="GlobalSettings",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"landing_page",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("login", "Login"),
|
||||||
|
("shared_bookmarks", "Shared Bookmarks"),
|
||||||
|
],
|
||||||
|
default="login",
|
||||||
|
max_length=50,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 5.0.8 on 2024-08-31 17:54
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookmarks", "0037_globalsettings"),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="globalsettings",
|
||||||
|
name="guest_profile_user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -84,7 +84,8 @@ class Bookmark(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def tag_names(self):
|
def tag_names(self):
|
||||||
return [tag.name for tag in self.tags.all()]
|
names = [tag.name for tag in self.tags.all()]
|
||||||
|
return sorted(names)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.resolved_title + " (" + self.url[:30] + "...)"
|
return self.resolved_title + " (" + self.url[:30] + "...)"
|
||||||
@@ -169,7 +170,9 @@ class BookmarkForm(forms.ModelForm):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def has_notes(self):
|
def has_notes(self):
|
||||||
return self.instance and self.instance.notes
|
return self.initial.get("notes", None) or (
|
||||||
|
self.instance and self.instance.notes
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookmarkSearch:
|
class BookmarkSearch:
|
||||||
@@ -492,3 +495,45 @@ class FeedToken(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.key
|
return self.key
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSettings(models.Model):
|
||||||
|
LANDING_PAGE_LOGIN = "login"
|
||||||
|
LANDING_PAGE_SHARED_BOOKMARKS = "shared_bookmarks"
|
||||||
|
LANDING_PAGE_CHOICES = [
|
||||||
|
(LANDING_PAGE_LOGIN, "Login"),
|
||||||
|
(LANDING_PAGE_SHARED_BOOKMARKS, "Shared Bookmarks"),
|
||||||
|
]
|
||||||
|
|
||||||
|
landing_page = models.CharField(
|
||||||
|
max_length=50,
|
||||||
|
choices=LANDING_PAGE_CHOICES,
|
||||||
|
blank=False,
|
||||||
|
default=LANDING_PAGE_LOGIN,
|
||||||
|
)
|
||||||
|
guest_profile_user = models.ForeignKey(
|
||||||
|
get_user_model(), on_delete=models.SET_NULL, null=True, blank=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls):
|
||||||
|
instance = GlobalSettings.objects.first()
|
||||||
|
if not instance:
|
||||||
|
instance = GlobalSettings()
|
||||||
|
instance.save()
|
||||||
|
return instance
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.pk and GlobalSettings.objects.exists():
|
||||||
|
raise Exception("There is already one instance of GlobalSettings")
|
||||||
|
return super(GlobalSettings, self).save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalSettingsForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = GlobalSettings
|
||||||
|
fields = ["landing_page", "guest_profile_user"]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(GlobalSettingsForm, self).__init__(*args, **kwargs)
|
||||||
|
self.fields["guest_profile_user"].empty_label = "Standard profile"
|
||||||
|
|||||||
@@ -16,27 +16,21 @@ def get_tags(script: str, url: str):
|
|||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
domain_pattern = re.sub("^https?://", "", parts[0])
|
# to parse a host name from the pattern URL, ensure it has a scheme
|
||||||
path_pattern = None
|
pattern_url = "//" + re.sub("^https?://", "", parts[0])
|
||||||
qs_pattern = None
|
parsed_pattern = urlparse(pattern_url)
|
||||||
|
|
||||||
if "/" in domain_pattern:
|
if not _domains_matches(parsed_pattern.hostname, parsed_url.hostname):
|
||||||
i = domain_pattern.index("/")
|
|
||||||
path_pattern = domain_pattern[i:]
|
|
||||||
domain_pattern = domain_pattern[:i]
|
|
||||||
|
|
||||||
if path_pattern and "?" in path_pattern:
|
|
||||||
i = path_pattern.index("?")
|
|
||||||
qs_pattern = path_pattern[i + 1 :]
|
|
||||||
path_pattern = path_pattern[:i]
|
|
||||||
|
|
||||||
if not _domains_matches(domain_pattern, parsed_url.netloc):
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if path_pattern and not _path_matches(path_pattern, parsed_url.path):
|
if parsed_pattern.path and not _path_matches(
|
||||||
|
parsed_pattern.path, parsed_url.path
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if qs_pattern and not _qs_matches(qs_pattern, parsed_url.query):
|
if parsed_pattern.query and not _qs_matches(
|
||||||
|
parsed_pattern.query, parsed_url.query
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for tag in parts[1:]:
|
for tag in parts[1:]:
|
||||||
|
|||||||
@@ -245,12 +245,18 @@ def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
|||||||
tag_names = parse_tag_string(tag_string)
|
tag_names = parse_tag_string(tag_string)
|
||||||
|
|
||||||
if user.profile.auto_tagging_rules:
|
if user.profile.auto_tagging_rules:
|
||||||
auto_tag_names = auto_tagging.get_tags(
|
try:
|
||||||
user.profile.auto_tagging_rules, bookmark.url
|
auto_tag_names = auto_tagging.get_tags(
|
||||||
)
|
user.profile.auto_tagging_rules, bookmark.url
|
||||||
for auto_tag_name in auto_tag_names:
|
)
|
||||||
if auto_tag_name not in tag_names:
|
for auto_tag_name in auto_tag_names:
|
||||||
tag_names.append(auto_tag_name)
|
if auto_tag_name not in tag_names:
|
||||||
|
tag_names.append(auto_tag_name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to auto-tag bookmark. url={bookmark.url}",
|
||||||
|
exc_info=e,
|
||||||
|
)
|
||||||
|
|
||||||
tags = get_or_create_tags(tag_names, user)
|
tags = get_or_create_tags(tag_names, user)
|
||||||
bookmark.tags.set(tags)
|
bookmark.tags.set(tags)
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ def import_netscape_html(
|
|||||||
for batch in batches:
|
for batch in batches:
|
||||||
_import_batch(batch, user, options, tag_cache, result)
|
_import_batch(batch, user, options, tag_cache, result)
|
||||||
|
|
||||||
# Create snapshots for newly imported bookmarks
|
|
||||||
tasks.schedule_bookmarks_without_snapshots(user)
|
|
||||||
# Load favicons for newly imported bookmarks
|
# Load favicons for newly imported bookmarks
|
||||||
tasks.schedule_bookmarks_without_favicons(user)
|
tasks.schedule_bookmarks_without_favicons(user)
|
||||||
# Load previews for newly imported bookmarks
|
# Load previews for newly imported bookmarks
|
||||||
|
|||||||
@@ -12,9 +12,8 @@ from django.utils import timezone, formats
|
|||||||
from huey import crontab
|
from huey import crontab
|
||||||
from huey.contrib.djhuey import HUEY as huey
|
from huey.contrib.djhuey import HUEY as huey
|
||||||
from huey.exceptions import TaskLockedException
|
from huey.exceptions import TaskLockedException
|
||||||
from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecordFound
|
from waybackpy.exceptions import WaybackError, TooManyRequestsError
|
||||||
|
|
||||||
import bookmarks.services.wayback
|
|
||||||
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
from bookmarks.models import Bookmark, BookmarkAsset, UserProfile
|
||||||
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
|
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
|
||||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||||
@@ -66,29 +65,6 @@ def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bo
|
|||||||
_create_web_archive_snapshot_task(bookmark.id, force_update)
|
_create_web_archive_snapshot_task(bookmark.id, force_update)
|
||||||
|
|
||||||
|
|
||||||
def _load_newest_snapshot(bookmark: Bookmark):
|
|
||||||
try:
|
|
||||||
logger.info(f"Load existing snapshot for bookmark. url={bookmark.url}")
|
|
||||||
cdx_api = bookmarks.services.wayback.CustomWaybackMachineCDXServerAPI(
|
|
||||||
bookmark.url
|
|
||||||
)
|
|
||||||
existing_snapshot = cdx_api.newest()
|
|
||||||
|
|
||||||
if existing_snapshot:
|
|
||||||
bookmark.web_archive_snapshot_url = existing_snapshot.archive_url
|
|
||||||
bookmark.save(update_fields=["web_archive_snapshot_url"])
|
|
||||||
logger.info(
|
|
||||||
f"Using newest snapshot. url={bookmark.url} from={existing_snapshot.datetime_timestamp}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except NoCDXRecordFound:
|
|
||||||
logger.info(f"Could not find any snapshots for bookmark. url={bookmark.url}")
|
|
||||||
except WaybackError as error:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to load existing snapshot. url={bookmark.url}", exc_info=error
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_snapshot(bookmark: Bookmark):
|
def _create_snapshot(bookmark: Bookmark):
|
||||||
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
|
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
|
||||||
archive = waybackpy.WaybackMachineSaveAPI(
|
archive = waybackpy.WaybackMachineSaveAPI(
|
||||||
@@ -117,48 +93,27 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
|||||||
return
|
return
|
||||||
except TooManyRequestsError:
|
except TooManyRequestsError:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}"
|
f"Failed to create snapshot due to rate limiting. url={bookmark.url}"
|
||||||
)
|
)
|
||||||
except WaybackError as error:
|
except WaybackError as error:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}",
|
f"Failed to create snapshot. url={bookmark.url}",
|
||||||
exc_info=error,
|
exc_info=error,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load the newest snapshot as fallback
|
|
||||||
_load_newest_snapshot(bookmark)
|
|
||||||
|
|
||||||
|
|
||||||
@task()
|
@task()
|
||||||
def _load_web_archive_snapshot_task(bookmark_id: int):
|
def _load_web_archive_snapshot_task(bookmark_id: int):
|
||||||
try:
|
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
# for now to prevent errors when huey tries to run the task
|
||||||
except Bookmark.DoesNotExist:
|
pass
|
||||||
return
|
|
||||||
# Skip if snapshot exists
|
|
||||||
if bookmark.web_archive_snapshot_url:
|
|
||||||
return
|
|
||||||
# Load the newest snapshot
|
|
||||||
_load_newest_snapshot(bookmark)
|
|
||||||
|
|
||||||
|
|
||||||
def schedule_bookmarks_without_snapshots(user: User):
|
|
||||||
if is_web_archive_integration_active(user):
|
|
||||||
_schedule_bookmarks_without_snapshots_task(user.id)
|
|
||||||
|
|
||||||
|
|
||||||
@task()
|
@task()
|
||||||
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||||
user = get_user_model().objects.get(id=user_id)
|
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||||
bookmarks_without_snapshots = Bookmark.objects.filter(
|
# for now to prevent errors when huey tries to run the task
|
||||||
web_archive_snapshot_url__exact="", owner=user
|
pass
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: Implement bulk task creation
|
|
||||||
for bookmark in bookmarks_without_snapshots:
|
|
||||||
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating
|
|
||||||
# new ones when processing bookmarks in bulk
|
|
||||||
_load_web_archive_snapshot_task(bookmark.id)
|
|
||||||
|
|
||||||
|
|
||||||
def is_favicon_feature_active(user: User) -> bool:
|
def is_favicon_feature_active(user: User) -> bool:
|
||||||
|
|||||||
@@ -1,42 +1,20 @@
|
|||||||
import time
|
import datetime
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
import waybackpy
|
from django.utils import timezone
|
||||||
import waybackpy.utils
|
|
||||||
from waybackpy.exceptions import NoCDXRecordFound
|
|
||||||
|
|
||||||
|
|
||||||
class CustomWaybackMachineCDXServerAPI(waybackpy.WaybackMachineCDXServerAPI):
|
def generate_fallback_webarchive_url(
|
||||||
|
url: str, timestamp: datetime.datetime
|
||||||
|
) -> str | None:
|
||||||
"""
|
"""
|
||||||
Customized WaybackMachineCDXServerAPI to work around some issues with retrieving the newest snapshot.
|
Generate a URL to the web archive for the given URL and timestamp.
|
||||||
See https://github.com/akamhy/waybackpy/issues/176
|
A snapshot for the specific timestamp might not exist, in which case the
|
||||||
|
web archive will show the closest snapshot to the given timestamp.
|
||||||
|
If there is no snapshot at all the URL will be invalid.
|
||||||
"""
|
"""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
if not timestamp:
|
||||||
|
timestamp = timezone.now()
|
||||||
|
|
||||||
def newest(self):
|
return f"https://web.archive.org/web/{timestamp.strftime('%Y%m%d%H%M%S')}/{url}"
|
||||||
unix_timestamp = int(time.time())
|
|
||||||
self.closest = waybackpy.utils.unix_timestamp_to_wayback_timestamp(
|
|
||||||
unix_timestamp
|
|
||||||
)
|
|
||||||
self.sort = "closest"
|
|
||||||
self.limit = -5
|
|
||||||
|
|
||||||
newest_snapshot = None
|
|
||||||
for snapshot in self.snapshots():
|
|
||||||
newest_snapshot = snapshot
|
|
||||||
break
|
|
||||||
|
|
||||||
if not newest_snapshot:
|
|
||||||
raise NoCDXRecordFound(
|
|
||||||
"Wayback Machine's CDX server did not return any records "
|
|
||||||
+ "for the query. The URL may not have any archives "
|
|
||||||
+ " on the Wayback Machine or the URL may have been recently "
|
|
||||||
+ "archived and is still not available on the CDX server."
|
|
||||||
)
|
|
||||||
|
|
||||||
return newest_snapshot
|
|
||||||
|
|
||||||
def add_payload(self, payload: Dict[str, str]) -> None:
|
|
||||||
super().add_payload(payload)
|
|
||||||
# Set fastLatest query param, as we are only using this API to get the latest snapshot and using fastLatest
|
|
||||||
# makes searching for latest snapshots faster
|
|
||||||
payload["fastLatest"] = "true"
|
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import user_logged_in
|
|
||||||
from django.db.backends.signals import connection_created
|
from django.db.backends.signals import connection_created
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
|
||||||
from bookmarks.services import tasks
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(user_logged_in)
|
|
||||||
def user_logged_in(sender, request, user, **kwargs):
|
|
||||||
tasks.schedule_bookmarks_without_snapshots(user)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(connection_created)
|
@receiver(connection_created)
|
||||||
def extend_sqlite(connection=None, **kwargs):
|
def extend_sqlite(connection=None, **kwargs):
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea.custom-css {
|
textarea.monospace {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-group > input[type=submit] {
|
.input-group > input[type=submit] {
|
||||||
|
|||||||
@@ -195,3 +195,10 @@ ul.menu li:first-child {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hide tooltips on mobile
|
||||||
|
@media (pointer:coarse) {
|
||||||
|
.tooltip::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
<span>Reader mode</span>
|
<span>Reader mode</span>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if details.bookmark.web_archive_snapshot_url %}
|
{% if details.web_archive_snapshot_url %}
|
||||||
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
|
<a class="weblink" href="{{ details.web_archive_snapshot_url }}"
|
||||||
target="{{ details.profile.bookmark_link_target }}">
|
target="{{ details.profile.bookmark_link_target }}">
|
||||||
{% if details.show_link_icons %}
|
{% if details.show_link_icons %}
|
||||||
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|||||||
@@ -114,16 +114,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="d-flex justify-between">
|
<div class="d-flex justify-between">
|
||||||
<a href="{% url 'bookmarks:index' %}" class="d-flex align-center">
|
<a href="{% url 'bookmarks:root' %}" class="d-flex align-center">
|
||||||
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
<img class="logo" src="{% static 'logo.png' %}" alt="Application logo">
|
||||||
<h1>LINKDING</h1>
|
<h1>LINKDING</h1>
|
||||||
</a>
|
</a>
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
{# Only show nav items menu when logged in #}
|
{# Only show nav items menu when logged in #}
|
||||||
{% include 'bookmarks/nav_menu.html' %}
|
{% include 'bookmarks/nav_menu.html' %}
|
||||||
{% elif has_public_shares %}
|
{% else %}
|
||||||
{# Otherwise show link to shared bookmarks if there are publicly shared bookmarks #}
|
{# Otherwise show login link #}
|
||||||
<a href="{% url 'bookmarks:shared' %}" class="btn btn-link">Shared bookmarks</a>
|
<a href="{% url 'login' %}" class="btn btn-link">Login</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -7,13 +7,14 @@
|
|||||||
{% include 'settings/nav.html' %}
|
{% include 'settings/nav.html' %}
|
||||||
|
|
||||||
{# Profile section #}
|
{# Profile section #}
|
||||||
|
{% if success_message %}
|
||||||
|
<div class="toast toast-success mb-4">{{ success_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error_message %}
|
||||||
|
<div class="toast toast-error mb-4">{{ error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
{% if success_message %}
|
|
||||||
<div class="toast toast-success mb-4">{{ success_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if error_message %}
|
|
||||||
<div class="toast toast-error mb-4">{{ error_message }}</div>
|
|
||||||
{% endif %}
|
|
||||||
<h2>Profile</h2>
|
<h2>Profile</h2>
|
||||||
<p>
|
<p>
|
||||||
<a href="{% url 'change_password' %}">Change password</a>
|
<a href="{% url 'change_password' %}">Change password</a>
|
||||||
@@ -123,7 +124,7 @@
|
|||||||
<summary>Auto Tagging</summary>
|
<summary>Auto Tagging</summary>
|
||||||
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
|
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
{{ form.auto_tagging_rules|add_class:"form-input custom-css"|attr:"rows:6" }}
|
{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
@@ -225,7 +226,7 @@ reddit.com/r/Music music reddit</pre>
|
|||||||
<summary>Custom CSS</summary>
|
<summary>Custom CSS</summary>
|
||||||
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
|
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
{{ form.custom_css|add_class:"form-input custom-css"|attr:"rows:6" }}
|
{{ form.custom_css|add_class:"form-input monospace"|attr:"rows:6" }}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
@@ -238,6 +239,37 @@ reddit.com/r/Music music reddit</pre>
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{# Global settings section #}
|
||||||
|
{% if global_settings_form %}
|
||||||
|
<section class="content-area">
|
||||||
|
<h2>Global settings</h2>
|
||||||
|
<form action="{% url 'bookmarks:settings.general' %}" method="post" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ global_settings_form.landing_page.id_for_label }}" class="form-label">Landing page</label>
|
||||||
|
{{ global_settings_form.landing_page|add_class:"form-select width-25 width-sm-100" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
The page that unauthenticated users are redirected to when accessing the root URL.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ global_settings_form.guest_profile_user.id_for_label }}" class="form-label">Guest user
|
||||||
|
profile</label>
|
||||||
|
{{ global_settings_form.guest_profile_user|add_class:"form-select width-25 width-sm-100" }}
|
||||||
|
<div class="form-input-hint">
|
||||||
|
The user profile to use for users that are not logged in. This will affect how publicly shared bookmarks
|
||||||
|
are displayed regarding theme, bookmark list settings, etc. You can either use your own profile or create
|
||||||
|
a dedicated user for this purpose. By default, a standard profile with fixed settings is used.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="submit" name="update_global_settings" value="Save" class="btn btn-primary mt-2">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Import section #}
|
{# Import section #}
|
||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>Import</h2>
|
<h2>Import</h2>
|
||||||
|
|||||||
@@ -1,70 +1,90 @@
|
|||||||
{% extends "bookmarks/layout.html" %}
|
{% extends "bookmarks/layout.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="settings-page">
|
<div class="settings-page">
|
||||||
|
|
||||||
{% include 'settings/nav.html' %}
|
{% include 'settings/nav.html' %}
|
||||||
|
|
||||||
<section class="content-area">
|
<section class="content-area">
|
||||||
<h2>Browser Extension</h2>
|
<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>
|
<p>The browser extension allows you to quickly add new bookmarks without leaving the page that you are on. The
|
||||||
<ul>
|
extension is available in the official extension stores for:</p>
|
||||||
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
|
<ul>
|
||||||
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe" target="_blank">Chrome</a></li>
|
<li><a href="https://addons.mozilla.org/firefox/addon/linkding-extension/" target="_blank">Firefox</a></li>
|
||||||
</ul>
|
<li><a href="https://chrome.google.com/webstore/detail/linkding-extension/beakmhbijpdhipnjhnclmhgjlddhidpe"
|
||||||
<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>
|
target="_blank">Chrome</a></li>
|
||||||
<h2>Bookmarklet</h2>
|
</ul>
|
||||||
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding application
|
<p>The extension is <a href="https://github.com/sissbruecker/linkding-extension" target="_blank">open source</a>
|
||||||
first. Here's how it works:</p>
|
as well, which enables you to build and manually load it into any browser that supports Chrome extensions.</p>
|
||||||
<ul>
|
<h2>Bookmarklet</h2>
|
||||||
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
<p>The bookmarklet is an alternative, cross-browser way to quickly add new bookmarks without opening the linkding
|
||||||
<li>Open the website that you want to bookmark</li>
|
application first. Here's how it works:</p>
|
||||||
<li>Click the bookmarklet in your browsers toolbar</li>
|
<ul>
|
||||||
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
<li>Drag the bookmarklet below into your browsers bookmark bar / toolbar</li>
|
||||||
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
<li>Open the website that you want to bookmark</li>
|
||||||
</ul>
|
<li>Click the bookmarklet in your browsers toolbar</li>
|
||||||
<p>Drag the following bookmarklet to your browsers toolbar:</p>
|
<li>linkding opens in a new window or tab and allows you to add a bookmark for the site</li>
|
||||||
<a href="javascript: {% include 'bookmarks/bookmarklet.js' %}"
|
<li>After saving the bookmark the linkding window closes and you are back on your website</li>
|
||||||
class="btn btn-primary">📎 Add bookmark</a>
|
</ul>
|
||||||
</section>
|
<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">
|
<section class="content-area">
|
||||||
<h2>REST API</h2>
|
<h2>REST API</h2>
|
||||||
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
<p>The following token can be used to authenticate 3rd-party applications against the REST API:</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="columns">
|
<div class="columns">
|
||||||
<div class="column width-50 width-md-100">
|
<div class="column width-50 width-md-100">
|
||||||
<input class="form-input" value="{{ api_token }}" readonly>
|
<input class="form-input" value="{{ api_token }}" readonly>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<strong>Please treat this token as you would any other credential.</strong>
|
<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.
|
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>.
|
If you think that a token was compromised you can revoke (delete) it in the <a
|
||||||
After deleting the token, a new one will be generated when you reload this settings page.
|
href="{% url 'admin:authtoken_tokenproxy_changelist' %}">admin panel</a>.
|
||||||
</p>
|
After deleting the token, a new one will be generated when you reload this settings page.
|
||||||
</section>
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<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 style="list-style-position: outside;">
|
<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="{{ 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>
|
<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>
|
||||||
</ul>
|
</li>
|
||||||
<p>
|
</ul>
|
||||||
All URLs support appending a <code>q</code> URL parameter for specifying a search query.
|
<p>
|
||||||
You can get an example by doing a search in the bookmarks view and then copying the parameter from the URL.
|
All URLs support the following URL parameters:
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<ul style="list-style-position: outside;">
|
||||||
<strong>Please note that these URLs include an authentication token that should be treated like any other credential.</strong>
|
<li>A <code>limit</code> parameter for specifying the maximum number of bookmarks to include in the feed. By
|
||||||
Any party with access to these URLs can read all your bookmarks.
|
default, only the latest 100 matching bookmarks are included.
|
||||||
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>.
|
</li>
|
||||||
After deleting the feed token, new URLs will be generated when you reload this settings page.
|
<li>A <code>q</code> URL parameter for specifying a search query. You can get an example by doing a search in
|
||||||
</p>
|
the bookmarks view and then copying the parameter from the URL.
|
||||||
</section>
|
</li>
|
||||||
</div>
|
<li>An <code>unread</code> parameter for filtering for unread or read bookmarks. Use <code>yes</code> for unread
|
||||||
|
bookmarks and <code>no</code> for read bookmarks.
|
||||||
|
</li>
|
||||||
|
<li>A <code>shared</code> parameter for filtering for shared or unshared bookmarks. Use <code>yes</code> for
|
||||||
|
shared bookmarks and <code>no</code> for unshared bookmarks.
|
||||||
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class BookmarkFactoryMixin:
|
|||||||
|
|
||||||
return self.user
|
return self.user
|
||||||
|
|
||||||
|
def setup_superuser(self):
|
||||||
|
return User.objects.create_superuser(
|
||||||
|
"superuser", "superuser@example.com", "password123"
|
||||||
|
)
|
||||||
|
|
||||||
def setup_bookmark(
|
def setup_bookmark(
|
||||||
self,
|
self,
|
||||||
is_archived: bool = False,
|
is_archived: bool = False,
|
||||||
@@ -87,6 +92,8 @@ class BookmarkFactoryMixin:
|
|||||||
shared: bool = False,
|
shared: bool = False,
|
||||||
with_tags: bool = False,
|
with_tags: bool = False,
|
||||||
with_web_archive_snapshot_url: bool = False,
|
with_web_archive_snapshot_url: bool = False,
|
||||||
|
with_favicon_file: bool = False,
|
||||||
|
with_preview_image_file: bool = False,
|
||||||
user: User = None,
|
user: User = None,
|
||||||
):
|
):
|
||||||
user = user or self.get_or_create_test_user()
|
user = user or self.get_or_create_test_user()
|
||||||
@@ -118,6 +125,12 @@ class BookmarkFactoryMixin:
|
|||||||
web_archive_snapshot_url = ""
|
web_archive_snapshot_url = ""
|
||||||
if with_web_archive_snapshot_url:
|
if with_web_archive_snapshot_url:
|
||||||
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
|
web_archive_snapshot_url = f"https://web.archive.org/web/{i}"
|
||||||
|
favicon_file = ""
|
||||||
|
if with_favicon_file:
|
||||||
|
favicon_file = f"favicon_{i}.png"
|
||||||
|
preview_image_file = ""
|
||||||
|
if with_preview_image_file:
|
||||||
|
preview_image_file = f"preview_image_{i}.png"
|
||||||
bookmark = self.setup_bookmark(
|
bookmark = self.setup_bookmark(
|
||||||
url=url,
|
url=url,
|
||||||
title=title,
|
title=title,
|
||||||
@@ -126,6 +139,8 @@ class BookmarkFactoryMixin:
|
|||||||
shared=shared,
|
shared=shared,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||||
|
favicon_file=favicon_file,
|
||||||
|
preview_image_file=preview_image_file,
|
||||||
user=user,
|
user=user,
|
||||||
)
|
)
|
||||||
bookmarks.append(bookmark)
|
bookmarks.append(bookmark)
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
|
||||||
|
|
||||||
|
|
||||||
class AnonymousViewTestCase(TestCase, BookmarkFactoryMixin):
|
|
||||||
def assertSharedBookmarksLinkCount(self, response, count):
|
|
||||||
url = reverse("bookmarks:shared")
|
|
||||||
self.assertContains(
|
|
||||||
response,
|
|
||||||
f'<a href="{url}" class="btn btn-link">Shared bookmarks</a>',
|
|
||||||
count=count,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_publicly_shared_bookmarks_link(self):
|
|
||||||
# should not render link if no public shares exist
|
|
||||||
user = self.setup_user(enable_sharing=True)
|
|
||||||
self.setup_bookmark(user=user, shared=True)
|
|
||||||
|
|
||||||
response = self.client.get(reverse("login"))
|
|
||||||
self.assertSharedBookmarksLinkCount(response, 0)
|
|
||||||
|
|
||||||
# should render link if public shares exist
|
|
||||||
user.profile.enable_public_sharing = True
|
|
||||||
user.profile.save()
|
|
||||||
|
|
||||||
response = self.client.get(reverse("login"))
|
|
||||||
self.assertSharedBookmarksLinkCount(response, 1)
|
|
||||||
@@ -12,7 +12,18 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["example"]))
|
self.assertEqual(tags, {"example"})
|
||||||
|
|
||||||
|
def test_auto_tag_by_domain_works_with_port(self):
|
||||||
|
script = """
|
||||||
|
example.com example
|
||||||
|
test.com test
|
||||||
|
"""
|
||||||
|
url = "https://example.com:8080/"
|
||||||
|
|
||||||
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
|
self.assertEqual(tags, {"example"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_ignores_case(self):
|
def test_auto_tag_by_domain_ignores_case(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -22,7 +33,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["example"]))
|
self.assertEqual(tags, {"example"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_should_add_all_tags(self):
|
def test_auto_tag_by_domain_should_add_all_tags(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -32,7 +43,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["one", "two", "three"]))
|
self.assertEqual(tags, {"one", "two", "three"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_work_with_idn_domains(self):
|
def test_auto_tag_by_domain_work_with_idn_domains(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -42,7 +53,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["tag1"]))
|
self.assertEqual(tags, {"tag1"})
|
||||||
|
|
||||||
script = """
|
script = """
|
||||||
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
|
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
|
||||||
@@ -51,7 +62,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["tag1"]))
|
self.assertEqual(tags, {"tag1"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_and_path(self):
|
def test_auto_tag_by_domain_and_path(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -63,7 +74,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["one"]))
|
self.assertEqual(tags, {"one"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_and_path_ignores_case(self):
|
def test_auto_tag_by_domain_and_path_ignores_case(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -73,7 +84,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["one"]))
|
self.assertEqual(tags, {"one"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
|
def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -85,7 +96,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["one"]))
|
self.assertEqual(tags, {"one"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_ignores_domain_in_path(self):
|
def test_auto_tag_by_domain_ignores_domain_in_path(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -107,7 +118,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["example", "test"]))
|
self.assertEqual(tags, {"example", "test"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_matches_domain_rtl(self):
|
def test_auto_tag_by_domain_matches_domain_rtl(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -128,7 +139,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["https", "http"]))
|
self.assertEqual(tags, {"https", "http"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
|
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -154,7 +165,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["tag1", "tag2", "tag5", "tag6", "tag7"]))
|
self.assertEqual(tags, {"tag1", "tag2", "tag5", "tag6", "tag7"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
|
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -165,7 +176,7 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["tag1"]))
|
self.assertEqual(tags, {"tag1"})
|
||||||
|
|
||||||
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
|
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
|
||||||
script = """
|
script = """
|
||||||
@@ -176,4 +187,4 @@ class AutoTaggingTestCase(TestCase):
|
|||||||
|
|
||||||
tags = auto_tagging.get_tags(script, url)
|
tags = auto_tagging.get_tags(script, url)
|
||||||
|
|
||||||
self.assertEqual(tags, set(["tag1", "tag2"]))
|
self.assertEqual(tags, {"tag1", "tag2"})
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import datetime
|
||||||
import re
|
import re
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import formats
|
from django.utils import formats, timezone
|
||||||
|
|
||||||
from bookmarks.models import BookmarkAsset, UserProfile
|
from bookmarks.models import BookmarkAsset, UserProfile
|
||||||
from bookmarks.services import bookmarks, tasks
|
from bookmarks.services import bookmarks, tasks
|
||||||
@@ -180,7 +181,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
# no latest snapshot
|
# no latest snapshot
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 1)
|
self.assertEqual(self.count_weblinks(soup), 2)
|
||||||
|
|
||||||
# snapshot is not complete
|
# snapshot is not complete
|
||||||
self.setup_asset(
|
self.setup_asset(
|
||||||
@@ -194,7 +195,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
status=BookmarkAsset.STATUS_FAILURE,
|
status=BookmarkAsset.STATUS_FAILURE,
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 1)
|
self.assertEqual(self.count_weblinks(soup), 2)
|
||||||
|
|
||||||
# not a snapshot
|
# not a snapshot
|
||||||
self.setup_asset(
|
self.setup_asset(
|
||||||
@@ -203,7 +204,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
status=BookmarkAsset.STATUS_COMPLETE,
|
status=BookmarkAsset.STATUS_COMPLETE,
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 1)
|
self.assertEqual(self.count_weblinks(soup), 2)
|
||||||
|
|
||||||
# snapshot is complete
|
# snapshot is complete
|
||||||
asset = self.setup_asset(
|
asset = self.setup_asset(
|
||||||
@@ -212,20 +213,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
status=BookmarkAsset.STATUS_COMPLETE,
|
status=BookmarkAsset.STATUS_COMPLETE,
|
||||||
)
|
)
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
self.assertEqual(self.count_weblinks(soup), 2)
|
self.assertEqual(self.count_weblinks(soup), 3)
|
||||||
|
|
||||||
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
|
reader_mode_url = reverse("bookmarks:assets.read", args=[asset.id])
|
||||||
link = self.find_weblink(soup, reader_mode_url)
|
link = self.find_weblink(soup, reader_mode_url)
|
||||||
self.assertIsNotNone(link)
|
self.assertIsNotNone(link)
|
||||||
|
|
||||||
def test_internet_archive_link(self):
|
def test_internet_archive_link_with_snapshot_url(self):
|
||||||
# without snapshot url
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
soup = self.get_details(bookmark)
|
|
||||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
|
||||||
self.assertIsNone(link)
|
|
||||||
|
|
||||||
# with snapshot url
|
|
||||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||||
soup = self.get_details(bookmark)
|
soup = self.get_details(bookmark)
|
||||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||||
@@ -264,6 +258,21 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
|||||||
image = link.select_one("svg")
|
image = link.select_one("svg")
|
||||||
self.assertIsNotNone(image)
|
self.assertIsNotNone(image)
|
||||||
|
|
||||||
|
def test_internet_archive_link_with_fallback_url(self):
|
||||||
|
date_added = timezone.datetime(
|
||||||
|
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
bookmark = self.setup_bookmark(url="https://example.com/", added=date_added)
|
||||||
|
fallback_web_archive_url = (
|
||||||
|
"https://web.archive.org/web/20230811214511/https://example.com/"
|
||||||
|
)
|
||||||
|
|
||||||
|
soup = self.get_details(bookmark)
|
||||||
|
link = self.find_weblink(soup, fallback_web_archive_url)
|
||||||
|
self.assertIsNotNone(link)
|
||||||
|
self.assertEqual(link["href"], fallback_web_archive_url)
|
||||||
|
self.assertEqual(link.text.strip(), "Internet Archive")
|
||||||
|
|
||||||
def test_weblinks_respect_target_setting(self):
|
def test_weblinks_respect_target_setting(self):
|
||||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||||
from bookmarks.tests.helpers import (
|
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||||
BookmarkFactoryMixin,
|
|
||||||
HtmlTestMixin,
|
|
||||||
collapse_whitespace,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||||
|
|||||||
@@ -100,6 +100,29 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_should_prefill_notes_from_url_parameter(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:new")
|
||||||
|
+ "?notes=%2A%2AFind%2A%2A%20more%20info%20%5Bhere%5D%28http%3A%2F%2Fexample.com%29"
|
||||||
|
)
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"""
|
||||||
|
<details class="notes" open="">
|
||||||
|
<summary>
|
||||||
|
<span class="form-label d-inline-block">Notes</span>
|
||||||
|
</summary>
|
||||||
|
<label for="id_notes" class="text-assistive">Notes</label>
|
||||||
|
<textarea name="notes" cols="40" rows="8" class="form-input" id="id_notes">**Find** more info [here](http://example.com)</textarea>
|
||||||
|
<div class="form-input-hint">
|
||||||
|
Additional notes, supports Markdown.
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
""",
|
||||||
|
html,
|
||||||
|
)
|
||||||
|
|
||||||
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
|
def test_should_enable_auto_close_when_specified_in_url_parameter(self):
|
||||||
response = self.client.get(reverse("bookmarks:new") + "?auto_close")
|
response = self.client.get(reverse("bookmarks:new") + "?auto_close")
|
||||||
html = response.content.decode()
|
html = response.content.decode()
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
expectation["website_title"] = bookmark.website_title
|
expectation["website_title"] = bookmark.website_title
|
||||||
expectation["website_description"] = bookmark.website_description
|
expectation["website_description"] = bookmark.website_description
|
||||||
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
|
expectation["web_archive_snapshot_url"] = bookmark.web_archive_snapshot_url
|
||||||
|
expectation["favicon_url"] = (
|
||||||
|
f"http://testserver/static/{bookmark.favicon_file}"
|
||||||
|
if bookmark.favicon_file
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
expectation["preview_image_url"] = (
|
||||||
|
f"http://testserver/static/{bookmark.preview_image_file}"
|
||||||
|
if bookmark.preview_image_file
|
||||||
|
else None
|
||||||
|
)
|
||||||
expectation["is_archived"] = bookmark.is_archived
|
expectation["is_archived"] = bookmark.is_archived
|
||||||
expectation["unread"] = bookmark.unread
|
expectation["unread"] = bookmark.unread
|
||||||
expectation["shared"] = bookmark.shared
|
expectation["shared"] = bookmark.shared
|
||||||
@@ -65,7 +75,11 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
def test_list_bookmarks_with_more_details(self):
|
def test_list_bookmarks_with_more_details(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
bookmarks = self.setup_numbered_bookmarks(
|
bookmarks = self.setup_numbered_bookmarks(
|
||||||
5, with_tags=True, with_web_archive_snapshot_url=True
|
5,
|
||||||
|
with_tags=True,
|
||||||
|
with_web_archive_snapshot_url=True,
|
||||||
|
with_favicon_file=True,
|
||||||
|
with_preview_image_file=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.get(
|
response = self.get(
|
||||||
@@ -171,6 +185,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
)
|
)
|
||||||
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
||||||
|
|
||||||
|
def test_list_archived_bookmarks_with_more_details(self):
|
||||||
|
self.authenticate()
|
||||||
|
archived_bookmarks = self.setup_numbered_bookmarks(
|
||||||
|
5,
|
||||||
|
archived=True,
|
||||||
|
with_tags=True,
|
||||||
|
with_web_archive_snapshot_url=True,
|
||||||
|
with_favicon_file=True,
|
||||||
|
with_preview_image_file=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.get(
|
||||||
|
reverse("bookmarks:bookmark-archived"),
|
||||||
|
expected_status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
self.assertBookmarkListEqual(response.data["results"], archived_bookmarks)
|
||||||
|
|
||||||
def test_list_archived_bookmarks_should_filter_by_query(self):
|
def test_list_archived_bookmarks_should_filter_by_query(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
search_value = self.get_random_string()
|
search_value = self.get_random_string()
|
||||||
@@ -220,6 +251,26 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
)
|
)
|
||||||
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
|
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
|
||||||
|
|
||||||
|
def test_list_shared_bookmarks_with_more_details(self):
|
||||||
|
self.authenticate()
|
||||||
|
|
||||||
|
other_user = self.setup_user(enable_sharing=True)
|
||||||
|
shared_bookmarks = self.setup_numbered_bookmarks(
|
||||||
|
5,
|
||||||
|
shared=True,
|
||||||
|
user=other_user,
|
||||||
|
with_tags=True,
|
||||||
|
with_web_archive_snapshot_url=True,
|
||||||
|
with_favicon_file=True,
|
||||||
|
with_preview_image_file=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.get(
|
||||||
|
reverse("bookmarks:bookmark-shared"),
|
||||||
|
expected_status_code=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
self.assertBookmarkListEqual(response.data["results"], shared_bookmarks)
|
||||||
|
|
||||||
def test_list_only_publicly_shared_bookmarks_when_not_logged_in(self):
|
def test_list_only_publicly_shared_bookmarks_when_not_logged_in(self):
|
||||||
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||||
user2 = self.setup_user(enable_sharing=True)
|
user2 = self.setup_user(enable_sharing=True)
|
||||||
@@ -701,6 +752,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
url="https://example.com",
|
url="https://example.com",
|
||||||
title="Example title",
|
title="Example title",
|
||||||
description="Example description",
|
description="Example description",
|
||||||
|
favicon_file="favicon.png",
|
||||||
|
preview_image_file="preview.png",
|
||||||
)
|
)
|
||||||
|
|
||||||
url = reverse("bookmarks:bookmark-check")
|
url = reverse("bookmarks:bookmark-check")
|
||||||
@@ -715,6 +768,12 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(bookmark.url, bookmark_data["url"])
|
self.assertEqual(bookmark.url, bookmark_data["url"])
|
||||||
self.assertEqual(bookmark.title, bookmark_data["title"])
|
self.assertEqual(bookmark.title, bookmark_data["title"])
|
||||||
self.assertEqual(bookmark.description, bookmark_data["description"])
|
self.assertEqual(bookmark.description, bookmark_data["description"])
|
||||||
|
self.assertEqual(
|
||||||
|
"http://testserver/static/favicon.png", bookmark_data["favicon_url"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"http://testserver/static/preview.png", bookmark_data["preview_image_url"]
|
||||||
|
)
|
||||||
|
|
||||||
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
|
def test_check_returns_existing_metadata_if_url_is_bookmarked(self):
|
||||||
self.authenticate()
|
self.authenticate()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import datetime
|
||||||
from typing import Type
|
from typing import Type
|
||||||
|
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
@@ -36,15 +37,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
html,
|
html,
|
||||||
)
|
)
|
||||||
|
|
||||||
def assertDateLabel(self, html: str, label_content: str):
|
|
||||||
self.assertInHTML(
|
|
||||||
f"""
|
|
||||||
<span>{label_content}</span>
|
|
||||||
<span>|</span>
|
|
||||||
""",
|
|
||||||
html,
|
|
||||||
)
|
|
||||||
|
|
||||||
def assertWebArchiveLink(
|
def assertWebArchiveLink(
|
||||||
self, html: str, label_content: str, url: str, link_target: str = "_blank"
|
self, html: str, label_content: str, url: str, link_target: str = "_blank"
|
||||||
):
|
):
|
||||||
@@ -465,14 +457,21 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
style = bookmark_list["style"]
|
style = bookmark_list["style"]
|
||||||
self.assertIn("--ld-bookmark-description-max-lines:3;", style)
|
self.assertIn("--ld-bookmark-description-max-lines:3;", style)
|
||||||
|
|
||||||
def test_should_respect_absolute_date_setting(self):
|
def test_bookmark_tag_ordering(self):
|
||||||
bookmark = self.setup_date_format_test(
|
bookmark = self.setup_bookmark()
|
||||||
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
|
tag3 = self.setup_tag(name="tag3")
|
||||||
)
|
tag1 = self.setup_tag(name="tag1")
|
||||||
html = self.render_template()
|
tag2 = self.setup_tag(name="tag2")
|
||||||
formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
|
bookmark.tags.add(tag3, tag1, tag2)
|
||||||
|
|
||||||
self.assertDateLabel(html, formatted_date)
|
html = self.render_template()
|
||||||
|
soup = self.make_soup(html)
|
||||||
|
tags = soup.select_one(".tags")
|
||||||
|
tag_links = tags.find_all("a")
|
||||||
|
self.assertEqual(len(tag_links), 3)
|
||||||
|
self.assertEqual(tag_links[0].text, "#tag1")
|
||||||
|
self.assertEqual(tag_links[1].text, "#tag2")
|
||||||
|
self.assertEqual(tag_links[2].text, "#tag3")
|
||||||
|
|
||||||
def test_should_render_web_archive_link_with_absolute_date_setting(self):
|
def test_should_render_web_archive_link_with_absolute_date_setting(self):
|
||||||
bookmark = self.setup_date_format_test(
|
bookmark = self.setup_date_format_test(
|
||||||
@@ -486,12 +485,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
html, formatted_date, bookmark.web_archive_snapshot_url
|
html, formatted_date, bookmark.web_archive_snapshot_url
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_should_respect_relative_date_setting(self):
|
|
||||||
self.setup_date_format_test(UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE)
|
|
||||||
html = self.render_template()
|
|
||||||
|
|
||||||
self.assertDateLabel(html, "1 week ago")
|
|
||||||
|
|
||||||
def test_should_render_web_archive_link_with_relative_date_setting(self):
|
def test_should_render_web_archive_link_with_relative_date_setting(self):
|
||||||
bookmark = self.setup_date_format_test(
|
bookmark = self.setup_date_format_test(
|
||||||
UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||||
@@ -501,6 +494,27 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
|||||||
|
|
||||||
self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)
|
self.assertWebArchiveLink(html, "1 week ago", bookmark.web_archive_snapshot_url)
|
||||||
|
|
||||||
|
def test_should_render_generated_web_archive_link_without_saved_snapshot_url(self):
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
user.profile.bookmark_date_display = UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
|
||||||
|
user.profile.save()
|
||||||
|
|
||||||
|
date_added = timezone.datetime(
|
||||||
|
2023, 8, 11, 21, 45, 11, tzinfo=datetime.timezone.utc
|
||||||
|
)
|
||||||
|
bookmark = self.setup_bookmark(
|
||||||
|
url="https://example.com/article", added=date_added
|
||||||
|
)
|
||||||
|
|
||||||
|
html = self.render_template()
|
||||||
|
formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
|
||||||
|
|
||||||
|
self.assertWebArchiveLink(
|
||||||
|
html,
|
||||||
|
formatted_date,
|
||||||
|
"https://web.archive.org/web/20230811214511/https://example.com/article",
|
||||||
|
)
|
||||||
|
|
||||||
def test_bookmark_link_target_should_be_blank_by_default(self):
|
def test_bookmark_link_target_should_be_blank_by_default(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
html = self.render_template()
|
html = self.render_template()
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import datetime
|
|
||||||
import os.path
|
import os.path
|
||||||
from dataclasses import dataclass
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import waybackpy
|
import waybackpy
|
||||||
@@ -10,8 +8,6 @@ from django.test import TestCase, override_settings
|
|||||||
from huey.contrib.djhuey import HUEY as huey
|
from huey.contrib.djhuey import HUEY as huey
|
||||||
from waybackpy.exceptions import WaybackError
|
from waybackpy.exceptions import WaybackError
|
||||||
|
|
||||||
import bookmarks.services.favicon_loader
|
|
||||||
import bookmarks.services.wayback
|
|
||||||
from bookmarks.models import BookmarkAsset, UserProfile
|
from bookmarks.models import BookmarkAsset, UserProfile
|
||||||
from bookmarks.services import tasks, singlefile
|
from bookmarks.services import tasks, singlefile
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
@@ -29,12 +25,6 @@ def create_wayback_machine_save_api_mock(
|
|||||||
return mock_api
|
return mock_api
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MockCdxSnapshot:
|
|
||||||
archive_url: str
|
|
||||||
datetime_timestamp: datetime.datetime
|
|
||||||
|
|
||||||
|
|
||||||
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -50,17 +40,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
)
|
)
|
||||||
self.mock_save_api_patcher.start()
|
self.mock_save_api_patcher.start()
|
||||||
|
|
||||||
self.mock_cdx_api = mock.Mock()
|
|
||||||
self.mock_cdx_api.newest.return_value = MockCdxSnapshot(
|
|
||||||
"https://example.com/newest_snapshot", datetime.datetime.now()
|
|
||||||
)
|
|
||||||
self.mock_cdx_api_patcher = mock.patch.object(
|
|
||||||
bookmarks.services.wayback,
|
|
||||||
"CustomWaybackMachineCDXServerAPI",
|
|
||||||
return_value=self.mock_cdx_api,
|
|
||||||
)
|
|
||||||
self.mock_cdx_api_patcher.start()
|
|
||||||
|
|
||||||
self.mock_load_favicon_patcher = mock.patch(
|
self.mock_load_favicon_patcher = mock.patch(
|
||||||
"bookmarks.services.favicon_loader.load_favicon"
|
"bookmarks.services.favicon_loader.load_favicon"
|
||||||
)
|
)
|
||||||
@@ -90,7 +69,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.mock_save_api_patcher.stop()
|
self.mock_save_api_patcher.stop()
|
||||||
self.mock_cdx_api_patcher.stop()
|
|
||||||
self.mock_load_favicon_patcher.stop()
|
self.mock_load_favicon_patcher.stop()
|
||||||
self.mock_singlefile_create_snapshot_patcher.stop()
|
self.mock_singlefile_create_snapshot_patcher.stop()
|
||||||
self.mock_load_preview_image_patcher.stop()
|
self.mock_load_preview_image_patcher.stop()
|
||||||
@@ -142,45 +120,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertEqual(bookmark.web_archive_snapshot_url, "https://other.com")
|
self.assertEqual(bookmark.web_archive_snapshot_url, "https://other.com")
|
||||||
|
|
||||||
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
self.mock_save_api.save.side_effect = WaybackError
|
|
||||||
|
|
||||||
tasks.create_web_archive_snapshot(
|
|
||||||
self.get_or_create_test_user(), bookmark, False
|
|
||||||
)
|
|
||||||
|
|
||||||
bookmark.refresh_from_db()
|
|
||||||
self.mock_cdx_api.newest.assert_called_once()
|
|
||||||
self.assertEqual(
|
|
||||||
"https://example.com/newest_snapshot",
|
|
||||||
bookmark.web_archive_snapshot_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
self.mock_save_api.save.side_effect = WaybackError
|
|
||||||
self.mock_cdx_api.newest.return_value = None
|
|
||||||
|
|
||||||
tasks.create_web_archive_snapshot(
|
|
||||||
self.get_or_create_test_user(), bookmark, False
|
|
||||||
)
|
|
||||||
|
|
||||||
bookmark.refresh_from_db()
|
|
||||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
|
||||||
|
|
||||||
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
self.mock_save_api.save.side_effect = WaybackError
|
|
||||||
self.mock_cdx_api.newest.side_effect = WaybackError
|
|
||||||
|
|
||||||
tasks.create_web_archive_snapshot(
|
|
||||||
self.get_or_create_test_user(), bookmark, False
|
|
||||||
)
|
|
||||||
|
|
||||||
bookmark.refresh_from_db()
|
|
||||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
|
||||||
|
|
||||||
def test_create_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
|
def test_create_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
@@ -203,69 +142,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
bookmark.web_archive_snapshot_url,
|
bookmark.web_archive_snapshot_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_update_snapshot_url(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
|
|
||||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
|
||||||
|
|
||||||
bookmark.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEqual(self.executed_count(), 1)
|
|
||||||
self.mock_cdx_api.newest.assert_called_once()
|
|
||||||
self.assertEqual(
|
|
||||||
"https://example.com/newest_snapshot", bookmark.web_archive_snapshot_url
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
|
|
||||||
tasks._load_web_archive_snapshot_task(123)
|
|
||||||
|
|
||||||
self.assertEqual(self.executed_count(), 1)
|
|
||||||
self.mock_cdx_api.newest.assert_not_called()
|
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
|
|
||||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com")
|
|
||||||
|
|
||||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
|
||||||
|
|
||||||
self.assertEqual(self.executed_count(), 1)
|
|
||||||
self.mock_cdx_api.newest.assert_not_called()
|
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
self.mock_cdx_api.newest.return_value = None
|
|
||||||
|
|
||||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
|
||||||
|
|
||||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
self.mock_cdx_api.newest.side_effect = WaybackError
|
|
||||||
|
|
||||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
|
||||||
|
|
||||||
self.assertEqual("", bookmark.web_archive_snapshot_url)
|
|
||||||
|
|
||||||
def test_load_web_archive_snapshot_should_not_save_stale_bookmark_data(self):
|
|
||||||
bookmark = self.setup_bookmark()
|
|
||||||
|
|
||||||
# update bookmark during API call to check that saving
|
|
||||||
# the snapshot does not overwrite updated bookmark data
|
|
||||||
def mock_newest_impl():
|
|
||||||
bookmark.title = "Updated title"
|
|
||||||
bookmark.save()
|
|
||||||
return mock.DEFAULT
|
|
||||||
|
|
||||||
self.mock_cdx_api.newest.side_effect = mock_newest_impl
|
|
||||||
|
|
||||||
tasks._load_web_archive_snapshot_task(bookmark.id)
|
|
||||||
bookmark.refresh_from_db()
|
|
||||||
|
|
||||||
self.assertEqual("Updated title", bookmark.title)
|
|
||||||
self.assertEqual(
|
|
||||||
"https://example.com/newest_snapshot", bookmark.web_archive_snapshot_url
|
|
||||||
)
|
|
||||||
|
|
||||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||||
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(
|
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(
|
||||||
self,
|
self,
|
||||||
@@ -292,59 +168,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertEqual(self.executed_count(), 0)
|
self.assertEqual(self.executed_count(), 0)
|
||||||
|
|
||||||
def test_schedule_bookmarks_without_snapshots_should_load_snapshot_for_all_bookmarks_without_snapshot(
|
|
||||||
self,
|
|
||||||
):
|
|
||||||
user = self.get_or_create_test_user()
|
|
||||||
self.setup_bookmark()
|
|
||||||
self.setup_bookmark()
|
|
||||||
self.setup_bookmark()
|
|
||||||
self.setup_bookmark(web_archive_snapshot_url="https://example.com")
|
|
||||||
self.setup_bookmark(web_archive_snapshot_url="https://example.com")
|
|
||||||
self.setup_bookmark(web_archive_snapshot_url="https://example.com")
|
|
||||||
|
|
||||||
tasks.schedule_bookmarks_without_snapshots(user)
|
|
||||||
|
|
||||||
self.assertEqual(self.executed_count(), 4)
|
|
||||||
self.assertEqual(self.mock_cdx_api.newest.call_count, 3)
|
|
||||||
|
|
||||||
def test_schedule_bookmarks_without_snapshots_should_only_update_user_owned_bookmarks(
|
|
||||||
self,
|
|
||||||
):
|
|
||||||
user = self.get_or_create_test_user()
|
|
||||||
other_user = User.objects.create_user(
|
|
||||||
"otheruser", "otheruser@example.com", "password123"
|
|
||||||
)
|
|
||||||
self.setup_bookmark()
|
|
||||||
self.setup_bookmark()
|
|
||||||
self.setup_bookmark()
|
|
||||||
self.setup_bookmark(user=other_user)
|
|
||||||
self.setup_bookmark(user=other_user)
|
|
||||||
self.setup_bookmark(user=other_user)
|
|
||||||
|
|
||||||
tasks.schedule_bookmarks_without_snapshots(user)
|
|
||||||
|
|
||||||
self.assertEqual(self.mock_cdx_api.newest.call_count, 3)
|
|
||||||
|
|
||||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
|
||||||
def test_schedule_bookmarks_without_snapshots_should_not_run_when_background_tasks_are_disabled(
|
|
||||||
self,
|
|
||||||
):
|
|
||||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
|
||||||
|
|
||||||
self.assertEqual(self.executed_count(), 0)
|
|
||||||
|
|
||||||
def test_schedule_bookmarks_without_snapshots_should_not_run_when_web_archive_integration_is_disabled(
|
|
||||||
self,
|
|
||||||
):
|
|
||||||
self.user.profile.web_archive_integration = (
|
|
||||||
UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED
|
|
||||||
)
|
|
||||||
self.user.profile.save()
|
|
||||||
tasks.schedule_bookmarks_without_snapshots(self.user)
|
|
||||||
|
|
||||||
self.assertEqual(self.executed_count(), 0)
|
|
||||||
|
|
||||||
def test_load_favicon_should_create_favicon_file(self):
|
def test_load_favicon_should_create_favicon_file(self):
|
||||||
bookmark = self.setup_bookmark()
|
bookmark = self.setup_bookmark()
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,26 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
self.token = FeedToken.objects.get_or_create(user=user)[0]
|
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):
|
def test_all_returns_404_for_unknown_feed_token(self):
|
||||||
response = self.client.get(reverse("bookmarks:feeds.all", args=["foo"]))
|
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])
|
reverse("bookmarks:feeds.all", args=[self.token.key])
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertFeedItems(response, bookmarks)
|
||||||
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)
|
|
||||||
|
|
||||||
def test_all_returns_only_user_owned_bookmarks(self):
|
def test_all_returns_only_user_owned_bookmarks(self):
|
||||||
other_user = User.objects.create_user(
|
other_user = User.objects.create_user(
|
||||||
@@ -115,23 +91,6 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
|
|
||||||
self.assertContains(response, "<item>", count=0)
|
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):
|
def test_unread_returns_404_for_unknown_feed_token(self):
|
||||||
response = self.client.get(reverse("bookmarks:feeds.unread", args=["foo"]))
|
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])
|
reverse("bookmarks:feeds.unread", args=[self.token.key])
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertFeedItems(response, unread_bookmarks)
|
||||||
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)
|
|
||||||
|
|
||||||
def test_unread_returns_only_user_owned_bookmarks(self):
|
def test_unread_returns_only_user_owned_bookmarks(self):
|
||||||
other_user = User.objects.create_user(
|
other_user = User.objects.create_user(
|
||||||
@@ -265,53 +180,7 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
reverse("bookmarks:feeds.shared", args=[self.token.key])
|
reverse("bookmarks:feeds.shared", args=[self.token.key])
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertFeedItems(response, shared_bookmarks)
|
||||||
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):
|
def test_public_shared_does_not_require_auth(self):
|
||||||
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
|
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"))
|
response = self.client.get(reverse("bookmarks:feeds.public_shared"))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertFeedItems(response, public_shared_bookmarks)
|
||||||
|
|
||||||
self.assertContains(response, "<item>", 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:
|
self.setup_bookmark()
|
||||||
expected_item = (
|
self.setup_bookmark()
|
||||||
"<item>"
|
self.setup_bookmark()
|
||||||
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):
|
feed_url = reverse("bookmarks:feeds.all", args=[self.token.key])
|
||||||
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}"
|
url = feed_url + f"?q={bookmark1.title}"
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
@@ -398,3 +252,117 @@ class FeedsTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "<item>", count=1)
|
self.assertContains(response, "<item>", count=1)
|
||||||
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
|
self.assertContains(response, f"<guid>{bookmark2.url}</guid>", count=1)
|
||||||
|
|
||||||
|
def test_unread_parameter(self):
|
||||||
|
self.setup_bookmark(unread=True),
|
||||||
|
self.setup_bookmark(unread=True),
|
||||||
|
self.setup_bookmark(unread=False),
|
||||||
|
self.setup_bookmark(unread=False),
|
||||||
|
self.setup_bookmark(unread=False),
|
||||||
|
self.setup_bookmark(unread=False),
|
||||||
|
|
||||||
|
# without unread parameter
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:feeds.all", args=[self.token.key])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=6)
|
||||||
|
|
||||||
|
# with unread=yes
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?unread=yes"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=2)
|
||||||
|
|
||||||
|
# with unread=no
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?unread=no"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=4)
|
||||||
|
|
||||||
|
def test_shared_parameter(self):
|
||||||
|
self.setup_bookmark(shared=True)
|
||||||
|
self.setup_bookmark(shared=True)
|
||||||
|
self.setup_bookmark(shared=False)
|
||||||
|
self.setup_bookmark(shared=False)
|
||||||
|
self.setup_bookmark(shared=False)
|
||||||
|
self.setup_bookmark(shared=False)
|
||||||
|
|
||||||
|
# without shared parameter
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:feeds.all", args=[self.token.key])
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=6)
|
||||||
|
|
||||||
|
# with shared=yes
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?shared=yes"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=2)
|
||||||
|
|
||||||
|
# with shared=no
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("bookmarks:feeds.all", args=[self.token.key]) + "?shared=no"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "<item>", count=4)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|||||||
@@ -444,17 +444,6 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
|||||||
self.assertEqual(Bookmark.objects.all()[0].description, "Example description")
|
self.assertEqual(Bookmark.objects.all()[0].description, "Example description")
|
||||||
self.assertEqual(Bookmark.objects.all()[0].notes, "Updated notes")
|
self.assertEqual(Bookmark.objects.all()[0].notes, "Updated notes")
|
||||||
|
|
||||||
def test_schedule_snapshot_creation(self):
|
|
||||||
user = self.get_or_create_test_user()
|
|
||||||
test_html = self.render_html(tags_html="")
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
tasks, "schedule_bookmarks_without_snapshots"
|
|
||||||
) as mock_schedule_bookmarks_without_snapshots:
|
|
||||||
import_netscape_html(test_html, user)
|
|
||||||
|
|
||||||
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
|
|
||||||
|
|
||||||
def test_schedule_favicon_loading(self):
|
def test_schedule_favicon_loading(self):
|
||||||
user = self.get_or_create_test_user()
|
user = self.get_or_create_test_user()
|
||||||
test_html = self.render_html(tags_html="")
|
test_html = self.render_html(tags_html="")
|
||||||
|
|||||||
40
bookmarks/tests/test_root_view.py
Normal file
40
bookmarks/tests/test_root_view.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import GlobalSettings
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
|
||||||
|
|
||||||
|
class RootViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def test_unauthenticated_user_redirect_to_login_by_default(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:root"))
|
||||||
|
self.assertRedirects(response, reverse("login"))
|
||||||
|
|
||||||
|
def test_unauthenticated_redirect_to_shared_bookmarks_if_configured_in_global_settings(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
settings = GlobalSettings.get()
|
||||||
|
settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
|
||||||
|
settings.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:root"))
|
||||||
|
self.assertRedirects(response, reverse("bookmarks:shared"))
|
||||||
|
|
||||||
|
def test_authenticated_user_always_redirected_to_bookmarks(self):
|
||||||
|
self.client.force_login(self.get_or_create_test_user())
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:root"))
|
||||||
|
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||||
|
|
||||||
|
settings = GlobalSettings.get()
|
||||||
|
settings.landing_page = GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS
|
||||||
|
settings.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:root"))
|
||||||
|
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||||
|
|
||||||
|
settings.landing_page = GlobalSettings.LANDING_PAGE_LOGIN
|
||||||
|
settings.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:root"))
|
||||||
|
self.assertRedirects(response, reverse("bookmarks:index"))
|
||||||
@@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from requests import RequestException
|
from requests import RequestException
|
||||||
|
|
||||||
from bookmarks.models import UserProfile
|
from bookmarks.models import UserProfile, GlobalSettings
|
||||||
from bookmarks.services import tasks
|
from bookmarks.services import tasks
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
from bookmarks.views.settings import app_version, get_version_info
|
from bookmarks.views.settings import app_version, get_version_info
|
||||||
@@ -465,3 +465,82 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
|||||||
self.assertSuccessMessage(
|
self.assertSuccessMessage(
|
||||||
html, "Queued 5 missing snapshots. This may take a while...", count=0
|
html, "Queued 5 missing snapshots. This may take a while...", count=0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_update_global_settings(self):
|
||||||
|
superuser = self.setup_superuser()
|
||||||
|
self.client.force_login(superuser)
|
||||||
|
selectable_user = self.setup_user()
|
||||||
|
|
||||||
|
# Update global settings
|
||||||
|
form_data = {
|
||||||
|
"update_global_settings": "",
|
||||||
|
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
|
||||||
|
"guest_profile_user": selectable_user.id,
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertSuccessMessage(response.content.decode(), "Global settings updated")
|
||||||
|
|
||||||
|
global_settings = GlobalSettings.get()
|
||||||
|
self.assertEqual(global_settings.landing_page, form_data["landing_page"])
|
||||||
|
self.assertEqual(global_settings.guest_profile_user, selectable_user)
|
||||||
|
|
||||||
|
# Revert settings
|
||||||
|
form_data = {
|
||||||
|
"update_global_settings": "",
|
||||||
|
"landing_page": GlobalSettings.LANDING_PAGE_LOGIN,
|
||||||
|
"guest_profile_user": "",
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertSuccessMessage(response.content.decode(), "Global settings updated")
|
||||||
|
|
||||||
|
global_settings = GlobalSettings.get()
|
||||||
|
global_settings.refresh_from_db()
|
||||||
|
self.assertEqual(global_settings.landing_page, form_data["landing_page"])
|
||||||
|
self.assertIsNone(global_settings.guest_profile_user)
|
||||||
|
|
||||||
|
def test_update_global_settings_should_not_be_called_without_respective_form_action(
|
||||||
|
self,
|
||||||
|
):
|
||||||
|
superuser = self.setup_superuser()
|
||||||
|
self.client.force_login(superuser)
|
||||||
|
|
||||||
|
form_data = {
|
||||||
|
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertSuccessMessage(
|
||||||
|
response.content.decode(), "Global settings updated", count=0
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_update_global_settings_checks_for_superuser(self):
|
||||||
|
form_data = {
|
||||||
|
"update_global_settings": "",
|
||||||
|
"landing_page": GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS,
|
||||||
|
}
|
||||||
|
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_global_settings_only_visible_for_superuser(self):
|
||||||
|
response = self.client.get(reverse("bookmarks:settings.general"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"<h2>Global settings</h2>",
|
||||||
|
html,
|
||||||
|
count=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
superuser = self.setup_superuser()
|
||||||
|
self.client.force_login(superuser)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("bookmarks:settings.general"))
|
||||||
|
html = response.content.decode()
|
||||||
|
|
||||||
|
self.assertInHTML(
|
||||||
|
"<h2>Global settings</h2>",
|
||||||
|
html,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
from bookmarks.services import tasks
|
|
||||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
|
||||||
|
|
||||||
|
|
||||||
class SignalsTestCase(TestCase, BookmarkFactoryMixin):
|
|
||||||
def test_login_should_schedule_snapshot_creation(self):
|
|
||||||
user = self.get_or_create_test_user()
|
|
||||||
|
|
||||||
with patch.object(
|
|
||||||
tasks, "schedule_bookmarks_without_snapshots"
|
|
||||||
) as mock_schedule_bookmarks_without_snapshots:
|
|
||||||
self.client.force_login(user)
|
|
||||||
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
|
|
||||||
47
bookmarks/tests/test_user_profile_middleware.py
Normal file
47
bookmarks/tests/test_user_profile_middleware.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import UserProfile, GlobalSettings
|
||||||
|
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||||
|
from bookmarks.middlewares import standard_profile
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileMiddlewareTestCase(TestCase, BookmarkFactoryMixin):
|
||||||
|
def test_unauthenticated_user_should_use_standard_profile_by_default(self):
|
||||||
|
response = self.client.get(reverse("login"))
|
||||||
|
|
||||||
|
self.assertEqual(standard_profile, response.wsgi_request.user_profile)
|
||||||
|
|
||||||
|
def test_unauthenticated_user_should_use_custom_configured_profile(self):
|
||||||
|
guest_user = self.setup_user()
|
||||||
|
guest_user_profile = guest_user.profile
|
||||||
|
guest_user_profile.theme = UserProfile.THEME_DARK
|
||||||
|
guest_user_profile.save()
|
||||||
|
|
||||||
|
global_settings = GlobalSettings.get()
|
||||||
|
global_settings.guest_profile_user = guest_user
|
||||||
|
global_settings.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse("login"))
|
||||||
|
|
||||||
|
self.assertEqual(guest_user_profile, response.wsgi_request.user_profile)
|
||||||
|
|
||||||
|
def test_authenticated_user_should_use_own_profile(self):
|
||||||
|
guest_user = self.setup_user()
|
||||||
|
guest_user_profile = guest_user.profile
|
||||||
|
guest_user_profile.theme = UserProfile.THEME_DARK
|
||||||
|
guest_user_profile.save()
|
||||||
|
|
||||||
|
global_settings = GlobalSettings.get()
|
||||||
|
global_settings.guest_profile_user = guest_user
|
||||||
|
global_settings.save()
|
||||||
|
|
||||||
|
user = self.get_or_create_test_user()
|
||||||
|
user_profile = user.profile
|
||||||
|
user_profile.theme = UserProfile.THEME_LIGHT
|
||||||
|
user_profile.save()
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("login"), follow=True)
|
||||||
|
|
||||||
|
self.assertEqual(user_profile, response.wsgi_request.user_profile)
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from django.urls import re_path
|
from django.urls import re_path
|
||||||
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
|
||||||
@@ -14,10 +13,8 @@ from bookmarks.views import partials
|
|||||||
|
|
||||||
app_name = "bookmarks"
|
app_name = "bookmarks"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Redirect root to bookmarks index
|
# Root view handling redirection based on user authentication
|
||||||
re_path(
|
re_path(r"^$", views.root, name="root"),
|
||||||
r"^$", RedirectView.as_view(pattern_name="bookmarks:index", permanent=False)
|
|
||||||
),
|
|
||||||
# Bookmarks
|
# Bookmarks
|
||||||
path("bookmarks", views.bookmarks.index, name="index"),
|
path("bookmarks", views.bookmarks.index, name="index"),
|
||||||
path("bookmarks/action", views.bookmarks.index_action, name="index.action"),
|
path("bookmarks/action", views.bookmarks.index_action, name="index.action"),
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ from .settings import *
|
|||||||
from .toasts import *
|
from .toasts import *
|
||||||
from .health import health
|
from .health import health
|
||||||
from .manifest import manifest
|
from .manifest import manifest
|
||||||
|
from .root import root
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ def new(request):
|
|||||||
initial_url = request.GET.get("url")
|
initial_url = request.GET.get("url")
|
||||||
initial_title = request.GET.get("title")
|
initial_title = request.GET.get("title")
|
||||||
initial_description = request.GET.get("description")
|
initial_description = request.GET.get("description")
|
||||||
|
initial_notes = request.GET.get("notes")
|
||||||
initial_auto_close = "auto_close" in request.GET
|
initial_auto_close = "auto_close" in request.GET
|
||||||
initial_mark_unread = request.user.profile.default_mark_unread
|
initial_mark_unread = request.user.profile.default_mark_unread
|
||||||
|
|
||||||
@@ -214,6 +215,8 @@ def new(request):
|
|||||||
form.initial["title"] = initial_title
|
form.initial["title"] = initial_title
|
||||||
if initial_description:
|
if initial_description:
|
||||||
form.initial["description"] = initial_description
|
form.initial["description"] = initial_description
|
||||||
|
if initial_notes:
|
||||||
|
form.initial["notes"] = initial_notes
|
||||||
if initial_auto_close:
|
if initial_auto_close:
|
||||||
form.initial["auto_close"] = "true"
|
form.initial["auto_close"] = "true"
|
||||||
if initial_mark_unread:
|
if initial_mark_unread:
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from typing import Set, List
|
from typing import Set, List
|
||||||
import re
|
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.handlers.wsgi import WSGIRequest
|
from django.core.handlers.wsgi import WSGIRequest
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from bookmarks import queries
|
from bookmarks import queries
|
||||||
from bookmarks import utils
|
from bookmarks import utils
|
||||||
@@ -18,6 +18,7 @@ from bookmarks.models import (
|
|||||||
UserProfile,
|
UserProfile,
|
||||||
Tag,
|
Tag,
|
||||||
)
|
)
|
||||||
|
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||||
|
|
||||||
DEFAULT_PAGE_SIZE = 30
|
DEFAULT_PAGE_SIZE = 30
|
||||||
CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
|
CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
|
||||||
@@ -144,6 +145,10 @@ class BookmarkItem:
|
|||||||
self.notes = bookmark.notes
|
self.notes = bookmark.notes
|
||||||
self.tag_names = bookmark.tag_names
|
self.tag_names = bookmark.tag_names
|
||||||
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
|
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
|
||||||
|
if not self.web_archive_snapshot_url:
|
||||||
|
self.web_archive_snapshot_url = generate_fallback_webarchive_url(
|
||||||
|
bookmark.url, bookmark.date_added
|
||||||
|
)
|
||||||
self.favicon_file = bookmark.favicon_file
|
self.favicon_file = bookmark.favicon_file
|
||||||
self.preview_image_file = bookmark.preview_image_file
|
self.preview_image_file = bookmark.preview_image_file
|
||||||
self.is_archived = bookmark.is_archived
|
self.is_archived = bookmark.is_archived
|
||||||
@@ -412,6 +417,12 @@ class BookmarkDetailsContext:
|
|||||||
# For now hide files section if snapshots are not supported
|
# For now hide files section if snapshots are not supported
|
||||||
self.show_files = settings.LD_ENABLE_SNAPSHOTS
|
self.show_files = settings.LD_ENABLE_SNAPSHOTS
|
||||||
|
|
||||||
|
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
|
||||||
|
if not self.web_archive_snapshot_url:
|
||||||
|
self.web_archive_snapshot_url = generate_fallback_webarchive_url(
|
||||||
|
bookmark.url, bookmark.date_added
|
||||||
|
)
|
||||||
|
|
||||||
self.assets = [
|
self.assets = [
|
||||||
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
|
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
|
||||||
]
|
]
|
||||||
|
|||||||
18
bookmarks/views/root.py
Normal file
18
bookmarks/views/root.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from django.http import HttpResponseRedirect
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from bookmarks.models import GlobalSettings
|
||||||
|
|
||||||
|
|
||||||
|
def root(request):
|
||||||
|
# Redirect unauthenticated users to the configured landing page
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
settings = GlobalSettings.get()
|
||||||
|
|
||||||
|
if settings.landing_page == GlobalSettings.LANDING_PAGE_SHARED_BOOKMARKS:
|
||||||
|
return HttpResponseRedirect(reverse("bookmarks:shared"))
|
||||||
|
else:
|
||||||
|
return HttpResponseRedirect(reverse("login"))
|
||||||
|
|
||||||
|
# Redirect authenticated users to the bookmarks page
|
||||||
|
return HttpResponseRedirect(reverse("bookmarks:index"))
|
||||||
@@ -6,13 +6,20 @@ import requests
|
|||||||
from django.conf import settings as django_settings
|
from django.conf import settings as django_settings
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db.models import prefetch_related_objects
|
from django.db.models import prefetch_related_objects
|
||||||
from django.http import HttpResponseRedirect, HttpResponse
|
from django.http import HttpResponseRedirect, HttpResponse
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.authtoken.models import Token
|
from rest_framework.authtoken.models import Token
|
||||||
|
|
||||||
from bookmarks.models import Bookmark, UserProfileForm, FeedToken
|
from bookmarks.models import (
|
||||||
|
Bookmark,
|
||||||
|
UserProfileForm,
|
||||||
|
FeedToken,
|
||||||
|
GlobalSettings,
|
||||||
|
GlobalSettingsForm,
|
||||||
|
)
|
||||||
from bookmarks.services import exporter, tasks
|
from bookmarks.services import exporter, tasks
|
||||||
from bookmarks.services import importer
|
from bookmarks.services import importer
|
||||||
from bookmarks.utils import app_version
|
from bookmarks.utils import app_version
|
||||||
@@ -23,6 +30,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@login_required
|
@login_required
|
||||||
def general(request):
|
def general(request):
|
||||||
profile_form = None
|
profile_form = None
|
||||||
|
global_settings_form = None
|
||||||
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
|
enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
|
||||||
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
|
has_snapshot_support = django_settings.LD_ENABLE_SNAPSHOTS
|
||||||
success_message = _find_message_with_tag(
|
success_message = _find_message_with_tag(
|
||||||
@@ -37,6 +45,9 @@ def general(request):
|
|||||||
if "update_profile" in request.POST:
|
if "update_profile" in request.POST:
|
||||||
profile_form = update_profile(request)
|
profile_form = update_profile(request)
|
||||||
success_message = "Profile updated"
|
success_message = "Profile updated"
|
||||||
|
if "update_global_settings" in request.POST:
|
||||||
|
global_settings_form = update_global_settings(request)
|
||||||
|
success_message = "Global settings updated"
|
||||||
if "refresh_favicons" in request.POST:
|
if "refresh_favicons" in request.POST:
|
||||||
tasks.schedule_refresh_favicons(request.user)
|
tasks.schedule_refresh_favicons(request.user)
|
||||||
success_message = "Scheduled favicon update. This may take a while..."
|
success_message = "Scheduled favicon update. This may take a while..."
|
||||||
@@ -52,11 +63,15 @@ def general(request):
|
|||||||
if not profile_form:
|
if not profile_form:
|
||||||
profile_form = UserProfileForm(instance=request.user_profile)
|
profile_form = UserProfileForm(instance=request.user_profile)
|
||||||
|
|
||||||
|
if request.user.is_superuser and not global_settings_form:
|
||||||
|
global_settings_form = GlobalSettingsForm(instance=GlobalSettings.get())
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"settings/general.html",
|
"settings/general.html",
|
||||||
{
|
{
|
||||||
"form": profile_form,
|
"form": profile_form,
|
||||||
|
"global_settings_form": global_settings_form,
|
||||||
"enable_refresh_favicons": enable_refresh_favicons,
|
"enable_refresh_favicons": enable_refresh_favicons,
|
||||||
"has_snapshot_support": has_snapshot_support,
|
"has_snapshot_support": has_snapshot_support,
|
||||||
"success_message": success_message,
|
"success_message": success_message,
|
||||||
@@ -83,6 +98,17 @@ def update_profile(request):
|
|||||||
return form
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
def update_global_settings(request):
|
||||||
|
user = request.user
|
||||||
|
if not user.is_superuser:
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
form = GlobalSettingsForm(request.POST, instance=GlobalSettings.get())
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
# Cache API call response, for one hour when using get_ttl_hash with default params
|
# Cache API call response, for one hour when using get_ttl_hash with default params
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_version_info(ttl_hash=None):
|
def get_version_info(ttl_hash=None):
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
linkding:
|
linkding:
|
||||||
container_name: "${LD_CONTAINER_NAME:-linkding}"
|
container_name: "${LD_CONTAINER_NAME:-linkding}"
|
||||||
@@ -10,4 +8,4 @@ services:
|
|||||||
- "${LD_HOST_DATA_DIR:-./data}:/etc/linkding/data"
|
- "${LD_HOST_DATA_DIR:-./data}:/etc/linkding/data"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -69,6 +69,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
|||||||
|
|
||||||
|
|
||||||
FROM python:3.11.8-alpine3.19 AS linkding
|
FROM python:3.11.8-alpine3.19 AS linkding
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||||
# install runtime dependencies
|
# install runtime dependencies
|
||||||
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
||||||
# create www-data user and group
|
# create www-data user and group
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ RUN wget https://www.sqlite.org/${SQLITE_RELEASE_YEAR}/sqlite-amalgamation-${SQL
|
|||||||
|
|
||||||
|
|
||||||
FROM python:3.11.8-slim-bookworm as linkding
|
FROM python:3.11.8-slim-bookworm as linkding
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||||
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
RUN apt-get update && apt-get -y install mime-support libpq-dev libicu-dev libssl3 curl
|
||||||
WORKDIR /etc/linkding
|
WORKDIR /etc/linkding
|
||||||
# copy prod dependencies
|
# copy prod dependencies
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ Example response:
|
|||||||
"website_title": "Website title",
|
"website_title": "Website title",
|
||||||
"website_description": "Website description",
|
"website_description": "Website description",
|
||||||
"web_archive_snapshot_url": "https://web.archive.org/web/20200926094623/https://example.com",
|
"web_archive_snapshot_url": "https://web.archive.org/web/20200926094623/https://example.com",
|
||||||
|
"favicon_url": "http://127.0.0.1:8000/static/https_example_com.png",
|
||||||
|
"preview_image_url": "http://127.0.0.1:8000/static/0ac5c53db923727765216a3a58e70522.jpg",
|
||||||
"is_archived": false,
|
"is_archived": false,
|
||||||
"unread": false,
|
"unread": false,
|
||||||
"shared": false,
|
"shared": false,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkding",
|
"name": "linkding",
|
||||||
"version": "1.31.0",
|
"version": "1.32.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ libsass
|
|||||||
playwright
|
playwright
|
||||||
pytest
|
pytest
|
||||||
pytest-django
|
pytest-django
|
||||||
|
pytest-xdist
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ click==8.1.7
|
|||||||
# via black
|
# via black
|
||||||
coverage==7.4.1
|
coverage==7.4.1
|
||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
django==5.0.3
|
django==5.0.8
|
||||||
# via
|
# via
|
||||||
# django-appconf
|
# django-appconf
|
||||||
# django-debug-toolbar
|
# django-debug-toolbar
|
||||||
@@ -22,6 +22,8 @@ django-compressor==4.4
|
|||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
django-debug-toolbar==4.2.0
|
django-debug-toolbar==4.2.0
|
||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
|
execnet==2.1.1
|
||||||
|
# via pytest-xdist
|
||||||
greenlet==3.0.3
|
greenlet==3.0.3
|
||||||
# via playwright
|
# via playwright
|
||||||
iniconfig==2.0.0
|
iniconfig==2.0.0
|
||||||
@@ -48,8 +50,11 @@ pytest==8.0.0
|
|||||||
# via
|
# via
|
||||||
# -r requirements.dev.in
|
# -r requirements.dev.in
|
||||||
# pytest-django
|
# pytest-django
|
||||||
|
# pytest-xdist
|
||||||
pytest-django==4.7.0
|
pytest-django==4.7.0
|
||||||
# via -r requirements.dev.in
|
# via -r requirements.dev.in
|
||||||
|
pytest-xdist==3.6.1
|
||||||
|
# via -r requirements.dev.in
|
||||||
rcssmin==1.1.1
|
rcssmin==1.1.1
|
||||||
# via django-compressor
|
# via django-compressor
|
||||||
rjsmin==1.2.1
|
rjsmin==1.2.1
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ bleach==6.1.0
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
bleach-allowlist==1.0.3
|
bleach-allowlist==1.0.3
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
certifi==2023.11.17
|
certifi==2024.7.4
|
||||||
# via requests
|
# via requests
|
||||||
cffi==1.16.0
|
cffi==1.16.0
|
||||||
# via cryptography
|
# via cryptography
|
||||||
@@ -27,7 +27,7 @@ cryptography==42.0.5
|
|||||||
# josepy
|
# josepy
|
||||||
# mozilla-django-oidc
|
# mozilla-django-oidc
|
||||||
# pyopenssl
|
# pyopenssl
|
||||||
django==5.0.3
|
django==5.0.8
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
# django-registration
|
# django-registration
|
||||||
@@ -39,7 +39,7 @@ django-sass-processor==1.4
|
|||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
django-widget-tweaks==1.5.0
|
django-widget-tweaks==1.5.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.15.2
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
huey==2.5.0
|
huey==2.5.0
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
@@ -59,8 +59,6 @@ pyopenssl==24.1.0
|
|||||||
# via josepy
|
# via josepy
|
||||||
python-dateutil==2.8.2
|
python-dateutil==2.8.2
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
pytz==2023.3.post1
|
|
||||||
# via djangorestframework
|
|
||||||
requests==2.32.0
|
requests==2.32.0
|
||||||
# via
|
# via
|
||||||
# -r requirements.in
|
# -r requirements.in
|
||||||
@@ -76,7 +74,7 @@ sqlparse==0.5.0
|
|||||||
# via django
|
# via django
|
||||||
supervisor==4.2.5
|
supervisor==4.2.5
|
||||||
# via -r requirements.in
|
# via -r requirements.in
|
||||||
urllib3==2.1.0
|
urllib3==2.2.2
|
||||||
# via
|
# via
|
||||||
# requests
|
# requests
|
||||||
# waybackpy
|
# waybackpy
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ TEMPLATES = [
|
|||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
"bookmarks.context_processors.toasts",
|
"bookmarks.context_processors.toasts",
|
||||||
"bookmarks.context_processors.public_shares",
|
|
||||||
"bookmarks.context_processors.app_version",
|
"bookmarks.context_processors.app_version",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -114,7 +113,7 @@ LOGOUT_REDIRECT_URL = "/" + LD_CONTEXT_PATH + "login"
|
|||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = os.getenv("TZ", "UTC")
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.31.0
|
1.32.0
|
||||||
|
|||||||
Reference in New Issue
Block a user