Merge branch 'main' into create-book
This commit is contained in:
@ -102,7 +102,7 @@ class ActivityObject:
|
||||
if allow_create and \
|
||||
hasattr(model, 'ignore_activity') and \
|
||||
model.ignore_activity(self):
|
||||
return None
|
||||
raise ActivitySerializerError()
|
||||
|
||||
# check for an existing instance
|
||||
instance = instance or model.find_existing(self.serialize())
|
||||
|
@ -26,7 +26,7 @@ class Book(ActivityObject):
|
||||
librarythingKey: str = ''
|
||||
goodreadsKey: str = ''
|
||||
|
||||
cover: Image = field(default_factory=lambda: {})
|
||||
cover: Image = None
|
||||
type: str = 'Book'
|
||||
|
||||
|
||||
|
@ -26,6 +26,7 @@ class AbstractMinimalConnector(ABC):
|
||||
'books_url',
|
||||
'covers_url',
|
||||
'search_url',
|
||||
'isbn_search_url',
|
||||
'max_query_count',
|
||||
'name',
|
||||
'identifier',
|
||||
@ -61,6 +62,30 @@ class AbstractMinimalConnector(ABC):
|
||||
results.append(self.format_search_result(doc))
|
||||
return results
|
||||
|
||||
def isbn_search(self, query):
|
||||
''' isbn search '''
|
||||
params = {}
|
||||
resp = requests.get(
|
||||
'%s%s' % (self.isbn_search_url, query),
|
||||
params=params,
|
||||
headers={
|
||||
'Accept': 'application/json; charset=utf-8',
|
||||
'User-Agent': settings.USER_AGENT,
|
||||
},
|
||||
)
|
||||
if not resp.ok:
|
||||
resp.raise_for_status()
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError as e:
|
||||
logger.exception(e)
|
||||
raise ConnectorException('Unable to parse json response', e)
|
||||
results = []
|
||||
|
||||
for doc in self.parse_isbn_search_data(data):
|
||||
results.append(self.format_isbn_search_result(doc))
|
||||
return results
|
||||
|
||||
@abstractmethod
|
||||
def get_or_create_book(self, remote_id):
|
||||
''' pull up a book record by whatever means possible '''
|
||||
@ -73,6 +98,14 @@ class AbstractMinimalConnector(ABC):
|
||||
def format_search_result(self, search_result):
|
||||
''' create a SearchResult obj from json '''
|
||||
|
||||
@abstractmethod
|
||||
def parse_isbn_search_data(self, data):
|
||||
''' turn the result json from a search into a list '''
|
||||
|
||||
@abstractmethod
|
||||
def format_isbn_search_result(self, search_result):
|
||||
''' create a SearchResult obj from json '''
|
||||
|
||||
|
||||
class AbstractConnector(AbstractMinimalConnector):
|
||||
''' generic book data connector '''
|
||||
|
@ -19,3 +19,11 @@ class Connector(AbstractMinimalConnector):
|
||||
def format_search_result(self, search_result):
|
||||
search_result['connector'] = self
|
||||
return SearchResult(**search_result)
|
||||
|
||||
def parse_isbn_search_data(self, data):
|
||||
return data
|
||||
|
||||
def format_isbn_search_result(self, search_result):
|
||||
search_result['connector'] = self
|
||||
return SearchResult(**search_result)
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
''' interface with whatever connectors the app has '''
|
||||
import importlib
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from requests import HTTPError
|
||||
@ -15,13 +16,31 @@ class ConnectorException(HTTPError):
|
||||
def search(query, min_confidence=0.1):
|
||||
''' find books based on arbitary keywords '''
|
||||
results = []
|
||||
|
||||
# Have we got a ISBN ?
|
||||
isbn = re.sub('[\W_]', '', query)
|
||||
maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
|
||||
|
||||
dedup_slug = lambda r: '%s/%s/%s' % (r.title, r.author, r.year)
|
||||
result_index = set()
|
||||
for connector in get_connectors():
|
||||
try:
|
||||
result_set = connector.search(query, min_confidence=min_confidence)
|
||||
except (HTTPError, ConnectorException):
|
||||
continue
|
||||
result_set = None
|
||||
if maybe_isbn:
|
||||
# Search on ISBN
|
||||
if not connector.isbn_search_url or connector.isbn_search_url == '':
|
||||
result_set = []
|
||||
else:
|
||||
try:
|
||||
result_set = connector.isbn_search(isbn)
|
||||
except (HTTPError, ConnectorException):
|
||||
pass
|
||||
|
||||
# if no isbn search or results, we fallback to generic search
|
||||
if result_set == None or result_set == []:
|
||||
try:
|
||||
result_set = connector.search(query, min_confidence=min_confidence)
|
||||
except (HTTPError, ConnectorException):
|
||||
continue
|
||||
|
||||
result_set = [r for r in result_set \
|
||||
if dedup_slug(r) not in result_index]
|
||||
@ -41,6 +60,12 @@ def local_search(query, min_confidence=0.1, raw=False):
|
||||
return connector.search(query, min_confidence=min_confidence, raw=raw)
|
||||
|
||||
|
||||
def isbn_local_search(query, raw=False):
|
||||
''' only look at local search results '''
|
||||
connector = load_connector(models.Connector.objects.get(local=True))
|
||||
return connector.isbn_search(query, raw=raw)
|
||||
|
||||
|
||||
def first_search_result(query, min_confidence=0.1):
|
||||
''' search until you find a result that fits '''
|
||||
for connector in get_connectors():
|
||||
|
@ -129,6 +129,22 @@ class Connector(AbstractConnector):
|
||||
)
|
||||
|
||||
|
||||
def parse_isbn_search_data(self, data):
|
||||
return list(data.values())
|
||||
|
||||
def format_isbn_search_result(self, search_result):
|
||||
# build the remote id from the openlibrary key
|
||||
key = self.books_url + search_result['key']
|
||||
authors = search_result.get('authors') or [{'name': 'Unknown'}]
|
||||
author_names = [ author.get('name') for author in authors]
|
||||
return SearchResult(
|
||||
title=search_result.get('title'),
|
||||
key=key,
|
||||
author=', '.join(author_names),
|
||||
connector=self,
|
||||
year=search_result.get('publish_date'),
|
||||
)
|
||||
|
||||
def load_edition_data(self, olkey):
|
||||
''' query openlibrary for editions of a work '''
|
||||
url = '%s/works/%s/editions' % (self.books_url, olkey)
|
||||
|
@ -33,6 +33,31 @@ class Connector(AbstractConnector):
|
||||
search_results.sort(key=lambda r: r.confidence, reverse=True)
|
||||
return search_results
|
||||
|
||||
def isbn_search(self, query, raw=False):
|
||||
''' search your local database '''
|
||||
if not query:
|
||||
return []
|
||||
|
||||
filters = [{f: query} for f in ['isbn_10', 'isbn_13']]
|
||||
results = models.Edition.objects.filter(
|
||||
reduce(operator.or_, (Q(**f) for f in filters))
|
||||
).distinct()
|
||||
|
||||
# when there are multiple editions of the same work, pick the default.
|
||||
# it would be odd for this to happen.
|
||||
results = results.filter(parent_work__default_edition__id=F('id')) \
|
||||
or results
|
||||
|
||||
search_results = []
|
||||
for result in results:
|
||||
if raw:
|
||||
search_results.append(result)
|
||||
else:
|
||||
search_results.append(self.format_search_result(result))
|
||||
if len(search_results) >= 10:
|
||||
break
|
||||
return search_results
|
||||
|
||||
|
||||
def format_search_result(self, search_result):
|
||||
return SearchResult(
|
||||
@ -47,6 +72,19 @@ class Connector(AbstractConnector):
|
||||
)
|
||||
|
||||
|
||||
def format_isbn_search_result(self, search_result):
|
||||
return SearchResult(
|
||||
title=search_result.title,
|
||||
key=search_result.remote_id,
|
||||
author=search_result.author_text,
|
||||
year=search_result.published_date.year if \
|
||||
search_result.published_date else None,
|
||||
connector=self,
|
||||
confidence=search_result.rank if \
|
||||
hasattr(search_result, 'rank') else 1,
|
||||
)
|
||||
|
||||
|
||||
def is_work_data(self, data):
|
||||
pass
|
||||
|
||||
@ -59,6 +97,10 @@ class Connector(AbstractConnector):
|
||||
def get_authors_from_data(self, data):
|
||||
return None
|
||||
|
||||
def parse_isbn_search_data(self, data):
|
||||
''' it's already in the right format, don't even worry about it '''
|
||||
return data
|
||||
|
||||
def parse_search_data(self, data):
|
||||
''' it's already in the right format, don't even worry about it '''
|
||||
return data
|
||||
|
@ -66,6 +66,7 @@ def init_connectors():
|
||||
books_url='https://%s/book' % DOMAIN,
|
||||
covers_url='https://%s/images/covers' % DOMAIN,
|
||||
search_url='https://%s/search?q=' % DOMAIN,
|
||||
isbn_search_url='https://%s/isbn/' % DOMAIN,
|
||||
priority=1,
|
||||
)
|
||||
|
||||
@ -77,6 +78,7 @@ def init_connectors():
|
||||
books_url='https://bookwyrm.social/book',
|
||||
covers_url='https://bookwyrm.social/images/covers',
|
||||
search_url='https://bookwyrm.social/search?q=',
|
||||
isbn_search_url='https://bookwyrm.social/isbn/',
|
||||
priority=2,
|
||||
)
|
||||
|
||||
@ -88,6 +90,7 @@ def init_connectors():
|
||||
books_url='https://openlibrary.org',
|
||||
covers_url='https://covers.openlibrary.org',
|
||||
search_url='https://openlibrary.org/search?q=',
|
||||
isbn_search_url='https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:',
|
||||
priority=3,
|
||||
)
|
||||
|
||||
|
18
bookwyrm/migrations/0047_connector_isbn_search_url.py
Normal file
18
bookwyrm/migrations/0047_connector_isbn_search_url.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.0.7 on 2021-02-28 16:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bookwyrm', '0046_sitesettings_privacy_policy'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='connector',
|
||||
name='isbn_search_url',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
@ -449,7 +449,7 @@ def broadcast_task(sender_id, activity, recipients):
|
||||
for recipient in recipients:
|
||||
try:
|
||||
sign_and_send(sender, activity, recipient)
|
||||
except (HTTPError, SSLError) as e:
|
||||
except (HTTPError, SSLError, ConnectionError) as e:
|
||||
logger.exception(e)
|
||||
|
||||
|
||||
|
@ -37,6 +37,10 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
||||
self.remote_id = None
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def broadcast(self, activity, sender, software='bookwyrm'):
|
||||
''' only send book data updates to other bookwyrm instances '''
|
||||
super().broadcast(activity, sender, software=software)
|
||||
|
||||
|
||||
class Book(BookDataModel):
|
||||
''' a generic book, which can mean either an edition or a work '''
|
||||
@ -91,7 +95,7 @@ class Book(BookDataModel):
|
||||
@property
|
||||
def alt_text(self):
|
||||
''' image alt test '''
|
||||
text = '%s cover' % self.title
|
||||
text = '%s' % self.title
|
||||
if self.edition_info:
|
||||
text += ' (%s)' % self.edition_info
|
||||
return text
|
||||
|
@ -22,6 +22,7 @@ class Connector(BookWyrmModel):
|
||||
books_url = models.CharField(max_length=255)
|
||||
covers_url = models.CharField(max_length=255)
|
||||
search_url = models.CharField(max_length=255, null=True, blank=True)
|
||||
isbn_search_url = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
politeness_delay = models.IntegerField(null=True, blank=True) #seconds
|
||||
max_query_count = models.IntegerField(null=True, blank=True)
|
||||
|
@ -7,6 +7,7 @@ from bookwyrm import activitypub
|
||||
from .activitypub_mixin import ActivityMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
from .status import Status
|
||||
|
||||
class Favorite(ActivityMixin, BookWyrmModel):
|
||||
''' fav'ing a post '''
|
||||
@ -17,6 +18,11 @@ class Favorite(ActivityMixin, BookWyrmModel):
|
||||
|
||||
activity_serializer = activitypub.Like
|
||||
|
||||
@classmethod
|
||||
def ignore_activity(cls, activity):
|
||||
''' don't bother with incoming favs of unknown statuses '''
|
||||
return not Status.objects.filter(remote_id=activity.object).exists()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
''' update user active time '''
|
||||
self.user.last_active_date = timezone.now()
|
||||
|
@ -3,7 +3,7 @@ import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
@ -208,6 +208,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
# an id needs to be set before we can proceed with related models
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# make users editors by default
|
||||
try:
|
||||
self.groups.add(Group.objects.get(name='editor'))
|
||||
except Group.DoesNotExist:
|
||||
# this should only happen in tests
|
||||
pass
|
||||
|
||||
# create keys and shelves for new local users
|
||||
self.key_pair = KeyPair.objects.create(
|
||||
remote_id='%s/#main-key' % self.remote_id)
|
||||
|
@ -141,6 +141,7 @@ LANGUAGE_CODE = 'en-us'
|
||||
LANGUAGES = [
|
||||
('en-us', _('English')),
|
||||
('de-de', _('German')),
|
||||
('es', _('Spanish')),
|
||||
('fr-fr', _('French')),
|
||||
('zh-cn', _('Simplified Chinese')),
|
||||
]
|
||||
|
@ -1,3 +1,8 @@
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 20%;
|
||||
}
|
||||
|
||||
/* --- --- */
|
||||
.image {
|
||||
overflow: hidden;
|
||||
|
@ -8,9 +8,12 @@ window.onload = function() {
|
||||
Array.from(document.getElementsByClassName('interaction'))
|
||||
.forEach(t => t.onsubmit = interact);
|
||||
|
||||
// select all
|
||||
Array.from(document.getElementsByClassName('select-all'))
|
||||
.forEach(t => t.onclick = selectAll);
|
||||
// Toggle all checkboxes.
|
||||
document
|
||||
.querySelectorAll('[data-action="toggle-all"]')
|
||||
.forEach(input => {
|
||||
input.addEventListener('change', toggleAllCheckboxes);
|
||||
});
|
||||
|
||||
// tab groups
|
||||
Array.from(document.getElementsByClassName('tab-group'))
|
||||
@ -136,9 +139,20 @@ function interact(e) {
|
||||
.forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1));
|
||||
}
|
||||
|
||||
function selectAll(e) {
|
||||
e.target.parentElement.parentElement.querySelectorAll('[type="checkbox"]')
|
||||
.forEach(t => t.checked=true);
|
||||
/**
|
||||
* Toggle all descendant checkboxes of a target.
|
||||
*
|
||||
* Use `data-target="ID_OF_TARGET"` on the node being listened to.
|
||||
*
|
||||
* @param {Event} event - change Event
|
||||
* @return {undefined}
|
||||
*/
|
||||
function toggleAllCheckboxes(event) {
|
||||
const mainCheckbox = event.target;
|
||||
|
||||
document
|
||||
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
|
||||
.forEach(checkbox => {checkbox.checked = mainCheckbox.checked;});
|
||||
}
|
||||
|
||||
function toggleMenu(e) {
|
||||
|
@ -35,7 +35,7 @@
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-narrow">
|
||||
<div class="column is-one-fifth">
|
||||
{% include 'snippets/book_cover.html' with book=book size=large %}
|
||||
{% include 'snippets/rate_action.html' with user=request.user book=book %}
|
||||
{% include 'snippets/shelve_button/shelve_button.html' %}
|
||||
@ -93,7 +93,7 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<div class="column is-three-fifths">
|
||||
<div class="block">
|
||||
<h3 class="field is-grouped">
|
||||
{% include 'snippets/stars.html' with rating=rating %}
|
||||
@ -201,7 +201,7 @@
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<div class="column is-one-fifth">
|
||||
{% if book.subjects %}
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">{% trans "Subjects" %}</h2>
|
||||
@ -217,7 +217,7 @@
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">{% trans "Places" %}</h2>
|
||||
<ul>
|
||||
{% for place in book.subject_placess %}
|
||||
{% for place in book.subject_places %}
|
||||
<li>{{ place }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
@ -252,10 +252,10 @@
|
||||
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
|
||||
<div class="media-content">
|
||||
<div>
|
||||
{% include 'snippets/username.html' with user=rating.user %}
|
||||
<a href="{{ rating.user.local_path }}">{{ rating.user.display_name }}</a>
|
||||
</div>
|
||||
<div class="field is-grouped mb-0">
|
||||
<div>{% trans "rated it" %}</div>
|
||||
<div class="is-flex">
|
||||
<p class="mr-1">{% trans "rated it" %}</p>
|
||||
{% include 'snippets/stars.html' with rating=rating.rating %}
|
||||
</div>
|
||||
<div>
|
||||
|
@ -5,7 +5,7 @@
|
||||
{% block dropdown-trigger %}{% endblock %}
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<ul class="dropdown-content" role="menu" id="menu-options-{{ book.id }}">
|
||||
<ul class="dropdown-content" role="menu" id="menu-options-{{ uuid }}">
|
||||
{% block dropdown-list %}{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -1,8 +1,15 @@
|
||||
<div class="modal hidden" id="{{ controls_text }}-{{ controls_uid }}">
|
||||
<div
|
||||
role="dialog"
|
||||
class="modal hidden"
|
||||
id="{{ controls_text }}-{{ controls_uid }}"
|
||||
aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}"
|
||||
aria-modal="true"
|
||||
>
|
||||
{# @todo Implement focus traps to prevent tabbing out of the modal. #}
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head" tabindex="0" id="modal-title-{{ controls_text }}-{{ controls_uid }}">
|
||||
<h2 class="modal-card-title">
|
||||
<h2 class="modal-card-title" id="modal-card-title-{{ controls_text }}-{{ controls_uid }}">
|
||||
{% block modal-title %}{% endblock %}
|
||||
</h2>
|
||||
{% include 'snippets/toggle/toggle_button.html' with label="close" class="delete" nonbutton=True %}
|
||||
@ -18,7 +25,6 @@
|
||||
</footer>
|
||||
{% block modal-form-close %}{% endblock %}
|
||||
</div>
|
||||
<label class="modal-close is-large" for="{{ controls_text }}-{{ controls_uid }}" aria-label="close"></label>
|
||||
{% include 'snippets/toggle/toggle_button.html' with label="close" class="modal-close is-large" nonbutton=True %}
|
||||
</div>
|
||||
|
||||
|
@ -54,11 +54,11 @@
|
||||
<div class="tile is-child box has-background-white-bis">
|
||||
<h2 class="title is-4">{% trans "Your Account" %}</h2>
|
||||
{% include 'user/user_preview.html' with user=request.user %}
|
||||
{% if request.user.summary %}
|
||||
<div class="box content">
|
||||
{% if request.user.summary %}
|
||||
{{ request.user.summary | to_markdown | safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -1,4 +1,5 @@
|
||||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
{% if book %}
|
||||
<div class="columns">
|
||||
<div class="column is-narrow">
|
||||
@ -8,7 +9,7 @@
|
||||
<div class="column">
|
||||
<h3 class="title is-5"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
|
||||
{% if book.authors %}
|
||||
<p class="subtitle is-5">by {% include 'snippets/authors.html' with book=book %}</p>
|
||||
<p class="subtitle is-5">{% trans "by" %} {% include 'snippets/authors.html' with book=book %}</p>
|
||||
{% endif %}
|
||||
{% if book|book_description %}
|
||||
<blockquote class="content">{{ book|book_description|to_markdown|safe|truncatewords_html:50 }}</blockquote>
|
||||
|
@ -1,11 +1,12 @@
|
||||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
{% if book %}
|
||||
<a href="{{ book.local_path }}">{% include 'snippets/book_cover.html' with book=book %}</a>
|
||||
{% include 'snippets/stars.html' with rating=book|rating:request.user %}
|
||||
|
||||
<h3 class="title is-6"><a href="/book/{{ book.id }}">{{ book.title }}</a></h3>
|
||||
{% if book.authors %}
|
||||
<p class="subtitle is-6">by {% include 'snippets/authors.html' with book=book %}</p>
|
||||
<p class="subtitle is-6">{% trans "by" %} {% include 'snippets/authors.html' with book=book %}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endif %}
|
||||
|
@ -6,13 +6,13 @@
|
||||
<h1 class="title">{% blocktrans %}{{ tab_title }} Timeline{% endblocktrans %}</h1>
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
<li class="{% if tab == 'home' %}is-active{% endif %}">
|
||||
<li class="{% if tab == 'home' %}is-active{% endif %}"{% if tab == 'home' %} aria-current="page"{% endif %}>
|
||||
<a href="/#feed">{% trans "Home" %}</a>
|
||||
</li>
|
||||
<li class="{% if tab == 'local' %}is-active{% endif %}">
|
||||
<li class="{% if tab == 'local' %}is-active{% endif %}"{% if tab == 'local' %} aria-current="page"{% endif %}>
|
||||
<a href="/local#feed">{% trans "Local" %}</a>
|
||||
</li>
|
||||
<li class="{% if tab == 'federated' %}is-active{% endif %}">
|
||||
<li class="{% if tab == 'federated' %}is-active{% endif %}"{% if tab == 'federated' %} aria-current="page"{% endif %}>
|
||||
<a href="/federated#feed">{% trans "Federated" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -4,7 +4,7 @@
|
||||
{% block panel %}
|
||||
<header class="block">
|
||||
<a href="/#feed" class="button" data-back>
|
||||
<span class="icon icon-arrow-left" aira-hidden="true"></span>
|
||||
<span class="icon icon-arrow-left" aria-hidden="true"></span>
|
||||
<span>{% trans "Back" %}</span>
|
||||
</a>
|
||||
</header>
|
||||
|
@ -5,7 +5,7 @@
|
||||
|
||||
{% block title %}{% trans "Import Status" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% block content %}{% spaceless %}
|
||||
<div class="block">
|
||||
<h1 class="title">{% trans "Import Status" %}</h1>
|
||||
|
||||
@ -36,8 +36,19 @@
|
||||
{% if not job.retry %}
|
||||
<form name="retry" action="/import/{{ job.id }}" method="post">
|
||||
{% csrf_token %}
|
||||
<ul>
|
||||
<fieldset>
|
||||
|
||||
{% with failed_count=failed_items|length %}
|
||||
{% if failed_count > 10 %}
|
||||
<p class="block">
|
||||
<a href="#select-all-failed-imports">
|
||||
{% blocktrans %}Jump to the bottom of the list to select the {{ failed_count }} items which failed to import.{% endblocktrans %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<fieldset id="failed-imports">
|
||||
<ul>
|
||||
{% for item in failed_items %}
|
||||
<li class="pb-1">
|
||||
<input class="checkbox" type="checkbox" name="import_item" value="{{ item.id }}" id="import-item-{{ item.id }}">
|
||||
@ -51,15 +62,28 @@
|
||||
</p>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</fieldset>
|
||||
</ul>
|
||||
<div class="block pt-1 select-all">
|
||||
<label class="label">
|
||||
<input type="checkbox" class="checkbox">
|
||||
</ul>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mt-3">
|
||||
<a name="select-all-failed-imports"></a>
|
||||
|
||||
<label class="label is-inline">
|
||||
<input
|
||||
id="toggle-all-checkboxes-failed-imports"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
data-action="toggle-all"
|
||||
data-target="failed-imports"
|
||||
/>
|
||||
{% trans "Select all" %}
|
||||
</label>
|
||||
</div>
|
||||
<button class="button" type="submit">{% trans "Retry items" %}</button>
|
||||
|
||||
<button class="button is-block mt-3" type="submit">{% trans "Retry items" %}</button>
|
||||
</fieldset>
|
||||
|
||||
<hr>
|
||||
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for item in failed_items %}
|
||||
@ -123,4 +147,4 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endspaceless %}{% endblock %}
|
||||
|
33
bookwyrm/templates/isbn_search_results.html
Normal file
33
bookwyrm/templates/isbn_search_results.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% extends 'layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Search Results" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% with book_results|first as local_results %}
|
||||
<div class="block">
|
||||
<h1 class="title">{% blocktrans %}Search Results for "{{ query }}"{% endblocktrans %}</h1>
|
||||
</div>
|
||||
|
||||
<div class="block columns">
|
||||
<div class="column">
|
||||
<h2 class="title">{% trans "Matching Books" %}</h2>
|
||||
<section class="block">
|
||||
{% if not results %}
|
||||
<p>{% blocktrans %}No books found for "{{ query }}"{% endblocktrans %}</p>
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for result in results %}
|
||||
<li class="pd-4">
|
||||
<a href="{{ result.key }}">{% include 'snippets/search_result_text.html' with result=result link=True %}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="column">
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endblock %}
|
@ -22,7 +22,7 @@
|
||||
<meta name="twitter:image:alt" content="BookWyrm Logo">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar container" role="navigation" aria-label="main navigation">
|
||||
<nav class="navbar container" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
|
||||
@ -67,12 +67,20 @@
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<div class="navbar-link pulldown-menu" role="button" aria-expanded="false" tabindex="0" aria-haspopup="true" aria-controls="navbar-dropdown"><p>
|
||||
<a
|
||||
href="{{ user.local_path }}"
|
||||
class="navbar-link pulldown-menu"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
tabindex="0"
|
||||
aria-haspopup="true"
|
||||
aria-controls="navbar-dropdown"
|
||||
>
|
||||
{% include 'snippets/avatar.html' with user=request.user %}
|
||||
{% include 'snippets/username.html' with user=request.user %}
|
||||
</p></div>
|
||||
<span class="ml-2">{{ user.display_name }}</span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown" id="navbar-dropdown">
|
||||
<li>
|
||||
<a href="/direct-messages" class="navbar-item">
|
||||
@ -95,7 +103,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.bookwyrm.create_invites or perms.bookwyrm.edit_instance_settings%}
|
||||
<hr class="navbar-divider">
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.create_invites %}
|
||||
<li>
|
||||
@ -111,7 +119,7 @@
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<hr class="navbar-divider">
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
<li>
|
||||
<a href="/logout" class="navbar-item">
|
||||
{% trans 'Log out' %}
|
||||
@ -141,12 +149,12 @@
|
||||
<div class="columns is-variable is-1">
|
||||
<div class="column">
|
||||
<label class="is-sr-only" for="id_localname">{% trans "Username:" %}</label>
|
||||
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="username">
|
||||
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname" placeholder="{% trans 'username' %}">
|
||||
</div>
|
||||
<div class="column">
|
||||
<label class="is-sr-only" for="id_password">{% trans "Username:" %}</label>
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="password">
|
||||
<p class="help"><a href="/password-reset">Forgot your password?</a></p>
|
||||
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
|
||||
<p class="help"><a href="/password-reset">{% trans "Forgot your password?" %}</a></p>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
<button class="button is-primary" type="submit">{% trans "Log in" %}</button>
|
||||
@ -157,7 +165,7 @@
|
||||
{% if site.allow_registration and request.path != '' and request.path != '/' %}
|
||||
<div class="column is-narrow">
|
||||
<a href="/" class="button is-link">
|
||||
Join
|
||||
{% trans "Join" %}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
@ -191,7 +199,7 @@
|
||||
{% if site.support_link %}
|
||||
<div class="column">
|
||||
<span class="icon icon-heart"></span>
|
||||
Support {{ site.name }} on <a href="{{ site.support_link }}" target="_blank">{{ site.support_title }}</a>
|
||||
{% blocktrans with site_name=site.name support_link=site.support_link support_title=site.support_title %}Support {{ site_name }} on <a href="{{ support_link }}" target="_blank">{{ support_title }}</a>{% endblocktrans %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="column">
|
||||
|
10
bookwyrm/templates/lists/created_text.html
Normal file
10
bookwyrm/templates/lists/created_text.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% load i18n %}
|
||||
{% spaceless %}
|
||||
|
||||
{% if list.curation != 'open' %}
|
||||
{% blocktrans with username=list.user.display_name path=list.user.local_path %}Created and curated by <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with username=list.user.display_name path=list.user.local_path %}Created by <a href="{{ path }}">{{ username }}</a>{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
{% endspaceless %}
|
@ -24,7 +24,7 @@
|
||||
{% include 'snippets/book_titleby.html' with book=item.book %}
|
||||
</td>
|
||||
<td>
|
||||
{% include 'snippets/username.html' with user=item.user %}
|
||||
<a href="{{ item.user.local_path }}">{{ item.user.display_name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="field has-addons">
|
||||
|
@ -1,5 +1,4 @@
|
||||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
<div class="columns is-multiline">
|
||||
{% for list in lists %}
|
||||
<div class="column is-one-quarter">
|
||||
@ -16,7 +15,9 @@
|
||||
</div>
|
||||
<div class="card-content is-flex-grow-0">
|
||||
{% if list.description %}{{ list.description | to_markdown | safe | truncatewords_html:20 }}{% endif %}
|
||||
<p class="subtitle help">{% if list.curation != 'open' %}{% trans "Created and curated by" %}{% else %}{% trans "Created by" %}{% endif %} {% include 'snippets/username.html' with user=list.user %}</p>
|
||||
<p class="subtitle help">
|
||||
{% include 'lists/created_text.html' with list=list %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -8,8 +8,9 @@
|
||||
<header class="columns content is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">{{ list.name }} <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span></h1>
|
||||
<p class="subtitle help">{% if list.curation != 'open' %}{% trans "Created and curated by" %}{% else %}{% trans "Created by" %} {% include 'snippets/username.html' with user=list.user %}</p>
|
||||
{% endif %}
|
||||
<p class="subtitle help">
|
||||
{% include 'lists/created_text.html' with list=list %}
|
||||
</p>
|
||||
{% include 'snippets/trimmed_text.html' with full=list.description %}
|
||||
</div>
|
||||
{% if request.user == list.user %}
|
||||
|
@ -6,6 +6,6 @@
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<h1 class="title">{% trans "Not Found" %}</h1>
|
||||
<p>{% trans "The page your requested doesn't seem to exist!" %}</p>
|
||||
<p>{% trans "The page you requested doesn't seem to exist!" %}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -42,8 +42,10 @@
|
||||
<p>
|
||||
{# DESCRIPTION #}
|
||||
{% if notification.related_user %}
|
||||
{% include 'snippets/avatar.html' with user=notification.related_user %}
|
||||
{% include 'snippets/username.html' with user=notification.related_user %}
|
||||
<a href="{{ notification.related_user.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=notification.related_user %}
|
||||
{{ notification.related_user.display_name }}
|
||||
</a>
|
||||
{% if notification.notification_type == 'FAVORITE' %}
|
||||
{% if related_status.status_type == 'Review' %}
|
||||
{% blocktrans with book_title=related_status.book.title related_path=related_status.local_path %}favorited your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
@ -87,11 +89,11 @@
|
||||
</div>
|
||||
{% elif notification.notification_type == 'BOOST' %}
|
||||
{% if related_status.status_type == 'Review' %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}boosted your <a href="{{ related_path }}">review of <em>{{ book.title }}</em></a>{% endblocktrans %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}boosted your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Comment' %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}boosted your <a href="{{ related_path }}">comment on<em>{{ book.title }}</em></a>{% endblocktrans %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}boosted your <a href="{{ related_path }}">comment on<em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% elif related_status.status_type == 'Quotation' %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}boosted your <a href="{{ related_path }}">quote from <em>{{ book.title }}</em></a>{% endblocktrans %}
|
||||
{% blocktrans with related_path=related_status.local_path book_title=related_status.book.title %}boosted your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with related_path=related_status.local_path %}boosted your <a href="{{ related_path }}">status</a>{% endblocktrans %}
|
||||
{% endif %}
|
||||
@ -114,7 +116,9 @@
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
{% if related_status.content %}
|
||||
<a href="{{ related_status.local_path }}">{{ related_status.content | safe | truncatewords_html:10 }}</a>
|
||||
<a href="{{ related_status.local_path }}">
|
||||
{{ related_status.content | safe | truncatewords_html:10 }}{% if related_status.mention_books %} <em>{{ related_status.mention_books.first.title }}</em>{% endif %}
|
||||
</a>
|
||||
{% elif related_status.quote %}
|
||||
<a href="{{ related_status.local_path }}">{{ related_status.quote | safe | truncatewords_html:10 }}</a>
|
||||
{% elif related_status.rating %}
|
||||
|
@ -15,7 +15,7 @@
|
||||
{% for user in request.user.blocks.all %}
|
||||
<li class="is-flex">
|
||||
<p>
|
||||
{% include 'snippets/avatar.html' with user=user %} {% include 'snippets/username.html' with user=user %}
|
||||
<a href="{{ user.local_path }}">{% include 'snippets/avatar.html' with user=user %} {{ user.display_name }}</a>
|
||||
</p>
|
||||
<p class="mr-2">
|
||||
{% include 'snippets/block_button.html' with user=user %}
|
||||
|
@ -79,8 +79,10 @@
|
||||
<ul>
|
||||
{% for result in user_results %}
|
||||
<li class="block">
|
||||
{% include 'snippets/avatar.html' with user=result %}</h2>
|
||||
{% include 'snippets/username.html' with user=result show_full=True %}</h2>
|
||||
<a href="{{ result.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=result %}
|
||||
{{ result.display_name }}
|
||||
</a> ({{ result.username }})
|
||||
{% include 'snippets/follow_button.html' with user=result %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
@ -1,3 +1,3 @@
|
||||
{% load bookwyrm_tags %}
|
||||
<img class="avatar image {% if large %}is-96x96{% else %}is-32x32{% endif %}" src="{% if user.avatar %}/images/{{ user.avatar }}{% else %}/static/images/default_avi.jpg{% endif %}" alt="{{ user.alt_text }}">
|
||||
<img class="avatar image {% if large %}is-96x96{% else %}is-32x32{% endif %}" src="{% if user.avatar %}/images/{{ user.avatar }}{% else %}/static/images/default_avi.jpg{% endif %}" {% if ariaHide %}aria-hidden="true"{% endif %} alt="{{ user.alt_text }}">
|
||||
|
||||
|
@ -6,8 +6,7 @@
|
||||
<div class="no-cover book-cover">
|
||||
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover">
|
||||
<div>
|
||||
<p>{{ book.title }}</p>
|
||||
<p>({{ book.edition_info }})</p>
|
||||
<p>{{ book.alt_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% load i18n %}
|
||||
<nav class="pagination" role="navigation" aria-label="pagination">
|
||||
<nav class="pagination" aria-label="pagination">
|
||||
{% if page.has_previous %}
|
||||
<p class="pagination-previous">
|
||||
<a href="{{ path }}?page={{ page.previous_page_number }}{{ anchor }}">
|
||||
|
@ -67,4 +67,4 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% include 'snippets/delete_readthrough_modal.html' with controls_text="delete-readthrough" controls_uid=readthrough.id %}
|
||||
{% include 'snippets/delete_readthrough_modal.html' with controls_text="delete-readthrough" controls_uid=readthrough.id no_body=True %}
|
||||
|
@ -18,7 +18,7 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<hr class="navbar-divider">
|
||||
<li class="navbar-divider" role="presentation"></li>
|
||||
<li>
|
||||
<form class="dropdown-item pt-0 pb-0" name="shelve" action="/unshelve/" method="post">
|
||||
{% csrf_token %}
|
||||
|
@ -2,8 +2,10 @@
|
||||
{% load i18n %}
|
||||
{% if not status.deleted %}
|
||||
{% if status.status_type == 'Announce' %}
|
||||
{% include 'snippets/avatar.html' with user=status.user %}
|
||||
{% include 'snippets/username.html' with user=status.user %}
|
||||
<a href="{{ status.user.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=status.user %}
|
||||
{{ status.user.display_name }}
|
||||
</a>
|
||||
{% trans "boosted" %}
|
||||
{% include 'snippets/status/status_body.html' with status=status|boosted_status %}
|
||||
{% else %}
|
||||
|
@ -3,10 +3,12 @@
|
||||
<div class="block">
|
||||
{% if status.status_type == 'Review' %}
|
||||
<div>
|
||||
<h3 class="title is-5 has-subtitle">
|
||||
{% if status.name %}<span dir="auto">{{ status.name }}</span><br>{% endif %}
|
||||
{% if status.name %}
|
||||
<h3 class="title is-5 has-subtitle" dir="auto">
|
||||
{{ status.name|escape }}
|
||||
</h3>
|
||||
<p class="subtitle">{% include 'snippets/stars.html' with rating=status.rating %}</p>
|
||||
{% endif %}
|
||||
{% include 'snippets/stars.html' with rating=status.rating %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -35,7 +37,7 @@
|
||||
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Announce' %}
|
||||
{% include 'snippets/trimmed_text.html' with full=status.content|safe %}
|
||||
{% endif %}
|
||||
{% if status.attachments %}
|
||||
{% if status.attachments.exists %}
|
||||
<div class="block">
|
||||
<div class="columns">
|
||||
{% for attachment in status.attachments.all %}
|
||||
|
@ -1,7 +1,9 @@
|
||||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
{% include 'snippets/avatar.html' with user=status.user %}
|
||||
{% include 'snippets/username.html' with user=status.user %}
|
||||
<a href="{{ status.user.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %}
|
||||
{{ status.user.display_name }}
|
||||
</a>
|
||||
|
||||
{% if status.status_type == 'GeneratedNote' %}
|
||||
{{ status.content | safe }}
|
||||
@ -15,7 +17,17 @@
|
||||
{% trans "quoted" %}
|
||||
{% elif status.reply_parent %}
|
||||
{% with parent_status=status|parent %}
|
||||
replied to {% include 'snippets/username.html' with user=parent_status.user possessive=True %} <a href="{{parent_status.remote_id }}">{% if parent_status.status_type == 'GeneratedNote' %}update{% else %}{{ parent_status.status_type | lower }}{% endif %}</a>
|
||||
|
||||
{% if parent_status.status_type == 'Review' %}
|
||||
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">review</a>{% endblocktrans %}
|
||||
{% elif parent_status.status_type == 'Comment' %}
|
||||
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">comment</a>{% endblocktrans %}
|
||||
{% elif parent_status.status_type == 'Quotation' %}
|
||||
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">quote</a>{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with username=parent_status.user.display_name user_path=parent_status.user.local_path status_path=parent_status.local_path %}replied to <a href="{{ user_path }}">{{ username}}'s</a> <a href="{{ status_path }}">status</a>{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% if status.book %}
|
||||
|
@ -8,19 +8,29 @@
|
||||
{% with full|to_markdown|safe|truncatewords_html:60 as trimmed %}
|
||||
{% if trimmed != full %}
|
||||
<div id="hide-full-{{ uuid }}">
|
||||
<div class="content" id="trimmed-{{ uuid }}"><span dir="auto">{{ trimmed }}</span>
|
||||
{% trans "Show more" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
|
||||
<div class="content" id="trimmed-{{ uuid }}">
|
||||
<p dir="auto">{{ trimmed }}</p>
|
||||
|
||||
<div>
|
||||
{% trans "Show more" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="full-{{ uuid }}" class="hidden">
|
||||
<div class="content"><span dir="auto">{{ full }}</span>
|
||||
{% trans "Show less" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
|
||||
<div class="content">
|
||||
<div dir="auto">{{ full }}</div>
|
||||
|
||||
<div>
|
||||
{% trans "Show less" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="content"><span dir="auto">{{ full }}</span></div>
|
||||
<div class="content">
|
||||
<div dir="auto">{{ full }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
|
@ -1,2 +0,0 @@
|
||||
{% load bookwyrm_tags %}
|
||||
<a href="{{ user.local_path }}" class="user">{% if user.name %}{{ user.name }}{% else %}{{ user | username }}{% endif %}</a>{% if possessive %}'s{% endif %}{% if show_full and user.name or show_full and user.localname %} ({{ user.username }}){% endif %}
|
@ -11,19 +11,22 @@
|
||||
{% block panel %}
|
||||
<div class="block">
|
||||
<h2 class="title">{% trans "Followers" %}</h2>
|
||||
{% for followers in followers %}
|
||||
{% for follower in followers %}
|
||||
<div class="block columns">
|
||||
<div class="column">
|
||||
{% include 'snippets/avatar.html' with user=followers %}
|
||||
{% include 'snippets/username.html' with user=followers show_full=True %}
|
||||
<a href="{{ follower.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=follower %}
|
||||
{{ follower.display_name }}
|
||||
</a>
|
||||
({{ follower.username }})
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{% include 'snippets/follow_button.html' with user=followers %}
|
||||
{% include 'snippets/follow_button.html' with user=follower %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not followers.count %}
|
||||
<div>{% blocktrans with username=user|username %}{{ username }} has no followers{% endblocktrans %}</div>
|
||||
<div>{% blocktrans with username=user.display_name %}{{ username }} has no followers{% endblocktrans %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -14,8 +14,11 @@
|
||||
{% for follower in user.following.all %}
|
||||
<div class="block columns">
|
||||
<div class="column">
|
||||
{% include 'snippets/avatar.html' with user=follower %}
|
||||
{% include 'snippets/username.html' with user=follower show_full=True %}
|
||||
<a href="{{ follower.local_path }}">
|
||||
{% include 'snippets/avatar.html' with user=follower %}
|
||||
{{ follower.display_name }}
|
||||
</a>
|
||||
({{ follower.username }})
|
||||
</div>
|
||||
<div class="column">
|
||||
{% include 'snippets/follow_button.html' with user=follower %}
|
||||
|
@ -1,7 +1,7 @@
|
||||
{% extends 'user/user_layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ user.name }}{% endblock %}
|
||||
{% block title %}{{ user.display_name }}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
<div class="columns is-mobile">
|
||||
@ -67,7 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% for activity in activities %}
|
||||
<div class="block" id="feed">
|
||||
<div class="block" id="feed-{{ activity.id }}">
|
||||
{% include 'snippets/status/status.html' with status=activity %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
@ -17,11 +17,11 @@
|
||||
{% include 'user/user_preview.html' with user=user %}
|
||||
</div>
|
||||
|
||||
{% if user.summary %}
|
||||
<div class="column box has-background-white-bis content">
|
||||
{% if user.summary %}
|
||||
{{ user.summary | to_markdown | safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not is_self and request.user.is_authenticated %}
|
||||
{% include 'snippets/follow_button.html' with user=user %}
|
||||
@ -33,7 +33,7 @@
|
||||
{% for requester in user.follower_requests.all %}
|
||||
<div class="row shrink">
|
||||
<p>
|
||||
{% include 'snippets/username.html' with user=requester show_full=True %}
|
||||
<a href="{{ requester.local_path }}">{{ requester.display_name }}</a> ({{ requester.username }})
|
||||
</p>
|
||||
{% include 'snippets/follow_request_buttons.html' with user=requester %}
|
||||
</div>
|
||||
@ -56,7 +56,7 @@
|
||||
<a href="{{ url }}">{% trans "Reading Goal" %}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if is_self or user.lists.exists %}
|
||||
{% if is_self or user.list_set.exists %}
|
||||
{% url 'user-lists' user|username as url %}
|
||||
<li{% if url in request.path %} class="is-active"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Lists" %}</a>
|
||||
|
@ -1 +0,0 @@
|
||||
from . import *
|
@ -208,7 +208,10 @@ class BaseActivity(TestCase):
|
||||
# sets the celery task call to the function call
|
||||
with patch(
|
||||
'bookwyrm.activitypub.base_activity.set_related_field.delay'):
|
||||
update_data.to_model(model=models.Status, instance=status)
|
||||
with patch('bookwyrm.models.status.Status.ignore_activity') \
|
||||
as discarder:
|
||||
discarder.return_value = False
|
||||
update_data.to_model(model=models.Status, instance=status)
|
||||
self.assertIsNone(status.attachments.first())
|
||||
|
||||
|
||||
|
@ -42,6 +42,10 @@ class AbstractConnector(TestCase):
|
||||
return search_result
|
||||
def parse_search_data(self, data):
|
||||
return data
|
||||
def format_isbn_search_result(self, search_result):
|
||||
return search_result
|
||||
def parse_isbn_search_data(self, data):
|
||||
return data
|
||||
def is_work_data(self, data):
|
||||
return data['type'] == 'work'
|
||||
def get_edition_from_work_data(self, data):
|
||||
|
@ -18,6 +18,7 @@ class AbstractConnector(TestCase):
|
||||
books_url='https://example.com/books',
|
||||
covers_url='https://example.com/covers',
|
||||
search_url='https://example.com/search?q=',
|
||||
isbn_search_url='https://example.com/isbn',
|
||||
)
|
||||
|
||||
class TestConnector(abstract_connector.AbstractMinimalConnector):
|
||||
@ -28,6 +29,10 @@ class AbstractConnector(TestCase):
|
||||
pass
|
||||
def parse_search_data(self, data):
|
||||
return data
|
||||
def format_isbn_search_result(self, search_result):
|
||||
return search_result
|
||||
def parse_isbn_search_data(self, data):
|
||||
return data
|
||||
self.test_connector = TestConnector('example.com')
|
||||
|
||||
|
||||
@ -39,6 +44,7 @@ class AbstractConnector(TestCase):
|
||||
self.assertEqual(connector.books_url, 'https://example.com/books')
|
||||
self.assertEqual(connector.covers_url, 'https://example.com/covers')
|
||||
self.assertEqual(connector.search_url, 'https://example.com/search?q=')
|
||||
self.assertEqual(connector.isbn_search_url, 'https://example.com/isbn')
|
||||
self.assertIsNone(connector.name)
|
||||
self.assertEqual(connector.identifier, 'example.com')
|
||||
self.assertIsNone(connector.max_query_count)
|
||||
|
@ -27,6 +27,7 @@ class Openlibrary(TestCase):
|
||||
books_url='https://openlibrary.org',
|
||||
covers_url='https://covers.openlibrary.org',
|
||||
search_url='https://openlibrary.org/search?q=',
|
||||
isbn_search_url='https://openlibrary.org/isbn',
|
||||
)
|
||||
self.connector = Connector('openlibrary.org')
|
||||
|
||||
@ -149,6 +150,34 @@ class Openlibrary(TestCase):
|
||||
self.assertEqual(result.connector, self.connector)
|
||||
|
||||
|
||||
def test_parse_isbn_search_result(self):
|
||||
''' extract the results from the search json response '''
|
||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||
'../data/ol_isbn_search.json')
|
||||
search_data = json.loads(datafile.read_bytes())
|
||||
result = self.connector.parse_isbn_search_data(search_data)
|
||||
self.assertIsInstance(result, list)
|
||||
self.assertEqual(len(result), 1)
|
||||
|
||||
|
||||
def test_format_isbn_search_result(self):
|
||||
''' translate json from openlibrary into SearchResult '''
|
||||
datafile = pathlib.Path(__file__).parent.joinpath(
|
||||
'../data/ol_isbn_search.json')
|
||||
search_data = json.loads(datafile.read_bytes())
|
||||
results = self.connector.parse_isbn_search_data(search_data)
|
||||
self.assertIsInstance(results, list)
|
||||
|
||||
result = self.connector.format_isbn_search_result(results[0])
|
||||
self.assertIsInstance(result, SearchResult)
|
||||
self.assertEqual(result.title, 'Les ombres errantes')
|
||||
self.assertEqual(
|
||||
result.key, 'https://openlibrary.org/books/OL16262504M')
|
||||
self.assertEqual(result.author, 'Pascal Quignard')
|
||||
self.assertEqual(result.year, '2002')
|
||||
self.assertEqual(result.connector, self.connector)
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_load_edition_data(self):
|
||||
''' format url from key and make request '''
|
||||
|
45
bookwyrm/tests/data/ol_isbn_search.json
Normal file
45
bookwyrm/tests/data/ol_isbn_search.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"ISBN:9782070427796": {
|
||||
"url": "https://openlibrary.org/books/OL16262504M/Les_ombres_errantes",
|
||||
"key": "/books/OL16262504M",
|
||||
"title": "Les ombres errantes",
|
||||
"authors": [
|
||||
{
|
||||
"url": "https://openlibrary.org/authors/OL269675A/Pascal_Quignard",
|
||||
"name": "Pascal Quignard"
|
||||
}
|
||||
],
|
||||
"by_statement": "Pascal Quignard.",
|
||||
"identifiers": {
|
||||
"goodreads": [
|
||||
"1835483"
|
||||
],
|
||||
"librarything": [
|
||||
"983474"
|
||||
],
|
||||
"isbn_10": [
|
||||
"207042779X"
|
||||
],
|
||||
"openlibrary": [
|
||||
"OL16262504M"
|
||||
]
|
||||
},
|
||||
"classifications": {
|
||||
"dewey_decimal_class": [
|
||||
"848/.91403"
|
||||
]
|
||||
},
|
||||
"publishers": [
|
||||
{
|
||||
"name": "Gallimard"
|
||||
}
|
||||
],
|
||||
"publish_places": [
|
||||
{
|
||||
"name": "Paris"
|
||||
}
|
||||
],
|
||||
"publish_date": "2002",
|
||||
"notes": "Hardback published Grasset, 2002."
|
||||
}
|
||||
}
|
@ -81,7 +81,7 @@ class Book(TestCase):
|
||||
book.save()
|
||||
self.assertEqual(book.edition_info, 'worm, Glorbish language, 2020')
|
||||
self.assertEqual(
|
||||
book.alt_text, 'Test Edition cover (worm, Glorbish language, 2020)')
|
||||
book.alt_text, 'Test Edition (worm, Glorbish language, 2020)')
|
||||
|
||||
|
||||
def test_get_rank(self):
|
||||
|
@ -150,7 +150,7 @@ class Status(TestCase):
|
||||
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
|
||||
(settings.DOMAIN, self.book.cover.url))
|
||||
self.assertEqual(
|
||||
activity['attachment'][0].name, 'Test Edition cover')
|
||||
activity['attachment'][0].name, 'Test Edition')
|
||||
|
||||
def test_comment_to_activity(self, _):
|
||||
''' subclass of the base model version with a "pure" serializer '''
|
||||
@ -177,7 +177,7 @@ class Status(TestCase):
|
||||
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
|
||||
(settings.DOMAIN, self.book.cover.url))
|
||||
self.assertEqual(
|
||||
activity['attachment'][0].name, 'Test Edition cover')
|
||||
activity['attachment'][0].name, 'Test Edition')
|
||||
|
||||
def test_quotation_to_activity(self, _):
|
||||
''' subclass of the base model version with a "pure" serializer '''
|
||||
@ -207,7 +207,7 @@ class Status(TestCase):
|
||||
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
|
||||
(settings.DOMAIN, self.book.cover.url))
|
||||
self.assertEqual(
|
||||
activity['attachment'][0].name, 'Test Edition cover')
|
||||
activity['attachment'][0].name, 'Test Edition')
|
||||
|
||||
def test_review_to_activity(self, _):
|
||||
''' subclass of the base model version with a "pure" serializer '''
|
||||
@ -238,7 +238,7 @@ class Status(TestCase):
|
||||
self.assertEqual(activity['attachment'][0].url, 'https://%s%s' % \
|
||||
(settings.DOMAIN, self.book.cover.url))
|
||||
self.assertEqual(
|
||||
activity['attachment'][0].name, 'Test Edition cover')
|
||||
activity['attachment'][0].name, 'Test Edition')
|
||||
|
||||
def test_favorite(self, _):
|
||||
''' fav a status '''
|
||||
|
@ -74,7 +74,7 @@ class Inbox(TestCase):
|
||||
mock_valid.return_value = False
|
||||
result = self.client.post(
|
||||
'/user/mouse/inbox',
|
||||
'{"type": "Test", "object": "exists"}',
|
||||
'{"type": "Announce", "object": "exists"}',
|
||||
content_type="application/json"
|
||||
)
|
||||
self.assertEqual(result.status_code, 401)
|
||||
@ -484,7 +484,7 @@ class Inbox(TestCase):
|
||||
'actor': 'https://example.com/users/rat',
|
||||
'type': 'Like',
|
||||
'published': 'Mon, 25 May 2020 19:31:20 GMT',
|
||||
'object': 'https://example.com/status/1',
|
||||
'object': self.status.remote_id,
|
||||
}
|
||||
|
||||
views.inbox.activity_task(activity)
|
||||
@ -494,6 +494,21 @@ class Inbox(TestCase):
|
||||
self.assertEqual(fav.remote_id, 'https://example.com/fav/1')
|
||||
self.assertEqual(fav.user, self.remote_user)
|
||||
|
||||
def test_ignore_favorite(self):
|
||||
''' don't try to save an unknown status '''
|
||||
activity = {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
'id': 'https://example.com/fav/1',
|
||||
'actor': 'https://example.com/users/rat',
|
||||
'type': 'Like',
|
||||
'published': 'Mon, 25 May 2020 19:31:20 GMT',
|
||||
'object': 'https://unknown.status/not-found',
|
||||
}
|
||||
|
||||
views.inbox.activity_task(activity)
|
||||
|
||||
self.assertFalse(models.Favorite.objects.exists())
|
||||
|
||||
def test_handle_unfavorite(self):
|
||||
''' fav a status '''
|
||||
activity = {
|
||||
|
54
bookwyrm/tests/views/test_isbn.py
Normal file
54
bookwyrm/tests/views/test_isbn.py
Normal file
@ -0,0 +1,54 @@
|
||||
''' test for app action functionality '''
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.template.response import TemplateResponse
|
||||
from django.test import TestCase
|
||||
from django.test.client import RequestFactory
|
||||
|
||||
from bookwyrm import models, views
|
||||
from bookwyrm.connectors import abstract_connector
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
class IsbnViews(TestCase):
|
||||
''' tag views'''
|
||||
def setUp(self):
|
||||
''' we need basic test data and mocks '''
|
||||
self.factory = RequestFactory()
|
||||
self.local_user = models.User.objects.create_user(
|
||||
'mouse@local.com', 'mouse@mouse.com', 'mouseword',
|
||||
local=True, localname='mouse',
|
||||
remote_id='https://example.com/users/mouse',
|
||||
)
|
||||
self.work = models.Work.objects.create(title='Test Work')
|
||||
self.book = models.Edition.objects.create(
|
||||
title='Test Book',
|
||||
isbn_13='1234567890123',
|
||||
remote_id='https://example.com/book/1',
|
||||
parent_work=self.work
|
||||
)
|
||||
models.Connector.objects.create(
|
||||
identifier='self',
|
||||
connector_file='self_connector',
|
||||
local=True
|
||||
)
|
||||
models.SiteSettings.objects.create()
|
||||
|
||||
|
||||
def test_isbn_json_response(self):
|
||||
''' searches local data only and returns book data in json format '''
|
||||
view = views.Isbn.as_view()
|
||||
request = self.factory.get('')
|
||||
with patch('bookwyrm.views.isbn.is_api_request') as is_api:
|
||||
is_api.return_value = True
|
||||
response = view(request, isbn='1234567890123')
|
||||
self.assertIsInstance(response, JsonResponse)
|
||||
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]['title'], 'Test Book')
|
||||
self.assertEqual(
|
||||
data[0]['key'], 'https://%s/book/%d' % (DOMAIN, self.book.id))
|
||||
|
@ -64,6 +64,10 @@ class ShelfViews(TestCase):
|
||||
pass
|
||||
def parse_search_data(self, data):
|
||||
pass
|
||||
def format_isbn_search_result(self, search_result):
|
||||
return search_result
|
||||
def parse_isbn_search_data(self, data):
|
||||
return data
|
||||
models.Connector.objects.create(
|
||||
identifier='example.com',
|
||||
connector_file='openlibrary',
|
||||
|
@ -137,6 +137,9 @@ urlpatterns = [
|
||||
re_path(r'^switch-edition/?$', views.switch_edition),
|
||||
re_path(r'^create-book/?$', views.EditBook.as_view()),
|
||||
|
||||
# isbn
|
||||
re_path(r'^isbn/(?P<isbn>\d+)(.json)?/?$', views.Isbn.as_view()),
|
||||
|
||||
# author
|
||||
re_path(r'^author/(?P<author_id>\d+)(.json)?/?$', views.Author.as_view()),
|
||||
re_path(r'^author/(?P<author_id>\d+)/edit/?$', views.EditAuthor.as_view()),
|
||||
|
@ -31,3 +31,4 @@ from .site import Site
|
||||
from .status import CreateStatus, DeleteStatus
|
||||
from .updates import Updates
|
||||
from .user import User, EditUser, Followers, Following
|
||||
from .isbn import Isbn
|
||||
|
@ -90,7 +90,7 @@ class Book(View):
|
||||
'rating': reviews.aggregate(Avg('rating'))['rating__avg'],
|
||||
'tags': models.UserTag.objects.filter(book=book),
|
||||
'lists': privacy_filter(
|
||||
request.user, book.list_set.all()
|
||||
request.user, book.list_set.filter(listitem__approved=True)
|
||||
),
|
||||
'user_tags': user_tags,
|
||||
'user_shelves': user_shelves,
|
||||
|
@ -20,7 +20,7 @@ class Inbox(View):
|
||||
''' requests sent by outside servers'''
|
||||
def post(self, request, username=None):
|
||||
''' only works as POST request '''
|
||||
# first let's do some basic checks to see if this is legible
|
||||
# make sure the user's inbox even exists
|
||||
if username:
|
||||
try:
|
||||
models.User.objects.get(localname=username)
|
||||
@ -33,6 +33,11 @@ class Inbox(View):
|
||||
except json.decoder.JSONDecodeError:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
if not 'object' in activity_json or \
|
||||
not 'type' in activity_json or \
|
||||
not activity_json['type'] in activitypub.activity_objects:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
# verify the signature
|
||||
if not has_valid_signature(request, activity_json):
|
||||
if activity_json['type'] == 'Delete':
|
||||
@ -42,12 +47,6 @@ class Inbox(View):
|
||||
return HttpResponse()
|
||||
return HttpResponse(status=401)
|
||||
|
||||
# just some quick smell tests before we try to parse the json
|
||||
if not 'object' in activity_json or \
|
||||
not 'type' in activity_json or \
|
||||
not activity_json['type'] in activitypub.activity_objects:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
activity_task.delay(activity_json)
|
||||
return HttpResponse()
|
||||
|
||||
@ -63,7 +62,11 @@ def activity_task(activity_json):
|
||||
|
||||
# cool that worked, now we should do the action described by the type
|
||||
# (create, update, delete, etc)
|
||||
activity.action()
|
||||
try:
|
||||
activity.action()
|
||||
except activitypub.ActivitySerializerError:
|
||||
# this is raised if the activity is discarded
|
||||
return
|
||||
|
||||
|
||||
def has_valid_signature(request, activity):
|
||||
|
29
bookwyrm/views/isbn.py
Normal file
29
bookwyrm/views/isbn.py
Normal file
@ -0,0 +1,29 @@
|
||||
''' isbn search view '''
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from bookwyrm import forms, models
|
||||
from bookwyrm.connectors import connector_manager
|
||||
from .helpers import is_api_request
|
||||
|
||||
# pylint: disable= no-self-use
|
||||
class Isbn(View):
|
||||
''' search a book by isbn '''
|
||||
def get(self, request, isbn):
|
||||
''' info about a book '''
|
||||
book_results = connector_manager.isbn_local_search(isbn)
|
||||
|
||||
if is_api_request(request):
|
||||
return JsonResponse([r.json() for r in book_results], safe=False)
|
||||
|
||||
data = {
|
||||
'title': 'ISBN Search Results',
|
||||
'results': book_results,
|
||||
'query': isbn,
|
||||
}
|
||||
return TemplateResponse(request, 'isbn_search_results.html', data)
|
Reference in New Issue
Block a user