Preview website title + description in bookmark form
Fix unnecessary selects when rendering bookmarks
This commit is contained in:
parent
0e872c754b
commit
e07da529f1
|
@ -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>
|
||||
|
|
|
@ -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()]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -49,3 +49,10 @@ ul.bookmark-list {
|
|||
color: $alternative-color-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-form {
|
||||
|
||||
.form-icon.loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'),
|
||||
]
|
||||
|
|
|
@ -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())
|
Loading…
Reference in New Issue