-
{% if details.is_editable %}
diff --git a/.gitignore b/.gitignore index f7dd1e1..a9663a7 100644 --- a/.gitignore +++ b/.gitignore @@ -194,3 +194,5 @@ typings/ # ublock + chromium /uBlock0.chromium /chromium-profile +# direnv +/.direnv diff --git a/bookmarks/api/routes.py b/bookmarks/api/routes.py index bda6471..41f7321 100644 --- a/bookmarks/api/routes.py +++ b/bookmarks/api/routes.py @@ -99,7 +99,10 @@ class BookmarkViewSet( # Either return metadata from existing bookmark, or scrape from URL if bookmark: metadata = WebsiteMetadata( - url, bookmark.website_title, bookmark.website_description + url, + bookmark.website_title, + bookmark.website_description, + None, ) else: metadata = website_loader.load_website_metadata(url) diff --git a/bookmarks/migrations/0034_bookmark_preview_image_file_and_more.py b/bookmarks/migrations/0034_bookmark_preview_image_file_and_more.py new file mode 100644 index 0000000..1e3aca7 --- /dev/null +++ b/bookmarks/migrations/0034_bookmark_preview_image_file_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.3 on 2024-05-07 07:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookmarks", "0033_userprofile_default_mark_unread"), + ] + + operations = [ + migrations.AddField( + model_name="bookmark", + name="preview_image_file", + field=models.CharField(blank=True, max_length=512, null=True), + ), + migrations.AddField( + model_name="userprofile", + name="enable_preview_images", + field=models.BooleanField(default=False), + ), + ] diff --git a/bookmarks/models.py b/bookmarks/models.py index b922bd2..b15963c 100644 --- a/bookmarks/models.py +++ b/bookmarks/models.py @@ -59,6 +59,7 @@ class Bookmark(models.Model): website_description = models.TextField(blank=True, null=True) web_archive_snapshot_url = models.CharField(max_length=2048, blank=True) favicon_file = models.CharField(max_length=512, blank=True) + preview_image_file = models.CharField(max_length=512, blank=True, null=True) unread = models.BooleanField(default=False) is_archived = models.BooleanField(default=False) shared = models.BooleanField(default=False) @@ -394,6 +395,7 @@ class UserProfile(models.Model): enable_sharing = models.BooleanField(default=False, null=False) enable_public_sharing = models.BooleanField(default=False, null=False) enable_favicons = models.BooleanField(default=False, null=False) + enable_preview_images = models.BooleanField(default=False, null=False) display_url = models.BooleanField(default=False, null=False) display_view_bookmark_action = models.BooleanField(default=True, null=False) display_edit_bookmark_action = models.BooleanField(default=True, null=False) @@ -420,6 +422,7 @@ class UserProfileForm(forms.ModelForm): "enable_sharing", "enable_public_sharing", "enable_favicons", + "enable_preview_images", "enable_automatic_html_snapshots", "display_url", "display_view_bookmark_action", diff --git a/bookmarks/services/bookmarks.py b/bookmarks/services/bookmarks.py index 72ec16d..5e96dbb 100644 --- a/bookmarks/services/bookmarks.py +++ b/bookmarks/services/bookmarks.py @@ -40,6 +40,8 @@ def create_bookmark(bookmark: Bookmark, tag_string: str, current_user: User): tasks.create_web_archive_snapshot(current_user, bookmark, False) # Load favicon tasks.load_favicon(current_user, bookmark) + # Load preview image + tasks.load_preview_image(current_user, bookmark) # Create HTML snapshot if current_user.profile.enable_automatic_html_snapshots: tasks.create_html_snapshot(bookmark) @@ -58,6 +60,8 @@ def update_bookmark(bookmark: Bookmark, tag_string, current_user: User): bookmark.save() # Update favicon tasks.load_favicon(current_user, bookmark) + # Update preview image + tasks.load_preview_image(current_user, bookmark) if has_url_changed: # Update web archive snapshot, if URL changed diff --git a/bookmarks/services/preview_image_loader.py b/bookmarks/services/preview_image_loader.py new file mode 100644 index 0000000..2311f65 --- /dev/null +++ b/bookmarks/services/preview_image_loader.py @@ -0,0 +1,46 @@ +import logging +import mimetypes +import os.path +import hashlib +from pathlib import Path + +import requests +from django.conf import settings +from bookmarks.services import website_loader + +logger = logging.getLogger(__name__) + + +def _ensure_preview_folder(): + Path(settings.LD_PREVIEW_FOLDER).mkdir(parents=True, exist_ok=True) + + +def _url_to_filename(preview_image: str) -> str: + return hashlib.md5(preview_image.encode()).hexdigest() + + +def _get_image_path(preview_image_file: str) -> Path: + return Path(os.path.join(settings.LD_PREVIEW_FOLDER, preview_image_file)) + + +def load_preview_image(url: str) -> str | None: + _ensure_preview_folder() + + metadata = website_loader.load_website_metadata(url) + if not metadata.preview_image: + logger.debug(f"Could not find preview image in metadata: {url}") + return None + + logger.debug(f"Loading preview image: {metadata.preview_image}") + with requests.get(metadata.preview_image, stream=True) as response: + content_type = response.headers["Content-Type"] + preview_image_hash = _url_to_filename(url) + file_extension = mimetypes.guess_extension(content_type) + preview_image_file = f"{preview_image_hash}{file_extension}" + preview_image_path = _get_image_path(preview_image_file) + with open(preview_image_path, "wb") as file: + for chunk in response.iter_content(chunk_size=8192): + file.write(chunk) + logger.debug(f"Saved preview image as: {preview_image_path}") + + return preview_image_file diff --git a/bookmarks/services/tasks.py b/bookmarks/services/tasks.py index e97f55c..e42c942 100644 --- a/bookmarks/services/tasks.py +++ b/bookmarks/services/tasks.py @@ -15,7 +15,7 @@ from waybackpy.exceptions import WaybackError, TooManyRequestsError, NoCDXRecord import bookmarks.services.wayback from bookmarks.models import Bookmark, BookmarkAsset, UserProfile -from bookmarks.services import favicon_loader, singlefile +from bookmarks.services import favicon_loader, singlefile, preview_image_loader from bookmarks.services.website_loader import DEFAULT_USER_AGENT logger = logging.getLogger(__name__) @@ -221,6 +221,30 @@ def _schedule_refresh_favicons_task(user_id: int): _load_favicon_task(bookmark.id) +def load_preview_image(user: User, bookmark: Bookmark): + if user.profile.enable_preview_images and not settings.LD_DISABLE_BACKGROUND_TASKS: + _load_preview_image_task(bookmark.id) + + +@task() +def _load_preview_image_task(bookmark_id: int): + try: + bookmark = Bookmark.objects.get(id=bookmark_id) + except Bookmark.DoesNotExist: + return + + logger.info(f"Load preview image for bookmark. url={bookmark.url}") + + new_preview_image_file = preview_image_loader.load_preview_image(bookmark.url) + + if new_preview_image_file != bookmark.preview_image_file: + bookmark.preview_image_file = new_preview_image_file + bookmark.save(update_fields=["preview_image_file"]) + logger.info( + f"Successfully updated preview image for bookmark. url={bookmark.url} preview_image_file={new_preview_image_file}" + ) + + def is_html_snapshot_feature_active() -> bool: return settings.LD_ENABLE_SNAPSHOTS and not settings.LD_DISABLE_BACKGROUND_TASKS diff --git a/bookmarks/services/website_loader.py b/bookmarks/services/website_loader.py index 644e2ea..389e374 100644 --- a/bookmarks/services/website_loader.py +++ b/bookmarks/services/website_loader.py @@ -1,6 +1,7 @@ import logging from dataclasses import dataclass from functools import lru_cache +from urllib.parse import urljoin import requests from bs4 import BeautifulSoup @@ -15,12 +16,14 @@ class WebsiteMetadata: url: str title: str description: str + preview_image: str | None def to_dict(self): return { "url": self.url, "title": self.title, "description": self.description, + "preview_image": self.preview_image, } @@ -30,6 +33,7 @@ class WebsiteMetadata: def load_website_metadata(url: str): title = None description = None + preview_image = None try: start = timezone.now() page_text = load_page(url) @@ -55,10 +59,21 @@ def load_website_metadata(url: str): else None ) + image_tag = soup.find("meta", attrs={"property": "og:image"}) + preview_image = image_tag["content"].strip() if image_tag else None + if ( + preview_image + and not preview_image.startswith("http://") + and not preview_image.startswith("https://") + ): + preview_image = urljoin(url, preview_image) + end = timezone.now() logger.debug(f"Parsing duration: {end - start}") finally: - return WebsiteMetadata(url=url, title=title, description=description) + return WebsiteMetadata( + url=url, title=title, description=description, preview_image=preview_image + ) CHUNK_SIZE = 50 * 1024 diff --git a/bookmarks/styles/bookmark-details.scss b/bookmarks/styles/bookmark-details.scss index d19ed1a..25aee2a 100644 --- a/bookmarks/styles/bookmark-details.scss +++ b/bookmarks/styles/bookmark-details.scss @@ -33,6 +33,15 @@ text-overflow: ellipsis; } + .preview-image { + margin: $unit-4 0; + + img { + max-width: 100%; + max-height: 200px; + } + } + dl { margin-bottom: 0; } diff --git a/bookmarks/styles/bookmark-page.scss b/bookmarks/styles/bookmark-page.scss index 8ce1e03..32797d2 100644 --- a/bookmarks/styles/bookmark-page.scss +++ b/bookmarks/styles/bookmark-page.scss @@ -128,8 +128,25 @@ ul.bookmark-list { /* Bookmarks */ li[ld-bookmark-item] { position: relative; + display: flex; + gap: $unit-2; margin-top: $unit-2; + .content { + flex: 1 1 0; + min-width: 0; + } + + img.preview-image { + flex: 0 0 auto; + width: 100px; + height: 60px; + margin-top: $unit-h; + object-fit: cover; + border-radius: $border-radius; + border: solid 1px $border-color-dark; + } + .form-checkbox.bulk-edit-checkbox { display: none; } @@ -346,7 +363,7 @@ $bulk-edit-transition-duration: 400ms; transition: all $bulk-edit-transition-duration; .form-icon { - top: 0; + top: 0; } } diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html index 6e3214e..dadfae0 100644 --- a/bookmarks/templates/bookmarks/bookmark_list.html +++ b/bookmarks/templates/bookmarks/bookmark_list.html @@ -10,138 +10,143 @@ data-bookmarks-total="{{ bookmark_list.bookmarks_total }}"> {% for bookmark_item in bookmark_list.items %}