Skip to content

Commit

Permalink
Add support for bookmark thumbnails (sissbruecker#721)
Browse files Browse the repository at this point in the history
* Preview Image

* fix tests

* add test

* download preview image

* relative path

* gst

* details view

* fix tests

* Improve preview image styles

* Remove preview image URL from model

* Revert form changes

* update tests

* make it work in uwsgi

---------

Co-authored-by: Sascha Ißbrücker <[email protected]>
  • Loading branch information
vslinko and sissbruecker committed May 7, 2024
1 parent e2415f6 commit 87cd406
Show file tree
Hide file tree
Showing 26 changed files with 632 additions and 139 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,5 @@ typings/
# ublock + chromium
/uBlock0.chromium
/chromium-profile
# direnv
/.direnv
5 changes: 4 additions & 1 deletion bookmarks/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,10 @@ def check(self, request):
# 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)
Expand Down
23 changes: 23 additions & 0 deletions bookmarks/migrations/0034_bookmark_preview_image_file_and_more.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
3 changes: 3 additions & 0 deletions bookmarks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -420,6 +422,7 @@ class Meta:
"enable_sharing",
"enable_public_sharing",
"enable_favicons",
"enable_preview_images",
"enable_automatic_html_snapshots",
"display_url",
"display_view_bookmark_action",
Expand Down
4 changes: 4 additions & 0 deletions bookmarks/services/bookmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
46 changes: 46 additions & 0 deletions bookmarks/services/preview_image_loader.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 25 additions & 1 deletion bookmarks/services/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

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__)
Expand Down Expand Up @@ -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

Expand Down
17 changes: 16 additions & 1 deletion bookmarks/services/website_loader.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
}


Expand All @@ -30,6 +33,7 @@ def to_dict(self):
def load_website_metadata(url: str):
title = None
description = None
preview_image = None
try:
start = timezone.now()
page_text = load_page(url)
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions bookmarks/styles/bookmark-details.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@
text-overflow: ellipsis;
}

.preview-image {
margin: $unit-4 0;

img {
max-width: 100%;
max-height: 200px;
}
}

dl {
margin-bottom: 0;
}
Expand Down
19 changes: 18 additions & 1 deletion bookmarks/styles/bookmark-page.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -346,7 +363,7 @@ $bulk-edit-transition-duration: 400ms;
transition: all $bulk-edit-transition-duration;

.form-icon {
top: 0;
top: 0;
}
}

Expand Down
Loading

0 comments on commit 87cd406

Please sign in to comment.