Preview website title + description in bookmark form
Fix unnecessary selects when rendering bookmarks
This commit is contained in:
parent
0e872c754b
commit
e07da529f1
3
.idea/misc.xml
generated
3
.idea/misc.xml
generated
@ -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>
|
||||||
|
@ -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()]
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
|
||||||
|
37
bookmarks/services/website_loader.py
Normal file
37
bookmarks/services/website_loader.py
Normal 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
|
@ -49,3 +49,10 @@ ul.bookmark-list {
|
|||||||
color: $alternative-color-dark;
|
color: $alternative-color-dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bookmarks-form {
|
||||||
|
|
||||||
|
.form-icon.loading {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
{% 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 %}
|
||||||
|
<div class="form-group {% if form.url.errors %}has-error{% endif %}">
|
||||||
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
<label for="{{ form.url.id_for_label }}" class="form-label">URL</label>
|
||||||
{{ form.url|add_class:"form-input"|attr:"autofocus" }}
|
{{ form.url|add_class:"form-input"|attr:"autofocus" }}
|
||||||
{% if form.url.errors %}
|
{% if form.url.errors %}
|
||||||
@ -9,32 +10,72 @@
|
|||||||
{{ form.url.errors }}
|
{{ form.url.errors }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.title.id_for_label }}" class="form-label">Tags</label>
|
<label for="{{ form.title.id_for_label }}" class="form-label">Tags</label>
|
||||||
{{ form.tag_string|add_class:"form-input" }}
|
{{ form.tag_string|add_class:"form-input" }}
|
||||||
<div class="form-input-hint">
|
<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.
|
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>
|
||||||
{{ form.tag_string.errors }}
|
{{ form.tag_string.errors }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group has-icon-right">
|
||||||
<label for="{{ form.title.id_for_label }}" class="form-label">Custom title</label>
|
<label for="{{ form.title.id_for_label }}" class="form-label">Title</label>
|
||||||
|
<div class="has-icon-right">
|
||||||
{{ form.title|add_class:"form-input" }}
|
{{ form.title|add_class:"form-input" }}
|
||||||
|
<i class="form-icon loading"></i>
|
||||||
|
</div>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Optional, leave empty to use title from website.
|
Optional, leave empty to use title from website.
|
||||||
</div>
|
</div>
|
||||||
{{ form.title.errors }}
|
{{ form.title.errors }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="{{ form.description.id_for_label }}" class="form-label">Custom description</label>
|
<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" }}
|
{{ form.description|add_class:"form-input"|attr:"rows:4" }}
|
||||||
|
<i class="form-icon loading"></i>
|
||||||
|
</div>
|
||||||
<div class="form-input-hint">
|
<div class="form-input-hint">
|
||||||
Optional, leave empty to use description from website.
|
Optional, leave empty to use description from website.
|
||||||
</div>
|
</div>
|
||||||
{{ form.description.errors }}
|
{{ form.description.errors }}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-2">
|
<div class="form-group mt-2">
|
||||||
<input type="submit" value="Save" class="btn btn-primary mr-2">
|
<input type="submit" value="Save" class="btn btn-primary mr-2">
|
||||||
<a href="{% url 'bookmarks:index' %}" class="btn">Nevermind</a>
|
<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>
|
</div>
|
||||||
|
@ -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
9
bookmarks/views/api.py
Normal 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())
|
Loading…
Reference in New Issue
Block a user