Compare commits

...

14 Commits

Author SHA1 Message Date
Sascha Ißbrücker
695b0dc300 Bump version 2024-06-16 11:45:06 +02:00
Sascha Ißbrücker
fe40139838 Make backup include preview images 2024-06-16 10:37:02 +02:00
Sascha Ißbrücker
44b49a4cfe Preview auto tags in bookmark form (#737) 2024-06-16 10:04:38 +02:00
dependabot[bot]
469883a674 --- (#740)
updated-dependencies:
- dependency-name: requests
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-16 10:01:36 +02:00
Viacheslav Slinko
fa5f78cf71 Automatically add tags to bookmarks based on URL pattern (#736)
* [WIP] DSL

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* upd

* dsl2

* full feature

* upd

* upd

* upd

* upd

* rename to auto_tagging_rules

* update migration after rebase

* add REST API tests

* improve settings view

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-05-17 09:39:46 +02:00
Viacheslav Slinko
e03f536925 Add option for disabling tag grouping (#735)
* Configurable tag grouping

* update tag group name

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-05-17 08:38:08 +02:00
Viacheslav Slinko
a92a35cfb8 Thumbnails lazy loading (#734) 2024-05-16 09:44:38 +02:00
Viacheslav Slinko
ff334e0888 Hide tooltip on mobile (#733) 2024-05-15 09:06:30 +02:00
Sascha Ißbrücker
0f9ba57fef Load missing thumbnails after enabling the feature (#725) 2024-05-10 09:50:19 +02:00
Viacheslav Slinko
b4376a9ff1 Load bookmark thumbnails after import (#724)
* Update thumbnails after import

* Safer way to download thumbnails

* small test improvements

* add missing tests

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-05-10 09:19:00 +02:00
Viacheslav Slinko
87cd4061cb Add support for bookmark thumbnails (#721)
* Preview Image

* fix tests

* add test

* download preview image

* relative path

* gst

* details view

* fix tests

* Improve preview image styles

* Remove preview image URL from model

* Revert form changes

* update tests

* make it work in uwsgi

---------

Co-authored-by: Sascha Ißbrücker <sascha.issbruecker@gmail.com>
2024-05-07 18:58:52 +02:00
Sascha Ißbrücker
e2415f652b Remove leading/trailing whitespace in description 2024-04-21 18:56:01 +02:00
Sascha Ißbrücker
9cf5eb5ec0 Use temp dir for favicon loader tests 2024-04-20 19:45:57 +02:00
Sascha Ißbrücker
023a213ba6 Update CHANGELOG.md 2024-04-20 19:23:21 +02:00
45 changed files with 1554 additions and 177 deletions

2
.gitignore vendored
View File

@@ -194,3 +194,5 @@ typings/
# ublock + chromium
/uBlock0.chromium
/chromium-profile
# direnv
/.direnv

View File

@@ -1,5 +1,23 @@
# Changelog
## 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

View File

@@ -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,
@@ -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,
)

View File

@@ -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()

View File

@@ -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):

View 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),
),
]

View 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,
),
),
]

View 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),
),
]

View File

@@ -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",
]

View 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

View File

@@ -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)

View File

@@ -83,6 +83,8 @@ def import_netscape_html(
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}")

View 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

View File

@@ -7,6 +7,7 @@ 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
@@ -15,7 +16,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
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__)
@@ -166,6 +167,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 +228,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

View File

@@ -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

View File

@@ -33,6 +33,15 @@
text-overflow: ellipsis;
}
.preview-image {
margin: $unit-4 0;
img {
max-width: 100%;
max-height: 200px;
}
}
dl {
margin-bottom: 0;
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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';
}
});
}

View File

@@ -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 custom-css"|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>

View File

@@ -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:

View 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"]))

View File

@@ -300,6 +300,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()

View File

@@ -440,6 +440,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 +526,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 +613,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 +674,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 +689,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()
@@ -687,9 +737,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()

View File

@@ -20,7 +20,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 ""
)
@@ -148,19 +148,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 +325,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 +394,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
@@ -640,6 +662,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

View File

@@ -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",

View File

@@ -74,11 +74,18 @@ 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):
@@ -86,6 +93,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
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
@@ -507,6 +515,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()

View File

@@ -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(

View File

@@ -465,3 +465,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)

View 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])

View File

@@ -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,
):

View File

@@ -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)),

View File

@@ -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"

View File

@@ -145,6 +145,7 @@ class BookmarkItem:
self.tag_names = bookmark.tag_names
self.web_archive_snapshot_url = bookmark.web_archive_snapshot_url
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 +216,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 +264,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 +298,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 +332,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,6 +407,7 @@ 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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "linkding",
"version": "1.30.0",
"version": "1.31.0",
"description": "",
"main": "index.js",
"scripts": {

View File

@@ -61,7 +61,7 @@ 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

View File

@@ -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")

View File

@@ -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

View File

@@ -1 +1 @@
1.30.0
1.31.0