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>
<stackPath>/usr/local/bin/stack</stackPath>
</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">
<output url="file://$PROJECT_DIR$/out" />
</component>

View File

@ -42,8 +42,9 @@ class Bookmark(models.Model):
tags = models.ManyToManyField(Tag)
# Attributes might be calculated in query
tag_count = 0
tag_string = ''
tag_count = 0 # Projection for number of associated tags
tag_string = '' # Projection for list of tag names, comma-separated
tag_projection = False # Tracks if the above projections were loaded
@property
def resolved_title(self):
@ -55,7 +56,8 @@ class Bookmark(models.Model):
@property
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)
else:
return [tag.name for tag in self.tags.all()]

View File

@ -1,5 +1,5 @@
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
@ -20,7 +20,8 @@ def query_bookmarks(user: User, query_string: str):
# Add aggregated tag info to bookmark instances
query_set = Bookmark.objects \
.annotate(tag_count=Count('tags'),
tag_string=Concat('tags__name'))
tag_string=Concat('tags__name'),
tag_projection=Value(True, BooleanField()))
# Filter for 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):
query_set = Tag.objects;
query_set = Tag.objects
# Filter for 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.utils import timezone
from bookmarks.models import Bookmark, BookmarkForm, parse_tag_string
from services.tags import get_or_create_tags
from services.website_loader import load_website_metadata
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):
# noinspection PyBroadException
try:
page_text = load_page(bookmark.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
bookmark.website_title = title
bookmark.website_description = description
except Exception:
bookmark.website_title = None
bookmark.website_description = None
metadata = load_website_metadata(bookmark.url)
bookmark.website_title = metadata.title
bookmark.website_description = metadata.description
def _update_bookmark_tags(bookmark: Bookmark, tag_string: str, user: User):
tag_names = parse_tag_string(tag_string, ' ')
tags = get_or_create_tags(tag_names, user)
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;
}
}
.bookmarks-form {
.form-icon.loading {
visibility: hidden;
}
}

View File

@ -1,40 +1,81 @@
{% load widget_tweaks %}
{% csrf_token %}
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
{{ form.url|add_class:"form-input"|attr:"autofocus" }}
{% if form.url.errors %}
<div class="bookmarks-form">
{% csrf_token %}
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
{{ 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">
{{ 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>
{% 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">
Enter any number of tags separated by space and without the hash (#). If a tag does not exist it will be automatically created.
{{ form.tag_string.errors }}
</div>
{{ form.tag_string.errors }}
</div>
<div class="form-group">
<label for="{{ form.title.id_for_label }}" class="form-label">Custom title</label>
{{ form.title|add_class:"form-input" }}
<div class="form-input-hint">
Optional, leave empty to use title from website.
<div class="form-group has-icon-right">
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
<div class="has-icon-right">
{{ form.title|add_class:"form-input" }}
<i class="form-icon loading"></i>
</div>
<div class="form-input-hint">
Optional, leave empty to use title from website.
</div>
{{ form.title.errors }}
</div>
{{ form.title.errors }}
</div>
<div class="form-group">
<label for="{{ form.description.id_for_label }}" class="form-label">Custom description</label>
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
<div class="form-input-hint">
Optional, leave empty to use description from website.
<div class="form-group">
<label for="{{ form.description.id_for_label }}" class="form-label">Description</label>
<div class="has-icon-right">
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
<i class="form-icon loading"></i>
</div>
<div class="form-input-hint">
Optional, leave empty to use description from website.
</div>
{{ form.description.errors }}
</div>
{{ form.description.errors }}
</div>
<div class="form-group mt-2">
<input type="submit" value="Save" class="btn btn-primary mr-2">
<a href="{% url 'bookmarks:index' %}" class="btn">Nevermind</a>
<div class="form-group mt-2">
<input type="submit" value="Save" class="btn btn-primary mr-2">
<a href="{% url 'bookmarks:index' %}" class="btn">Nevermind</a>
</div>
<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>

View File

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