Add option for showing bookmark favicons (#390)

* Implement favicon loader

* Implement load favicon task

* Show favicons in bookmark list

* Add missing migration

* Load missing favicons on import

* Automatically refresh favicons

* Add enable favicon setting

* Update uwsgi config to host favicons

* Improve settings wording

* Fix favicon loader test setup

* Document LD_FAVICON_PROVIDER setting

* Add refresh favicons button
This commit is contained in:
Sascha Ißbrücker 2023-01-21 16:36:10 +01:00 committed by GitHub
parent 4cb39fae99
commit 814401be2e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 786 additions and 47 deletions

View file

@ -0,0 +1,18 @@
# Generated by Django 4.1 on 2023-01-07 23:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0017_userprofile_enable_sharing'),
]
operations = [
migrations.AddField(
model_name='bookmark',
name='favicon_file',
field=models.CharField(blank=True, max_length=512),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 4.1 on 2023-01-09 21:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('bookmarks', '0018_bookmark_favicon_file'),
]
operations = [
migrations.AddField(
model_name='userprofile',
name='enable_favicons',
field=models.BooleanField(default=False),
),
]

View file

@ -53,6 +53,7 @@ class Bookmark(models.Model):
website_title = models.CharField(max_length=512, blank=True, null=True) website_title = models.CharField(max_length=512, blank=True, null=True)
website_description = models.TextField(blank=True, null=True) website_description = models.TextField(blank=True, null=True)
web_archive_snapshot_url = models.CharField(max_length=2048, blank=True) web_archive_snapshot_url = models.CharField(max_length=2048, blank=True)
favicon_file = models.CharField(max_length=512, blank=True)
unread = models.BooleanField(default=False) unread = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False) is_archived = models.BooleanField(default=False)
shared = models.BooleanField(default=False) shared = models.BooleanField(default=False)
@ -161,12 +162,13 @@ class UserProfile(models.Model):
web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False, web_archive_integration = models.CharField(max_length=10, choices=WEB_ARCHIVE_INTEGRATION_CHOICES, blank=False,
default=WEB_ARCHIVE_INTEGRATION_DISABLED) default=WEB_ARCHIVE_INTEGRATION_DISABLED)
enable_sharing = models.BooleanField(default=False, null=False) enable_sharing = models.BooleanField(default=False, null=False)
enable_favicons = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm): class UserProfileForm(forms.ModelForm):
class Meta: class Meta:
model = UserProfile model = UserProfile
fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing'] fields = ['theme', 'bookmark_date_display', 'bookmark_link_target', 'web_archive_integration', 'enable_sharing', 'enable_favicons']
@receiver(post_save, sender=get_user_model()) @receiver(post_save, sender=get_user_model())

View file

@ -30,6 +30,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
bookmark.save() bookmark.save()
# Create snapshot on web archive # Create snapshot on web archive
tasks.create_web_archive_snapshot(current_user, bookmark, False) tasks.create_web_archive_snapshot(current_user, bookmark, False)
# Load favicon
tasks.load_favicon(current_user, bookmark)
return bookmark return bookmark
@ -43,6 +45,9 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Update dates # Update dates
bookmark.date_modified = timezone.now() bookmark.date_modified = timezone.now()
bookmark.save() bookmark.save()
# Update favicon
tasks.load_favicon(current_user, bookmark)
if has_url_changed: if has_url_changed:
# Update web archive snapshot, if URL changed # Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True) tasks.create_web_archive_snapshot(current_user, bookmark, True)

View file

@ -0,0 +1,57 @@
import os.path
import re
import shutil
import time
from pathlib import Path
from urllib.parse import urlparse
import requests
from django.conf import settings
max_file_age = 60 * 60 * 24 # 1 day
def _ensure_favicon_folder():
Path(settings.LD_FAVICON_FOLDER).mkdir(parents=True, exist_ok=True)
def _url_to_filename(url: str) -> str:
name = re.sub(r'\W+', '_', url)
return f'{name}.png'
def _get_base_url(url: str) -> str:
parsed_uri = urlparse(url)
return f'{parsed_uri.scheme}://{parsed_uri.hostname}'
def _get_favicon_path(favicon_file: str) -> Path:
return Path(os.path.join(settings.LD_FAVICON_FOLDER, favicon_file))
def _is_stale(path: Path) -> bool:
stat = path.stat()
file_age = time.time() - stat.st_mtime
return file_age >= max_file_age
def load_favicon(url: str) -> str:
# Get base URL so that we can reuse favicons for multiple bookmarks with the same host
base_url = _get_base_url(url)
favicon_name = _url_to_filename(base_url)
favicon_path = _get_favicon_path(favicon_name)
# Load icon if it doesn't exist yet or has become stale
if not favicon_path.exists() or _is_stale(favicon_path):
# Create favicon folder if not exists
_ensure_favicon_folder()
# Load favicon from provider, save to file
favicon_url = settings.LD_FAVICON_PROVIDER.format(url=base_url)
response = requests.get(favicon_url, stream=True)
with open(favicon_path, 'wb') as file:
shutil.copyfileobj(response.raw, file)
del response
return favicon_name

View file

@ -74,6 +74,8 @@ def import_netscape_html(html: str, user: User):
# Create snapshots for newly imported bookmarks # Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user) tasks.schedule_bookmarks_without_snapshots(user)
# Load favicons for newly imported bookmarks
tasks.schedule_bookmarks_without_favicons(user)
end = timezone.now() end = timezone.now()
logger.debug(f'Import duration: {end - import_start}') logger.debug(f'Import duration: {end - import_start}')

View file

@ -2,6 +2,7 @@ import logging
import waybackpy import waybackpy
from background_task import background from background_task import background
from background_task.models import Task
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -10,6 +11,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
import bookmarks.services.wayback import bookmarks.services.wayback
from bookmarks.models import Bookmark, UserProfile from bookmarks.models import Bookmark, UserProfile
from bookmarks.services.website_loader import DEFAULT_USER_AGENT from bookmarks.services.website_loader import DEFAULT_USER_AGENT
from bookmarks.services import favicon_loader
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -72,7 +74,8 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
logger.error( logger.error(
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}') f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
except WaybackError as error: except WaybackError as error:
logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}', exc_info=error) logger.error(f'Failed to create snapshot, trying to load newest snapshot as fallback. url={bookmark.url}',
exc_info=error)
# Load the newest snapshot as fallback # Load the newest snapshot as fallback
_load_newest_snapshot(bookmark) _load_newest_snapshot(bookmark)
@ -105,3 +108,67 @@ def _schedule_bookmarks_without_snapshots_task(user_id: int):
# To prevent rate limit errors from the Wayback API only try to load the latest snapshots instead of creating # 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 # new ones when processing bookmarks in bulk
_load_web_archive_snapshot_task(bookmark.id) _load_web_archive_snapshot_task(bookmark.id)
def is_favicon_feature_active(user: User) -> bool:
background_tasks_enabled = not settings.LD_DISABLE_BACKGROUND_TASKS
return background_tasks_enabled and user.profile.enable_favicons
def load_favicon(user: User, bookmark: Bookmark):
if is_favicon_feature_active(user):
_load_favicon_task(bookmark.id)
@background()
def _load_favicon_task(bookmark_id: int):
try:
bookmark = Bookmark.objects.get(id=bookmark_id)
except Bookmark.DoesNotExist:
return
logger.info(f'Load favicon for bookmark. url={bookmark.url}')
new_favicon = favicon_loader.load_favicon(bookmark.url)
if new_favicon != bookmark.favicon_file:
bookmark.favicon_file = new_favicon
bookmark.save()
logger.info(f'Successfully updated favicon for bookmark. url={bookmark.url} icon={new_favicon}')
def schedule_bookmarks_without_favicons(user: User):
if is_favicon_feature_active(user):
_schedule_bookmarks_without_favicons_task(user.id)
@background()
def _schedule_bookmarks_without_favicons_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks = Bookmark.objects.filter(favicon_file__exact='', owner=user)
tasks = []
for bookmark in bookmarks:
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
tasks.append(task)
Task.objects.bulk_create(tasks)
def schedule_refresh_favicons(user: User):
if is_favicon_feature_active(user) and settings.LD_ENABLE_REFRESH_FAVICONS:
_schedule_refresh_favicons_task(user.id)
@background()
def _schedule_refresh_favicons_task(user_id: int):
user = get_user_model().objects.get(id=user_id)
bookmarks = Bookmark.objects.filter(owner=user)
tasks = []
for bookmark in bookmarks:
task = Task.objects.new_task(task_name='bookmarks.services.tasks._load_favicon_task', args=(bookmark.id,))
tasks.append(task)
Task.objects.bulk_create(tasks)

View file

@ -58,6 +58,12 @@ ul.bookmark-list {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.title img {
width: 16px;
height: 16px;
vertical-align: text-top;
}
.description { .description {
color: $gray-color-dark; color: $gray-color-dark;

View file

@ -1,3 +1,4 @@
{% load static %}
{% load shared %} {% load shared %}
{% load pagination %} {% load pagination %}
{% htmlmin %} {% htmlmin %}
@ -11,6 +12,9 @@
<div class="title"> <div class="title">
<a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener" <a href="{{ bookmark.url }}" target="{{ link_target }}" rel="noopener"
class="{% if bookmark.unread %}text-italic{% endif %}"> class="{% if bookmark.unread %}text-italic{% endif %}">
{% if bookmark.favicon_file and request.user.profile.enable_favicons %}
<img src="{% static bookmark.favicon_file %}" alt="">
{% endif %}
{{ bookmark.resolved_title }} {{ bookmark.resolved_title }}
</a> </a>
</div> </div>

View file

@ -36,6 +36,30 @@
Whether to open bookmarks a new page or in the same page. Whether to open bookmarks a new page or in the same page.
</div> </div>
</div> </div>
<div class="form-group">
<label for="{{ form.enable_favicons.id_for_label }}" class="form-checkbox">
{{ form.enable_favicons }}
<i class="form-icon"></i> Enable Favicons
</label>
<div class="form-input-hint">
Automatically loads favicons for bookmarked websites and displays them next to each bookmark.
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" target="_blank">options
documentation</a> on how to configure a custom favicon provider.
Icons are downloaded in the background, and it may take a while for them to show up.
</div>
{% if request.user.profile.enable_favicons and enable_refresh_favicons %}
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
{% endif %}
{% if refresh_favicons_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ refresh_favicons_success_message }}
</p>
</div>
{% endif %}
</div>
<div class="form-group"> <div class="form-group">
<label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive <label for="{{ form.web_archive_integration.id_for_label }}" class="form-label">Internet Archive
integration</label> integration</label>
@ -61,7 +85,14 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="submit" value="Save" class="btn btn-primary mt-2"> <input type="submit" name="update_profile" value="Save" class="btn btn-primary mt-2">
{% if update_profile_success_message %}
<div class="has-success">
<p class="form-input-hint">
{{ update_profile_success_message }}
</p>
</div>
{% endif %}
</div> </div>
</form> </form>
</section> </section>

View file

@ -33,6 +33,7 @@ class BookmarkFactoryMixin:
website_title: str = '', website_title: str = '',
website_description: str = '', website_description: str = '',
web_archive_snapshot_url: str = '', web_archive_snapshot_url: str = '',
favicon_file: str = '',
): ):
if not title: if not title:
title = get_random_string(length=32) title = get_random_string(length=32)
@ -56,6 +57,7 @@ class BookmarkFactoryMixin:
unread=unread, unread=unread,
shared=shared, shared=shared,
web_archive_snapshot_url=web_archive_snapshot_url, web_archive_snapshot_url=web_archive_snapshot_url,
favicon_file=favicon_file,
) )
bookmark.save() bookmark.save()
for tag in tags: for tag in tags:

View file

@ -79,6 +79,17 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
</span> </span>
''', html, count=count) ''', html, count=count)
def assertFaviconVisible(self, html: str, bookmark: Bookmark):
self.assertFaviconCount(html, bookmark, 1)
def assertFaviconHidden(self, html: str, bookmark: Bookmark):
self.assertFaviconCount(html, bookmark, 0)
def assertFaviconCount(self, html: str, bookmark: Bookmark, count=1):
self.assertInHTML(f'''
<img src="/static/{bookmark.favicon_file}" alt="">
''', html, count=count)
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str: def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
rf = RequestFactory() rf = RequestFactory()
request = rf.get(url) request = rf.get(url)
@ -211,3 +222,33 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
<a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a> <a class="text-gray" href="?q=foo&user={bookmark.owner.username}">{bookmark.owner.username}</a>
</span> </span>
''', html) ''', html)
def test_favicon_should_be_visible_when_favicons_enabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
html = self.render_default_template([bookmark])
self.assertFaviconVisible(html, bookmark)
def test_favicon_should_be_hidden_when_there_is_no_icon(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
bookmark = self.setup_bookmark(favicon_file='')
html = self.render_default_template([bookmark])
self.assertFaviconHidden(html, bookmark)
def test_favicon_should_be_hidden_when_favicons_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = False
profile.save()
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
html = self.render_default_template([bookmark])
self.assertFaviconHidden(html, bookmark)

View file

@ -67,6 +67,13 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False) mock_create_web_archive_snapshot.assert_called_once_with(self.user, bookmark, False)
def test_create_should_load_favicon(self):
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
bookmark_data = Bookmark(url='https://example.com')
bookmark = create_bookmark(bookmark_data, 'tag1,tag2', self.user)
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_update_should_create_web_archive_snapshot_if_url_did_change(self): def test_update_should_create_web_archive_snapshot_if_url_did_change(self):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot: with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
@ -109,6 +116,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
mock_load_website_metadata.assert_not_called() mock_load_website_metadata.assert_not_called()
def test_update_should_update_favicon(self):
with patch.object(tasks, 'load_favicon') as mock_load_favicon:
bookmark = self.setup_bookmark()
bookmark.title = 'updated title'
update_bookmark(bookmark, 'tag1,tag2', self.user)
mock_load_favicon.assert_called_once_with(self.user, bookmark)
def test_archive_bookmark(self): def test_archive_bookmark(self):
bookmark = Bookmark( bookmark = Bookmark(
url='https://example.com', url='https://example.com',

View file

@ -1,6 +1,6 @@
import datetime import datetime
from dataclasses import dataclass from dataclasses import dataclass
from unittest.mock import patch from unittest import mock
import waybackpy import waybackpy
from background_task.models import Task from background_task.models import Task
@ -8,6 +8,7 @@ from django.contrib.auth.models import User
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from waybackpy.exceptions import WaybackError from waybackpy.exceptions import WaybackError
import bookmarks.services.favicon_loader
import bookmarks.services.wayback import bookmarks.services.wayback
from bookmarks.models import UserProfile from bookmarks.models import UserProfile
from bookmarks.services import tasks from bookmarks.services import tasks
@ -53,12 +54,14 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self): def setUp(self):
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
user.profile.enable_favicons = True
user.profile.save() user.profile.save()
@disable_logging @disable_logging
def run_pending_task(self, task_function): def run_pending_task(self, task_function):
func = getattr(task_function, 'task_function', None) func = getattr(task_function, 'task_function', None)
task = Task.objects.all()[0] task = Task.objects.all()[0]
self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params() args, kwargs = task.params()
func(*args, **kwargs) func(*args, **kwargs)
task.delete() task.delete()
@ -69,6 +72,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks = Task.objects.all() tasks = Task.objects.all()
for task in tasks: for task in tasks:
self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params() args, kwargs = task.params()
func(*args, **kwargs) func(*args, **kwargs)
task.delete() task.delete()
@ -76,7 +80,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_update_snapshot_url(self): def test_create_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()): with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI', return_value=MockWaybackMachineSaveAPI()):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db() bookmark.refresh_from_db()
@ -84,8 +88,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot') self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot')
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self): def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI()) as mock_save_api: return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
tasks._create_web_archive_snapshot_task(123, False) tasks._create_web_archive_snapshot_task(123, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
@ -94,8 +98,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self): def test_create_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com') bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI()) as mock_save_api: return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
@ -104,8 +108,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_force_update_snapshot(self): def test_create_web_archive_snapshot_should_force_update_snapshot(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com') bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI('https://other.com')): return_value=MockWaybackMachineSaveAPI('https://other.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db() bookmark.refresh_from_db()
@ -115,10 +119,10 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self): def test_create_web_archive_snapshot_should_use_newest_snapshot_as_fallback(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()): return_value=MockWaybackMachineCDXServerAPI()):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
@ -128,10 +132,10 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self): def test_create_web_archive_snapshot_should_ignore_missing_newest_snapshot(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)): return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
@ -141,10 +145,10 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self): def test_create_web_archive_snapshot_should_ignore_newest_snapshot_errors(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with patch.object(waybackpy, 'WaybackMachineSaveAPI', with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
return_value=MockWaybackMachineSaveAPI(fail_on_save=True)): return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)): return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False) tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
self.run_pending_task(tasks._create_web_archive_snapshot_task) self.run_pending_task(tasks._create_web_archive_snapshot_task)
@ -154,8 +158,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_load_web_archive_snapshot_should_update_snapshot_url(self): def test_load_web_archive_snapshot_should_update_snapshot_url(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()): return_value=MockWaybackMachineCDXServerAPI()):
tasks._load_web_archive_snapshot_task(bookmark.id) tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
@ -163,8 +167,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url) self.assertEqual('https://example.com/newest_snapshot', bookmark.web_archive_snapshot_url)
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self): def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api: return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
tasks._load_web_archive_snapshot_task(123) tasks._load_web_archive_snapshot_task(123)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
@ -173,8 +177,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self): def test_load_web_archive_snapshot_should_skip_if_snapshot_exists(self):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com') bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api: return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
tasks._load_web_archive_snapshot_task(bookmark.id) tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
@ -183,8 +187,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_load_web_archive_snapshot_should_handle_missing_snapshot(self): def test_load_web_archive_snapshot_should_handle_missing_snapshot(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)): return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
tasks._load_web_archive_snapshot_task(bookmark.id) tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
@ -193,8 +197,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_load_web_archive_snapshot_should_handle_wayback_errors(self): def test_load_web_archive_snapshot_should_handle_wayback_errors(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI', with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)): return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
tasks._load_web_archive_snapshot_task(bookmark.id) tasks._load_web_archive_snapshot_task(bookmark.id)
self.run_pending_task(tasks._load_web_archive_snapshot_task) self.run_pending_task(tasks._load_web_archive_snapshot_task)
@ -262,3 +266,157 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks.schedule_bookmarks_without_snapshots(self.user) tasks.schedule_bookmarks_without_snapshots(self.user)
self.assertEqual(Task.objects.count(), 0) self.assertEqual(Task.objects.count(), 0)
def test_load_favicon_should_create_favicon_file(self):
bookmark = self.setup_bookmark()
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
mock_load_favicon.return_value = 'https_example_com.png'
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.run_pending_task(tasks._load_favicon_task)
bookmark.refresh_from_db()
self.assertEqual(bookmark.favicon_file, 'https_example_com.png')
def test_load_favicon_should_update_favicon_file(self):
bookmark = self.setup_bookmark(favicon_file='https_example_com.png')
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
mock_load_favicon.return_value = 'https_example_updated_com.png'
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.run_pending_task(tasks._load_favicon_task)
mock_load_favicon.assert_called()
bookmark.refresh_from_db()
self.assertEqual(bookmark.favicon_file, 'https_example_updated_com.png')
def test_load_favicon_should_handle_missing_bookmark(self):
with mock.patch('bookmarks.services.favicon_loader.load_favicon') as mock_load_favicon:
tasks._load_favicon_task(123)
self.run_pending_task(tasks._load_favicon_task)
mock_load_favicon.assert_not_called()
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_load_favicon_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.assertEqual(Task.objects.count(), 0)
def test_load_favicon_should_not_run_when_favicon_feature_is_disabled(self):
self.user.profile.enable_favicons = False
self.user.profile.save()
bookmark = self.setup_bookmark()
tasks.load_favicon(self.get_or_create_test_user(), bookmark)
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_favicons_should_load_favicon_for_all_bookmarks_without_favicon(self):
user = self.get_or_create_test_user()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(favicon_file='https_example_com.png')
self.setup_bookmark(favicon_file='https_example_com.png')
self.setup_bookmark(favicon_file='https_example_com.png')
tasks.schedule_bookmarks_without_favicons(user)
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 3)
for task in task_list:
self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task')
def test_schedule_bookmarks_without_favicons_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_favicons(user)
self.run_pending_task(tasks._schedule_bookmarks_without_favicons_task)
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 3)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_bookmarks_without_favicons_should_not_run_when_background_tasks_are_disabled(self):
bookmark = self.setup_bookmark()
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
def test_schedule_bookmarks_without_favicons_should_not_run_when_favicon_feature_is_disabled(self):
self.user.profile.enable_favicons = False
self.user.profile.save()
bookmark = self.setup_bookmark()
tasks.schedule_bookmarks_without_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
def test_schedule_refresh_favicons_should_update_favicon_for_all_bookmarks(self):
user = self.get_or_create_test_user()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark()
self.setup_bookmark(favicon_file='https_example_com.png')
self.setup_bookmark(favicon_file='https_example_com.png')
self.setup_bookmark(favicon_file='https_example_com.png')
tasks.schedule_refresh_favicons(user)
self.run_pending_task(tasks._schedule_refresh_favicons_task)
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 6)
for task in task_list:
self.assertEqual(task.task_name, 'bookmarks.services.tasks._load_favicon_task')
def test_schedule_refresh_favicons_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_refresh_favicons(user)
self.run_pending_task(tasks._schedule_refresh_favicons_task)
task_list = Task.objects.all()
self.assertEqual(task_list.count(), 3)
@override_settings(LD_DISABLE_BACKGROUND_TASKS=True)
def test_schedule_refresh_favicons_should_not_run_when_background_tasks_are_disabled(self):
self.setup_bookmark()
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
@override_settings(LD_ENABLE_REFRESH_FAVICONS=False)
def test_schedule_refresh_favicons_should_not_run_when_refresh_is_disabled(self):
self.setup_bookmark()
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)
def test_schedule_refresh_favicons_should_not_run_when_favicon_feature_is_disabled(self):
self.user.profile.enable_favicons = False
self.user.profile.save()
self.setup_bookmark()
tasks.schedule_refresh_favicons(self.get_or_create_test_user())
self.assertEqual(Task.objects.count(), 0)

View file

@ -0,0 +1,127 @@
import io
import os.path
import time
from pathlib import Path
from unittest import mock
from django.conf import settings
from django.test import TestCase
from bookmarks.services import favicon_loader
mock_icon_data = b'mock_icon'
class FaviconLoaderTestCase(TestCase):
def setUp(self) -> None:
self.ensure_favicon_folder()
self.clear_favicon_folder()
def create_mock_response(self, icon_data=mock_icon_data):
mock_response = mock.Mock()
mock_response.raw = io.BytesIO(icon_data)
return mock_response
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():
file.unlink()
def get_icon_path(self, filename):
return Path(os.path.join(settings.LD_FAVICON_FOLDER, filename))
def icon_exists(self, filename):
return self.get_icon_path(filename).exists()
def get_icon_data(self, filename):
return self.get_icon_path(filename).read_bytes()
def count_icons(self):
files = os.listdir(settings.LD_FAVICON_FOLDER)
return len(files)
def test_load_favicon(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
# should create icon file
self.assertTrue(self.icon_exists('https_example_com.png'))
# should store image data
self.assertEqual(mock_icon_data, self.get_icon_data('https_example_com.png'))
def test_load_favicon_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_FAVICON_FOLDER)
folder.rmdir()
self.assertFalse(folder.exists())
favicon_loader.load_favicon('https://example.com')
self.assertTrue(folder.exists())
def test_load_favicon_creates_single_icon_for_same_base_url(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
favicon_loader.load_favicon('https://example.com?foo=bar')
favicon_loader.load_favicon('https://example.com/foo')
self.assertEqual(1, self.count_icons())
self.assertTrue(self.icon_exists('https_example_com.png'))
def test_load_favicon_creates_multiple_icons_for_different_base_url(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
favicon_loader.load_favicon('https://sub.example.com')
favicon_loader.load_favicon('https://other-domain.com')
self.assertEqual(3, self.count_icons())
self.assertTrue(self.icon_exists('https_example_com.png'))
self.assertTrue(self.icon_exists('https_sub_example_com.png'))
self.assertTrue(self.icon_exists('https_other_domain_com.png'))
def test_load_favicon_caches_icons(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
mock_get.assert_called()
mock_get.reset_mock()
favicon_loader.load_favicon('https://example.com')
mock_get.assert_not_called()
def test_load_favicon_updates_stale_icon(self):
with mock.patch('requests.get') as mock_get:
mock_get.return_value = self.create_mock_response()
favicon_loader.load_favicon('https://example.com')
icon_path = self.get_icon_path('https_example_com.png')
updated_mock_icon_data = b'updated_mock_icon'
mock_get.return_value = self.create_mock_response(icon_data=updated_mock_icon_data)
mock_get.reset_mock()
# change icon modification date so it is not stale yet
nearly_one_day_ago = time.time() - 60 * 60 * 23
os.utime(icon_path.absolute(), (nearly_one_day_ago, nearly_one_day_ago))
favicon_loader.load_favicon('https://example.com')
mock_get.assert_not_called()
# change icon modification date so it is considered stale
one_day_ago = time.time() - 60 * 60 * 24
os.utime(icon_path.absolute(), (one_day_ago, one_day_ago))
favicon_loader.load_favicon('https://example.com')
mock_get.assert_called()
self.assertEqual(updated_mock_icon_data, self.get_icon_data('https_example_com.png'))

View file

@ -262,3 +262,12 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
import_netscape_html(test_html, user) import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(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='')
with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons:
import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_favicons.assert_called_once_with(user)

View file

@ -1,12 +1,13 @@
import random import random
from django.test import TestCase
from django.urls import reverse
from unittest.mock import patch, Mock from unittest.mock import patch, Mock
import requests import requests
from django.test import TestCase, override_settings
from django.urls import reverse
from requests import RequestException from requests import RequestException
from bookmarks.models import UserProfile from bookmarks.models import UserProfile
from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin
from bookmarks.views.settings import app_version, get_version_info from bookmarks.views.settings import app_version, get_version_info
@ -17,6 +18,20 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
self.client.force_login(user) self.client.force_login(user)
def create_profile_form_data(self, overrides=None):
if not overrides:
overrides = {}
form_data = {
'theme': UserProfile.THEME_AUTO,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_RELATIVE,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_BLANK,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_DISABLED,
'enable_sharing': False,
'enable_favicons': False,
}
return {**form_data, **overrides}
def test_should_render_successfully(self): def test_should_render_successfully(self):
response = self.client.get(reverse('bookmarks:settings.general')) response = self.client.get(reverse('bookmarks:settings.general'))
@ -28,15 +43,18 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general')) self.assertRedirects(response, reverse('login') + '?next=' + reverse('bookmarks:settings.general'))
def test_should_save_profile(self): def test_update_profile(self):
form_data = { form_data = {
'update_profile': '',
'theme': UserProfile.THEME_DARK, 'theme': UserProfile.THEME_DARK,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN, 'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF, 'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED, 'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
'enable_sharing': True, 'enable_sharing': True,
'enable_favicons': True,
} }
response = self.client.post(reverse('bookmarks:settings.general'), form_data) response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
self.user.profile.refresh_from_db() self.user.profile.refresh_from_db()
@ -46,6 +64,118 @@ class SettingsGeneralViewTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target']) self.assertEqual(self.user.profile.bookmark_link_target, form_data['bookmark_link_target'])
self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration']) self.assertEqual(self.user.profile.web_archive_integration, form_data['web_archive_integration'])
self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing']) self.assertEqual(self.user.profile.enable_sharing, form_data['enable_sharing'])
self.assertEqual(self.user.profile.enable_favicons, form_data['enable_favicons'])
self.assertInHTML('''
<p class="form-input-hint">Profile updated</p>
''', html)
def test_update_profile_should_not_be_called_without_respective_form_action(self):
form_data = {
'theme': UserProfile.THEME_DARK,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
self.user.profile.refresh_from_db()
self.assertEqual(response.status_code, 200)
self.assertEqual(self.user.profile.theme, UserProfile.THEME_AUTO)
self.assertInHTML('''
<p class="form-input-hint">Profile updated</p>
''', html, count=0)
def test_enable_favicons_should_schedule_icon_update(self):
with patch.object(tasks, 'schedule_bookmarks_without_favicons') as mock_schedule_bookmarks_without_favicons:
# Enabling favicons schedules update
form_data = self.create_profile_form_data({
'update_profile': '',
'enable_favicons': True,
})
self.client.post(reverse('bookmarks:settings.general'), form_data)
mock_schedule_bookmarks_without_favicons.assert_called_once_with(self.user)
# No update scheduled if favicons are already enabled
mock_schedule_bookmarks_without_favicons.reset_mock()
self.client.post(reverse('bookmarks:settings.general'), form_data)
mock_schedule_bookmarks_without_favicons.assert_not_called()
# No update scheduled when disabling favicons
form_data = self.create_profile_form_data({
'enable_favicons': False,
})
self.client.post(reverse('bookmarks:settings.general'), form_data)
mock_schedule_bookmarks_without_favicons.assert_not_called()
def test_refresh_favicons(self):
with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons:
form_data = {
'refresh_favicons': '',
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
mock_schedule_refresh_favicons.assert_called_once()
self.assertInHTML('''
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
''', html)
def test_refresh_favicons_should_not_be_called_without_respective_form_action(self):
with patch.object(tasks, 'schedule_refresh_favicons') as mock_schedule_refresh_favicons:
form_data = {
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
html = response.content.decode()
mock_schedule_refresh_favicons.assert_not_called()
self.assertInHTML('''
<p class="form-input-hint">
Scheduled favicon update. This may take a while...
</p>
''', html, count=0)
def test_refresh_favicons_should_be_visible_when_favicons_enabled_in_profile(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
response = self.client.get(reverse('bookmarks:settings.general'))
html = response.content.decode()
self.assertInHTML('''
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
''', html, count=1)
def test_refresh_favicons_should_not_be_visible_when_favicons_disabled_in_profile(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = False
profile.save()
response = self.client.get(reverse('bookmarks:settings.general'))
html = response.content.decode()
self.assertInHTML('''
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
''', html, count=0)
@override_settings(LD_ENABLE_REFRESH_FAVICONS=False)
def test_refresh_favicons_should_not_be_visible_when_disabled(self):
profile = self.get_or_create_test_user().profile
profile.enable_favicons = True
profile.save()
response = self.client.get(reverse('bookmarks:settings.general'))
html = response.content.decode()
self.assertInHTML('''
<button class="btn mt-2" name="refresh_favicons">Refresh Favicons</button>
''', html, count=0)
def test_about_shows_version_info(self): def test_about_shows_version_info(self):
response = self.client.get(reverse('bookmarks:settings.general')) response = self.client.get(reverse('bookmarks:settings.general'))

View file

@ -3,6 +3,7 @@ import time
from functools import lru_cache from functools import lru_cache
import requests import requests
from django.conf import settings as django_settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import prefetch_related_objects from django.db.models import prefetch_related_objects
@ -13,7 +14,7 @@ from rest_framework.authtoken.models import Token
from bookmarks.models import UserProfileForm, FeedToken from bookmarks.models import UserProfileForm, FeedToken
from bookmarks.queries import query_bookmarks from bookmarks.queries import query_bookmarks
from bookmarks.services import exporter from bookmarks.services import exporter, tasks
from bookmarks.services import importer from bookmarks.services import importer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -28,24 +29,48 @@ except Exception as exc:
@login_required @login_required
def general(request): def general(request):
if request.method == 'POST': profile_form = None
form = UserProfileForm(request.POST, instance=request.user.profile) enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
if form.is_valid(): update_profile_success_message = None
form.save() refresh_favicons_success_message = None
else:
form = UserProfileForm(instance=request.user.profile)
import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success') import_success_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_success')
import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors') import_errors_message = _find_message_with_tag(messages.get_messages(request), 'bookmark_import_errors')
version_info = get_version_info(get_ttl_hash()) version_info = get_version_info(get_ttl_hash())
if request.method == 'POST':
if 'update_profile' in request.POST:
profile_form = update_profile(request)
update_profile_success_message = 'Profile updated'
if 'refresh_favicons' in request.POST:
tasks.schedule_refresh_favicons(request.user)
refresh_favicons_success_message = 'Scheduled favicon update. This may take a while...'
if not profile_form:
profile_form = UserProfileForm(instance=request.user.profile)
return render(request, 'settings/general.html', { return render(request, 'settings/general.html', {
'form': form, 'form': profile_form,
'enable_refresh_favicons': enable_refresh_favicons,
'update_profile_success_message': update_profile_success_message,
'refresh_favicons_success_message': refresh_favicons_success_message,
'import_success_message': import_success_message, 'import_success_message': import_success_message,
'import_errors_message': import_errors_message, 'import_errors_message': import_errors_message,
'version_info': version_info, 'version_info': version_info,
}) })
def update_profile(request):
user = request.user
profile = user.profile
favicons_were_enabled = profile.enable_favicons
form = UserProfileForm(request.POST, instance=profile)
if form.is_valid():
form.save()
if profile.enable_favicons and not favicons_were_enabled:
tasks.schedule_bookmarks_without_favicons(request.user)
return form
# Cache API call response, for one hour when using get_ttl_hash with default params # Cache API call response, for one hour when using get_ttl_hash with default params
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
def get_version_info(ttl_hash=None): def get_version_info(ttl_hash=None):

View file

@ -5,6 +5,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
# Create data folder if it does not exist # Create data folder if it does not exist
mkdir -p data mkdir -p data
# Create favicon folder if it does not exist
mkdir -p data/favicons
# Run database migration # Run database migration
python manage.py migrate python manage.py migrate

View file

@ -149,3 +149,15 @@ Values: `Integer` | Default = None
The port of the database server. The port of the database server.
Should use the default port if left empty, for example `5432` for PostgresSQL. Should use the default port if left empty, for example `5432` for PostgresSQL.
### `LD_FAVICON_PROVIDER`
Values: `String` | Default = `https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON`
The favicon provider used for downloading icons if they are enabled in the user profile settings.
The default provider is a Google service that automatically detects the correct favicon for a website, and provides icons in consistent image format (PNG) and in a consistent image size.
This setting allows to configure a custom provider in form of a URL.
When calling the provider with the URL of a website, it must return the image data for the favicon of that website.
The configured favicon provider URL must contain a `{url}` placeholder that will be replaced with the URL of the website for which to download the favicon.
See the default URL for an example.

View file

@ -140,6 +140,7 @@ STATICFILES_FINDERS = [
# Enable SASS processor to find custom folder for SCSS sources through static file finders # Enable SASS processor to find custom folder for SCSS sources through static file finders
STATICFILES_DIRS = [ STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'bookmarks', 'styles'), os.path.join(BASE_DIR, 'bookmarks', 'styles'),
os.path.join(BASE_DIR, 'data', 'favicons'),
] ]
# REST framework # REST framework
@ -222,3 +223,9 @@ else:
DATABASES = { DATABASES = {
'default': default_database 'default': default_database
} }
# Favicons
LD_DEFAULT_FAVICON_PROVIDER = 'https://t1.gstatic.com/faviconV2?url={url}&client=SOCIAL&type=FAVICON'
LD_FAVICON_PROVIDER = os.getenv('LD_FAVICON_PROVIDER', LD_DEFAULT_FAVICON_PROVIDER)
LD_FAVICON_FOLDER = os.path.join(BASE_DIR, 'data', 'favicons')
LD_ENABLE_REFRESH_FAVICONS = os.getenv('LD_ENABLE_REFRESH_FAVICONS', True) in (True, 'True', '1')

View file

@ -1,8 +1,8 @@
[uwsgi] [uwsgi]
chdir = /etc/linkding
module = siteroot.wsgi:application module = siteroot.wsgi:application
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
static-map = /static=static static-map = /static=static
static-map = /static=data/favicons
processes = 2 processes = 2
threads = 2 threads = 2
pidfile = /tmp/linkding.pid pidfile = /tmp/linkding.pid
@ -15,6 +15,7 @@ die-on-term = true
if-env = LD_CONTEXT_PATH if-env = LD_CONTEXT_PATH
static-map = /%(_)static=static static-map = /%(_)static=static
static-map = /%(_)static=data/favicons
endif = endif =
if-env = LD_REQUEST_TIMEOUT if-env = LD_REQUEST_TIMEOUT