diff --git a/bookmarks/migrations/0018_bookmark_favicon_file.py b/bookmarks/migrations/0018_bookmark_favicon_file.py
new file mode 100644
index 0000000..a61eb22
--- /dev/null
+++ b/bookmarks/migrations/0018_bookmark_favicon_file.py
@@ -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),
+ ),
+ ]
diff --git a/bookmarks/migrations/0019_userprofile_enable_favicons.py b/bookmarks/migrations/0019_userprofile_enable_favicons.py
new file mode 100644
index 0000000..c64ff87
--- /dev/null
+++ b/bookmarks/migrations/0019_userprofile_enable_favicons.py
@@ -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),
+ ),
+ ]
diff --git a/bookmarks/models.py b/bookmarks/models.py
index bec325e..c652474 100644
--- a/bookmarks/models.py
+++ b/bookmarks/models.py
@@ -53,6 +53,7 @@ class Bookmark(models.Model):
website_title = models.CharField(max_length=512, blank=True, null=True)
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)
unread = models.BooleanField(default=False)
is_archived = 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,
default=WEB_ARCHIVE_INTEGRATION_DISABLED)
enable_sharing = models.BooleanField(default=False, null=False)
+ enable_favicons = models.BooleanField(default=False, null=False)
class UserProfileForm(forms.ModelForm):
class Meta:
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())
diff --git a/bookmarks/services/bookmarks.py b/bookmarks/services/bookmarks.py
index c499e64..2c6679e 100644
--- a/bookmarks/services/bookmarks.py
+++ b/bookmarks/services/bookmarks.py
@@ -30,6 +30,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User):
bookmark.save()
# Create snapshot on web archive
tasks.create_web_archive_snapshot(current_user, bookmark, False)
+ # Load favicon
+ tasks.load_favicon(current_user, bookmark)
return bookmark
@@ -43,6 +45,9 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User):
# Update dates
bookmark.date_modified = timezone.now()
bookmark.save()
+ # Update favicon
+ tasks.load_favicon(current_user, bookmark)
+
if has_url_changed:
# Update web archive snapshot, if URL changed
tasks.create_web_archive_snapshot(current_user, bookmark, True)
diff --git a/bookmarks/services/favicon_loader.py b/bookmarks/services/favicon_loader.py
new file mode 100644
index 0000000..ac77b1c
--- /dev/null
+++ b/bookmarks/services/favicon_loader.py
@@ -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
diff --git a/bookmarks/services/importer.py b/bookmarks/services/importer.py
index fd9c315..588c86c 100644
--- a/bookmarks/services/importer.py
+++ b/bookmarks/services/importer.py
@@ -74,6 +74,8 @@ def import_netscape_html(html: str, user: User):
# Create snapshots for newly imported bookmarks
tasks.schedule_bookmarks_without_snapshots(user)
+ # Load favicons for newly imported bookmarks
+ tasks.schedule_bookmarks_without_favicons(user)
end = timezone.now()
logger.debug(f'Import duration: {end - import_start}')
diff --git a/bookmarks/services/tasks.py b/bookmarks/services/tasks.py
index 16e4c1c..13ea32f 100644
--- a/bookmarks/services/tasks.py
+++ b/bookmarks/services/tasks.py
@@ -2,6 +2,7 @@ import logging
import waybackpy
from background_task import background
+from background_task.models import Task
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
@@ -10,6 +11,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord
import bookmarks.services.wayback
from bookmarks.models import Bookmark, UserProfile
from bookmarks.services.website_loader import DEFAULT_USER_AGENT
+from bookmarks.services import favicon_loader
logger = logging.getLogger(__name__)
@@ -72,7 +74,8 @@ def _create_web_archive_snapshot_task(bookmark_id: int, force_update: bool):
logger.error(
f'Failed to create snapshot due to rate limiting, trying to load newest snapshot as fallback. url={bookmark.url}')
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_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
# new ones when processing bookmarks in bulk
_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)
diff --git a/bookmarks/styles/bookmarks.scss b/bookmarks/styles/bookmarks.scss
index 8ed4bc0..b523378 100644
--- a/bookmarks/styles/bookmarks.scss
+++ b/bookmarks/styles/bookmarks.scss
@@ -58,6 +58,12 @@ ul.bookmark-list {
text-overflow: ellipsis;
}
+ .title img {
+ width: 16px;
+ height: 16px;
+ vertical-align: text-top;
+ }
+
.description {
color: $gray-color-dark;
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index 5cbae67..1efdf3b 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -1,3 +1,4 @@
+{% load static %}
{% load shared %}
{% load pagination %}
{% htmlmin %}
@@ -11,6 +12,9 @@
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html
index 6c4fbcc..94c58a5 100644
--- a/bookmarks/templates/settings/general.html
+++ b/bookmarks/templates/settings/general.html
@@ -36,6 +36,30 @@
Whether to open bookmarks a new page or in the same page.
+
Internet Archive
integration
@@ -61,7 +85,14 @@
diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py
index ef57ed1..e0e87a5 100644
--- a/bookmarks/tests/helpers.py
+++ b/bookmarks/tests/helpers.py
@@ -33,6 +33,7 @@ class BookmarkFactoryMixin:
website_title: str = '',
website_description: str = '',
web_archive_snapshot_url: str = '',
+ favicon_file: str = '',
):
if not title:
title = get_random_string(length=32)
@@ -56,6 +57,7 @@ class BookmarkFactoryMixin:
unread=unread,
shared=shared,
web_archive_snapshot_url=web_archive_snapshot_url,
+ favicon_file=favicon_file,
)
bookmark.save()
for tag in tags:
diff --git a/bookmarks/tests/test_bookmarks_list_tag.py b/bookmarks/tests/test_bookmarks_list_tag.py
index 725946c..0178852 100644
--- a/bookmarks/tests/test_bookmarks_list_tag.py
+++ b/bookmarks/tests/test_bookmarks_list_tag.py
@@ -79,6 +79,17 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
''', 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'''
+
+ ''', html, count=count)
+
def render_template(self, bookmarks: [Bookmark], template: Template, url: str = '/test') -> str:
rf = RequestFactory()
request = rf.get(url)
@@ -211,3 +222,33 @@ class BookmarkListTagTest(TestCase, BookmarkFactoryMixin):
{bookmark.owner.username}
''', 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)
diff --git a/bookmarks/tests/test_bookmarks_service.py b/bookmarks/tests/test_bookmarks_service.py
index 98dfcb0..df10c84 100644
--- a/bookmarks/tests/test_bookmarks_service.py
+++ b/bookmarks/tests/test_bookmarks_service.py
@@ -67,6 +67,13 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
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):
with patch.object(tasks, 'create_web_archive_snapshot') as mock_create_web_archive_snapshot:
bookmark = self.setup_bookmark()
@@ -109,6 +116,14 @@ class BookmarkServiceTestCase(TestCase, BookmarkFactoryMixin):
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):
bookmark = Bookmark(
url='https://example.com',
diff --git a/bookmarks/tests/test_bookmarks_tasks.py b/bookmarks/tests/test_bookmarks_tasks.py
index e0a3c73..74fc443 100644
--- a/bookmarks/tests/test_bookmarks_tasks.py
+++ b/bookmarks/tests/test_bookmarks_tasks.py
@@ -1,6 +1,6 @@
import datetime
from dataclasses import dataclass
-from unittest.mock import patch
+from unittest import mock
import waybackpy
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 waybackpy.exceptions import WaybackError
+import bookmarks.services.favicon_loader
import bookmarks.services.wayback
from bookmarks.models import UserProfile
from bookmarks.services import tasks
@@ -53,12 +54,14 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def setUp(self):
user = self.get_or_create_test_user()
user.profile.web_archive_integration = UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED
+ user.profile.enable_favicons = True
user.profile.save()
@disable_logging
def run_pending_task(self, task_function):
func = getattr(task_function, 'task_function', None)
task = Task.objects.all()[0]
+ self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
@@ -69,6 +72,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
tasks = Task.objects.all()
for task in tasks:
+ self.assertEqual(task_function.name, task.task_name)
args, kwargs = task.params()
func(*args, **kwargs)
task.delete()
@@ -76,7 +80,7 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
def test_create_web_archive_snapshot_should_update_snapshot_url(self):
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)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
bookmark.refresh_from_db()
@@ -84,8 +88,8 @@ class BookmarkTasksTestCase(TestCase, BookmarkFactoryMixin):
self.assertEqual(bookmark.web_archive_snapshot_url, 'https://example.com/created_snapshot')
def test_create_web_archive_snapshot_should_handle_missing_bookmark_id(self):
- with patch.object(waybackpy, 'WaybackMachineSaveAPI',
- return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
+ with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
+ return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
tasks._create_web_archive_snapshot_task(123, False)
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):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
- with patch.object(waybackpy, 'WaybackMachineSaveAPI',
- return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
+ with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
+ return_value=MockWaybackMachineSaveAPI()) as mock_save_api:
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
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):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
- with patch.object(waybackpy, 'WaybackMachineSaveAPI',
- return_value=MockWaybackMachineSaveAPI('https://other.com')):
+ with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
+ return_value=MockWaybackMachineSaveAPI('https://other.com')):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, True)
self.run_pending_task(tasks._create_web_archive_snapshot_task)
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):
bookmark = self.setup_bookmark()
- with patch.object(waybackpy, 'WaybackMachineSaveAPI',
- return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
- with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
- return_value=MockWaybackMachineCDXServerAPI()):
+ with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
+ return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
+ with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
+ return_value=MockWaybackMachineCDXServerAPI()):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
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):
bookmark = self.setup_bookmark()
- with patch.object(waybackpy, 'WaybackMachineSaveAPI',
- return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
- with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
- return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
+ with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
+ return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
+ with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
+ return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
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):
bookmark = self.setup_bookmark()
- with patch.object(waybackpy, 'WaybackMachineSaveAPI',
- return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
- with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
- return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
+ with mock.patch.object(waybackpy, 'WaybackMachineSaveAPI',
+ return_value=MockWaybackMachineSaveAPI(fail_on_save=True)):
+ with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
+ return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
tasks.create_web_archive_snapshot(self.get_or_create_test_user(), bookmark, False)
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):
bookmark = self.setup_bookmark()
- with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
- return_value=MockWaybackMachineCDXServerAPI()):
+ with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
+ return_value=MockWaybackMachineCDXServerAPI()):
tasks._load_web_archive_snapshot_task(bookmark.id)
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)
def test_load_web_archive_snapshot_should_handle_missing_bookmark_id(self):
- with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
- return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
+ with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
+ return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
tasks._load_web_archive_snapshot_task(123)
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):
bookmark = self.setup_bookmark(web_archive_snapshot_url='https://example.com')
- with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
- return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
+ with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
+ return_value=MockWaybackMachineCDXServerAPI()) as mock_cdx_api:
tasks._load_web_archive_snapshot_task(bookmark.id)
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):
bookmark = self.setup_bookmark()
- with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
- return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
+ with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
+ return_value=MockWaybackMachineCDXServerAPI(has_no_snapshot=True)):
tasks._load_web_archive_snapshot_task(bookmark.id)
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):
bookmark = self.setup_bookmark()
- with patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
- return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
+ with mock.patch.object(bookmarks.services.wayback, 'CustomWaybackMachineCDXServerAPI',
+ return_value=MockWaybackMachineCDXServerAPI(fail_loading_snapshot=True)):
tasks._load_web_archive_snapshot_task(bookmark.id)
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)
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)
diff --git a/bookmarks/tests/test_favicon_loader.py b/bookmarks/tests/test_favicon_loader.py
new file mode 100644
index 0000000..cdffff9
--- /dev/null
+++ b/bookmarks/tests/test_favicon_loader.py
@@ -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'))
diff --git a/bookmarks/tests/test_importer.py b/bookmarks/tests/test_importer.py
index b94bd41..24aef88 100644
--- a/bookmarks/tests/test_importer.py
+++ b/bookmarks/tests/test_importer.py
@@ -262,3 +262,12 @@ class ImporterTestCase(TestCase, BookmarkFactoryMixin, ImportTestMixin):
import_netscape_html(test_html, user)
mock_schedule_bookmarks_without_snapshots.assert_called_once_with(user)
+
+ def test_schedule_favicon_loading(self):
+ user = self.get_or_create_test_user()
+ test_html = self.render_html(tags_html='')
+
+ 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)
diff --git a/bookmarks/tests/test_settings_general_view.py b/bookmarks/tests/test_settings_general_view.py
index ea515bb..042c9d7 100644
--- a/bookmarks/tests/test_settings_general_view.py
+++ b/bookmarks/tests/test_settings_general_view.py
@@ -1,12 +1,13 @@
import random
-
-from django.test import TestCase
-from django.urls import reverse
from unittest.mock import patch, Mock
+
import requests
+from django.test import TestCase, override_settings
+from django.urls import reverse
from requests import RequestException
from bookmarks.models import UserProfile
+from bookmarks.services import tasks
from bookmarks.tests.helpers import BookmarkFactoryMixin
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()
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):
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'))
- def test_should_save_profile(self):
+ def test_update_profile(self):
form_data = {
+ 'update_profile': '',
'theme': UserProfile.THEME_DARK,
'bookmark_date_display': UserProfile.BOOKMARK_DATE_DISPLAY_HIDDEN,
'bookmark_link_target': UserProfile.BOOKMARK_LINK_TARGET_SELF,
'web_archive_integration': UserProfile.WEB_ARCHIVE_INTEGRATION_ENABLED,
'enable_sharing': True,
+ 'enable_favicons': True,
}
response = self.client.post(reverse('bookmarks:settings.general'), form_data)
+ html = response.content.decode()
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.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_favicons, form_data['enable_favicons'])
+ self.assertInHTML('''
+ Profile updated
+ ''', 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('''
+ Profile updated
+ ''', 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('''
+
+ Scheduled favicon update. This may take a while...
+
+ ''', 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('''
+
+ Scheduled favicon update. This may take a while...
+
+ ''', 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('''
+ Refresh Favicons
+ ''', 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('''
+ Refresh Favicons
+ ''', 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('''
+ Refresh Favicons
+ ''', html, count=0)
def test_about_shows_version_info(self):
response = self.client.get(reverse('bookmarks:settings.general'))
diff --git a/bookmarks/views/settings.py b/bookmarks/views/settings.py
index ffa213d..da9eda8 100644
--- a/bookmarks/views/settings.py
+++ b/bookmarks/views/settings.py
@@ -3,6 +3,7 @@ import time
from functools import lru_cache
import requests
+from django.conf import settings as django_settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
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.queries import query_bookmarks
-from bookmarks.services import exporter
+from bookmarks.services import exporter, tasks
from bookmarks.services import importer
logger = logging.getLogger(__name__)
@@ -28,24 +29,48 @@ except Exception as exc:
@login_required
def general(request):
- if request.method == 'POST':
- form = UserProfileForm(request.POST, instance=request.user.profile)
- if form.is_valid():
- form.save()
- else:
- form = UserProfileForm(instance=request.user.profile)
-
+ profile_form = None
+ enable_refresh_favicons = django_settings.LD_ENABLE_REFRESH_FAVICONS
+ update_profile_success_message = None
+ refresh_favicons_success_message = None
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')
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', {
- '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_errors_message': import_errors_message,
'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
@lru_cache(maxsize=1)
def get_version_info(ttl_hash=None):
diff --git a/bootstrap.sh b/bootstrap.sh
index e843e2e..3283a4a 100755
--- a/bootstrap.sh
+++ b/bootstrap.sh
@@ -5,6 +5,8 @@ LD_SERVER_PORT="${LD_SERVER_PORT:-9090}"
# Create data folder if it does not exist
mkdir -p data
+# Create favicon folder if it does not exist
+mkdir -p data/favicons
# Run database migration
python manage.py migrate
diff --git a/docs/Options.md b/docs/Options.md
index 008cb30..8809e30 100644
--- a/docs/Options.md
+++ b/docs/Options.md
@@ -149,3 +149,15 @@ Values: `Integer` | Default = None
The port of the database server.
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.
diff --git a/siteroot/settings/base.py b/siteroot/settings/base.py
index e5cf817..a838044 100644
--- a/siteroot/settings/base.py
+++ b/siteroot/settings/base.py
@@ -140,6 +140,7 @@ STATICFILES_FINDERS = [
# Enable SASS processor to find custom folder for SCSS sources through static file finders
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'bookmarks', 'styles'),
+ os.path.join(BASE_DIR, 'data', 'favicons'),
]
# REST framework
@@ -222,3 +223,9 @@ else:
DATABASES = {
'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')
diff --git a/uwsgi.ini b/uwsgi.ini
index a15dcb6..7e9ab38 100644
--- a/uwsgi.ini
+++ b/uwsgi.ini
@@ -1,8 +1,8 @@
[uwsgi]
-chdir = /etc/linkding
module = siteroot.wsgi:application
env = DJANGO_SETTINGS_MODULE=siteroot.settings.prod
static-map = /static=static
+static-map = /static=data/favicons
processes = 2
threads = 2
pidfile = /tmp/linkding.pid
@@ -15,6 +15,7 @@ die-on-term = true
if-env = LD_CONTEXT_PATH
static-map = /%(_)static=static
+static-map = /%(_)static=data/favicons
endif =
if-env = LD_REQUEST_TIMEOUT