Preview website title + description in bookmark form

Fix unnecessary selects when rendering bookmarks
This commit is contained in:
Sascha Ißbrücker 2019-07-02 01:28:02 +02:00
parent 0e872c754b
commit e07da529f1
9 changed files with 145 additions and 59 deletions

3
.idea/misc.xml generated
View File

@ -4,6 +4,9 @@
<ghcPath>/usr/local/bin/ghc</ghcPath> <ghcPath>/usr/local/bin/ghc</ghcPath>
<stackPath>/usr/local/bin/stack</stackPath> <stackPath>/usr/local/bin/stack</stackPath>
</component> </component>
<component name="JavaScriptSettings">
<option name="languageLevel" value="ES6" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_6" project-jdk-name="Python 3.7 (.env)" project-jdk-type="Python SDK"> <component name="ProjectRootManager" version="2" languageLevel="JDK_1_6" project-jdk-name="Python 3.7 (.env)" project-jdk-type="Python SDK">
<output url="file://$PROJECT_DIR$/out" /> <output url="file://$PROJECT_DIR$/out" />
</component> </component>

View File

@ -42,8 +42,9 @@ class Bookmark(models.Model):
tags = models.ManyToManyField(Tag) tags = models.ManyToManyField(Tag)
# Attributes might be calculated in query # Attributes might be calculated in query
tag_count = 0 tag_count = 0 # Projection for number of associated tags
tag_string = '' tag_string = '' # Projection for list of tag names, comma-separated
tag_projection = False # Tracks if the above projections were loaded
@property @property
def resolved_title(self): def resolved_title(self):
@ -55,7 +56,8 @@ class Bookmark(models.Model):
@property @property
def tag_names(self): def tag_names(self):
if self.tag_string: # If tag projections were loaded then avoid querying all tags (=executing further selects)
if self.tag_string or self.tag_projection:
return parse_tag_string(self.tag_string) return parse_tag_string(self.tag_string)
else: else:
return [tag.name for tag in self.tags.all()] return [tag.name for tag in self.tags.all()]

View File

@ -1,5 +1,5 @@
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q, Count, Aggregate, CharField from django.db.models import Q, Count, Aggregate, CharField, Value, BooleanField
from bookmarks.models import Bookmark, Tag from bookmarks.models import Bookmark, Tag
@ -20,7 +20,8 @@ def query_bookmarks(user: User, query_string: str):
# Add aggregated tag info to bookmark instances # Add aggregated tag info to bookmark instances
query_set = Bookmark.objects \ query_set = Bookmark.objects \
.annotate(tag_count=Count('tags'), .annotate(tag_count=Count('tags'),
tag_string=Concat('tags__name')) tag_string=Concat('tags__name'),
tag_projection=Value(True, BooleanField()))
# Filter for user # Filter for user
query_set = query_set.filter(owner=user) query_set = query_set.filter(owner=user)
@ -49,7 +50,7 @@ def query_bookmarks(user: User, query_string: str):
def query_tags(user: User, query_string: str): def query_tags(user: User, query_string: str):
query_set = Tag.objects; query_set = Tag.objects
# Filter for user # Filter for user
query_set = query_set.filter(owner=user) query_set = query_set.filter(owner=user)

View File

@ -1,10 +1,9 @@
import requests
from bs4 import BeautifulSoup
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.utils import timezone from django.utils import timezone
from bookmarks.models import Bookmark, BookmarkForm, parse_tag_string from bookmarks.models import Bookmark, BookmarkForm, parse_tag_string
from services.tags import get_or_create_tags from services.tags import get_or_create_tags
from services.website_loader import load_website_metadata
def create_bookmark(form: BookmarkForm, current_user: User): def create_bookmark(form: BookmarkForm, current_user: User):
@ -34,28 +33,12 @@ def update_bookmark(form: BookmarkForm, current_user: User):
def _update_website_metadata(bookmark: Bookmark): def _update_website_metadata(bookmark: Bookmark):
# noinspection PyBroadException metadata = load_website_metadata(bookmark.url)
try: bookmark.website_title = metadata.title
page_text = load_page(bookmark.url) bookmark.website_description = metadata.description
soup = BeautifulSoup(page_text, 'html.parser')
title = soup.title.string if soup.title is not None else None
description_tag = soup.find('meta', attrs={'name': 'description'})
description = description_tag['content'] if description_tag is not None else None
bookmark.website_title = title
bookmark.website_description = description
except Exception:
bookmark.website_title = None
bookmark.website_description = None
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User): def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string, ' ') tag_names = parse_tag_string(tag_string, ' ')
tags = get_or_create_tags(tag_names, user) tags = get_or_create_tags(tag_names, user)
bookmark.tags.set(tags) bookmark.tags.set(tags)
def load_page(url: str):
r = requests.get(url)
return r.text

View File

@ -0,0 +1,37 @@
from dataclasses import dataclass
import requests
from bs4 import BeautifulSoup
@dataclass
class WebsiteMetadata:
url: str
title: str
description: str
def to_dict(self):
return {
'url': self.url,
'title': self.title,
'description': self.description,
}
def load_website_metadata(url: str):
title = None
description = None
try:
page_text = load_page(url)
soup = BeautifulSoup(page_text, 'html.parser')
title = soup.title.string if soup.title is not None else None
description_tag = soup.find('meta', attrs={'name': 'description'})
description = description_tag['content'] if description_tag is not None else None
finally:
return WebsiteMetadata(url=url, title=title, description=description)
def load_page(url: str):
r = requests.get(url)
return r.text

View File

@ -49,3 +49,10 @@ ul.bookmark-list {
color: $alternative-color-dark; color: $alternative-color-dark;
} }
} }
.bookmarks-form {
.form-icon.loading {
visibility: hidden;
}
}

View File

@ -1,40 +1,81 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% csrf_token %} <div class="bookmarks-form">
<div class="form-group {% if form.url.errors %}has-error{% endif %}"> {% csrf_token %}
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label> <div class="form-group {% if form.url.errors %}has-error{% endif %}">
{{ form.url|add_class:"form-input"|attr:"autofocus" }} <label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
{% if form.url.errors %} {{ form.url|add_class:"form-input"|attr:"autofocus" }}
{% if form.url.errors %}
<div class="form-input-hint">
{{ form.url.errors }}
</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.title.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input" }}
<div class="form-input-hint"> <div class="form-input-hint">
{{ form.url.errors }} Enter any number of tags separated by space and <strong>without</strong> the hash (#). If a tag does not exist it will be
automatically created.
</div> </div>
{% endif %} {{ form.tag_string.errors }}
</div>
<div class="form-group">
<label for="{{ form.title.id_for_label }}" class="form-label">Tags</label>
{{ form.tag_string|add_class:"form-input" }}
<div class="form-input-hint">
Enter any number of tags separated by space and without the hash (#). If a tag does not exist it will be automatically created.
</div> </div>
{{ form.tag_string.errors }} <div class="form-group has-icon-right">
</div> <label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="form-group"> <div class="has-icon-right">
<label for="{{ form.title.id_for_label }}" class="form-label">Custom title</label> {{ form.title|add_class:"form-input" }}
{{ form.title|add_class:"form-input" }} <i class="form-icon loading"></i>
<div class="form-input-hint"> </div>
Optional, leave empty to use title from website. <div class="form-input-hint">
Optional, leave empty to use title from website.
</div>
{{ form.title.errors }}
</div> </div>
{{ form.title.errors }} <div class="form-group">
</div> <label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<div class="form-group"> <div class="has-icon-right">
<label for="{{ form.description.id_for_label }}" class="form-label">Custom description</label> {{ form.description|add_class:"form-input"|attr:"rows:4" }}
{{ form.description|add_class:"form-input"|attr:"rows:4" }} <i class="form-icon loading"></i>
<div class="form-input-hint"> </div>
Optional, leave empty to use description from website. <div class="form-input-hint">
Optional, leave empty to use description from website.
</div>
{{ form.description.errors }}
</div> </div>
{{ form.description.errors }} <div class="form-group mt-2">
</div> <input type="submit" value="Save" class="btn btn-primary mr-2">
<div class="form-group mt-2"> <a href="{% url 'bookmarks:index' %}" class="btn">Nevermind</a>
<input type="submit" value="Save" class="btn btn-primary mr-2"> </div>
<a href="{% url 'bookmarks:index' %}" class="btn">Nevermind</a>
<script type="application/javascript">
/**
* Pre-fill title and description placeholders with metadata from website as soon as URL changes
*/
(function init() {
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
const descriptionInput = document.getElementById('{{ form.description.id_for_label }}');
urlInput.addEventListener('input', () => {
toggleIcon(titleInput, true);
toggleIcon(descriptionInput, true);
const websiteUrl = urlInput.value;
const requestUrl = `{% url 'bookmarks:api.website_metadata' %}?url=${websiteUrl}`;
fetch(requestUrl)
.then(response => response.json())
.then(metadata => {
titleInput.setAttribute('placeholder', metadata.title || '');
descriptionInput.setAttribute('placeholder', metadata.description || '');
toggleIcon(titleInput, false);
toggleIcon(descriptionInput, false);
});
});
function toggleIcon(input, show) {
const icon = input.parentNode.querySelector('i.form-icon');
icon.style['visibility'] = show ? 'visible' : 'hidden';
}
})();
</script>
</div> </div>

View File

@ -3,6 +3,7 @@ from django.urls import path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from . import views from . import views
from .views import api
app_name = 'bookmarks' app_name = 'bookmarks'
urlpatterns = [ urlpatterns = [
@ -13,4 +14,6 @@ urlpatterns = [
path('bookmarks/new', views.new, name='new'), path('bookmarks/new', views.new, name='new'),
path('bookmarks/<int:bookmark_id>/edit', views.edit, name='edit'), path('bookmarks/<int:bookmark_id>/edit', views.edit, name='edit'),
path('bookmarks/<int:bookmark_id>/remove', views.remove, name='remove'), path('bookmarks/<int:bookmark_id>/remove', views.remove, name='remove'),
# API
path('api/website_metadata', api.website_metadata, name='api.website_metadata'),
] ]

9
bookmarks/views/api.py Normal file
View File

@ -0,0 +1,9 @@
from django.http import JsonResponse
from services.website_loader import load_website_metadata
def website_metadata(request):
url = request.GET.get('url')
metadata = load_website_metadata(url)
return JsonResponse(metadata.to_dict())