mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-28 15:03:12 +08:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
190b5aeeca | ||
|
|
1122d18e18 | ||
|
|
0fe6304328 | ||
|
|
7d4e65976f | ||
|
|
749bc1ef63 | ||
|
|
36a84276a2 | ||
|
|
b72697b819 | ||
|
|
d9362c9b9c | ||
|
|
b0610db406 | ||
|
|
af16a9e727 | ||
|
|
d898c1be4d | ||
|
|
0282220307 | ||
|
|
bb243b382d | ||
|
|
fbc97a3841 | ||
|
|
380f5ed19c | ||
|
|
b28352fb28 | ||
|
|
695b0dc300 | ||
|
|
fe40139838 | ||
|
|
44b49a4cfe | ||
|
|
469883a674 | ||
|
|
fa5f78cf71 | ||
|
|
e03f536925 | ||
|
|
a92a35cfb8 | ||
|
|
ff334e0888 | ||
|
|
0f9ba57fef | ||
|
|
b4376a9ff1 | ||
|
|
87cd4061cb | ||
|
|
e2415f652b | ||
|
|
9cf5eb5ec0 | ||
|
|
023a213ba6 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -194,3 +194,5 @@ typings/
|
||||
# ublock + chromium
|
||||
/uBlock0.chromium
|
||||
/chromium-profile
|
||||
# direnv
|
||||
/.direnv
|
||||
|
||||
38
CHANGELOG.md
38
CHANGELOG.md
@@ -1,5 +1,43 @@
|
||||
# Changelog
|
||||
|
||||
## 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)
|
||||
|
||||
### What's Changed
|
||||
* Add reader mode by @sissbruecker in https://github.com/sissbruecker/linkding/pull/703
|
||||
* Allow uploading custom files for bookmarks by @sissbruecker in https://github.com/sissbruecker/linkding/pull/713
|
||||
* Add option for marking bookmarks as unread by default by @ab623 in https://github.com/sissbruecker/linkding/pull/706
|
||||
* Make blocking cookie banners more reliable by @sissbruecker in https://github.com/sissbruecker/linkding/pull/699
|
||||
* Close bookmark details with escape by @sissbruecker in https://github.com/sissbruecker/linkding/pull/702
|
||||
* Show proper name for bookmark assets in admin by @ab623 in https://github.com/sissbruecker/linkding/pull/708
|
||||
* Bump sqlparse from 0.4.4 to 0.5.0 by @dependabot in https://github.com/sissbruecker/linkding/pull/704
|
||||
|
||||
### New Contributors
|
||||
* @ab623 made their first contribution in https://github.com/sissbruecker/linkding/pull/706
|
||||
|
||||
**Full Changelog**: https://github.com/sissbruecker/linkding/compare/v1.29.0...v1.30.0
|
||||
|
||||
---
|
||||
|
||||
## v1.29.0 (14/04/2024)
|
||||
|
||||
### What's Changed
|
||||
|
||||
2
Makefile
2
Makefile
@@ -7,7 +7,7 @@ tasks:
|
||||
python manage.py process_tasks
|
||||
|
||||
test:
|
||||
pytest
|
||||
pytest -n auto
|
||||
|
||||
format:
|
||||
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).
|
||||
- [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)
|
||||
- [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)
|
||||
|
||||
## 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 |
@@ -11,6 +11,7 @@ from bookmarks.api.serializers import (
|
||||
UserProfileSerializer,
|
||||
)
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, User
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.bookmarks import (
|
||||
archive_bookmark,
|
||||
unarchive_bookmark,
|
||||
@@ -51,7 +52,7 @@ class BookmarkViewSet(
|
||||
return Bookmark.objects.all().filter(owner=user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
return {"user": self.request.user}
|
||||
return {"request": self.request, "user": self.request.user}
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
def archived(self, request):
|
||||
@@ -59,8 +60,8 @@ class BookmarkViewSet(
|
||||
search = BookmarkSearch.from_request(request.GET)
|
||||
query_set = queries.query_archived_bookmarks(user, user.profile, search)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@action(methods=["get"], detail=False)
|
||||
@@ -72,8 +73,8 @@ class BookmarkViewSet(
|
||||
user, request.user_profile, search, public_only
|
||||
)
|
||||
page = self.paginate_queryset(query_set)
|
||||
serializer = self.get_serializer_class()
|
||||
data = serializer(page, many=True).data
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
data = serializer.data
|
||||
return self.get_paginated_response(data)
|
||||
|
||||
@action(methods=["post"], detail=True)
|
||||
@@ -99,13 +100,26 @@ class BookmarkViewSet(
|
||||
# Either return metadata from existing bookmark, or scrape from URL
|
||||
if bookmark:
|
||||
metadata = WebsiteMetadata(
|
||||
url, bookmark.website_title, bookmark.website_description
|
||||
url,
|
||||
bookmark.website_title,
|
||||
bookmark.website_description,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
|
||||
# Return tags that would be automatically applied to the bookmark
|
||||
profile = request.user.profile
|
||||
auto_tags = []
|
||||
if profile.auto_tagging_rules:
|
||||
auto_tags = auto_tagging.get_tags(profile.auto_tagging_rules, url)
|
||||
|
||||
return Response(
|
||||
{"bookmark": existing_bookmark_data, "metadata": metadata.to_dict()},
|
||||
{
|
||||
"bookmark": existing_bookmark_data,
|
||||
"metadata": metadata.to_dict(),
|
||||
"auto_tags": auto_tags,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.db.models import prefetch_related_objects
|
||||
from django.templatetags.static import static
|
||||
from rest_framework import serializers
|
||||
from rest_framework.serializers import ListSerializer
|
||||
|
||||
@@ -31,6 +32,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"is_archived",
|
||||
"unread",
|
||||
"shared",
|
||||
@@ -42,6 +45,8 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
"website_title",
|
||||
"website_description",
|
||||
"web_archive_snapshot_url",
|
||||
"favicon_url",
|
||||
"preview_image_url",
|
||||
"date_added",
|
||||
"date_modified",
|
||||
]
|
||||
@@ -56,6 +61,24 @@ class BookmarkSerializer(serializers.ModelSerializer):
|
||||
shared = serializers.BooleanField(required=False, default=False)
|
||||
# Override readonly tag_names property to allow passing a list of tag names to create/update
|
||||
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):
|
||||
bookmark = Bookmark()
|
||||
|
||||
@@ -85,3 +85,25 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase):
|
||||
|
||||
page.get_by_label("URL").fill(bookmark.url)
|
||||
expect(details).to_have_attribute("open", value="")
|
||||
|
||||
def test_create_should_preview_auto_tags(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "github.com dev github"
|
||||
profile.save()
|
||||
|
||||
with sync_playwright() as p:
|
||||
# Open page with URL that should have auto tags
|
||||
browser = self.setup_browser(p)
|
||||
page = browser.new_page()
|
||||
url = self.live_server_url + reverse("bookmarks:new")
|
||||
url += f"?url=https%3A%2F%2Fgithub.com%2Fsissbruecker%2Flinkding"
|
||||
page.goto(url)
|
||||
|
||||
auto_tags_hint = page.locator(".form-input-hint.auto-tags")
|
||||
expect(auto_tags_hint).to_be_visible()
|
||||
expect(auto_tags_hint).to_have_text("Auto tags: dev github")
|
||||
|
||||
# Change to URL without auto tags
|
||||
page.get_by_label("URL").fill("https://example.com")
|
||||
|
||||
expect(auto_tags_hint).to_be_hidden()
|
||||
|
||||
@@ -48,6 +48,19 @@ class Command(BaseCommand):
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("favicons", file))
|
||||
|
||||
# Backup the previews folder
|
||||
if not os.path.exists(os.path.join("data", "previews")):
|
||||
self.stdout.write(
|
||||
self.style.WARNING("No previews folder found. Skipping...")
|
||||
)
|
||||
else:
|
||||
self.stdout.write("Backup bookmark previews...")
|
||||
previews_folder = os.path.join("data", "previews")
|
||||
for root, _, files in os.walk(previews_folder):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
zip_file.write(file_path, os.path.join("previews", file))
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Backup created at {backup_file}"))
|
||||
|
||||
def backup_database(self, backup_db_file):
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-10 07:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0033_userprofile_default_mark_unread"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="bookmark",
|
||||
name="preview_image_file",
|
||||
field=models.CharField(blank=True, max_length=512),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="enable_preview_images",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
22
bookmarks/migrations/0035_userprofile_tag_grouping.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-14 08:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0034_bookmark_preview_image_file_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="tag_grouping",
|
||||
field=models.CharField(
|
||||
choices=[("alphabetical", "Alphabetical"), ("disabled", "Disabled")],
|
||||
default="alphabetical",
|
||||
max_length=12,
|
||||
),
|
||||
),
|
||||
]
|
||||
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
18
bookmarks/migrations/0036_userprofile_auto_tagging_rules.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2024-05-17 07:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookmarks", "0035_userprofile_tag_grouping"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="auto_tagging_rules",
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
]
|
||||
@@ -59,6 +59,7 @@ class Bookmark(models.Model):
|
||||
website_description = models.TextField(blank=True, null=True)
|
||||
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
|
||||
favicon_file = models.CharField(max_length=512, blank=True)
|
||||
preview_image_file = models.CharField(max_length=512, blank=True)
|
||||
unread = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
shared = models.BooleanField(default=False)
|
||||
@@ -351,6 +352,12 @@ class UserProfile(models.Model):
|
||||
(TAG_SEARCH_STRICT, "Strict"),
|
||||
(TAG_SEARCH_LAX, "Lax"),
|
||||
]
|
||||
TAG_GROUPING_ALPHABETICAL = "alphabetical"
|
||||
TAG_GROUPING_DISABLED = "disabled"
|
||||
TAG_GROUPING_CHOICES = [
|
||||
(TAG_GROUPING_ALPHABETICAL, "Alphabetical"),
|
||||
(TAG_GROUPING_DISABLED, "Disabled"),
|
||||
]
|
||||
user = models.OneToOneField(
|
||||
get_user_model(), related_name="profile", on_delete=models.CASCADE
|
||||
)
|
||||
@@ -391,9 +398,16 @@ class UserProfile(models.Model):
|
||||
blank=False,
|
||||
default=TAG_SEARCH_STRICT,
|
||||
)
|
||||
tag_grouping = models.CharField(
|
||||
max_length=12,
|
||||
choices=TAG_GROUPING_CHOICES,
|
||||
blank=False,
|
||||
default=TAG_GROUPING_ALPHABETICAL,
|
||||
)
|
||||
enable_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_public_sharing = models.BooleanField(default=False, null=False)
|
||||
enable_favicons = models.BooleanField(default=False, null=False)
|
||||
enable_preview_images = models.BooleanField(default=False, null=False)
|
||||
display_url = models.BooleanField(default=False, null=False)
|
||||
display_view_bookmark_action = models.BooleanField(default=True, null=False)
|
||||
display_edit_bookmark_action = models.BooleanField(default=True, null=False)
|
||||
@@ -401,6 +415,7 @@ class UserProfile(models.Model):
|
||||
display_remove_bookmark_action = models.BooleanField(default=True, null=False)
|
||||
permanent_notes = models.BooleanField(default=False, null=False)
|
||||
custom_css = models.TextField(blank=True, null=False)
|
||||
auto_tagging_rules = models.TextField(blank=True, null=False)
|
||||
search_preferences = models.JSONField(default=dict, null=False)
|
||||
enable_automatic_html_snapshots = models.BooleanField(default=True, null=False)
|
||||
default_mark_unread = models.BooleanField(default=False, null=False)
|
||||
@@ -417,9 +432,11 @@ class UserProfileForm(forms.ModelForm):
|
||||
"bookmark_link_target",
|
||||
"web_archive_integration",
|
||||
"tag_search",
|
||||
"tag_grouping",
|
||||
"enable_sharing",
|
||||
"enable_public_sharing",
|
||||
"enable_favicons",
|
||||
"enable_preview_images",
|
||||
"enable_automatic_html_snapshots",
|
||||
"display_url",
|
||||
"display_view_bookmark_action",
|
||||
@@ -429,6 +446,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"permanent_notes",
|
||||
"default_mark_unread",
|
||||
"custom_css",
|
||||
"auto_tagging_rules",
|
||||
]
|
||||
|
||||
|
||||
|
||||
70
bookmarks/services/auto_tagging.py
Normal file
70
bookmarks/services/auto_tagging.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import re
|
||||
import idna
|
||||
|
||||
|
||||
def get_tags(script: str, url: str):
|
||||
parsed_url = urlparse(url.lower())
|
||||
result = set()
|
||||
|
||||
for line in script.lower().split("\n"):
|
||||
if "#" in line:
|
||||
i = line.index("#")
|
||||
line = line[:i]
|
||||
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
domain_pattern = re.sub("^https?://", "", parts[0])
|
||||
path_pattern = None
|
||||
qs_pattern = None
|
||||
|
||||
if "/" in domain_pattern:
|
||||
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
|
||||
|
||||
if path_pattern and not _path_matches(path_pattern, parsed_url.path):
|
||||
continue
|
||||
|
||||
if qs_pattern and not _qs_matches(qs_pattern, parsed_url.query):
|
||||
continue
|
||||
|
||||
for tag in parts[1:]:
|
||||
result.add(tag)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _path_matches(expected_path: str, actual_path: str) -> bool:
|
||||
return actual_path.startswith(expected_path)
|
||||
|
||||
|
||||
def _domains_matches(expected_domain: str, actual_domain: str) -> bool:
|
||||
expected_domain = idna.encode(expected_domain)
|
||||
actual_domain = idna.encode(actual_domain)
|
||||
|
||||
return actual_domain.endswith(expected_domain)
|
||||
|
||||
|
||||
def _qs_matches(expected_qs: str, actual_qs: str) -> bool:
|
||||
expected_qs = parse_qs(expected_qs, keep_blank_values=True)
|
||||
actual_qs = parse_qs(actual_qs, keep_blank_values=True)
|
||||
|
||||
for key in expected_qs:
|
||||
if key not in actual_qs:
|
||||
return False
|
||||
for value in expected_qs[key]:
|
||||
if value != "" and value not in actual_qs[key]:
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -10,6 +10,7 @@ from django.utils import timezone
|
||||
from bookmarks.models import Bookmark, BookmarkAsset, parse_tag_string
|
||||
from bookmarks.services import tasks
|
||||
from bookmarks.services import website_loader
|
||||
from bookmarks.services import auto_tagging
|
||||
from bookmarks.services.tags import get_or_create_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -40,6 +41,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
|
||||
tasks.create_web_archive_snapshot(current_user, bookmark, False)
|
||||
# Load favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
# Load preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
# Create HTML snapshot
|
||||
if current_user.profile.enable_automatic_html_snapshots:
|
||||
tasks.create_html_snapshot(bookmark)
|
||||
@@ -58,6 +61,8 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
|
||||
bookmark.save()
|
||||
# Update favicon
|
||||
tasks.load_favicon(current_user, bookmark)
|
||||
# Update preview image
|
||||
tasks.load_preview_image(current_user, bookmark)
|
||||
|
||||
if has_url_changed:
|
||||
# Update web archive snapshot, if URL changed
|
||||
@@ -238,6 +243,15 @@ def _update_website_metadata(bookmark: Bookmark):
|
||||
|
||||
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
|
||||
tag_names = parse_tag_string(tag_string)
|
||||
|
||||
if user.profile.auto_tagging_rules:
|
||||
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:
|
||||
tag_names.append(auto_tag_name)
|
||||
|
||||
tags = get_or_create_tags(tag_names, user)
|
||||
bookmark.tags.set(tags)
|
||||
|
||||
|
||||
@@ -79,10 +79,10 @@ def import_netscape_html(
|
||||
for batch in batches:
|
||||
_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
|
||||
tasks.schedule_bookmarks_without_favicons(user)
|
||||
# Load previews for newly imported bookmarks
|
||||
tasks.schedule_bookmarks_without_previews(user)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f"Import duration: {end - import_start}")
|
||||
|
||||
88
bookmarks/services/preview_image_loader.py
Normal file
88
bookmarks/services/preview_image_loader.py
Normal file
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import os.path
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from bookmarks.services import website_loader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_preview_folder():
|
||||
Path(settings.LD_PREVIEW_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _url_to_filename(preview_image: str) -> str:
|
||||
return hashlib.md5(preview_image.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_image_path(preview_image_file: str) -> Path:
|
||||
return Path(os.path.join(settings.LD_PREVIEW_FOLDER, preview_image_file))
|
||||
|
||||
|
||||
def load_preview_image(url: str) -> str | None:
|
||||
_ensure_preview_folder()
|
||||
|
||||
metadata = website_loader.load_website_metadata(url)
|
||||
if not metadata.preview_image:
|
||||
logger.debug(f"Could not find preview image in metadata: {url}")
|
||||
return None
|
||||
|
||||
image_url = metadata.preview_image
|
||||
|
||||
logger.debug(f"Loading preview image: {image_url}")
|
||||
with requests.get(image_url, stream=True) as response:
|
||||
if response.status_code < 200 or response.status_code >= 300:
|
||||
logger.debug(
|
||||
f"Bad response status code for preview image: {image_url} status_code={response.status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
if "Content-Length" not in response.headers:
|
||||
logger.debug(f"Empty Content-Length for preview image: {image_url}")
|
||||
return None
|
||||
|
||||
content_length = int(response.headers["Content-Length"])
|
||||
if content_length > settings.LD_PREVIEW_MAX_SIZE:
|
||||
logger.debug(
|
||||
f"Content-Length exceeds LD_PREVIEW_MAX_SIZE: {image_url} length={content_length}"
|
||||
)
|
||||
return None
|
||||
|
||||
if "Content-Type" not in response.headers:
|
||||
logger.debug(f"Empty Content-Type for preview image: {image_url}")
|
||||
return None
|
||||
|
||||
content_type = response.headers["Content-Type"].split(";", 1)[0]
|
||||
file_extension = mimetypes.guess_extension(content_type)
|
||||
|
||||
if file_extension not in settings.LD_PREVIEW_ALLOWED_EXTENSIONS:
|
||||
logger.debug(
|
||||
f"Unsupported Content-Type for preview image: {image_url} content_type={content_type}"
|
||||
)
|
||||
return None
|
||||
|
||||
preview_image_hash = _url_to_filename(url)
|
||||
preview_image_file = f"{preview_image_hash}{file_extension}"
|
||||
preview_image_path = _get_image_path(preview_image_file)
|
||||
|
||||
with open(preview_image_path, "wb") as file:
|
||||
downloaded = 0
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
downloaded += len(chunk)
|
||||
if downloaded > content_length:
|
||||
logger.debug(
|
||||
f"Content-Length mismatch for preview image: {image_url} length={content_length} downloaded={downloaded}"
|
||||
)
|
||||
file.close()
|
||||
preview_image_path.unlink()
|
||||
return None
|
||||
|
||||
file.write(chunk)
|
||||
|
||||
logger.debug(f"Saved preview image as: {preview_image_path}")
|
||||
|
||||
return preview_image_file
|
||||
@@ -7,15 +7,15 @@ import waybackpy
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone, formats
|
||||
from huey import crontab
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
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.services import favicon_loader, singlefile
|
||||
from bookmarks.services import favicon_loader, singlefile, preview_image_loader
|
||||
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -65,29 +65,6 @@ def create_web_archive_snapshot(user: User, bookmark: Bookmark, force_update: bo
|
||||
_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):
|
||||
logger.info(f"Create new snapshot for bookmark. url={bookmark.url}...")
|
||||
archive = waybackpy.WaybackMachineSaveAPI(
|
||||
@@ -116,48 +93,27 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
|
||||
return
|
||||
except TooManyRequestsError:
|
||||
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:
|
||||
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,
|
||||
)
|
||||
|
||||
# Load the newest snapshot as fallback
|
||||
_load_newest_snapshot(bookmark)
|
||||
|
||||
|
||||
@task()
|
||||
def _load_web_archive_snapshot_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
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)
|
||||
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||
# for now to prevent errors when huey tries to run the task
|
||||
pass
|
||||
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_snapshots_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks_without_snapshots = Bookmark.objects.filter(
|
||||
web_archive_snapshot_url__exact="", owner=user
|
||||
)
|
||||
|
||||
# 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)
|
||||
# Loading snapshots from CDX API has been removed, keeping the task function
|
||||
# for now to prevent errors when huey tries to run the task
|
||||
pass
|
||||
|
||||
|
||||
def is_favicon_feature_active(user: User) -> bool:
|
||||
@@ -166,6 +122,12 @@ def is_favicon_feature_active(user: User) -> bool:
|
||||
return background_tasks_enabled and user.profile.enable_favicons
|
||||
|
||||
|
||||
def is_preview_feature_active(user: User) -> bool:
|
||||
return (
|
||||
user.profile.enable_preview_images and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
)
|
||||
|
||||
|
||||
def load_favicon(user: User, bookmark: Bookmark):
|
||||
if is_favicon_feature_active(user):
|
||||
_load_favicon_task(bookmark.id)
|
||||
@@ -221,6 +183,51 @@ def _schedule_refresh_favicons_task(user_id: int):
|
||||
_load_favicon_task(bookmark.id)
|
||||
|
||||
|
||||
def load_preview_image(user: User, bookmark: Bookmark):
|
||||
if is_preview_feature_active(user):
|
||||
_load_preview_image_task(bookmark.id)
|
||||
|
||||
|
||||
@task()
|
||||
def _load_preview_image_task(bookmark_id: int):
|
||||
try:
|
||||
bookmark = Bookmark.objects.get(id=bookmark_id)
|
||||
except Bookmark.DoesNotExist:
|
||||
return
|
||||
|
||||
logger.info(f"Load preview image for bookmark. url={bookmark.url}")
|
||||
|
||||
new_preview_image_file = preview_image_loader.load_preview_image(bookmark.url)
|
||||
|
||||
if new_preview_image_file != bookmark.preview_image_file:
|
||||
bookmark.preview_image_file = new_preview_image_file or ""
|
||||
bookmark.save(update_fields=["preview_image_file"])
|
||||
logger.info(
|
||||
f"Successfully updated preview image for bookmark. url={bookmark.url} preview_image_file={new_preview_image_file}"
|
||||
)
|
||||
|
||||
|
||||
def schedule_bookmarks_without_previews(user: User):
|
||||
if is_preview_feature_active(user):
|
||||
_schedule_bookmarks_without_previews_task(user.id)
|
||||
|
||||
|
||||
@task()
|
||||
def _schedule_bookmarks_without_previews_task(user_id: int):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
bookmarks = Bookmark.objects.filter(
|
||||
Q(preview_image_file__exact=""),
|
||||
owner=user,
|
||||
)
|
||||
|
||||
# TODO: Implement bulk task creation
|
||||
for bookmark in bookmarks:
|
||||
try:
|
||||
_load_preview_image_task(bookmark.id)
|
||||
except Exception as exc:
|
||||
logging.exception(exc)
|
||||
|
||||
|
||||
def is_html_snapshot_feature_active() -> bool:
|
||||
return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS
|
||||
|
||||
|
||||
@@ -1,42 +1,20 @@
|
||||
import time
|
||||
from typing import Dict
|
||||
import datetime
|
||||
|
||||
import waybackpy
|
||||
import waybackpy.utils
|
||||
from waybackpy.exceptions import NoCDXRecordFound
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
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.
|
||||
See https://github.com/akamhy/waybackpy/issues/176
|
||||
Generate a URL to the web archive for the given URL and timestamp.
|
||||
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):
|
||||
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"
|
||||
return f"https://web.archive.org/web/{timestamp.strftime('%Y%m%d%H%M%S')}/{url}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -15,12 +16,14 @@ class WebsiteMetadata:
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
preview_image: str | None
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"preview_image": self.preview_image,
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +33,7 @@ class WebsiteMetadata:
|
||||
def load_website_metadata(url: str):
|
||||
title = None
|
||||
description = None
|
||||
preview_image = None
|
||||
try:
|
||||
start = timezone.now()
|
||||
page_text = load_page(url)
|
||||
@@ -55,10 +59,21 @@ def load_website_metadata(url: str):
|
||||
else None
|
||||
)
|
||||
|
||||
image_tag = soup.find("meta", attrs={"property": "og:image"})
|
||||
preview_image = image_tag["content"].strip() if image_tag else None
|
||||
if (
|
||||
preview_image
|
||||
and not preview_image.startswith("http://")
|
||||
and not preview_image.startswith("https://")
|
||||
):
|
||||
preview_image = urljoin(url, preview_image)
|
||||
|
||||
end = timezone.now()
|
||||
logger.debug(f"Parsing duration: {end - start}")
|
||||
finally:
|
||||
return WebsiteMetadata(url=url, title=title, description=description)
|
||||
return WebsiteMetadata(
|
||||
url=url, title=title, description=description, preview_image=preview_image
|
||||
)
|
||||
|
||||
|
||||
CHUNK_SIZE = 50 * 1024
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import user_logged_in
|
||||
from django.db.backends.signals import connection_created
|
||||
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)
|
||||
def extend_sqlite(connection=None, **kwargs):
|
||||
|
||||
@@ -33,6 +33,15 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
margin: $unit-4 0;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -36,12 +36,11 @@
|
||||
.form-input-hint.bookmark-exists {
|
||||
display: none;
|
||||
color: $warning-color;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $warning-color;
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-input-hint.auto-tags {
|
||||
display: none;
|
||||
color: $success-color;
|
||||
}
|
||||
|
||||
details.notes textarea {
|
||||
|
||||
@@ -128,8 +128,25 @@ ul.bookmark-list {
|
||||
/* Bookmarks */
|
||||
li[ld-bookmark-item] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: $unit-2;
|
||||
margin-top: $unit-2;
|
||||
|
||||
.content {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
img.preview-image {
|
||||
flex: 0 0 auto;
|
||||
width: 100px;
|
||||
height: 60px;
|
||||
margin-top: $unit-h;
|
||||
object-fit: cover;
|
||||
border-radius: $border-radius;
|
||||
border: solid 1px $border-color-dark;
|
||||
}
|
||||
|
||||
.form-checkbox.bulk-edit-checkbox {
|
||||
display: none;
|
||||
}
|
||||
@@ -182,6 +199,12 @@ li[ld-bookmark-item] {
|
||||
animation: 0.3s ease 0s appear;
|
||||
}
|
||||
|
||||
@media (pointer:coarse) {
|
||||
.title a[data-tooltip]::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.unread .title a {
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -346,7 +369,7 @@ $bulk-edit-transition-duration: 400ms;
|
||||
transition: all $bulk-edit-transition-duration;
|
||||
|
||||
.form-icon {
|
||||
top: 0;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
textarea.custom-css {
|
||||
textarea.monospace {
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.input-group > input[type=submit] {
|
||||
|
||||
@@ -195,3 +195,10 @@ ul.menu li:first-child {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide tooltips on mobile
|
||||
@media (pointer:coarse) {
|
||||
.tooltip::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,142 +10,143 @@
|
||||
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
|
||||
{% for bookmark_item in bookmark_list.items %}
|
||||
<li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
<div class="content">
|
||||
<div class="title">
|
||||
<label class="form-checkbox bulk-edit-checkbox">
|
||||
<input type="checkbox" name="bookmark_id" value="{{ bookmark_item.id }}">
|
||||
<i class="form-icon"></i>
|
||||
</label>
|
||||
{% if bookmark_item.favicon_file and bookmark_list.show_favicons %}
|
||||
<img class="favicon" src="{% static bookmark_item.favicon_file %}" alt="">
|
||||
{% endif %}
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener">
|
||||
<span>{{ bookmark_item.title }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_list.show_url %}
|
||||
<div class="url-path truncate">
|
||||
<a href="{{ bookmark_item.url }}" target="{{ bookmark_list.link_target }}" rel="noopener"
|
||||
class="url-display">
|
||||
{{ bookmark_item.url }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_list.description_display == 'inline' %}
|
||||
<div class="description inline truncate">
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">{{ bookmark_item.description }}</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<span class="tags">
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="markdown">{% markdown bookmark_item.notes %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions text-gray">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }} ∞
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||
ld-on="click" ld-target="body|append"
|
||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names and bookmark_item.description %} | {% endif %}
|
||||
{% if bookmark_item.description %}
|
||||
<span>{{ bookmark_item.description }}</span>
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if bookmark_item.description %}
|
||||
<div class="description separate">
|
||||
{{ bookmark_item.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if bookmark_item.tag_names %}
|
||||
<div class="tags">
|
||||
{% for tag_name in bookmark_item.tag_names %}
|
||||
<a href="?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_item.notes %}
|
||||
<div class="notes bg-gray text-gray-dark">
|
||||
<div class="markdown">
|
||||
{% markdown bookmark_item.notes %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="actions text-gray">
|
||||
{% if bookmark_item.display_date %}
|
||||
{% if bookmark_item.web_archive_snapshot_url %}
|
||||
<a href="{{ bookmark_item.web_archive_snapshot_url }}"
|
||||
title="Show snapshot on the Internet Archive Wayback Machine"
|
||||
target="{{ bookmark_list.link_target }}"
|
||||
rel="noopener">
|
||||
{{ bookmark_item.display_date }} ∞
|
||||
</a>
|
||||
{% else %}
|
||||
<span>{{ bookmark_item.display_date }}</span>
|
||||
{% endif %}
|
||||
<span>|</span>
|
||||
{% endif %}
|
||||
{# View link is visible for both owned and shared bookmarks #}
|
||||
{% if bookmark_list.show_view_action %}
|
||||
<a ld-fetch="{% url 'bookmarks:details_modal' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}"
|
||||
ld-on="click" ld-target="body|append"
|
||||
href="{% url 'bookmarks:details' bookmark_item.id %}">View</a>
|
||||
{% endif %}
|
||||
{% if bookmark_item.is_editable %}
|
||||
{# Bookmark owner actions #}
|
||||
{% if bookmark_list.show_edit_action %}
|
||||
<a href="{% url 'bookmarks:edit' bookmark_item.id %}?return_url={{ bookmark_list.return_url|urlencode }}">Edit</a>
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_archive_action %}
|
||||
{% if bookmark_item.is_archived %}
|
||||
<button type="submit" name="unarchive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Unarchive
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="submit" name="archive" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Archive
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if bookmark_list.show_remove_action %}
|
||||
<button ld-confirm-button type="submit" name="remove" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm">Remove
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{# Shared bookmark actions #}
|
||||
<span>Shared by
|
||||
<a href="?{% replace_query_param user=bookmark_item.owner.username %}">{{ bookmark_item.owner.username }}</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if bookmark_item.has_extra_actions %}
|
||||
<div class="extra-actions">
|
||||
<span class="hide-sm">|</span>
|
||||
{% if bookmark_item.show_mark_as_read %}
|
||||
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-unread"></use>
|
||||
</svg>
|
||||
Unread
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_unshare %}
|
||||
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
|
||||
class="btn btn-link btn-sm btn-icon"
|
||||
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-share"></use>
|
||||
</svg>
|
||||
Shared
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bookmark_item.show_notes_button %}
|
||||
<button type="button" class="btn btn-link btn-sm btn-icon toggle-notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<use xlink:href="#ld-icon-note"></use>
|
||||
</svg>
|
||||
Notes
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if bookmark_list.show_preview_images and bookmark_item.preview_image_file %}
|
||||
<img class="preview-image" src="{% static bookmark_item.preview_image_file %}" loading="lazy"/>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
<span>Reader mode</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if details.bookmark.web_archive_snapshot_url %}
|
||||
<a class="weblink" href="{{ details.bookmark.web_archive_snapshot_url }}"
|
||||
{% if details.web_archive_snapshot_url %}
|
||||
<a class="weblink" href="{{ details.web_archive_snapshot_url }}"
|
||||
target="{{ details.profile.bookmark_link_target }}">
|
||||
{% if details.show_link_icons %}
|
||||
<svg class="favicon" viewBox="0 0 76 86" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -37,6 +37,11 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
|
||||
<div class="preview-image">
|
||||
<img src="{% static details.bookmark.preview_image_file %}"/>
|
||||
</div>
|
||||
{% endif %}
|
||||
<dl class="grid columns-2 columns-sm-1 gap-0">
|
||||
{% if details.is_editable %}
|
||||
<div class="status col-2">
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
<label for="{{ form.tag_string.id_for_label }}" class="form-label">Tags</label>
|
||||
{{ form.tag_string|add_class:"form-input"|attr:"ld-tag-autocomplete"|attr:"autocomplete:off"|attr:"autocapitalize:off" }}
|
||||
<div class="form-input-hint">
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not
|
||||
exist it will be
|
||||
automatically created.
|
||||
Enter any number of tags separated by space and <strong>without</strong> the hash (#).
|
||||
If a tag does not exist it will be automatically created.
|
||||
</div>
|
||||
<div class="form-input-hint auto-tags"></div>
|
||||
{{ form.tag_string.errors }}
|
||||
</div>
|
||||
<div class="form-group has-icon-right">
|
||||
@@ -197,6 +197,18 @@
|
||||
} else {
|
||||
bookmarkExistsHint.style['display'] = 'none';
|
||||
}
|
||||
|
||||
// Preview auto tags
|
||||
const autoTags = data.auto_tags;
|
||||
const autoTagsHint = document.querySelector('.form-input-hint.auto-tags');
|
||||
|
||||
if (autoTags.length > 0) {
|
||||
autoTags.sort();
|
||||
autoTagsHint.style['display'] = 'block';
|
||||
autoTagsHint.innerHTML = `Auto tags: ${autoTags.join(" ")}`;
|
||||
} else {
|
||||
autoTagsHint.style['display'] = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,29 @@
|
||||
result will also include bookmarks where a search term matches otherwise.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.tag_grouping.id_for_label }}" class="form-label">Tag grouping</label>
|
||||
{{ form.tag_grouping|add_class:"form-select width-25 width-sm-100" }}
|
||||
<div class="form-input-hint">
|
||||
In alphabetical mode, tags will be grouped by the first letter.
|
||||
If disabled, tags will not be grouped.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<details {% if form.auto_tagging_rules.value %}open{% endif %}>
|
||||
<summary>Auto Tagging</summary>
|
||||
<label for="{{ form.auto_tagging_rules.id_for_label }}" class="text-assistive">Auto Tagging</label>
|
||||
<div class="mt-2">
|
||||
{{ form.auto_tagging_rules|add_class:"form-input monospace"|attr:"rows:6" }}
|
||||
</div>
|
||||
</details>
|
||||
<div class="form-input-hint">
|
||||
Automatically adds tags to bookmarks based on predefined rules.
|
||||
Each line is a single rule that maps a URL to one or more tags. For example:
|
||||
<pre>youtube.com video
|
||||
reddit.com/r/Music music reddit</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
|
||||
{{ form.enable_favicons }}
|
||||
@@ -117,6 +140,7 @@
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
|
||||
Enabling this feature automatically downloads all missing favicons.
|
||||
By default, this feature uses a <b>Google service</b> to download favicons.
|
||||
If you don't want to use this service, check the <a
|
||||
href="https://github.com/sissbruecker/linkding/blob/master/docs/Options.md#ld_favicon_provider"
|
||||
@@ -127,6 +151,16 @@
|
||||
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.enable_preview_images.id_for_label }}" class="form-checkbox">
|
||||
{{ form.enable_preview_images }}
|
||||
<i class="form-icon"></i> Enable Preview Images
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
Automatically loads preview images for bookmarked websites and displays them next to each bookmark.
|
||||
Enabling this feature automatically downloads all missing preview images.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
|
||||
integration</label>
|
||||
@@ -191,7 +225,7 @@
|
||||
<summary>Custom CSS</summary>
|
||||
<label for="{{ form.custom_css.id_for_label }}" class="text-assistive">Custom CSS</label>
|
||||
<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>
|
||||
</details>
|
||||
<div class="form-input-hint">
|
||||
|
||||
@@ -39,6 +39,7 @@ class BookmarkFactoryMixin:
|
||||
website_description: str = "",
|
||||
web_archive_snapshot_url: str = "",
|
||||
favicon_file: str = "",
|
||||
preview_image_file: str = "",
|
||||
added: datetime = None,
|
||||
):
|
||||
if title is None:
|
||||
@@ -67,6 +68,7 @@ class BookmarkFactoryMixin:
|
||||
shared=shared,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
favicon_file=favicon_file,
|
||||
preview_image_file=preview_image_file,
|
||||
)
|
||||
bookmark.save()
|
||||
for tag in tags:
|
||||
@@ -85,6 +87,8 @@ class BookmarkFactoryMixin:
|
||||
shared: bool = False,
|
||||
with_tags: 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 or self.get_or_create_test_user()
|
||||
@@ -116,6 +120,12 @@ class BookmarkFactoryMixin:
|
||||
web_archive_snapshot_url = ""
|
||||
if with_web_archive_snapshot_url:
|
||||
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(
|
||||
url=url,
|
||||
title=title,
|
||||
@@ -124,6 +134,8 @@ class BookmarkFactoryMixin:
|
||||
shared=shared,
|
||||
tags=tags,
|
||||
web_archive_snapshot_url=web_archive_snapshot_url,
|
||||
favicon_file=favicon_file,
|
||||
preview_image_file=preview_image_file,
|
||||
user=user,
|
||||
)
|
||||
bookmarks.append(bookmark)
|
||||
|
||||
179
bookmarks/tests/test_auto_tagging.py
Normal file
179
bookmarks/tests/test_auto_tagging.py
Normal file
@@ -0,0 +1,179 @@
|
||||
from bookmarks.services import auto_tagging
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
class AutoTaggingTestCase(TestCase):
|
||||
def test_auto_tag_by_domain(self):
|
||||
script = """
|
||||
example.com example
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["example"]))
|
||||
|
||||
def test_auto_tag_by_domain_ignores_case(self):
|
||||
script = """
|
||||
EXAMPLE.com example
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["example"]))
|
||||
|
||||
def test_auto_tag_by_domain_should_add_all_tags(self):
|
||||
script = """
|
||||
example.com one two three
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["one", "two", "three"]))
|
||||
|
||||
def test_auto_tag_by_domain_work_with_idn_domains(self):
|
||||
script = """
|
||||
रजिस्ट्री.भारत tag1
|
||||
"""
|
||||
url = "https://www.xn--81bg3cc2b2bk5hb.xn--h2brj9c/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1"]))
|
||||
|
||||
script = """
|
||||
xn--81bg3cc2b2bk5hb.xn--h2brj9c tag1
|
||||
"""
|
||||
url = "https://www.रजिस्ट्री.भारत/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1"]))
|
||||
|
||||
def test_auto_tag_by_domain_and_path(self):
|
||||
script = """
|
||||
example.com/one one
|
||||
example.com/two two
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com/one/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["one"]))
|
||||
|
||||
def test_auto_tag_by_domain_and_path_ignores_case(self):
|
||||
script = """
|
||||
example.com/One one
|
||||
"""
|
||||
url = "https://example.com/one/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["one"]))
|
||||
|
||||
def test_auto_tag_by_domain_and_path_matches_path_ltr(self):
|
||||
script = """
|
||||
example.com/one one
|
||||
example.com/two two
|
||||
test.com test
|
||||
"""
|
||||
url = "https://example.com/one/two"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["one"]))
|
||||
|
||||
def test_auto_tag_by_domain_ignores_domain_in_path(self):
|
||||
script = """
|
||||
example.com example
|
||||
"""
|
||||
url = "https://test.com/example.com"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_includes_subdomains(self):
|
||||
script = """
|
||||
example.com example
|
||||
test.example.com test
|
||||
some.example.com some
|
||||
"""
|
||||
url = "https://test.example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["example", "test"]))
|
||||
|
||||
def test_auto_tag_by_domain_matches_domain_rtl(self):
|
||||
script = """
|
||||
example.com example
|
||||
"""
|
||||
url = "https://example.com.bad-website.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_ignores_schema(self):
|
||||
script = """
|
||||
https://example.com/ https
|
||||
http://example.com/ http
|
||||
"""
|
||||
url = "http://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["https", "http"]))
|
||||
|
||||
def test_auto_tag_by_domain_ignores_lines_with_no_tags(self):
|
||||
script = """
|
||||
example.com
|
||||
"""
|
||||
url = "https://example.com/"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set([]))
|
||||
|
||||
def test_auto_tag_by_domain_path_and_qs(self):
|
||||
script = """
|
||||
example.com/page?a=b tag1 # true, matches a=b
|
||||
example.com/page?a=c&c=d tag2 # true, matches both a=c and c=d
|
||||
example.com/page?c=d&l=p tag3 # false, l=p doesn't exists
|
||||
example.com/page?a=bb tag4 # false bb != b
|
||||
example.com/page?a=b&a=c tag5 # true, matches both a=b and a=c
|
||||
example.com/page?a=B tag6 # true, matches a=b because case insensitive
|
||||
example.com/page?A=b tag7 # true, matches a=b because case insensitive
|
||||
"""
|
||||
url = "https://example.com/page/some?z=x&a=b&v=b&c=d&o=p&a=c"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1", "tag2", "tag5", "tag6", "tag7"]))
|
||||
|
||||
def test_auto_tag_by_domain_path_and_qs_with_empty_value(self):
|
||||
script = """
|
||||
example.com/page?a= tag1
|
||||
example.com/page?b= tag2
|
||||
"""
|
||||
url = "https://example.com/page/some?a=value"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1"]))
|
||||
|
||||
def test_auto_tag_by_domain_path_and_qs_works_with_encoded_url(self):
|
||||
script = """
|
||||
example.com/page?a=йцу tag1
|
||||
example.com/page?a=%D0%B9%D1%86%D1%83 tag2
|
||||
"""
|
||||
url = "https://example.com/page?a=%D0%B9%D1%86%D1%83"
|
||||
|
||||
tags = auto_tagging.get_tags(script, url)
|
||||
|
||||
self.assertEqual(tags, set(["tag1", "tag2"]))
|
||||
@@ -1,10 +1,11 @@
|
||||
import datetime
|
||||
import re
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import formats
|
||||
from django.utils import formats, timezone
|
||||
|
||||
from bookmarks.models import BookmarkAsset, UserProfile
|
||||
from bookmarks.services import bookmarks, tasks
|
||||
@@ -180,7 +181,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
# no latest snapshot
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 1)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
# snapshot is not complete
|
||||
self.setup_asset(
|
||||
@@ -194,7 +195,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
status=BookmarkAsset.STATUS_FAILURE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 1)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
# not a snapshot
|
||||
self.setup_asset(
|
||||
@@ -203,7 +204,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
soup = self.get_details(bookmark)
|
||||
self.assertEqual(self.count_weblinks(soup), 1)
|
||||
self.assertEqual(self.count_weblinks(soup), 2)
|
||||
|
||||
# snapshot is complete
|
||||
asset = self.setup_asset(
|
||||
@@ -212,20 +213,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
status=BookmarkAsset.STATUS_COMPLETE,
|
||||
)
|
||||
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])
|
||||
link = self.find_weblink(soup, reader_mode_url)
|
||||
self.assertIsNotNone(link)
|
||||
|
||||
def test_internet_archive_link(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
|
||||
def test_internet_archive_link_with_snapshot_url(self):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||
soup = self.get_details(bookmark)
|
||||
link = self.find_weblink(soup, bookmark.web_archive_snapshot_url)
|
||||
@@ -264,6 +258,21 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
image = link.select_one("svg")
|
||||
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):
|
||||
bookmark = self.setup_bookmark(web_archive_snapshot_url="https://example.com/")
|
||||
|
||||
@@ -300,6 +309,36 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
|
||||
web_archive_link["target"], UserProfile.BOOKMARK_LINK_TARGET_SELF
|
||||
)
|
||||
|
||||
def test_preview_image(self):
|
||||
# without image
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# with image
|
||||
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# preview images enabled, no image
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_preview_images = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNone(image)
|
||||
|
||||
# preview images enabled, image present
|
||||
bookmark = self.setup_bookmark(preview_image_file="example.png")
|
||||
soup = self.get_details(bookmark)
|
||||
image = soup.select_one("div.preview-image img")
|
||||
self.assertIsNotNone(image)
|
||||
self.assertEqual(image["src"], "/static/example.png")
|
||||
|
||||
def test_status(self):
|
||||
# renders form
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
@@ -6,11 +6,7 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from bookmarks.models import Bookmark, BookmarkSearch, Tag, UserProfile
|
||||
from bookmarks.tests.helpers import (
|
||||
BookmarkFactoryMixin,
|
||||
HtmlTestMixin,
|
||||
collapse_whitespace,
|
||||
)
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
|
||||
|
||||
|
||||
class BookmarkIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
@@ -36,6 +36,16 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
expectation["website_title"] = bookmark.website_title
|
||||
expectation["website_description"] = bookmark.website_description
|
||||
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["unread"] = bookmark.unread
|
||||
expectation["shared"] = bookmark.shared
|
||||
@@ -65,7 +75,11 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
def test_list_bookmarks_with_more_details(self):
|
||||
self.authenticate()
|
||||
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(
|
||||
@@ -171,6 +185,23 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
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):
|
||||
self.authenticate()
|
||||
search_value = self.get_random_string()
|
||||
@@ -220,6 +251,26 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
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):
|
||||
user1 = self.setup_user(enable_sharing=True, enable_public_sharing=True)
|
||||
user2 = self.setup_user(enable_sharing=True)
|
||||
@@ -440,6 +491,20 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertFalse(bookmark.shared)
|
||||
|
||||
def test_create_bookmark_should_add_tags_from_auto_tagging(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.authenticate()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
|
||||
data = {"url": "https://example.com/", "tag_names": [tag1.name]}
|
||||
self.post(reverse("bookmarks:bookmark-list"), data, status.HTTP_201_CREATED)
|
||||
bookmark = Bookmark.objects.get(url=data["url"])
|
||||
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_get_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -512,6 +577,22 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertEqual(updated_bookmark.shared, True)
|
||||
|
||||
def test_update_bookmark_adds_tags_from_auto_tagging(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.authenticate()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
|
||||
data = {"url": "https://example.com/", "tag_names": [tag1.name]}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
self.put(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_patch_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -583,6 +664,22 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(updated_bookmark.description, bookmark.description)
|
||||
self.assertListEqual(updated_bookmark.tag_names, bookmark.tag_names)
|
||||
|
||||
def test_patch_bookmark_adds_tags_from_auto_tagging(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
|
||||
self.authenticate()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
|
||||
data = {"tag_names": [tag1.name]}
|
||||
url = reverse("bookmarks:bookmark-detail", args=[bookmark.id])
|
||||
self.patch(url, data, expected_status_code=status.HTTP_200_OK)
|
||||
updated_bookmark = Bookmark.objects.get(id=bookmark.id)
|
||||
self.assertCountEqual(updated_bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_delete_bookmark(self):
|
||||
self.authenticate()
|
||||
bookmark = self.setup_bookmark()
|
||||
@@ -628,7 +725,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
website_loader, "load_website_metadata"
|
||||
) as mock_load_website_metadata:
|
||||
expected_metadata = WebsiteMetadata(
|
||||
"https://example.com", "Scraped metadata", "Scraped description"
|
||||
"https://example.com",
|
||||
"Scraped metadata",
|
||||
"Scraped description",
|
||||
"https://example.com/preview.png",
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
@@ -640,9 +740,10 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
metadata = response.data["metadata"]
|
||||
|
||||
self.assertIsNotNone(metadata)
|
||||
self.assertIsNotNone(expected_metadata.url, metadata["url"])
|
||||
self.assertIsNotNone(expected_metadata.title, metadata["title"])
|
||||
self.assertIsNotNone(expected_metadata.description, metadata["description"])
|
||||
self.assertEqual(expected_metadata.url, metadata["url"])
|
||||
self.assertEqual(expected_metadata.title, metadata["title"])
|
||||
self.assertEqual(expected_metadata.description, metadata["description"])
|
||||
self.assertEqual(expected_metadata.preview_image, metadata["preview_image"])
|
||||
|
||||
def test_check_returns_bookmark_if_url_is_bookmarked(self):
|
||||
self.authenticate()
|
||||
@@ -651,6 +752,8 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
url="https://example.com",
|
||||
title="Example title",
|
||||
description="Example description",
|
||||
favicon_file="favicon.png",
|
||||
preview_image_file="preview.png",
|
||||
)
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
@@ -665,6 +768,12 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(bookmark.url, bookmark_data["url"])
|
||||
self.assertEqual(bookmark.title, bookmark_data["title"])
|
||||
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):
|
||||
self.authenticate()
|
||||
@@ -687,9 +796,38 @@ class BookmarksApiTestCase(LinkdingApiTestCase, BookmarkFactoryMixin):
|
||||
|
||||
mock_load_website_metadata.assert_not_called()
|
||||
self.assertIsNotNone(metadata)
|
||||
self.assertIsNotNone(bookmark.url, metadata["url"])
|
||||
self.assertIsNotNone(bookmark.website_title, metadata["title"])
|
||||
self.assertIsNotNone(bookmark.website_description, metadata["description"])
|
||||
self.assertEqual(bookmark.url, metadata["url"])
|
||||
self.assertEqual(bookmark.website_title, metadata["title"])
|
||||
self.assertEqual(bookmark.website_description, metadata["description"])
|
||||
self.assertIsNone(metadata["preview_image"])
|
||||
|
||||
def test_check_returns_no_auto_tags_if_none_configured(self):
|
||||
self.authenticate()
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
auto_tags = response.data["auto_tags"]
|
||||
|
||||
self.assertCountEqual(auto_tags, [])
|
||||
|
||||
def test_check_returns_matching_auto_tags(self):
|
||||
self.authenticate()
|
||||
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = "example.com tag1 tag2"
|
||||
profile.save()
|
||||
|
||||
url = reverse("bookmarks:bookmark-check")
|
||||
check_url = urllib.parse.quote_plus("https://example.com")
|
||||
response = self.get(
|
||||
f"{url}?url={check_url}", expected_status_code=status.HTTP_200_OK
|
||||
)
|
||||
auto_tags = response.data["auto_tags"]
|
||||
|
||||
self.assertCountEqual(auto_tags, ["tag1", "tag2"])
|
||||
|
||||
def test_can_only_access_own_bookmarks(self):
|
||||
self.authenticate()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import datetime
|
||||
from typing import Type
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
@@ -20,7 +21,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
self, html: str, bookmark: Bookmark, link_target: str = "_blank"
|
||||
):
|
||||
favicon_img = (
|
||||
f'<img src="/static/{bookmark.favicon_file}" alt="">'
|
||||
f'<img class="favicon" src="/static/{bookmark.favicon_file}" alt="">'
|
||||
if bookmark.favicon_file
|
||||
else ""
|
||||
)
|
||||
@@ -36,15 +37,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
html,
|
||||
)
|
||||
|
||||
def assertDateLabel(self, html: str, label_content: str):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<span>{label_content}</span>
|
||||
<span>|</span>
|
||||
""",
|
||||
html,
|
||||
)
|
||||
|
||||
def assertWebArchiveLink(
|
||||
self, html: str, label_content: str, url: str, link_target: str = "_blank"
|
||||
):
|
||||
@@ -148,19 +140,41 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
)
|
||||
|
||||
def assertFaviconVisible(self, html: str, bookmark: Bookmark):
|
||||
self.assertFaviconCount(html, bookmark, 1)
|
||||
self.assertFavicon(html, bookmark, True)
|
||||
|
||||
def assertFaviconHidden(self, html: str, bookmark: Bookmark):
|
||||
self.assertFaviconCount(html, bookmark, 0)
|
||||
self.assertFavicon(html, bookmark, False)
|
||||
|
||||
def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1):
|
||||
self.assertInHTML(
|
||||
f"""
|
||||
<img src="/static/{bookmark.favicon_file}" alt="">
|
||||
""",
|
||||
html,
|
||||
count=count,
|
||||
)
|
||||
def assertFavicon(self, html: str, bookmark: Bookmark, visible=True):
|
||||
soup = self.make_soup(html)
|
||||
|
||||
favicon = soup.select_one(".favicon")
|
||||
|
||||
if not visible:
|
||||
self.assertIsNone(favicon)
|
||||
return
|
||||
|
||||
url = f"/static/{bookmark.favicon_file}"
|
||||
self.assertIsNotNone(favicon)
|
||||
self.assertEqual(favicon["src"], url)
|
||||
|
||||
def assertPreviewImageVisible(self, html: str, bookmark: Bookmark):
|
||||
self.assertPreviewImage(html, bookmark, True)
|
||||
|
||||
def assertPreviewImageHidden(self, html: str, bookmark: Bookmark):
|
||||
self.assertPreviewImage(html, bookmark, False)
|
||||
|
||||
def assertPreviewImage(self, html: str, bookmark: Bookmark, visible=True):
|
||||
soup = self.make_soup(html)
|
||||
preview_image = soup.select_one(".preview-image")
|
||||
|
||||
if not visible:
|
||||
self.assertIsNone(preview_image)
|
||||
return
|
||||
|
||||
url = f"/static/{bookmark.preview_image_file}"
|
||||
self.assertIsNotNone(preview_image)
|
||||
self.assertEqual(preview_image["src"], url)
|
||||
|
||||
def assertBookmarkURLCount(
|
||||
self, html: str, bookmark: Bookmark, link_target: str = "_blank", count=0
|
||||
@@ -303,7 +317,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
if has_description and has_tags:
|
||||
self.assertTrue("|" in description.text)
|
||||
|
||||
# contains description text
|
||||
# contains description text, without leading/trailing whitespace
|
||||
if has_description:
|
||||
description_text = description.find("span", text=bookmark.description)
|
||||
self.assertIsNotNone(description_text)
|
||||
@@ -372,10 +386,10 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
description = soup.select_one(".description")
|
||||
self.assertIsNone(description)
|
||||
else:
|
||||
# contains description text
|
||||
# contains description text, without leading/trailing whitespace
|
||||
description = soup.select_one(".description.separate")
|
||||
self.assertIsNotNone(description)
|
||||
self.assertEqual(description.text.strip(), bookmark.description)
|
||||
self.assertEqual(description.text, bookmark.description)
|
||||
|
||||
if not has_tags:
|
||||
# no tags element
|
||||
@@ -443,15 +457,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
style = bookmark_list["style"]
|
||||
self.assertIn("--ld-bookmark-description-max-lines:3;", style)
|
||||
|
||||
def test_should_respect_absolute_date_setting(self):
|
||||
bookmark = self.setup_date_format_test(
|
||||
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE
|
||||
)
|
||||
html = self.render_template()
|
||||
formatted_date = formats.date_format(bookmark.date_added, "SHORT_DATE_FORMAT")
|
||||
|
||||
self.assertDateLabel(html, formatted_date)
|
||||
|
||||
def test_should_render_web_archive_link_with_absolute_date_setting(self):
|
||||
bookmark = self.setup_date_format_test(
|
||||
UserProfile.BOOKMARK_DATE_DISPLAY_ABSOLUTE,
|
||||
@@ -464,12 +469,6 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
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):
|
||||
bookmark = self.setup_date_format_test(
|
||||
UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
|
||||
@@ -479,6 +478,27 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
|
||||
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):
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_template()
|
||||
@@ -640,6 +660,36 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
html,
|
||||
)
|
||||
|
||||
def test_preview_image_should_be_visible_when_preview_images_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_preview_images = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(preview_image_file="preview.png")
|
||||
html = self.render_template()
|
||||
|
||||
self.assertPreviewImageVisible(html, bookmark)
|
||||
|
||||
def test_preview_image_should_be_hidden_when_preview_images_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_preview_images = False
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark(preview_image_file="preview.png")
|
||||
html = self.render_template()
|
||||
|
||||
self.assertPreviewImageHidden(html, bookmark)
|
||||
|
||||
def test_preview_image_should_be_hidden_when_there_is_no_preview_image(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_preview_images = True
|
||||
profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
html = self.render_template()
|
||||
|
||||
self.assertPreviewImageHidden(html, bookmark)
|
||||
|
||||
def test_favicon_should_be_visible_when_favicons_enabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.enable_favicons = True
|
||||
|
||||
@@ -42,7 +42,10 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
website_loader, "load_website_metadata"
|
||||
) as mock_load_website_metadata:
|
||||
expected_metadata = WebsiteMetadata(
|
||||
"https://example.com", "Website title", "Website description"
|
||||
"https://example.com",
|
||||
"Website title",
|
||||
"Website description",
|
||||
"https://example.com/preview.png",
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
@@ -127,6 +130,18 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
mock_create_html_snapshot.assert_not_called()
|
||||
|
||||
def test_create_should_add_tags_from_auto_tagging(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
|
||||
bookmark_data = Bookmark(url="https://example.com")
|
||||
bookmark = create_bookmark(bookmark_data, tag1.name, self.user)
|
||||
|
||||
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
|
||||
with patch.object(
|
||||
tasks, "create_web_archive_snapshot"
|
||||
@@ -157,6 +172,7 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"https://example.com/updated",
|
||||
"Updated website title",
|
||||
"Updated website description",
|
||||
"https://example.com/preview.png",
|
||||
)
|
||||
mock_load_website_metadata.return_value = expected_metadata
|
||||
|
||||
@@ -197,6 +213,18 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
mock_create_html_snapshot.assert_not_called()
|
||||
|
||||
def test_update_should_add_tags_from_auto_tagging(self):
|
||||
tag1 = self.setup_tag()
|
||||
tag2 = self.setup_tag()
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.auto_tagging_rules = f"example.com {tag2.name}"
|
||||
profile.save()
|
||||
bookmark = self.setup_bookmark(url="https://example.com")
|
||||
|
||||
update_bookmark(bookmark, tag1.name, self.user)
|
||||
|
||||
self.assertCountEqual(bookmark.tags.all(), [tag1, tag2])
|
||||
|
||||
def test_archive_bookmark(self):
|
||||
bookmark = Bookmark(
|
||||
url="https://example.com",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import datetime
|
||||
import os.path
|
||||
from dataclasses import dataclass
|
||||
from unittest import mock
|
||||
|
||||
import waybackpy
|
||||
@@ -10,8 +8,6 @@ from django.test import TestCase, override_settings
|
||||
from huey.contrib.djhuey import HUEY as huey
|
||||
from waybackpy.exceptions import WaybackError
|
||||
|
||||
import bookmarks.services.favicon_loader
|
||||
import bookmarks.services.wayback
|
||||
from bookmarks.models import BookmarkAsset, UserProfile
|
||||
from bookmarks.services import tasks, singlefile
|
||||
from bookmarks.tests.helpers import BookmarkFactoryMixin
|
||||
@@ -29,12 +25,6 @@ def create_wayback_machine_save_api_mock(
|
||||
return mock_api
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockCdxSnapshot:
|
||||
archive_url: str
|
||||
datetime_timestamp: datetime.datetime
|
||||
|
||||
|
||||
class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
def setUp(self):
|
||||
@@ -50,17 +40,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
)
|
||||
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(
|
||||
"bookmarks.services.favicon_loader.load_favicon"
|
||||
)
|
||||
@@ -74,18 +53,25 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.mock_singlefile_create_snapshot_patcher.start()
|
||||
)
|
||||
|
||||
self.mock_load_preview_image_patcher = mock.patch(
|
||||
"bookmarks.services.preview_image_loader.load_preview_image"
|
||||
)
|
||||
self.mock_load_preview_image = self.mock_load_preview_image_patcher.start()
|
||||
self.mock_load_preview_image.return_value = "preview_image.png"
|
||||
|
||||
user = self.get_or_create_test_user()
|
||||
user.profile.web_archive_integration = (
|
||||
UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
|
||||
)
|
||||
user.profile.enable_favicons = True
|
||||
user.profile.enable_preview_images = True
|
||||
user.profile.save()
|
||||
|
||||
def tearDown(self):
|
||||
self.mock_save_api_patcher.stop()
|
||||
self.mock_cdx_api_patcher.stop()
|
||||
self.mock_load_favicon_patcher.stop()
|
||||
self.mock_singlefile_create_snapshot_patcher.stop()
|
||||
self.mock_load_preview_image_patcher.stop()
|
||||
huey.storage.flush_results()
|
||||
huey.immediate = False
|
||||
|
||||
@@ -134,45 +120,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
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):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
@@ -195,69 +142,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
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)
|
||||
def test_create_web_archive_snapshot_should_not_run_when_background_tasks_are_disabled(
|
||||
self,
|
||||
@@ -284,59 +168,6 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
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):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
@@ -507,6 +338,136 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
|
||||
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_load_preview_image_should_create_preview_image_file(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(self.executed_count(), 1)
|
||||
self.assertEqual(bookmark.preview_image_file, "preview_image.png")
|
||||
|
||||
def test_load_preview_image_should_update_preview_image_file(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
preview_image_file="preview_image.png",
|
||||
)
|
||||
|
||||
self.mock_load_preview_image.return_value = "preview_image_upd.png"
|
||||
|
||||
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.mock_load_preview_image.assert_called_once()
|
||||
self.assertEqual(bookmark.preview_image_file, "preview_image_upd.png")
|
||||
|
||||
def test_load_preview_image_should_set_blank_when_none_is_returned(self):
|
||||
bookmark = self.setup_bookmark(
|
||||
preview_image_file="preview_image.png",
|
||||
)
|
||||
|
||||
self.mock_load_preview_image.return_value = None
|
||||
|
||||
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
|
||||
|
||||
bookmark.refresh_from_db()
|
||||
self.mock_load_preview_image.assert_called_once()
|
||||
self.assertEqual(bookmark.preview_image_file, "")
|
||||
|
||||
def test_load_preview_image_should_handle_missing_bookmark(self):
|
||||
tasks._load_preview_image_task(123)
|
||||
|
||||
self.mock_load_preview_image.assert_not_called()
|
||||
|
||||
def test_load_preview_image_should_not_save_stale_bookmark_data(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
# update bookmark during API call to check that saving
|
||||
# the image does not overwrite updated bookmark data
|
||||
def mock_load_preview_image_impl(url):
|
||||
bookmark.title = "Updated title"
|
||||
bookmark.save()
|
||||
return "test.png"
|
||||
|
||||
self.mock_load_preview_image.side_effect = mock_load_preview_image_impl
|
||||
|
||||
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
|
||||
bookmark.refresh_from_db()
|
||||
|
||||
self.assertEqual(bookmark.title, "Updated title")
|
||||
self.assertEqual(bookmark.preview_image_file, "test.png")
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_load_preview_image_should_not_run_when_background_tasks_are_disabled(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
|
||||
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_load_preview_image_should_not_run_when_preview_image_feature_is_disabled(
|
||||
self,
|
||||
):
|
||||
self.user.profile.enable_preview_images = False
|
||||
self.user.profile.save()
|
||||
|
||||
bookmark = self.setup_bookmark()
|
||||
tasks.load_preview_image(self.get_or_create_test_user(), bookmark)
|
||||
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_previews_should_load_preview_for_all_bookmarks_without_preview(
|
||||
self,
|
||||
):
|
||||
user = self.get_or_create_test_user()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark()
|
||||
self.setup_bookmark(preview_image_file="test.png")
|
||||
self.setup_bookmark(preview_image_file="test.png")
|
||||
self.setup_bookmark(preview_image_file="test.png")
|
||||
|
||||
tasks.schedule_bookmarks_without_previews(user)
|
||||
|
||||
self.assertEqual(self.executed_count(), 4)
|
||||
self.assertEqual(self.mock_load_preview_image.call_count, 3)
|
||||
|
||||
def test_schedule_bookmarks_without_previews_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_previews(user)
|
||||
|
||||
self.assertEqual(self.mock_load_preview_image.call_count, 3)
|
||||
|
||||
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
|
||||
def test_schedule_bookmarks_without_previews_should_not_run_when_background_tasks_are_disabled(
|
||||
self,
|
||||
):
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_bookmarks_without_previews(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
def test_schedule_bookmarks_without_previews_should_not_run_when_preview_feature_is_disabled(
|
||||
self,
|
||||
):
|
||||
self.user.profile.enable_preview_images = False
|
||||
self.user.profile.save()
|
||||
|
||||
self.setup_bookmark()
|
||||
tasks.schedule_bookmarks_without_previews(self.get_or_create_test_user())
|
||||
|
||||
self.assertEqual(self.executed_count(), 0)
|
||||
|
||||
@override_settings(LD_ENABLE_SNAPSHOTS=True)
|
||||
def test_create_html_snapshot_should_create_pending_asset(self):
|
||||
bookmark = self.setup_bookmark()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import io
|
||||
import os.path
|
||||
import time
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock, skip
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, override_settings
|
||||
@@ -29,17 +30,21 @@ class MockStreamingResponse:
|
||||
|
||||
class FaviconLoaderTestCase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.ensure_favicon_folder()
|
||||
self.clear_favicon_folder()
|
||||
self.temp_favicon_folder = tempfile.TemporaryDirectory()
|
||||
self.favicon_folder_override = self.settings(
|
||||
LD_FAVICON_FOLDER=self.temp_favicon_folder.name
|
||||
)
|
||||
self.favicon_folder_override.enable()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_favicon_folder.cleanup()
|
||||
self.favicon_folder_override.disable()
|
||||
|
||||
def create_mock_response(self, icon_data=mock_icon_data, content_type="image/png"):
|
||||
mock_response = mock.Mock()
|
||||
mock_response.raw = io.BytesIO(icon_data)
|
||||
return MockStreamingResponse(icon_data, content_type)
|
||||
|
||||
def ensure_favicon_folder(self):
|
||||
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def clear_favicon_folder(self):
|
||||
folder = Path(settings.LD_FAVICON_FOLDER)
|
||||
for file in folder.iterdir():
|
||||
@@ -177,7 +182,6 @@ class FaviconLoaderTestCase(TestCase):
|
||||
self.assertTrue(self.icon_exists("https_example_com.png"))
|
||||
|
||||
self.clear_favicon_folder()
|
||||
self.ensure_favicon_folder()
|
||||
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response(
|
||||
|
||||
@@ -444,17 +444,6 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
self.assertEqual(Bookmark.objects.all()[0].description, "Example description")
|
||||
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):
|
||||
user = self.get_or_create_test_user()
|
||||
test_html = self.render_html(tags_html="")
|
||||
@@ -465,3 +454,14 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
|
||||
import_netscape_html(test_html, user)
|
||||
|
||||
mock_schedule_bookmarks_without_favicons.assert_called_once_with(user)
|
||||
|
||||
def test_schedule_preview_loading(self):
|
||||
user = self.get_or_create_test_user()
|
||||
test_html = self.render_html(tags_html="")
|
||||
|
||||
with patch.object(
|
||||
tasks, "schedule_bookmarks_without_previews"
|
||||
) as mock_schedule_bookmarks_without_previews:
|
||||
import_netscape_html(test_html, user)
|
||||
|
||||
mock_schedule_bookmarks_without_previews.assert_called_once_with(user)
|
||||
|
||||
205
bookmarks/tests/test_preview_image_loader.py
Normal file
205
bookmarks/tests/test_preview_image_loader.py
Normal file
@@ -0,0 +1,205 @@
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from bookmarks.services import preview_image_loader
|
||||
|
||||
mock_image_data = b"mock_image"
|
||||
|
||||
|
||||
class MockStreamingResponse:
|
||||
def __init__(
|
||||
self,
|
||||
url,
|
||||
data=mock_image_data,
|
||||
content_type="image/png",
|
||||
content_length=None,
|
||||
status_code=200,
|
||||
):
|
||||
self.url = url
|
||||
self.chunks = [data]
|
||||
self.status_code = status_code
|
||||
if not content_length:
|
||||
content_length = len(data)
|
||||
self.headers = {"Content-Type": content_type, "Content-Length": content_length}
|
||||
|
||||
def iter_content(self, **kwargs):
|
||||
return self.chunks
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
pass
|
||||
|
||||
|
||||
class PreviewImageLoaderTestCase(TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp_folder = tempfile.TemporaryDirectory()
|
||||
self.settings_override = self.settings(LD_PREVIEW_FOLDER=self.temp_folder.name)
|
||||
self.settings_override.enable()
|
||||
self.mock_load_website_metadata_patcher = mock.patch(
|
||||
"bookmarks.services.website_loader.load_website_metadata"
|
||||
)
|
||||
self.mock_load_website_metadata = (
|
||||
self.mock_load_website_metadata_patcher.start()
|
||||
)
|
||||
self.mock_load_website_metadata.return_value = mock.Mock(
|
||||
preview_image="https://example.com/image.png"
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temp_folder.cleanup()
|
||||
self.settings_override.disable()
|
||||
self.mock_load_website_metadata_patcher.stop()
|
||||
|
||||
def create_mock_response(
|
||||
self,
|
||||
url="https://example.com/image.png",
|
||||
icon_data=mock_image_data,
|
||||
content_type="image/png",
|
||||
content_length=len(mock_image_data),
|
||||
status_code=200,
|
||||
):
|
||||
mock_response = mock.Mock()
|
||||
mock_response.raw = io.BytesIO(icon_data)
|
||||
return MockStreamingResponse(
|
||||
url, icon_data, content_type, content_length, status_code
|
||||
)
|
||||
|
||||
def get_image_path(self, filename):
|
||||
return Path(os.path.join(settings.LD_PREVIEW_FOLDER, filename))
|
||||
|
||||
def assertImageExists(self, filename, data):
|
||||
self.assertTrue(self.get_image_path(filename).exists())
|
||||
self.assertEqual(self.get_image_path(filename).read_bytes(), data)
|
||||
|
||||
def assertNoImageExists(self):
|
||||
self.assertFalse(os.listdir(settings.LD_PREVIEW_FOLDER))
|
||||
|
||||
def test_load_preview_image(self):
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response()
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertIsNotNone(file)
|
||||
self.assertImageExists(file, mock_image_data)
|
||||
|
||||
def test_load_preview_image_returns_none_if_no_preview_image_detected(self):
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response()
|
||||
self.mock_load_website_metadata.return_value = mock.Mock(preview_image=None)
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertIsNone(file)
|
||||
self.assertNoImageExists()
|
||||
|
||||
def test_load_preview_image_returns_none_for_invalid_status_code(self):
|
||||
invalid_status_codes = [199, 300, 400, 500]
|
||||
|
||||
for status_code in invalid_status_codes:
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response(
|
||||
status_code=status_code
|
||||
)
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertIsNone(file)
|
||||
self.assertNoImageExists()
|
||||
|
||||
def test_load_preview_image_returns_none_if_content_length_exceeds_limit(self):
|
||||
# exceeds max size
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response(
|
||||
content_length=settings.LD_PREVIEW_MAX_SIZE + 1
|
||||
)
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertIsNone(file)
|
||||
self.assertNoImageExists()
|
||||
|
||||
# equals max size
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response(
|
||||
content_length=settings.LD_PREVIEW_MAX_SIZE
|
||||
)
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertIsNotNone(file)
|
||||
self.assertImageExists(file, mock_image_data)
|
||||
|
||||
def test_load_preview_image_returns_none_for_invalid_content_type(self):
|
||||
invalid_content_types = ["text/html", "application/json"]
|
||||
|
||||
for content_type in invalid_content_types:
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response(
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertIsNone(file)
|
||||
self.assertNoImageExists()
|
||||
|
||||
valid_content_types = ["image/png", "image/jpeg", "image/gif"]
|
||||
|
||||
for content_type in valid_content_types:
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response(
|
||||
content_type=content_type
|
||||
)
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertIsNotNone(file)
|
||||
self.assertImageExists(file, mock_image_data)
|
||||
|
||||
def test_load_preview_image_returns_none_if_download_exceeds_content_length(self):
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response(content_length=1)
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertIsNone(file)
|
||||
self.assertNoImageExists()
|
||||
|
||||
def test_load_preview_image_creates_folder_if_not_exists(self):
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response()
|
||||
|
||||
folder = Path(settings.LD_PREVIEW_FOLDER)
|
||||
folder.rmdir()
|
||||
|
||||
self.assertFalse(folder.exists())
|
||||
|
||||
preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertTrue(folder.exists())
|
||||
|
||||
def test_guess_file_extension(self):
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response(content_type="image/png")
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertImageExists(file, mock_image_data)
|
||||
self.assertEqual("png", file.split(".")[-1])
|
||||
|
||||
with mock.patch("requests.get") as mock_get:
|
||||
mock_get.return_value = self.create_mock_response(content_type="image/jpeg")
|
||||
|
||||
file = preview_image_loader.load_preview_image("https://example.com")
|
||||
|
||||
self.assertImageExists(file, mock_image_data)
|
||||
self.assertEqual("jpg", file.split(".")[-1])
|
||||
@@ -31,8 +31,10 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"enable_sharing": False,
|
||||
"enable_public_sharing": False,
|
||||
"enable_favicons": False,
|
||||
"enable_preview_images": False,
|
||||
"enable_automatic_html_snapshots": True,
|
||||
"tag_search": UserProfile.TAG_SEARCH_STRICT,
|
||||
"tag_grouping": UserProfile.TAG_GROUPING_ALPHABETICAL,
|
||||
"display_url": False,
|
||||
"display_view_bookmark_action": True,
|
||||
"display_edit_bookmark_action": True,
|
||||
@@ -40,6 +42,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"display_remove_bookmark_action": True,
|
||||
"permanent_notes": False,
|
||||
"custom_css": "",
|
||||
"auto_tagging_rules": "",
|
||||
}
|
||||
|
||||
return {**form_data, **overrides}
|
||||
@@ -88,8 +91,10 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"enable_sharing": True,
|
||||
"enable_public_sharing": True,
|
||||
"enable_favicons": True,
|
||||
"enable_preview_images": True,
|
||||
"enable_automatic_html_snapshots": False,
|
||||
"tag_search": UserProfile.TAG_SEARCH_LAX,
|
||||
"tag_grouping": UserProfile.TAG_GROUPING_DISABLED,
|
||||
"display_url": True,
|
||||
"display_view_bookmark_action": False,
|
||||
"display_edit_bookmark_action": False,
|
||||
@@ -98,6 +103,7 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
"permanent_notes": True,
|
||||
"default_mark_unread": True,
|
||||
"custom_css": "body { background-color: #000; }",
|
||||
"auto_tagging_rules": "example.com tag",
|
||||
}
|
||||
response = self.client.post(reverse("bookmarks:settings.general"), form_data)
|
||||
html = response.content.decode()
|
||||
@@ -131,11 +137,15 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.assertEqual(
|
||||
self.user.profile.enable_favicons, form_data["enable_favicons"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.user.profile.enable_preview_images, form_data["enable_preview_images"]
|
||||
)
|
||||
self.assertEqual(
|
||||
self.user.profile.enable_automatic_html_snapshots,
|
||||
form_data["enable_automatic_html_snapshots"],
|
||||
)
|
||||
self.assertEqual(self.user.profile.tag_search, form_data["tag_search"])
|
||||
self.assertEqual(self.user.profile.tag_grouping, form_data["tag_grouping"])
|
||||
self.assertEqual(self.user.profile.display_url, form_data["display_url"])
|
||||
self.assertEqual(
|
||||
self.user.profile.display_view_bookmark_action,
|
||||
@@ -160,6 +170,9 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
self.user.profile.default_mark_unread, form_data["default_mark_unread"]
|
||||
)
|
||||
self.assertEqual(self.user.profile.custom_css, form_data["custom_css"])
|
||||
self.assertEqual(
|
||||
self.user.profile.auto_tagging_rules, form_data["auto_tagging_rules"]
|
||||
)
|
||||
self.assertSuccessMessage(html, "Profile updated")
|
||||
|
||||
def test_update_profile_should_not_be_called_without_respective_form_action(self):
|
||||
@@ -291,6 +304,39 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
|
||||
count=0,
|
||||
)
|
||||
|
||||
def test_enable_preview_image_should_schedule_preview_update(self):
|
||||
with patch.object(
|
||||
tasks, "schedule_bookmarks_without_previews"
|
||||
) as mock_schedule_bookmarks_without_previews:
|
||||
# Enabling favicons schedules update
|
||||
form_data = self.create_profile_form_data(
|
||||
{
|
||||
"update_profile": "",
|
||||
"enable_preview_images": True,
|
||||
}
|
||||
)
|
||||
self.client.post(reverse("bookmarks:settings.general"), form_data)
|
||||
|
||||
mock_schedule_bookmarks_without_previews.assert_called_once_with(self.user)
|
||||
|
||||
# No update scheduled if favicons are already enabled
|
||||
mock_schedule_bookmarks_without_previews.reset_mock()
|
||||
|
||||
self.client.post(reverse("bookmarks:settings.general"), form_data)
|
||||
|
||||
mock_schedule_bookmarks_without_previews.assert_not_called()
|
||||
|
||||
# No update scheduled when disabling favicons
|
||||
form_data = self.create_profile_form_data(
|
||||
{
|
||||
"enable_preview_images": False,
|
||||
}
|
||||
)
|
||||
|
||||
self.client.post(reverse("bookmarks:settings.general"), form_data)
|
||||
|
||||
mock_schedule_bookmarks_without_previews.assert_not_called()
|
||||
|
||||
def test_automatic_html_snapshots_should_be_hidden_when_snapshots_not_supported(
|
||||
self,
|
||||
):
|
||||
|
||||
@@ -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)
|
||||
@@ -140,6 +140,43 @@ class TagCloudTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
|
||||
],
|
||||
)
|
||||
|
||||
def test_group_when_grouping_disabled(self):
|
||||
profile = self.get_or_create_test_user().profile
|
||||
profile.tag_grouping = UserProfile.TAG_GROUPING_DISABLED
|
||||
profile.save()
|
||||
|
||||
tags = [
|
||||
self.setup_tag(name="Cockatoo"),
|
||||
self.setup_tag(name="Badger"),
|
||||
self.setup_tag(name="Buffalo"),
|
||||
self.setup_tag(name="Chihuahua"),
|
||||
self.setup_tag(name="Alpaca"),
|
||||
self.setup_tag(name="Coyote"),
|
||||
self.setup_tag(name="Aardvark"),
|
||||
self.setup_tag(name="Bumblebee"),
|
||||
self.setup_tag(name="Armadillo"),
|
||||
]
|
||||
self.setup_bookmark(tags=tags)
|
||||
|
||||
rendered_template = self.render_template()
|
||||
|
||||
self.assertTagGroups(
|
||||
rendered_template,
|
||||
[
|
||||
[
|
||||
"Aardvark",
|
||||
"Alpaca",
|
||||
"Armadillo",
|
||||
"Badger",
|
||||
"Buffalo",
|
||||
"Bumblebee",
|
||||
"Chihuahua",
|
||||
"Cockatoo",
|
||||
"Coyote",
|
||||
],
|
||||
],
|
||||
)
|
||||
|
||||
def test_no_duplicate_tag_names(self):
|
||||
tags = [
|
||||
self.setup_tag(name="shared", user=self.setup_user(enable_sharing=True)),
|
||||
|
||||
@@ -29,7 +29,9 @@ class WebsiteLoaderTestCase(TestCase):
|
||||
# clear cached metadata before test run
|
||||
website_loader.load_website_metadata.cache_clear()
|
||||
|
||||
def render_html_document(self, title, description="", og_description=""):
|
||||
def render_html_document(
|
||||
self, title, description="", og_description="", og_image=""
|
||||
):
|
||||
meta_description = (
|
||||
f'<meta name="description" content="{description}">' if description else ""
|
||||
)
|
||||
@@ -38,6 +40,9 @@ class WebsiteLoaderTestCase(TestCase):
|
||||
if og_description
|
||||
else ""
|
||||
)
|
||||
meta_og_image = (
|
||||
f'<meta property="og:image" content="{og_image}">' if og_image else ""
|
||||
)
|
||||
return f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -46,6 +51,7 @@ class WebsiteLoaderTestCase(TestCase):
|
||||
<title>{title}</title>
|
||||
{meta_description}
|
||||
{meta_og_description}
|
||||
{meta_og_image}
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -105,6 +111,7 @@ class WebsiteLoaderTestCase(TestCase):
|
||||
metadata = website_loader.load_website_metadata("https://example.com")
|
||||
self.assertEqual("test title", metadata.title)
|
||||
self.assertEqual("test description", metadata.description)
|
||||
self.assertIsNone(metadata.preview_image)
|
||||
|
||||
def test_load_website_metadata_trims_title_and_description(self):
|
||||
with mock.patch(
|
||||
@@ -128,6 +135,44 @@ class WebsiteLoaderTestCase(TestCase):
|
||||
self.assertEqual("test title", metadata.title)
|
||||
self.assertEqual("test og description", metadata.description)
|
||||
|
||||
def test_load_website_metadata_using_og_image(self):
|
||||
with mock.patch(
|
||||
"bookmarks.services.website_loader.load_page"
|
||||
) as mock_load_page:
|
||||
mock_load_page.return_value = self.render_html_document(
|
||||
"test title", og_image="http://example.com/image.jpg"
|
||||
)
|
||||
metadata = website_loader.load_website_metadata("https://example.com")
|
||||
self.assertEqual("http://example.com/image.jpg", metadata.preview_image)
|
||||
|
||||
def test_load_website_metadata_gets_absolute_og_image_path_when_path_starts_with_dots(
|
||||
self,
|
||||
):
|
||||
with mock.patch(
|
||||
"bookmarks.services.website_loader.load_page"
|
||||
) as mock_load_page:
|
||||
mock_load_page.return_value = self.render_html_document(
|
||||
"test title", og_image="../image.jpg"
|
||||
)
|
||||
metadata = website_loader.load_website_metadata(
|
||||
"https://example.com/a/b/page.html"
|
||||
)
|
||||
self.assertEqual("https://example.com/a/image.jpg", metadata.preview_image)
|
||||
|
||||
def test_load_website_metadata_gets_absolute_og_image_path_when_path_starts_with_slash(
|
||||
self,
|
||||
):
|
||||
with mock.patch(
|
||||
"bookmarks.services.website_loader.load_page"
|
||||
) as mock_load_page:
|
||||
mock_load_page.return_value = self.render_html_document(
|
||||
"test title", og_image="/image.jpg"
|
||||
)
|
||||
metadata = website_loader.load_website_metadata(
|
||||
"https://example.com/a/b/page.html"
|
||||
)
|
||||
self.assertEqual("https://example.com/image.jpg", metadata.preview_image)
|
||||
|
||||
def test_load_website_metadata_prefers_description_over_og_description(self):
|
||||
with mock.patch(
|
||||
"bookmarks.services.website_loader.load_page"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import re
|
||||
import urllib.parse
|
||||
from typing import Set, List
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.handlers.wsgi import WSGIRequest
|
||||
from django.core.paginator import Paginator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
from bookmarks import queries
|
||||
from bookmarks import utils
|
||||
@@ -18,6 +18,7 @@ from bookmarks.models import (
|
||||
UserProfile,
|
||||
Tag,
|
||||
)
|
||||
from bookmarks.services.wayback import generate_fallback_webarchive_url
|
||||
|
||||
DEFAULT_PAGE_SIZE = 30
|
||||
CJK_RE = re.compile(r"[\u4e00-\u9fff]+")
|
||||
@@ -144,7 +145,12 @@ class BookmarkItem:
|
||||
self.notes = bookmark.notes
|
||||
self.tag_names = bookmark.tag_names
|
||||
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.preview_image_file = bookmark.preview_image_file
|
||||
self.is_archived = bookmark.is_archived
|
||||
self.unread = bookmark.unread
|
||||
self.owner = bookmark.owner
|
||||
@@ -215,6 +221,7 @@ class BookmarkListContext:
|
||||
self.show_archive_action = user_profile.display_archive_bookmark_action
|
||||
self.show_remove_action = user_profile.display_remove_bookmark_action
|
||||
self.show_favicons = user_profile.enable_favicons
|
||||
self.show_preview_images = user_profile.enable_preview_images
|
||||
self.show_notes = user_profile.permanent_notes
|
||||
|
||||
@staticmethod
|
||||
@@ -262,7 +269,16 @@ class TagGroup:
|
||||
return f"<{self.char} TagGroup>"
|
||||
|
||||
@staticmethod
|
||||
def create_tag_groups(tags: Set[Tag]):
|
||||
def create_tag_groups(mode: str, tags: Set[Tag]):
|
||||
if mode == UserProfile.TAG_GROUPING_ALPHABETICAL:
|
||||
return TagGroup._create_tag_groups_alphabetical(tags)
|
||||
elif mode == UserProfile.TAG_GROUPING_DISABLED:
|
||||
return TagGroup._create_tag_groups_disabled(tags)
|
||||
else:
|
||||
raise ValueError(f"{mode} is not a valid tag grouping mode")
|
||||
|
||||
@staticmethod
|
||||
def _create_tag_groups_alphabetical(tags: Set[Tag]):
|
||||
# Ensure groups, as well as tags within groups, are ordered alphabetically
|
||||
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
|
||||
group = None
|
||||
@@ -287,6 +303,18 @@ class TagGroup:
|
||||
groups.append(cjk_group)
|
||||
return groups
|
||||
|
||||
@staticmethod
|
||||
def _create_tag_groups_disabled(tags: Set[Tag]):
|
||||
if len(tags) == 0:
|
||||
return []
|
||||
|
||||
sorted_tags = sorted(tags, key=lambda x: str.lower(x.name))
|
||||
group = TagGroup("Ungrouped")
|
||||
for tag in sorted_tags:
|
||||
group.tags.append(tag)
|
||||
|
||||
return [group]
|
||||
|
||||
|
||||
class TagCloudContext:
|
||||
request_context = RequestContext
|
||||
@@ -309,7 +337,7 @@ class TagCloudContext:
|
||||
)
|
||||
has_selected_tags = len(unique_selected_tags) > 0
|
||||
unselected_tags = set(unique_tags).symmetric_difference(unique_selected_tags)
|
||||
groups = TagGroup.create_tag_groups(unselected_tags)
|
||||
groups = TagGroup.create_tag_groups(user_profile.tag_grouping, unselected_tags)
|
||||
|
||||
self.tags = unique_tags
|
||||
self.groups = groups
|
||||
@@ -384,10 +412,17 @@ class BookmarkDetailsContext:
|
||||
self.profile = request.user_profile
|
||||
self.is_editable = bookmark.owner == user
|
||||
self.sharing_enabled = user_profile.enable_sharing
|
||||
self.preview_image_enabled = user_profile.enable_preview_images
|
||||
self.show_link_icons = user_profile.enable_favicons and bookmark.favicon_file
|
||||
# For now hide files section if snapshots are not supported
|
||||
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 = [
|
||||
BookmarkAssetItem(asset) for asset in bookmark.bookmarkasset_set.all()
|
||||
]
|
||||
|
||||
@@ -70,11 +70,16 @@ def update_profile(request):
|
||||
user = request.user
|
||||
profile = user.profile
|
||||
favicons_were_enabled = profile.enable_favicons
|
||||
previews_were_enabled = profile.enable_preview_images
|
||||
form = UserProfileForm(request.POST, instance=profile)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
# Load missing favicons if the feature was just enabled
|
||||
if profile.enable_favicons and not favicons_were_enabled:
|
||||
tasks.schedule_bookmarks_without_favicons(request.user)
|
||||
# Load missing preview images if the feature was just enabled
|
||||
if profile.enable_preview_images and not previews_were_enabled:
|
||||
tasks.schedule_bookmarks_without_previews(request.user)
|
||||
return form
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
|
||||
mkdir -p data
|
||||
# Create favicon folder if it does not exist
|
||||
mkdir -p data/favicons
|
||||
# Create previews folder if it does not exist
|
||||
mkdir -p data/previews
|
||||
# Create assets folder if it does not exist
|
||||
mkdir -p data/assets
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
linkding:
|
||||
container_name: "${LD_CONTAINER_NAME:-linkding}"
|
||||
@@ -10,4 +8,4 @@ services:
|
||||
- "${LD_HOST_DATA_DIR:-./data}:/etc/linkding/data"
|
||||
env_file:
|
||||
- .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
|
||||
LABEL org.opencontainers.image.source="https://github.com/sissbruecker/linkding"
|
||||
# install runtime dependencies
|
||||
RUN apk update && apk add bash curl icu libpq mailcap libssl3
|
||||
# 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
|
||||
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
|
||||
WORKDIR /etc/linkding
|
||||
# copy prod dependencies
|
||||
|
||||
@@ -49,6 +49,8 @@ Example response:
|
||||
"website_title": "Website title",
|
||||
"website_description": "Website description",
|
||||
"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,
|
||||
"unread": false,
|
||||
"shared": false,
|
||||
|
||||
@@ -8,12 +8,13 @@ The data folder contains the following contents that are relevant for backups:
|
||||
- `db.sqlite3` - the SQLite database
|
||||
- `assets` - folder that contains HTML snapshots of bookmarks
|
||||
- `favicons` - folder that contains downloaded favicons
|
||||
- `previews` - folder that contains downloaded preview images
|
||||
|
||||
The following sections explain how to back up the individual contents.
|
||||
|
||||
## Full backup
|
||||
|
||||
linkding provides a CLI command to create a full backup of the data folder. This creates a zip file that contains backups of the database, assets, and favicons.
|
||||
linkding provides a CLI command to create a full backup of the data folder. This creates a zip file that contains backups of the database, assets, favicons, and preview images.
|
||||
|
||||
> [!NOTE]
|
||||
> This method assumes that you are using the default SQLite database.
|
||||
@@ -90,7 +91,7 @@ This is the least technical option to back up bookmarks, but has several limitat
|
||||
- It only exports your own bookmarks, not those of other users.
|
||||
- It does not export URLs of snapshots on the Internet Archive Wayback machine.
|
||||
- It does not export HTML snapshots of bookmarks. Even if you backup and restore the assets folder, the bookmarks will not be linked to the snapshots anymore.
|
||||
- It does not export favicons.
|
||||
- It does not export favicons or preview images.
|
||||
|
||||
Only use this method if you are fine with the above limitations.
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.25.0",
|
||||
"version": "1.30.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "linkding",
|
||||
"version": "1.25.0",
|
||||
"version": "1.30.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "linkding",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -6,3 +6,4 @@ libsass
|
||||
playwright
|
||||
pytest
|
||||
pytest-django
|
||||
pytest-xdist
|
||||
|
||||
@@ -12,7 +12,7 @@ click==8.1.7
|
||||
# via black
|
||||
coverage==7.4.1
|
||||
# via -r requirements.dev.in
|
||||
django==5.0.3
|
||||
django==5.0.8
|
||||
# via
|
||||
# django-appconf
|
||||
# django-debug-toolbar
|
||||
@@ -22,6 +22,8 @@ django-compressor==4.4
|
||||
# via -r requirements.dev.in
|
||||
django-debug-toolbar==4.2.0
|
||||
# via -r requirements.dev.in
|
||||
execnet==2.1.1
|
||||
# via pytest-xdist
|
||||
greenlet==3.0.3
|
||||
# via playwright
|
||||
iniconfig==2.0.0
|
||||
@@ -48,8 +50,11 @@ pytest==8.0.0
|
||||
# via
|
||||
# -r requirements.dev.in
|
||||
# pytest-django
|
||||
# pytest-xdist
|
||||
pytest-django==4.7.0
|
||||
# via -r requirements.dev.in
|
||||
pytest-xdist==3.6.1
|
||||
# via -r requirements.dev.in
|
||||
rcssmin==1.1.1
|
||||
# via django-compressor
|
||||
rjsmin==1.2.1
|
||||
|
||||
@@ -12,7 +12,7 @@ bleach==6.1.0
|
||||
# via -r requirements.in
|
||||
bleach-allowlist==1.0.3
|
||||
# via -r requirements.in
|
||||
certifi==2023.11.17
|
||||
certifi==2024.7.4
|
||||
# via requests
|
||||
cffi==1.16.0
|
||||
# via cryptography
|
||||
@@ -27,7 +27,7 @@ cryptography==42.0.5
|
||||
# josepy
|
||||
# mozilla-django-oidc
|
||||
# pyopenssl
|
||||
django==5.0.3
|
||||
django==5.0.8
|
||||
# via
|
||||
# -r requirements.in
|
||||
# django-registration
|
||||
@@ -39,7 +39,7 @@ django-sass-processor==1.4
|
||||
# via -r requirements.in
|
||||
django-widget-tweaks==1.5.0
|
||||
# via -r requirements.in
|
||||
djangorestframework==3.14.0
|
||||
djangorestframework==3.15.2
|
||||
# via -r requirements.in
|
||||
huey==2.5.0
|
||||
# via -r requirements.in
|
||||
@@ -59,9 +59,7 @@ pyopenssl==24.1.0
|
||||
# via josepy
|
||||
python-dateutil==2.8.2
|
||||
# via -r requirements.in
|
||||
pytz==2023.3.post1
|
||||
# via djangorestframework
|
||||
requests==2.31.0
|
||||
requests==2.32.0
|
||||
# via
|
||||
# -r requirements.in
|
||||
# mozilla-django-oidc
|
||||
@@ -76,7 +74,7 @@ sqlparse==0.5.0
|
||||
# via django
|
||||
supervisor==4.2.5
|
||||
# via -r requirements.in
|
||||
urllib3==2.1.0
|
||||
urllib3==2.2.2
|
||||
# via
|
||||
# requests
|
||||
# waybackpy
|
||||
|
||||
@@ -114,7 +114,7 @@ LOGOUT_REDIRECT_URL = "/" + LD_CONTEXT_PATH + "login"
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
TIME_ZONE = os.getenv("TZ", "UTC")
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
@@ -144,6 +144,7 @@ STATICFILES_FINDERS = [
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "bookmarks", "styles"),
|
||||
os.path.join(BASE_DIR, "data", "favicons"),
|
||||
os.path.join(BASE_DIR, "data", "previews"),
|
||||
]
|
||||
|
||||
# REST framework
|
||||
@@ -286,6 +287,18 @@ LD_ENABLE_REFRESH_FAVICONS = os.getenv("LD_ENABLE_REFRESH_FAVICONS", True) in (
|
||||
"1",
|
||||
)
|
||||
|
||||
# Previews settings
|
||||
LD_PREVIEW_FOLDER = os.path.join(BASE_DIR, "data", "previews")
|
||||
LD_PREVIEW_MAX_SIZE = int(os.getenv("LD_PREVIEW_MAX_SIZE", 5242880))
|
||||
LD_PREVIEW_ALLOWED_EXTENSIONS = [
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".gif",
|
||||
".svg",
|
||||
".webp",
|
||||
]
|
||||
|
||||
# Asset / snapshot settings
|
||||
LD_ASSET_FOLDER = os.path.join(BASE_DIR, "data", "assets")
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ module = siteroot.wsgi:application
|
||||
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
|
||||
static-map = /static=static
|
||||
static-map = /static=data/favicons
|
||||
static-map = /static=data/previews
|
||||
processes = 2
|
||||
threads = 2
|
||||
pidfile = /tmp/linkding.pid
|
||||
@@ -16,6 +17,7 @@ die-on-term = true
|
||||
if-env = LD_CONTEXT_PATH
|
||||
static-map = /%(_)static=static
|
||||
static-map = /%(_)static=data/favicons
|
||||
static-map = /%(_)static=data/previews
|
||||
endif =
|
||||
|
||||
if-env = LD_REQUEST_TIMEOUT
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.30.0
|
||||
1.31.1
|
||||
|
||||
Reference in New Issue
Block a user