Merge branch 'main' into list-not-loading

This commit is contained in:
Mouse Reeve 2021-12-09 11:10:26 -08:00
commit e9c58295f3
133 changed files with 12356 additions and 3041 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@
*.swp
**/__pycache__
.local
/nginx/nginx.conf
# VSCode
/.vscode

View file

@ -22,6 +22,11 @@ class ActivityStream(RedisStore):
stream_id = self.stream_id(user)
return f"{stream_id}-unread"
def unread_by_status_type_id(self, user):
"""the redis key for this user's unread count for this stream"""
stream_id = self.stream_id(user)
return f"{stream_id}-unread-by-type"
def get_rank(self, obj): # pylint: disable=no-self-use
"""statuses are sorted by date published"""
return obj.published_date.timestamp()
@ -35,6 +40,10 @@ class ActivityStream(RedisStore):
for user in self.get_audience(status):
# add to the unread status count
pipeline.incr(self.unread_id(user))
# add to the unread status count for status type
pipeline.hincrby(
self.unread_by_status_type_id(user), get_status_type(status), 1
)
# and go!
pipeline.execute()
@ -55,6 +64,7 @@ class ActivityStream(RedisStore):
"""load the statuses to be displayed"""
# clear unreads for this feed
r.set(self.unread_id(user), 0)
r.delete(self.unread_by_status_type_id(user))
statuses = self.get_store(self.stream_id(user))
return (
@ -75,6 +85,14 @@ class ActivityStream(RedisStore):
"""get the unread status count for this user's feed"""
return int(r.get(self.unread_id(user)) or 0)
def get_unread_count_by_status_type(self, user):
"""get the unread status count for this user's feed's status types"""
status_types = r.hgetall(self.unread_by_status_type_id(user))
return {
str(key.decode("utf-8")): int(value) or 0
for key, value in status_types.items()
}
def populate_streams(self, user):
"""go from zero to a timeline"""
self.populate_store(self.stream_id(user))
@ -460,7 +478,7 @@ def remove_status_task(status_ids):
@app.task(queue=HIGH)
def add_status_task(status_id, increment_unread=False):
"""add a status to any stream it should be in"""
status = models.Status.objects.get(id=status_id)
status = models.Status.objects.select_subclasses().get(id=status_id)
# we don't want to tick the unread count for csv import statuses, idk how better
# to check than just to see if the states is more than a few days old
if status.created_date < timezone.now() - timedelta(days=2):
@ -507,3 +525,20 @@ def handle_boost_task(boost_id):
stream.remove_object_from_related_stores(boosted, stores=audience)
for status in old_versions:
stream.remove_object_from_related_stores(status, stores=audience)
def get_status_type(status):
"""return status type even for boosted statuses"""
status_type = status.status_type.lower()
# Check if current status is a boost
if hasattr(status, "boost"):
# Act in accordance of your findings
if hasattr(status.boost.boosted_status, "review"):
status_type = "review"
if hasattr(status.boost.boosted_status, "comment"):
status_type = "comment"
if hasattr(status.boost.boosted_status, "quotation"):
status_type = "quotation"
return status_type

View file

@ -111,7 +111,7 @@ class AbstractConnector(AbstractMinimalConnector):
return existing.default_edition
return existing
# load the json
# load the json data from the remote data source
data = self.get_book_data(remote_id)
if self.is_work_data(data):
try:
@ -150,27 +150,37 @@ class AbstractConnector(AbstractMinimalConnector):
"""this allows connectors to override the default behavior"""
return get_data(remote_id)
def create_edition_from_data(self, work, edition_data):
def create_edition_from_data(self, work, edition_data, instance=None):
"""if we already have the work, we're ready"""
mapped_data = dict_from_mappings(edition_data, self.book_mappings)
mapped_data["work"] = work.remote_id
edition_activity = activitypub.Edition(**mapped_data)
edition = edition_activity.to_model(model=models.Edition, overwrite=False)
edition.connector = self.connector
edition.save()
edition = edition_activity.to_model(
model=models.Edition, overwrite=False, instance=instance
)
# if we're updating an existing instance, we don't need to load authors
if instance:
return edition
if not edition.connector:
edition.connector = self.connector
edition.save(broadcast=False, update_fields=["connector"])
for author in self.get_authors_from_data(edition_data):
edition.authors.add(author)
# use the authors from the work if none are found for the edition
if not edition.authors.exists() and work.authors.exists():
edition.authors.set(work.authors.all())
return edition
def get_or_create_author(self, remote_id):
def get_or_create_author(self, remote_id, instance=None):
"""load that author"""
existing = models.Author.find_existing_by_remote_id(remote_id)
if existing:
return existing
if not instance:
existing = models.Author.find_existing_by_remote_id(remote_id)
if existing:
return existing
data = self.get_book_data(remote_id)
@ -181,7 +191,24 @@ class AbstractConnector(AbstractMinimalConnector):
return None
# this will dedupe
return activity.to_model(model=models.Author, overwrite=False)
return activity.to_model(
model=models.Author, overwrite=False, instance=instance
)
def get_remote_id_from_model(self, obj):
"""given the data stored, how can we look this up"""
return getattr(obj, getattr(self, "generated_remote_link_field"))
def update_author_from_remote(self, obj):
"""load the remote data from this connector and add it to an existing author"""
remote_id = self.get_remote_id_from_model(obj)
return self.get_or_create_author(remote_id, instance=obj)
def update_book_from_remote(self, obj):
"""load the remote data from this connector and add it to an existing book"""
remote_id = self.get_remote_id_from_model(obj)
data = self.get_book_data(remote_id)
return self.create_edition_from_data(obj.parent_work, data, instance=obj)
@abstractmethod
def is_work_data(self, data):
@ -227,8 +254,12 @@ def get_data(url, params=None, timeout=10):
resp = requests.get(
url,
params=params,
headers={
"Accept": "application/json; charset=utf-8",
headers={ # pylint: disable=line-too-long
"Accept": (
"application/activity+json,"
' application/ld+json; profile="https://www.w3.org/ns/activitystreams",'
" application/json; charset=utf-8"
),
"User-Agent": settings.USER_AGENT,
},
timeout=timeout,

View file

@ -11,6 +11,8 @@ from .connector_manager import ConnectorException
class Connector(AbstractConnector):
"""instantiate a connector for inventaire"""
generated_remote_link_field = "inventaire_id"
def __init__(self, identifier):
super().__init__(identifier)
@ -67,7 +69,7 @@ class Connector(AbstractConnector):
extracted = list(data.get("entities").values())
try:
data = extracted[0]
except KeyError:
except (KeyError, IndexError):
raise ConnectorException("Invalid book data")
# flatten the data so that images, uri, and claims are on the same level
return {
@ -128,6 +130,7 @@ class Connector(AbstractConnector):
def load_edition_data(self, work_uri):
"""get a list of editions for a work"""
# pylint: disable=line-too-long
url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true"
return get_data(url)
@ -209,6 +212,11 @@ class Connector(AbstractConnector):
return ""
return data.get("extract")
def get_remote_id_from_model(self, obj):
"""use get_remote_id to figure out the link from a model obj"""
remote_id_value = obj.inventaire_id
return self.get_remote_id(remote_id_value)
def get_language_code(options, code="en"):
"""when there are a bunch of translation but we need a single field"""

View file

@ -12,6 +12,8 @@ from .openlibrary_languages import languages
class Connector(AbstractConnector):
"""instantiate a connector for OL"""
generated_remote_link_field = "openlibrary_link"
def __init__(self, identifier):
super().__init__(identifier)
@ -66,6 +68,7 @@ class Connector(AbstractConnector):
Mapping("born", remote_field="birth_date"),
Mapping("died", remote_field="death_date"),
Mapping("bio", formatter=get_description),
Mapping("isni", remote_field="remote_ids", formatter=get_isni),
]
def get_book_data(self, remote_id):
@ -224,6 +227,13 @@ def get_languages(language_blob):
return langs
def get_isni(remote_ids_blob):
"""extract the isni from the remote id data for the author"""
if not remote_ids_blob or not isinstance(remote_ids_blob, dict):
return None
return remote_ids_blob.get("isni")
def pick_default_edition(options):
"""favor physical copies with covers in english"""
if not options:

View file

@ -10,14 +10,9 @@ from bookwyrm.settings import DOMAIN
def email_data():
"""fields every email needs"""
site = models.SiteSettings.objects.get()
if site.logo_small:
logo_path = f"/images/{site.logo_small.url}"
else:
logo_path = "/static/images/logo-small.png"
return {
"site_name": site.name,
"logo": logo_path,
"logo": site.logo_small_url,
"domain": DOMAIN,
"user": None,
}
@ -46,6 +41,18 @@ def password_reset_email(reset_code):
send_email.delay(reset_code.user.email, *format_email("password_reset", data))
def moderation_report_email(report):
"""a report was created"""
data = email_data()
data["reporter"] = report.reporter.localname or report.reporter.username
data["reportee"] = report.user.localname or report.user.username
data["report_link"] = report.remote_id
for admin in models.User.objects.filter(groups__name__in=["admin", "moderator"]):
data["user"] = admin.display_name
send_email.delay(admin.email, *format_email("moderation_report", data))
def format_email(email_name, data):
"""render the email templates"""
subject = get_template(f"email/{email_name}/subject.html").render(data).strip()

View file

@ -9,6 +9,8 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import models
from bookwyrm.models.fields import ClearableFileInputWithWarning
from bookwyrm.models.user import FeedFilterChoices
class CustomForm(ModelForm):
@ -147,6 +149,17 @@ class EditUserForm(CustomForm):
"preferred_language",
]
help_texts = {f: None for f in fields}
widgets = {
"avatar": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_avatar"}
),
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
"email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
"discoverable": forms.CheckboxInput(
attrs={"aria-describedby": "desc_discoverable"}
),
}
class LimitedEditUserForm(CustomForm):
@ -160,6 +173,16 @@ class LimitedEditUserForm(CustomForm):
"discoverable",
]
help_texts = {f: None for f in fields}
widgets = {
"avatar": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_avatar"}
),
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
"discoverable": forms.CheckboxInput(
attrs={"aria-describedby": "desc_discoverable"}
),
}
class DeleteUserForm(CustomForm):
@ -174,6 +197,18 @@ class UserGroupForm(CustomForm):
fields = ["groups"]
class FeedStatusTypesForm(CustomForm):
class Meta:
model = models.User
fields = ["feed_status_types"]
help_texts = {f: None for f in fields}
widgets = {
"feed_status_types": widgets.CheckboxSelectMultiple(
choices=FeedFilterChoices,
),
}
class CoverForm(CustomForm):
class Meta:
model = models.Book
@ -196,6 +231,51 @@ class EditionForm(CustomForm):
"connector",
"search_vector",
]
widgets = {
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
"description": forms.Textarea(
attrs={"aria-describedby": "desc_description"}
),
"series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
"series_number": forms.TextInput(
attrs={"aria-describedby": "desc_series_number"}
),
"languages": forms.TextInput(
attrs={"aria-describedby": "desc_languages_help desc_languages"}
),
"publishers": forms.TextInput(
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
),
"first_published_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_first_published_date"}
),
"published_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_published_date"}
),
"cover": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_cover"}
),
"physical_format": forms.Select(
attrs={"aria-describedby": "desc_physical_format"}
),
"physical_format_detail": forms.TextInput(
attrs={"aria-describedby": "desc_physical_format_detail"}
),
"pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
"isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
"isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
"openlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_openlibrary_key"}
),
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}
),
"oclc_number": forms.TextInput(
attrs={"aria-describedby": "desc_oclc_number"}
),
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
}
class AuthorForm(CustomForm):
@ -213,7 +293,30 @@ class AuthorForm(CustomForm):
"inventaire_id",
"librarything_key",
"goodreads_key",
"isni",
]
widgets = {
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
"bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
"wikipedia_link": forms.TextInput(
attrs={"aria-describedby": "desc_wikipedia_link"}
),
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
"oepnlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_oepnlibrary_key"}
),
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}
),
"librarything_key": forms.TextInput(
attrs={"aria-describedby": "desc_librarything_key"}
),
"goodreads_key": forms.TextInput(
attrs={"aria-describedby": "desc_goodreads_key"}
),
}
class ImportForm(forms.Form):
@ -288,12 +391,37 @@ class SiteForm(CustomForm):
class Meta:
model = models.SiteSettings
exclude = []
widgets = {
"instance_short_description": forms.TextInput(
attrs={"aria-describedby": "desc_instance_short_description"}
),
"require_confirm_email": forms.CheckboxInput(
attrs={"aria-describedby": "desc_require_confirm_email"}
),
"invite_request_text": forms.Textarea(
attrs={"aria-describedby": "desc_invite_request_text"}
),
}
class AnnouncementForm(CustomForm):
class Meta:
model = models.Announcement
exclude = ["remote_id"]
widgets = {
"preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
"content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
"event_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_event_date"}
),
"start_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_start_date"}
),
"end_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_end_date"}
),
"active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
}
class ListForm(CustomForm):
@ -318,6 +446,9 @@ class EmailBlocklistForm(CustomForm):
class Meta:
model = models.EmailBlocklist
fields = ["domain"]
widgets = {
"avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
}
class IPBlocklistForm(CustomForm):

View file

@ -0,0 +1,33 @@
# Generated by Django 3.2.5 on 2021-11-17 18:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0117_alter_user_preferred_language"),
]
operations = [
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "Deutsch (German)"),
("es-es", "Español (Spanish)"),
("gl-es", "Galego (Galician)"),
("fr-fr", "Français (French)"),
("lt-lt", "Lietuvių (Lithuanian)"),
("pt-br", "Português - Brasil (Brazilian Portuguese)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
]

View file

@ -0,0 +1,32 @@
# Generated by Django 3.2.5 on 2021-11-24 10:15
import bookwyrm.models.user
import django.contrib.postgres.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0118_alter_user_preferred_language"),
]
operations = [
migrations.AddField(
model_name="user",
name="feed_status_types",
field=django.contrib.postgres.fields.ArrayField(
base_field=models.CharField(
choices=[
("review", "Reviews"),
("comment", "Comments"),
("quotation", "Quotations"),
("everything", "Everything else"),
],
max_length=10,
),
default=bookwyrm.models.user.get_feed_filter_choices,
size=8,
),
),
]

View file

@ -0,0 +1,29 @@
# Generated by Django 3.2.5 on 2021-12-04 10:55
from django.db import migrations, models
import uuid
def gen_uuid(apps, schema_editor):
"""sets an unique UUID for embed_key"""
book_lists = apps.get_model("bookwyrm", "List")
db_alias = schema_editor.connection.alias
for book_list in book_lists.objects.using(db_alias).all():
book_list.embed_key = uuid.uuid4()
book_list.save(broadcast=False)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0119_user_feed_status_types"),
]
operations = [
migrations.AddField(
model_name="list",
name="embed_key",
field=models.UUIDField(editable=False, null=True, unique=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
]

View file

@ -1,4 +1,5 @@
""" database schema for info about authors """
import re
from django.contrib.postgres.indexes import GinIndex
from django.db import models
@ -27,12 +28,23 @@ class Author(BookDataModel):
# idk probably other keys would be useful here?
born = fields.DateTimeField(blank=True, null=True)
died = fields.DateTimeField(blank=True, null=True)
name = fields.CharField(max_length=255, deduplication_field=True)
name = fields.CharField(max_length=255)
aliases = fields.ArrayField(
models.CharField(max_length=255), blank=True, default=list
)
bio = fields.HtmlField(null=True, blank=True)
@property
def isni_link(self):
"""generate the url from the isni id"""
clean_isni = re.sub(r"\s", "", self.isni)
return f"https://isni.org/isni/{clean_isni}"
@property
def openlibrary_link(self):
"""generate the url from the openlibrary id"""
return f"https://openlibrary.org/authors/{self.openlibrary_key}"
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
return f"https://{DOMAIN}/author/{self.id}"

View file

@ -52,6 +52,16 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
null=True,
)
@property
def openlibrary_link(self):
"""generate the url from the openlibrary id"""
return f"https://openlibrary.org/books/{self.openlibrary_key}"
@property
def inventaire_link(self):
"""generate the url from the inventaire id"""
return f"https://inventaire.io/entity/{self.inventaire_id}"
class Meta:
"""can't initialize this model, that wouldn't make sense"""

View file

@ -296,7 +296,7 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
super().__init__(*args, **kwargs)
def set_field_from_activity(self, instance, data, overwrite=True):
"""helper function for assinging a value to the field"""
"""helper function for assigning a value to the field"""
if not overwrite and getattr(instance, self.name).exists():
return False
@ -398,7 +398,11 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
if formatted is None or formatted is MISSING:
return False
if not overwrite and hasattr(instance, self.name):
if (
not overwrite
and hasattr(instance, self.name)
and getattr(instance, self.name)
):
return False
getattr(instance, self.name).save(*formatted, save=save)

View file

@ -1,4 +1,6 @@
""" make a list of books!! """
import uuid
from django.apps import apps
from django.db import models
from django.db.models import Q
@ -43,6 +45,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
through="ListItem",
through_fields=("book_list", "book"),
)
embed_key = models.UUIDField(unique=True, null=True, editable=False)
activity_serializer = activitypub.BookList
def get_remote_id(self):
@ -105,6 +108,12 @@ class List(OrderedCollectionMixin, BookWyrmModel):
group=None, curation="closed"
)
def save(self, *args, **kwargs):
"""on save, update embed_key and avoid clash with existing code"""
if not self.embed_key:
self.embed_key = uuid.uuid4()
return super().save(*args, **kwargs)
class ListItem(CollectionItemMixin, BookWyrmModel):
"""ok"""

View file

@ -1,5 +1,6 @@
""" the particulars for this instance of BookWyrm """
import datetime
from urllib.parse import urljoin
from django.db import models, IntegrityError
from django.dispatch import receiver
@ -7,9 +8,10 @@ from django.utils import timezone
from model_utils import FieldTracker
from bookwyrm.preview_images import generate_site_preview_image_task
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, STATIC_FULL_URL
from .base_model import BookWyrmModel, new_access_code
from .user import User
from .fields import get_absolute_url
class SiteSettings(models.Model):
@ -66,6 +68,28 @@ class SiteSettings(models.Model):
default_settings.save()
return default_settings
@property
def logo_url(self):
"""helper to build the logo url"""
return self.get_url("logo", "images/logo.png")
@property
def logo_small_url(self):
"""helper to build the logo url"""
return self.get_url("logo_small", "images/logo-small.png")
@property
def favicon_url(self):
"""helper to build the logo url"""
return self.get_url("favicon", "images/favicon.png")
def get_url(self, field, default_path):
"""get a media url or a default static path"""
uploaded = getattr(self, field, None)
if uploaded:
return get_absolute_url(uploaded)
return urljoin(STATIC_FULL_URL, default_path)
class SiteInvite(models.Model):
"""gives someone access to create an account on the instance"""

View file

@ -189,8 +189,10 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
if hasattr(activity, "name"):
activity.name = self.pure_name
activity.type = self.pure_type
books = [getattr(self, "book", None)] + list(self.mention_books.all())
if len(books) == 1 and books[0].preview_image:
book = getattr(self, "book", None)
books = [book] if book else []
books += list(self.mention_books.all())
if len(books) == 1 and getattr(books[0], "preview_image", None):
covers = [
activitypub.Document(
url=fields.get_absolute_url(books[0].preview_image),

View file

@ -4,11 +4,12 @@ from urllib.parse import urlparse
from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group
from django.contrib.postgres.fields import CICharField
from django.contrib.postgres.fields import ArrayField, CICharField
from django.core.validators import MinValueValidator
from django.dispatch import receiver
from django.db import models, transaction
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
import pytz
@ -27,6 +28,19 @@ from .federated_server import FederatedServer
from . import fields, Review
FeedFilterChoices = [
("review", _("Reviews")),
("comment", _("Comments")),
("quotation", _("Quotations")),
("everything", _("Everything else")),
]
def get_feed_filter_choices():
"""return a list of filter choice keys"""
return [f[0] for f in FeedFilterChoices]
def site_link():
"""helper for generating links to the site"""
protocol = "https" if USE_HTTPS else "http"
@ -128,6 +142,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
show_suggested_users = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False)
# feed options
feed_status_types = ArrayField(
models.CharField(max_length=10, blank=False, choices=FeedFilterChoices),
size=8,
default=get_feed_filter_choices,
)
preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
default=str(pytz.utc),

View file

@ -49,6 +49,28 @@ def get_font(font_name, size=28):
return font
def get_wrapped_text(text, font, content_width):
"""text wrap length depends on the max width of the content"""
low = 0
high = len(text)
try:
# ideal length is determined via binary search
while low < high:
mid = math.floor(low + high)
wrapped_text = textwrap.fill(text, width=mid)
width = font.getsize_multiline(wrapped_text)[0]
if width < content_width:
low = mid
else:
high = mid - 1
except AttributeError:
wrapped_text = text
return wrapped_text
def generate_texts_layer(texts, content_width):
"""Adds text for images"""
font_text_zero = get_font("bold", size=20)
@ -63,7 +85,8 @@ def generate_texts_layer(texts, content_width):
if "text_zero" in texts and texts["text_zero"]:
# Text one (Book title)
text_zero = textwrap.fill(texts["text_zero"], width=72)
text_zero = get_wrapped_text(texts["text_zero"], font_text_zero, content_width)
text_layer_draw.multiline_text(
(0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR
)
@ -75,7 +98,8 @@ def generate_texts_layer(texts, content_width):
if "text_one" in texts and texts["text_one"]:
# Text one (Book title)
text_one = textwrap.fill(texts["text_one"], width=28)
text_one = get_wrapped_text(texts["text_one"], font_text_one, content_width)
text_layer_draw.multiline_text(
(0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR
)
@ -87,7 +111,8 @@ def generate_texts_layer(texts, content_width):
if "text_two" in texts and texts["text_two"]:
# Text one (Book subtitle)
text_two = textwrap.fill(texts["text_two"], width=36)
text_two = get_wrapped_text(texts["text_two"], font_text_two, content_width)
text_layer_draw.multiline_text(
(0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR
)
@ -99,7 +124,10 @@ def generate_texts_layer(texts, content_width):
if "text_three" in texts and texts["text_three"]:
# Text three (Book authors)
text_three = textwrap.fill(texts["text_three"], width=36)
text_three = get_wrapped_text(
texts["text_three"], font_text_three, content_width
)
text_layer_draw.multiline_text(
(0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR
)
@ -317,15 +345,21 @@ def save_and_cleanup(image, instance=None):
"""Save and close the file"""
if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
return False
uuid = uuid4()
file_name = f"{instance.id}-{uuid}.jpg"
image_buffer = BytesIO()
try:
try:
old_path = instance.preview_image.name
file_name = instance.preview_image.name
except ValueError:
old_path = None
file_name = None
if not file_name or file_name == "":
uuid = uuid4()
file_name = f"{instance.id}-{uuid}.jpg"
# Clean up old file before saving
if file_name and default_storage.exists(file_name):
default_storage.delete(file_name)
# Save
image.save(image_buffer, format="jpeg", quality=75)
@ -345,10 +379,6 @@ def save_and_cleanup(image, instance=None):
else:
instance.save(update_fields=["preview_image"])
# Clean up old file after saving
if old_path and default_storage.exists(old_path):
default_storage.delete(old_path)
finally:
image_buffer.close()
return True

View file

@ -14,7 +14,7 @@ VERSION = "0.1.0"
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "3eb4edb1"
JS_CACHE = "3891b373"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -165,8 +165,9 @@ LANGUAGES = [
("en-us", _("English")),
("de-de", _("Deutsch (German)")),
("es-es", _("Español (Spanish)")),
("gl-es", _("Galego (Galician)")),
("fr-fr", _("Français (French)")),
("lt-lt", _("lietuvių (Lithuanian)")),
("lt-lt", _("Lietuvių (Lithuanian)")),
("pt-br", _("Português - Brasil (Brazilian Portuguese)")),
("zh-hans", _("简体中文 (Simplified Chinese)")),
("zh-hant", _("繁體中文 (Traditional Chinese)")),

View file

@ -20,6 +20,10 @@ body {
overflow: visible;
}
.card.has-border {
border: 1px solid #eee;
}
.scroll-x {
overflow: hidden;
overflow-x: auto;
@ -115,6 +119,34 @@ input[type=file]::file-selector-button:hover {
color: #363636;
}
details .dropdown-menu {
display: block !important;
}
details.dropdown[open] summary.dropdown-trigger::before {
content: "";
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
summary::marker {
content: none;
}
.detail-pinned-button summary {
position: absolute;
right: 0;
}
.detail-pinned-button form {
float: left;
width: -webkit-fill-available;
margin-top: 1em;
}
/** Shelving
******************************************************************************/

View file

@ -46,4 +46,5 @@
<glyph unicode="&#xe9d9;" glyph-name="star-full" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" />
<glyph unicode="&#xe9da;" glyph-name="heart" d="M755.188 896c-107.63 0-200.258-87.554-243.164-179-42.938 91.444-135.578 179-243.216 179-148.382 0-268.808-120.44-268.808-268.832 0-301.846 304.5-380.994 512.022-679.418 196.154 296.576 511.978 387.206 511.978 679.418 0 148.392-120.43 268.832-268.812 268.832z" />
<glyph unicode="&#xea0a;" glyph-name="plus" d="M992 576h-352v352c0 17.672-14.328 32-32 32h-192c-17.672 0-32-14.328-32-32v-352h-352c-17.672 0-32-14.328-32-32v-192c0-17.672 14.328-32 32-32h352v-352c0-17.672 14.328-32 32-32h192c17.672 0 32 14.328 32 32v352h352c17.672 0 32 14.328 32 32v192c0 17.672-14.328 32-32 32z" />
<glyph unicode="&#xea36;" glyph-name="download" d="M512-32l480 480h-288v512h-384v-512h-288z" />
</font></defs></svg>

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('../fonts/icomoon.eot?36x4a3');
src: url('../fonts/icomoon.eot?36x4a3#iefix') format('embedded-opentype'),
url('../fonts/icomoon.ttf?36x4a3') format('truetype'),
url('../fonts/icomoon.woff?36x4a3') format('woff'),
url('../fonts/icomoon.svg?36x4a3#icomoon') format('svg');
src: url('../fonts/icomoon.eot?r7jc98');
src: url('../fonts/icomoon.eot?r7jc98#iefix') format('embedded-opentype'),
url('../fonts/icomoon.ttf?r7jc98') format('truetype'),
url('../fonts/icomoon.woff?r7jc98') format('woff'),
url('../fonts/icomoon.svg?r7jc98#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -142,3 +142,6 @@
.icon-spinner:before {
content: "\e97a";
}
.icon-download:before {
content: "\ea36";
}

View file

@ -45,6 +45,13 @@ let BookWyrm = new class {
'change',
this.disableIfTooLarge.bind(this)
));
document.querySelectorAll('[data-duplicate]')
.forEach(node => node.addEventListener(
'click',
this.duplicateInput.bind(this)
))
}
/**
@ -59,6 +66,9 @@ let BookWyrm = new class {
document.querySelectorAll('input[type="file"]').forEach(
bookwyrm.disableIfTooLarge.bind(bookwyrm)
);
document.querySelectorAll('[data-copytext]').forEach(
bookwyrm.copyText.bind(bookwyrm)
);
});
}
@ -113,9 +123,44 @@ let BookWyrm = new class {
* @return {undefined}
*/
updateCountElement(counter, data) {
let count = data.count;
const count_by_type = data.count_by_type;
const currentCount = counter.innerText;
const count = data.count;
const hasMentions = data.has_mentions;
const allowedStatusTypesEl = document.getElementById('unread-notifications-wrapper');
// If we're on the right counter element
if (counter.closest('[data-poll-wrapper]').contains(allowedStatusTypesEl)) {
const allowedStatusTypes = JSON.parse(allowedStatusTypesEl.textContent);
// For keys in common between allowedStatusTypes and count_by_type
// This concerns 'review', 'quotation', 'comment'
count = allowedStatusTypes.reduce(function(prev, currentKey) {
const currentValue = count_by_type[currentKey] | 0;
return prev + currentValue;
}, 0);
// Add all the "other" in count_by_type if 'everything' is allowed
if (allowedStatusTypes.includes('everything')) {
// Clone count_by_type with 0 for reviews/quotations/comments
const count_by_everything_else = Object.assign(
{},
count_by_type,
{review: 0, quotation: 0, comment: 0}
);
count = Object.keys(count_by_everything_else).reduce(
function(prev, currentKey) {
const currentValue =
count_by_everything_else[currentKey] | 0
return prev + currentValue;
},
count
);
}
}
if (count != currentCount) {
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
@ -368,4 +413,73 @@ let BookWyrm = new class {
);
}
}
/**
* Display pop up window.
*
* @param {string} url Url to open
* @param {string} windowName windowName
* @return {undefined}
*/
displayPopUp(url, windowName) {
window.open(
url,
windowName,
"left=100,top=100,width=430,height=600"
);
}
duplicateInput (event ) {
const trigger = event.currentTarget;
const input_id = trigger.dataset['duplicate']
const orig = document.getElementById(input_id);
const parent = orig.parentNode;
const new_count = parent.querySelectorAll("input").length + 1
let input = orig.cloneNode();
input.id += ("-" + (new_count))
input.value = ""
let label = parent.querySelector("label").cloneNode();
label.setAttribute("for", input.id)
parent.appendChild(label)
parent.appendChild(input)
}
/**
* Set up a "click-to-copy" component from a textarea element
* with `data-copytext`, `data-copytext-label`, `data-copytext-success`
* attributes.
*
* @param {object} node - DOM node of the text container
* @return {undefined}
*/
copyText(textareaEl) {
const text = textareaEl.textContent;
const copyButtonEl = document.createElement('button');
copyButtonEl.textContent = textareaEl.dataset.copytextLabel;
copyButtonEl.classList.add(
"mt-2",
"button",
"is-small",
"is-fullwidth",
"is-primary",
"is-light"
);
copyButtonEl.addEventListener('click', () => {
navigator.clipboard.writeText(text).then(function() {
textareaEl.classList.add('is-success');
copyButtonEl.classList.replace('is-primary', 'is-success');
copyButtonEl.textContent = textareaEl.dataset.copytextSuccess;
});
});
textareaEl.parentNode.appendChild(copyButtonEl)
}
}();

View file

@ -187,6 +187,7 @@ let StatusCache = new class {
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", false));
// Remove existing disabled states
button.querySelectorAll("[data-shelf-dropdown-identifier] button")
.forEach(item => item.disabled = false);
@ -209,10 +210,10 @@ let StatusCache = new class {
.forEach(item => BookWyrm.addRemoveClass(item, "is-hidden", true));
// Close menu
let menu = button.querySelector(".dropdown-trigger[aria-expanded=true]");
let menu = button.querySelector("details[open]");
if (menu) {
menu.click();
menu.removeAttribute("open");
}
}

View file

@ -2,6 +2,7 @@
{% load i18n %}
{% load markdown %}
{% load humanize %}
{% load utilities %}
{% block title %}{{ author.name }}{% endblock %}
@ -13,7 +14,7 @@
</div>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
<div class="column is-narrow">
<a href="{{ author.local_path }}/edit">
<a href="{% url 'edit-author' author.id %}">
<span class="icon icon-pencil" title="{% trans 'Edit Author' %}" aria-hidden="True"></span>
<span class="is-hidden-mobile">{% trans "Edit Author" %}</span>
</a>
@ -22,94 +23,130 @@
</div>
</div>
<div class="block columns content" itemscope itemtype="https://schema.org/Person">
<div class="block columns is-flex-direction-row-reverse" itemscope itemtype="https://schema.org/Person">
<meta itemprop="name" content="{{ author.name }}">
{% if author.bio %}
<div class="column">
{% include "snippets/trimmed_text.html" with full=author.bio trim_length=200 %}
</div>
{% endif %}
{% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id %}
{% firstof author.aliases author.born author.died as details %}
{% firstof author.wikipedia_link author.openlibrary_key author.inventaire_id author.isni as links %}
{% if details or links %}
<div class="column is-two-fifths">
<div class="box py-2">
<dl>
{% if details %}
<section class="block content">
<h2 class="title is-4">{% trans "Author details" %}</h2>
<dl class="box">
{% if author.aliases %}
<div class="is-flex is-flex-wrap-wrap my-1">
<div class="is-flex is-flex-wrap-wrap mr-1">
<dt class="has-text-weight-bold mr-1">{% trans "Aliases:" %}</dt>
{% for alias in author.aliases %}
<dd itemprop="alternateName" content="{{alias}}">
{{alias}}{% if not forloop.last %},&nbsp;{% endif %}
</dd>
{% endfor %}
<dd>
{% include "snippets/trimmed_list.html" with items=author.aliases itemprop="alternateName" %}
</dd>
</div>
{% endif %}
{% if author.born %}
<div class="is-flex my-1">
<div class="is-flex mt-1">
<dt class="has-text-weight-bold mr-1">{% trans "Born:" %}</dt>
<dd itemprop="birthDate">{{ author.born|naturalday }}</dd>
</div>
{% endif %}
{% if author.died %}
<div class="is-flex my-1">
<div class="is-flex mt-1">
<dt class="has-text-weight-bold mr-1">{% trans "Died:" %}</dt>
<dd itemprop="deathDate">{{ author.died|naturalday }}</dd>
</div>
{% endif %}
</dl>
</section>
{% endif %}
{% if author.wikipedia_link %}
<p class="my-1">
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="noopener" target="_blank">
{% trans "Wikipedia" %}
</a>
</p>
{% endif %}
{% if links %}
<section>
<h2 class="title is-4">{% trans "External links" %}</h2>
<div class="box">
{% if author.wikipedia_link %}
<div>
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="noopener" target="_blank">
{% trans "Wikipedia" %}
</a>
</div>
{% endif %}
{% if author.openlibrary_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://openlibrary.org/authors/{{ author.openlibrary_key }}" target="_blank" rel="noopener">
{% trans "View on OpenLibrary" %}
</a>
</p>
{% endif %}
{% if author.isni %}
<div class="mt-1">
<a itemprop="sameAs" href="{{ author.isni_link }}" rel="noopener" target="_blank">
{% trans "View ISNI record" %}
</a>
</div>
{% endif %}
{% if author.inventaire_id %}
<p class="my-1">
<a itemprop="sameAs" href="https://inventaire.io/entity/{{ author.inventaire_id }}" target="_blank" rel="noopener">
{% trans "View on Inventaire" %}
</a>
</p>
{% endif %}
{% trans "Load data" as button_text %}
{% if author.openlibrary_key %}
<div class="mt-1 is-flex">
<a class="mr-3" itemprop="sameAs" href="{{ author.openlibrary_link }}" target="_blank" rel="noopener">
{% trans "View on OpenLibrary" %}
</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="ol_sync" controls_uid=author.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %}
{% include "author/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %}
{% endwith %}
{% endif %}
</div>
{% endif %}
{% if author.librarything_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="noopener">
{% trans "View on LibraryThing" %}
</a>
</p>
{% endif %}
{% if author.inventaire_id %}
<div class="mt-1 is-flex">
<a class="mr-3" itemprop="sameAs" href="{{ author.inventaire_link }}" target="_blank" rel="noopener">
{% trans "View on Inventaire" %}
</a>
{% if author.goodreads_key %}
<p class="my-1">
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener">
{% trans "View on Goodreads" %}
</a>
</p>
{% endif %}
</div>
</div>
{% endif %}
<div class="column">
{% if author.bio %}
{{ author.bio|to_markdown|safe }}
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="iv_sync" controls_uid=author.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %}
{% include "author/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %}
{% endwith %}
{% endif %}
</div>
{% endif %}
{% if author.librarything_key %}
<div class="mt-1">
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="noopener">
{% trans "View on LibraryThing" %}
</a>
</div>
{% endif %}
{% if author.goodreads_key %}
<div>
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener">
{% trans "View on Goodreads" %}
</a>
</div>
{% endif %}
</div>
</section>
{% endif %}
</div>
{% endif %}
</div>
<hr aria-hidden="true">
<div class="block">
<h3 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h3>
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
<div class="columns is-multiline is-mobile">
{% for book in books %}
<div class="column is-one-fifth">
{% include 'landing/small-book.html' with book=book %}
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
<div class="is-flex-grow-1">
{% include 'landing/small-book.html' with book=book %}
</div>
{% include 'snippets/shelve_button/shelve_button.html' with book=book %}
</div>
{% endfor %}

View file

@ -34,47 +34,41 @@
<div class="field">
<label class="label" for="id_name">{% trans "Name:" %}</label>
{{ form.name }}
{% for error in form.name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}
</div>
<div class="field">
<label class="label" for="id_aliases">{% trans "Aliases:" %}</label>
{{ form.aliases }}
<span class="help">{% trans "Separate multiple values with commas." %}</span>
{% for error in form.aliases.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.aliases.errors id="desc_aliases" %}
</div>
<div class="field">
<label class="label" for="id_bio">{% trans "Bio:" %}</label>
{{ form.bio }}
{% for error in form.bio.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.bio.errors id="desc_bio" %}
</div>
<p class="field"><label class="label" for="id_wikipedia_link">{% trans "Wikipedia link:" %}</label> {{ form.wikipedia_link }}</p>
{% for error in form.wikipedia_link.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.wikipedia_link.errors id="desc_wikipedia_link" %}
<div class="field">
<label class="label" for="id_born">{% trans "Birth date:" %}</label>
<input type="date" name="born" value="{{ form.born.value|date:'Y-m-d' }}" class="input" id="id_born">
{% for error in form.born.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.born.errors id="desc_born" %}
</div>
<div class="field">
<label class="label" for="id_died">{% trans "Death date:" %}</label>
<input type="date" name="died" value="{{ form.died.value|date:'Y-m-d' }}" class="input" id="id_died">
{% for error in form.died.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.died.errors id="desc_died" %}
</div>
</div>
<div class="column">
@ -82,33 +76,36 @@
<div class="field">
<label class="label" for="id_openlibrary_key">{% trans "Openlibrary key:" %}</label>
{{ form.openlibrary_key }}
{% for error in form.openlibrary_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.oepnlibrary_key.errors id="desc_oepnlibrary_key" %}
</div>
<div class="field">
<label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label>
{{ form.inventaire_id }}
{% for error in form.inventaire_id.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}
</div>
<div class="field">
<label class="label" for="id_librarything_key">{% trans "Librarything key:" %}</label>
{{ form.librarything_key }}
{% for error in form.librarything_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.librarything_key.errors id="desc_librarything_key" %}
</div>
<div class="field">
<label class="label" for="id_goodreads_key">{% trans "Goodreads key:" %}</label>
{{ form.goodreads_key }}
{% for error in form.goodreads_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.goodreads_key.errors id="desc_goodreads_key" %}
</div>
<div class="field">
<label class="label" for="id_isni">{% trans "ISNI:" %}</label>
{{ form.isni }}
{% include 'snippets/form_errors.html' with errors_list=form.isni.errors id="desc_isni" %}
</div>
</div>

View file

@ -0,0 +1,30 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}
{% trans "Load data" %}
{% endblock %}
{% block modal-form-open %}
<form name="{{ source }}-update" method="POST" action="{% url 'author-update-remote' author.id source %}">
{% csrf_token %}
{% endblock %}
{% block modal-body %}
<p>
{% blocktrans trimmed %}
Loading data will connect to <strong>{{ source_name }}</strong> and check for any metadata about this author which aren't present here. Existing metadata will not be overwritten.
{% endblocktrans %}
</p>
{% endblock %}
{% block modal-footer %}
<button class="button is-primary" type="submit">
<span>{% trans "Confirm" %}</span>
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -90,11 +90,28 @@
</div>
{% endwith %}
{% trans "Load data" as button_text %}
{% if book.openlibrary_key %}
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
<p>
<a href="{{ book.openlibrary_link }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="ol_sync" controls_uid=book.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %}
{% include "book/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %}
{% endwith %}
{% endif %}
</p>
{% endif %}
{% if book.inventaire_id %}
<p><a href="https://inventaire.io/entity/{{ book.inventaire_id }}" target="_blank" rel="noopener">{% trans "View on Inventaire" %}</a></p>
<p>
<a href="{{ book.inventaire_link }}" target="_blank" rel="noopener">{% trans "View on Inventaire" %}</a>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
{% with controls_text="iv_sync" controls_uid=book.id %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %}
{% include "book/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %}
{% endwith %}
{% endif %}
</p>
{% endif %}
</section>
</div>
@ -153,12 +170,21 @@
{# user's relationship to the book #}
<div class="block">
{% if user_shelfbooks.count > 0 %}
<h2 class="title is-5">
{% trans "You have shelved this edition in:" %}
</h2>
<ul>
{% for shelf in user_shelfbooks %}
<p>
{% blocktrans with path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}This edition is on your <a href="{{ path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}
{% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
</p>
<li class="box">
<a href="{{ shelf.shelf.local_path }}">{{ shelf.shelf.name }}</a>
<div class="mb-3">
{% include 'snippets/shelf_selector.html' with shelf=shelf.shelf class="is-small" readthrough=readthrough %}
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{% for shelf in other_edition_shelves %}
<p>
{% blocktrans with book_path=shelf.book.local_path shelf_path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}A <a href="{{ book_path }}">different edition</a> of this book is on your <a href="{{ shelf_path }}">{{ shelf_name }}</a> shelf.{% endblocktrans %}

View file

@ -1,6 +1,7 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load humanize %}
{% load utilities %}
{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %}
@ -52,19 +53,29 @@
{% for author in author_matches %}
<fieldset>
<legend class="title is-5 mb-1">
{% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %}
{% blocktrans with name=author.name %}Is "{{ name }}" one of these authors?{% endblocktrans %}
</legend>
{% with forloop.counter0 as counter %}
{% for match in author.matches %}
<label class="label mb-2">
<label class="label">
<input type="radio" name="author_match-{{ counter }}" value="{{ match.id }}" required>
{{ match.name }}
</label>
<p class="help">
<a href="{{ match.local_path }}" target="_blank">{% blocktrans with book_title=match.book_set.first.title %}Author of <em>{{ book_title }}</em>{% endblocktrans %}</a>
<p class="help ml-5 mb-2">
{% with book_title=match.book_set.first.title alt_title=match.bio %}
{% if book_title %}
<a href="{{ match.local_path }}" target="_blank">{% trans "Author of " %}<em>{{ book_title }}</em></a>
{% else %}
<a href="{{ match.id }}" target="_blank">{% if alt_title %}{% trans "Author of " %}<em>{{ alt_title }}</em>{% else %} {% trans "Find more information at isni.org" %}{% endif %}</a>
{% endif %}
{% endwith %}
</p>
<p class="help ml-5">
{{ author.existing_isnis|get_isni_bio:match }}
</p>
{{ author.existing_isnis|get_isni:match }}
{% endfor %}
<label class="label">
<label class="label mt-2">
<input type="radio" name="author_match-{{ counter }}" value="{{ author.name }}" required> {% trans "This is a new author" %}
</label>
{% endwith %}

View file

@ -12,106 +12,125 @@
<div class="columns">
<div class="column is-half">
<section class="block">
<h2 class="title is-4">{% trans "Metadata" %}</h2>
<h2 class="title is-4">
{% trans "Metadata" %}
</h2>
<div class="box">
<div class="field">
<label class="label" for="id_title">{% trans "Title:" %}</label>
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title">
{% for error in form.title.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<label class="label" for="id_title">
{% trans "Title:" %}
</label>
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title" aria-describedby="desc_title">
{% include 'snippets/form_errors.html' with errors_list=form.title.errors id="desc_title" %}
</div>
<div class="field">
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle">
{% for error in form.subtitle.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<label class="label" for="id_subtitle">
{% trans "Subtitle:" %}
</label>
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle" aria-describedby="desc_subtitle">
{% include 'snippets/form_errors.html' with errors_list=form.subtitle.errors id="desc_subtitle" %}
</div>
<div class="field">
<label class="label" for="id_description">{% trans "Description:" %}</label>
<label class="label" for="id_description">
{% trans "Description:" %}
</label>
{{ form.description }}
{% for error in form.description.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.description.errors id="desc_description" %}
</div>
<div class="columns">
<div class="column is-two-thirds">
<div class="field">
<label class="label" for="id_series">{% trans "Series:" %}</label>
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}">
{% for error in form.series.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<label class="label" for="id_series">
{% trans "Series:" %}
</label>
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}" aria-describedby="desc_series">
{% include 'snippets/form_errors.html' with errors_list=form.series.errors id="desc_series" %}
</div>
</div>
<div class="column is-one-third">
<div class="field">
<label class="label" for="id_series_number">{% trans "Series number:" %}</label>
<label class="label" for="id_series_number">
{% trans "Series number:" %}
</label>
{{ form.series_number }}
{% for error in form.series_number.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.series_number.errors id="desc_series_number" %}
</div>
</div>
</div>
<div class="field">
<label class="label" for="id_languages">{% trans "Languages:" %}</label>
<label class="label" for="id_languages">
{% trans "Languages:" %}
</label>
{{ form.languages }}
<span class="help">{% trans "Separate multiple values with commas." %}</span>
{% for error in form.languages.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<span class="help" id="desc_languages_help">
{% trans "Separate multiple values with commas." %}
</span>
{% include 'snippets/form_errors.html' with errors_list=form.languages.errors id="desc_languages" %}
</div>
</div>
</section>
<section class="block">
<h2 class="title is-4">{% trans "Publication" %}</h2>
<h2 class="title is-4">
{% trans "Publication" %}
</h2>
<div class="box">
<div class="field">
<label class="label" for="id_publishers">{% trans "Publisher:" %}</label>
<label class="label" for="id_publishers">
{% trans "Publisher:" %}
</label>
{{ form.publishers }}
<span class="help">{% trans "Separate multiple values with commas." %}</span>
{% for error in form.publishers.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<span class="help" id="desc_publishers_help">
{% trans "Separate multiple values with commas." %}
</span>
{% include 'snippets/form_errors.html' with errors_list=form.publishers.errors id="desc_publishers" %}
</div>
<div class="field">
<label class="label" for="id_first_published_date">{% trans "First published date:" %}</label>
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %}>
{% for error in form.first_published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<label class="label" for="id_first_published_date">
{% trans "First published date:" %}
</label>
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %} aria-describedby="desc_first_published_date">
{% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}
</div>
<div class="field">
<label class="label" for="id_published_date">{% trans "Published date:" %}</label>
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %}>
{% for error in form.published_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<label class="label" for="id_published_date">
{% trans "Published date:" %}
</label>
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %} aria-describedby="desc_published_date">
{% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}
</div>
</div>
</section>
<section class="block">
<h2 class="title is-4">{% trans "Authors" %}</h2>
<h2 class="title is-4">
{% trans "Authors" %}
</h2>
<div class="box">
{% if book.authors.exists %}
<fieldset>
{% for author in book.authors.all %}
<div class="is-flex is-justify-content-space-between">
<label class="label mb-2">
<input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %}>
<input type="checkbox" name="remove_authors" value="{{ author.id }}" {% if author.id|stringformat:"i" in remove_authors %}checked{% endif %} aria-describedby="desc_remove_author_{{author.id}}">
{% blocktrans with name=author.name %}Remove {{ name }}{% endblocktrans %}
</label>
<p class="help">
<p class="help" id="desc_remove_author_{{author.id}}">
<a href="{{ author.local_path }}">{% blocktrans with name=author.name %}Author page for {{ name }}{% endblocktrans %}</a>
</p>
</div>
@ -119,17 +138,27 @@
</fieldset>
{% endif %}
<div class="field">
<label class="label" for="id_add_author">{% trans "Add Authors:" %}</label>
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'John Doe, Jane Smith' %}" value="{{ add_author }}" {% if confirm_mode %}readonly{% endif %}>
<span class="help">{% trans "Separate multiple values with commas." %}</span>
<label class="label" for="id_add_author">
{% trans "Add Authors:" %}
</label>
{% for author in add_author %}
<label class="label is-sr-only" for="id_add_author{% if not forloop.first %}-{{forloop.counter}}{% endif %}">{% trans "Add Author" %}</label>
<input class="input" type="text" name="add_author" id="id_add_author{% if not forloop.first %}-{{forloop.counter}}{% endif %}" placeholder="{% trans 'Jane Doe' %}" value="{{ author }}" {% if confirm_mode %}readonly{% endif %}>
{% empty %}
<label class="label is-sr-only" for="id_add_author">{% trans "Add Author" %}</label>
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'Jane Doe' %}" value="{{ author }}" {% if confirm_mode %}readonly{% endif %}>
{% endfor %}
</div>
<span class="help"><button class="button is-small" type="button" data-duplicate="id_add_author" id="another_author_field">{% trans "Add Another Author" %}</button></span>
</div>
</section>
</div>
<div class="column is-half">
<section class="block">
<h2 class="title is-4">{% trans "Cover" %}</h2>
<h2 class="title is-4">
{% trans "Cover" %}
</h2>
<div class="box">
<div class="columns">
{% if book.cover %}
@ -140,108 +169,122 @@
<div class="column">
<div class="field">
<label class="label" for="id_cover">{% trans "Upload cover:" %}</label>
<label class="label" for="id_cover">
{% trans "Upload cover:" %}
</label>
{{ form.cover }}
</div>
<div class="field">
<label class="label" for="id_cover_url">
{% trans "Load cover from url:" %}
</label>
<input class="input" name="cover-url" id="id_cover_url" type="url" value="{{ cover_url|default:'' }}">
<input class="input" name="cover-url" id="id_cover_url" type="url" value="{{ cover_url|default:'' }}" aria-describedby="desc_cover">
</div>
{% for error in form.cover.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.cover.errors id="desc_cover" %}
</div>
</div>
</div>
</section>
<section class="block">
<h2 class="title is-4">{% trans "Physical Properties" %}</h2>
<h2 class="title is-4">
{% trans "Physical Properties" %}
</h2>
<div class="box">
<div class="columns">
<div class="column is-one-third">
<div class="field">
<label class="label" for="id_physical_format">{% trans "Format:" %}</label>
<label class="label" for="id_physical_format">
{% trans "Format:" %}
</label>
<div class="select">
{{ form.physical_format }}
</div>
{% for error in form.physical_format.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %}
</div>
</div>
<div class="column">
<div class="field">
<label class="label" for="id_physical_format_detail">{% trans "Format details:" %}</label>
<label class="label" for="id_physical_format_detail">
{% trans "Format details:" %}
</label>
{{ form.physical_format_detail }}
{% for error in form.physical_format_detail.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.physical_format_detail.errors id="desc_physical_format_detail" %}
</div>
</div>
</div>
<div class="field">
<label class="label" for="id_pages">{% trans "Pages:" %}</label>
<label class="label" for="id_pages">
{% trans "Pages:" %}
</label>
{{ form.pages }}
{% for error in form.pages.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.pages.errors id="desc_pages" %}
</div>
</div>
</section>
<section class="block">
<h2 class="title is-4">{% trans "Book Identifiers" %}</h2>
<h2 class="title is-4">
{% trans "Book Identifiers" %}
</h2>
<div class="box">
<div class="field">
<label class="label" for="id_isbn_13">{% trans "ISBN 13:" %}</label>
<label class="label" for="id_isbn_13">
{% trans "ISBN 13:" %}
</label>
{{ form.isbn_13 }}
{% for error in form.isbn_13.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.isbn_13.errors id="desc_isbn_13" %}
</div>
<div class="field">
<label class="label" for="id_isbn_10">{% trans "ISBN 10:" %}</label>
<label class="label" for="id_isbn_10">
{% trans "ISBN 10:" %}
</label>
{{ form.isbn_10 }}
{% for error in form.isbn_10.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.isbn_10.errors id="desc_isbn_10" %}
</div>
<div class="field">
<label class="label" for="id_openlibrary_key">{% trans "Openlibrary ID:" %}</label>
<label class="label" for="id_openlibrary_key">
{% trans "Openlibrary ID:" %}
</label>
{{ form.openlibrary_key }}
{% for error in form.openlibrary_key.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.openlibrary_key.errors id="desc_openlibrary_key" %}
</div>
<div class="field">
<label class="label" for="id_inventaire_id">{% trans "Inventaire ID:" %}</label>
<label class="label" for="id_inventaire_id">
{% trans "Inventaire ID:" %}
</label>
{{ form.inventaire_id }}
{% for error in form.inventaire_id.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}
</div>
<div class="field">
<label class="label" for="id_oclc_number">{% trans "OCLC Number:" %}</label>
<label class="label" for="id_oclc_number">
{% trans "OCLC Number:" %}
</label>
{{ form.oclc_number }}
{% for error in form.oclc_number.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.oclc_number.errors id="desc_oclc_number" %}
</div>
<div class="field">
<label class="label" for="id_asin">{% trans "ASIN:" %}</label>
<label class="label" for="id_asin">
{% trans "ASIN:" %}
</label>
{{ form.asin }}
{% for error in form.ASIN.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.ASIN.errors id="desc_ASIN" %}
</div>
</div>
</section>

View file

@ -0,0 +1,30 @@
{% extends 'components/modal.html' %}
{% load i18n %}
{% block modal-title %}
{% trans "Load data" %}
{% endblock %}
{% block modal-form-open %}
<form name="{{ source }}-update" method="POST" action="{% url 'book-update-remote' book.id source %}">
{% csrf_token %}
{% endblock %}
{% block modal-body %}
<p>
{% blocktrans trimmed %}
Loading data will connect to <strong>{{ source_name }}</strong> and check for any metadata about this book which aren't present here. Existing metadata will not be overwritten.
{% endblocktrans %}
</p>
{% endblock %}
{% block modal-footer %}
<button class="button is-primary" type="submit">
<span>{% trans "Confirm" %}</span>
</button>
{% trans "Cancel" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with text=button_text %}
{% endblock %}
{% block modal-form-close %}</form>{% endblock %}

View file

@ -2,25 +2,20 @@
{% load utilities %}
{% with 0|uuid as uuid %}
<div
<details
id="menu_{{ uuid }}"
class="
dropdown control
{% if right %}is-right{% endif %}
"
>
<button
class="button dropdown-trigger pulldown-menu {{ class }}"
type="button"
aria-expanded="false"
aria-haspopup="true"
aria-controls="menu_options_{{ uuid }}"
data-controls="menu_{{ uuid }}"
<summary
class="button control dropdown-trigger pulldown-menu {{ class }}"
>
{% block dropdown-trigger %}{% endblock %}
</button>
</summary>
<div class="dropdown-menu">
<div class="dropdown-menu control">
<ul
id="menu_options_{{ uuid }}"
class="dropdown-content p-0 is-clipped"
@ -29,6 +24,6 @@
{% block dropdown-list %}{% endblock %}
</ul>
</div>
</div>
</details>
{% endwith %}
{% endspaceless %}

View file

@ -18,7 +18,7 @@
</p>
<form name="directory" method="POST" action="{% url 'directory' %}">
{% csrf_token %}
<button class="button is-primary" type="submit">Join Directory</button>
<button class="button is-primary" type="submit">{% trans "Join Directory" %}</button>
<p class="help">
{% url 'prefs-profile' as path %}
{% blocktrans with path=path %}You can opt-out at any time in your <a href="{{ path }}">profile settings.</a>{% endblocktrans %}
@ -28,7 +28,7 @@
<div class="column is-narrow">
{% trans "Dismiss message" as button_text %}
<button type="button" class="delete set-display" data-id="hide_join_directory" data-value="true">
<span>Dismiss message</span>
<span>{% trans "Dismiss message" %}</span>
</button>
</div>
</div></div>

View file

@ -2,7 +2,7 @@
<div style="font-family: BlinkMacSystemFont,-apple-system,'Segoe UI',Roboto,Oxygen,Ubuntu,Cantarell,'Fira Sans','Droid Sans','Helvetica Neue',Helvetica,Arial,sans-serif; border-radius: 6px; background-color: #efefef; max-width: 632px;">
<div style="padding: 1rem; overflow: auto;">
<div style="float: left; margin-right: 1rem;">
<a style="color: #3273dc;" href="https://{{ domain }}" style="text-decoration: none;"><img src="https://{{ domain }}/{{ logo }}" alt="logo"></a>
<a style="color: #3273dc;" href="https://{{ domain }}" style="text-decoration: none;"><img src="{{ logo }}" alt="logo"></a>
</div>
<div>
<a style="color: black; text-decoration: none" href="https://{{ domain }}" style="text-decoration: none;"><strong>{{ site_name }}</strong><br>

View file

@ -0,0 +1,11 @@
{% extends 'email/html_layout.html' %}
{% load i18n %}
{% block content %}
<p>
{% blocktrans %}@{{ reporter }} has flagged behavior by @{{ reportee }} for moderation. {% endblocktrans %}
</p>
{% trans "View report" as text %}
{% include 'email/snippets/action.html' with path=report_link text=text %}
{% endblock %}

View file

@ -0,0 +1,2 @@
{% load i18n %}
{% blocktrans %}New report for {{ site_name }}{% endblocktrans %}

View file

@ -0,0 +1,9 @@
{% extends 'email/text_layout.html' %}
{% load i18n %}
{% block content %}
{% blocktrans %}@{{ reporter}} has flagged behavior by @{{ reportee }} for moderation. {% endblocktrans %}
{% trans "View report" %}
{{ report_link }}
{% endblock %}

View file

@ -1,4 +1,4 @@
<html lang="{% get_lang %}">
<html lang="en">
<body>
<div>
<strong>Subject:</strong> {% include subject_path %}

View file

@ -0,0 +1,53 @@
{% load layout %}
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="{% get_lang %}">
<head>
<title>{% block title %}BookWyrm{% endblock %} - {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{% static "css/vendor/bulma.min.css" %}">
<link rel="stylesheet" href="{% static "css/vendor/icons.css" %}">
<link rel="stylesheet" href="{% static "css/bookwyrm.css" %}">
<base target="_blank">
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}{% get_media_prefix %}{{ site.favicon }}{% else %}{% static "images/favicon.ico" %}{% endif %}">
</head>
<body>
<header class="section py-3">
<a href="/" class="is-flex is-align-items-center">
<img class="image logo is-flex-shrink-0" style="height: 32px" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
<span class="title is-5 ml-2">{{ site.name }}</span>
</a>
</header>
<main class="section py-3">
{% block content %}
{% endblock %}
</main>
<footer class="section py-3">
<p>
<a href="{% url 'about' %}">
{% blocktrans with site_name=site.name %}About {{ site_name }}{% endblocktrans %}
</a>
</p>
{% if site.admin_email %}
<p>
<a href="mailto:{{ site.admin_email }}">
{% trans "Contact site admin" %}
</a>
</p>
{% endif %}
<p>
<a href="https://joinbookwyrm.com/">
{% trans "Join Bookwyrm" %}
</a>
</p>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -6,20 +6,62 @@
<h1 class="title">
{{ tab.name }}
</h1>
<div class="tabs">
<ul>
{% for stream in streams %}
<li class="{% if tab.key == stream.key %}is-active{% endif %}"{% if tab.key == stream.key %} aria-current="page"{% endif %}>
<a href="/{{ stream.key }}#feed">{{ stream.shortname }}</a>
</li>
{% endfor %}
</ul>
<div class="block is-clipped">
<div class="is-pulled-left">
<div class="tabs">
<ul>
{% for stream in streams %}
<li class="{% if tab.key == stream.key %}is-active{% endif %}"{% if tab.key == stream.key %} aria-current="page"{% endif %}>
<a href="/{{ stream.key }}#feed">{{ stream.shortname }}</a>
</li>
{% endfor %}
</ul>
</div>
</div>
{# feed settings #}
<details class="detail-pinned-button" {% if settings_saved %}open{% endif %}>
<summary class="control">
<span class="button">
<span class="icon icon-dots-three m-0-mobile" aria-hidden="true"></span>
<span class="is-sr-only-mobile">{{ _("Feed settings") }}</span>
</span>
</summary>
<form class="notification level is-align-items-flex-end" method="post" action="/{{ tab.key }}#feed">
{% csrf_token %}
<div class="level-left">
<div class="field">
<div class="control">
<span class="is-flex is-align-items-baseline">
<label class="label mt-2 mb-1">Status types</label>
{% if settings_saved %}
<span class="tag is-success is-light ml-2">{{ _("Saved!") }}</span>
{% endif %}
</span>
{% for name, value in feed_status_types_options %}
<label class="mr-2">
<input type="checkbox" name="feed_status_types" value="{{ name }}" {% if name in user.feed_status_types %}checked=""{% endif %}/>
{{ value }}
</label>
{% endfor %}
</div>
</div>
</div>
<div class="level-right control">
<button class="button is-small is-primary is-outlined" type="submit">
{{ _("Save settings") }}
</button>
</div>
</form>
</details>
</div>
{# announcements and system messages #}
{% if not activities.number > 1 %}
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
{% blocktrans with tab_key=tab.key %}load <span data-poll="stream/{{ tab_key }}">0</span> unread status(es){% endblocktrans %}
{{ allowed_status_types|json_script:"unread-notifications-wrapper" }}
</a>
{% if request.user.show_goal and not goal and tab.key == 'home' %}
@ -36,6 +78,7 @@
{% if not activities %}
<div class="block content">
<p>{% trans "There aren't any activities right now! Try following a user to get started" %}</p>
<p>{% if user.feed_status_types|length < 4 %}{% trans "Alternatively, you can try enabling more status types" %}{% endif %}</p>
{% if request.user.show_suggested_users and suggested_users %}
{# suggested users for when things are very lonely #}

View file

@ -2,6 +2,18 @@
{% load i18n %}
{% load bookwyrm_tags %}
{% block opengraph_images %}
{% firstof status.book status.mention_books.first as book %}
{% if book %}
{% include 'snippets/opengraph_images.html' with image=preview %}
{% else %}
{% include 'snippets/opengraph_images.html' %}
{% endif %}
{% endblock %}
{% block panel %}
<header class="block">
<a href="/#feed" class="button" data-back>

View file

@ -4,9 +4,14 @@
<div class="select is-small mt-1 mb-3">
<select name="{{ book.id }}" aria-label="{% blocktrans with book_title=book.title %}Have you read {{ book_title }}?{% endblocktrans %}">
<option disabled selected value>Add to your books</option>
<option disabled selected value>{% trans 'Add to your books' %}</option>
{% for shelf in user_shelves %}
<option value="{{ shelf.id }}">{{ shelf.name }}</option>
<option value="{{ shelf.id }}">
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
{% else %}{{ shelf.name }}{% endif %}
</option>
{% endfor %}
</select>
</div>

View file

@ -14,16 +14,14 @@
<div class="block">
<label class="label" for="id_name">{% trans "Display name:" %}</label>
<input type="text" name="name" maxlength="100" class="input" id="id_name" placeholder="{{ user.localname }}" value="{% if request.user.name %}{{ request.user.name }}{% endif %}">
{% for error in form.name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}
</div>
<div class="block">
<label class="label" for="id_summary">{% trans "Summary:" %}</label>
<textarea name="summary" cols="None" rows="None" class="textarea" id="id_summary" placeholder="{% trans 'A little bit about you' %}">{% if request.user.summary %}{{ request.user.summary }}{% endif %}</textarea>
{% for error in form.summary.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.summary.errors id="desc_summary" %}
</div>
</div>
@ -31,9 +29,8 @@
<div class="block">
<label class="label" for="id_avatar">{% trans "Avatar:" %}</label>
{{ form.avatar }}
{% for error in form.avatar.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.avatar.errors id="desc_avatar" %}
</div>
</div>
</div>

View file

@ -46,8 +46,10 @@
{% include 'snippets/book_cover.html' with book=guess cover_class='is-h-s' size='small' %}
</a>
</div>
<div class="column">
{% include 'snippets/book_titleby.html' with book=guess %}
<div class="column content">
<p>
{% include 'snippets/book_titleby.html' with book=guess %}
</p>
<div class="content is-flex">
<form class="pr-2" name="approve-{{ item.id }}" method="POST" action="{% url 'import-approve' job.id item.id %}">
{% csrf_token %}

View file

@ -65,10 +65,9 @@
{% csrf_token %}
<div class="block">
<label for="id_request_email" class="label">{% trans "Email address:" %}</label>
<input type="email" name="email" maxlength="255" class="input" required="" id="id_request_email">
{% for error in request_form.email.errors %}
<p class="help is-danger">{{ error|escape }}</p>
{% endfor %}
<input type="email" name="email" maxlength="255" class="input" required="" id="id_request_email" aria-describedby="desc_request_email">
{% include 'snippets/form_errors.html' with errors_list=request_form.email.errors id="desc_request_email" %}
</div>
<button type="submit" class="button is-link">{% trans "Submit" %}</button>
</form>

View file

@ -26,11 +26,10 @@
<div class="field">
<label class="label" for="id_password_confirm">{% trans "Password:" %}</label>
<div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_confirm" aria-describedby="desc_password">
</div>
{% for error in login_form.password.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}
</div>
<div class="field is-grouped">
<div class="control">

View file

@ -8,21 +8,33 @@
<div class="column">
<div class="block">
<h1 class="title">{% trans "Reset Password" %}</h1>
{% for error in errors %}
<p class="is-danger">{{ error }}</p>
{% endfor %}
{% if errors %}
<div id="form_errors">
{% for error in errors %}
<p class="is-danger">
{{ error }}
</p>
{% endfor %}
</div>
{% endif %}
<form name="password-reset" method="post" action="/password-reset/{{ code }}">
{% csrf_token %}
<div class="field">
<label class="label" for="id_new_password">{% trans "Password:" %}</label>
<label class="label" for="id_new_password">
{% trans "Password:" %}
</label>
<div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_new_password">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_new_password" aria-describedby="form_errors">
</div>
</div>
<div class="field">
<label class="label" for="id_confirm_password">{% trans "Confirm password:" %}</label>
<label class="label" for="id_confirm_password">
{% trans "Confirm password:" %}
</label>
<div class="control">
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password">
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password" aria-describedby="form_errors">
</div>
</div>
<div class="field is-grouped">

View file

@ -34,7 +34,7 @@
<div class="container">
<div class="navbar-brand">
<a class="navbar-item" href="/">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="Home page">
<img class="image logo" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static "images/logo-small.png" %}{% endif %}" alt="{% blocktrans with site_name=site.name %}{{ site_name }} home page{% endblocktrans %}">
</a>
<form class="navbar-item column" action="{% url 'search' %}">
<div class="field has-addons">
@ -267,5 +267,6 @@
<script src="{% static "js/status_cache.js" %}?v={{ js_cache }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,59 @@
{% extends 'embed-layout.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load bookwyrm_group_tags %}
{% load markdown %}
{% block title %}{% blocktrans with list_name=list.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}}{% endblocktrans %}{% endblock title %}
{% block content %}
<div class="mt-3">
<h1 class="title is-4">
{{ list.name }}
<span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h1>
<p class="subtitle is-size-6">
{% include 'lists/created_text.html' with list=list %}
{% blocktrans with site_name=site.name %}on <a href="/">{{ site_name }}</a>{% endblocktrans %}
</p>
<div class="block content">
{% include 'snippets/trimmed_text.html' with full=list.description %}
</div>
<section>
{% if not items.object_list.exists %}
<p>{% trans "This list is currently empty" %}</p>
{% else %}
<ol start="{{ items.start_index }}" class="ordered-list">
{% for item in items %}
{% with book=item.book %}
<li class="mb-5 card is-shadowless has-border">
<div class="card-content p-0 mb-0 columns is-gapless is-mobile">
<div class="column is-3-mobile is-2-tablet is-cover align to-t">
<a href="{{ item.book.local_path }}" aria-hidden="true">
{% include 'snippets/book_cover.html' with cover_class='is-w-auto is-h-m-tablet is-align-items-flex-start' size='medium' %}
</a>
</div>
<div class="column mx-3 my-2">
<h2 class="title is-6 mb-1">
{% include 'snippets/book_titleby.html' %}
</h2>
<p>
{% include 'snippets/stars.html' with rating=item.book|rating:request.user %}
</p>
<div>
{{ book|book_description|to_markdown|default:""|safe|truncatewords_html:20 }}
</div>
</div>
</div>
</li>
{% endwith %}
{% endfor %}
</ol>
{% endif %}
{% include "snippets/pagination.html" with page=items %}
</section>
</div>
{% endblock %}

View file

@ -186,6 +186,13 @@
{% endfor %}
{% endif %}
{% endif %}
<div>
<h2 class="title is-5 mt-6" id="embed-label">
{% trans "Embed this list on a website" %}
</h2>
<textarea readonly class="textarea is-small" aria-labelledby="embed-label" data-copytext data-copytext-label="{% trans 'Copy embed code' %}" data-copytext-success="{% trans 'Copied!' %}"><iframe style="border-width:0;" id="bookwyrm_list_embed" width="400" height="600" title="{% blocktrans with list_name=list.name site_name=site.name owner=list.user.display_name %}{{ list_name }}, a list by {{owner}} on {{ site_name }}{% endblocktrans %}" src="{{ embed_url }}"></iframe></textarea>
</div>
</section>
</div>
{% endblock %}

View file

@ -7,8 +7,8 @@
<div class="column is-one-quarter">
<div class="card is-stretchable">
<header class="card-header">
<h4 class="card-header-title">
<a href="{{ list.local_path }}">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
<h4 class="card-header-title is-clipped">
<a href="{{ list.local_path }}" class="is-clipped">{{ list.name }}</a> <span class="subtitle">{% include 'snippets/privacy-icons.html' with item=list %}</span>
</h4>
{% if request.user.is_authenticated and request.user|saved:list %}
<div class="card-header-icon">

View file

@ -0,0 +1,70 @@
{% load i18n %}
{% block content %}
<div class="block">
{% if error == 'invalid_username' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> is not a valid username{% endblocktrans %}.</p>
<p>{% trans 'Check you have the correct username before trying again' %}.</p>
</div>
{% elif error == 'user_not_found' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> could not be found or <code>{{ remote_domain }}</code> does not support identity discovery{% endblocktrans %}.</p>
<p>{% trans 'Check you have the correct username before trying again' %}.</p>
</div>
{% elif error == 'not_supported' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> was found but <code>{{ remote_domain }}</code> does not support 'remote follow'{% endblocktrans %}.</p>
<p>{% blocktrans %}Try searching for <strong>{{ user }}</strong> on <code>{{ remote_domain }}</code> instead{% endblocktrans %}.</p>
</div>
{% elif not request.user.is_authenticated %}
<div class="navbar-item">
<div class="columns">
<div class="column">
<form name="login" method="post" action="{% url 'login' %}?next={{ request.path }}?acct={{ user.remote_id }}">
{% csrf_token %}
<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="{% trans 'username' %}">
</div>
<div class="column">
<label class="is-sr-only" for="id_password">{% trans "Password:" %}</label>
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password" placeholder="{% trans 'password' %}">
<p class="help"><a href="{% url '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>
</div>
</div>
</form>
</div>
</div>
</div>
{% elif error == 'ostatus_subscribe' %}
<div class="notification is-warning has-text-centered" role="status">
<p>{% blocktrans %}Something went wrong trying to follow <strong>{{ account }}</strong>{% endblocktrans %}</p>
<p>{% trans 'Check you have the correct username before trying again.' %}</p>
</div>
{% elif error == 'is_blocked' %}
<div class="notification is-danger has-text-centered" role="status">
<p>{% blocktrans %}You have blocked <strong>{{ account }}</strong>{% endblocktrans %}</p>
</div>
{% elif error == 'has_blocked' %}
<div class="notification is-danger has-text-centered" role="status">
<p>{% blocktrans %}<strong>{{ account }}</strong> has blocked you{% endblocktrans %}</p>
</div>
{% elif error == 'already_following' %}
<div class="notification is-success has-text-centered" role="status">
<p>{% blocktrans %}You are already following <strong>{{ account }}</strong>{% endblocktrans %}</p>
</div>
{% elif error == 'already_requested' %}
<div class="notification is-success has-text-centered" role="status">
<p>{% blocktrans %}You have already requested to follow <strong>{{ account }}</strong>{% endblocktrans %}</p>
</div>
{% endif %}
</div>
<div class="block is-pulled-right">
<button type="button" class="button" onclick="closeWindow()">Close window</button>
</div>
{% endblock %}

View file

@ -0,0 +1,46 @@
{% extends 'ostatus/template.html' %}
{% load i18n %}
{% load utilities %}
{% block heading %}
{% blocktrans with username=user.localname sitename=site.name %}Follow {{ username }} on the fediverse{% endblocktrans %}
{% endblock %}
{% block content %}
<div class="block card">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">
{{ user.display_name }}
{% if user.manually_approves_followers %}
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
<span class="is-sr-only">{% trans "Locked account" %}</span>
</span>
{% endif %}
</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
</div>
</div>
</div>
</div>
<div class="block">
<p>{% blocktrans with username=user.display_name %}Follow {{ username }} from another Fediverse account like BookWyrm, Mastodon, or Pleroma.{% endblocktrans %}</p>
</div>
<div class="card">
<section class="card-content content">
<form name="remote-follow" method="post" action="{% url 'remote-follow' %}">
{% csrf_token %}
<input type="hidden" name="user" value="{{ user.id }}">
<label class="label" for="remote_user">{% trans 'User handle to follow from:' %}</label>
<input class="input" type="text" name="remote_user" id="remote_user" placeholder="user@example.social" required>
<button class="button mt-1 is-primary" type="submit">{% trans 'Follow!' %}</button>
</form>
</section>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% load i18n %}
{% if request.user == user %}
{% else %}
<div class="field mb-0">
<div class="control">
<a class="button is-small is-link" href="{% url 'remote-follow-page' %}?user={{ user.username }}" target="_blank" rel="noopener noreferrer" onclick="BookWyrm.displayPopUp(`{% url 'remote-follow-page' %}?user={{ user.username }}`, `remoteFollow`); return false;" aria-describedby="remote_follow_warning">
{% blocktrans with username=user.localname %}Follow on Fediverse{% endblocktrans %}
</a>
</div>
<p id="remote_follow_warning" class="mt-1 is-size-7 has-text-weight-light">
{% trans 'This link opens in a pop-up window' %}
</p>
</div>
{% endif %}

View file

@ -0,0 +1,63 @@
{% extends 'ostatus/template.html' %}
{% load i18n %}
{% load utilities %}
{% load markdown %}
{% block title %}
{% if not request.user.is_authenticated %}
{% blocktrans with sitename=site.name %}Log in to {{ sitename }}{% endblocktrans %}
{% elif error %}
{% blocktrans with sitename=site.name %}Error following from {{ sitename }}{% endblocktrans %}
{% else %}
{% blocktrans with sitename=site.name %}Follow from {{ sitename }}{% endblocktrans %}
{% endif %}
{% endblock %}
{% block heading %}
{% if error %}
{% trans 'Uh oh...' %}
{% elif not request.user.is_authenticated %}
{% trans "Let's log in first..." %}
{% else %}
{% blocktrans with sitename=site.name %}Follow from {{ sitename }}{% endblocktrans %}
{% endif %}
{% endblock %}
{% block content %}
{% if error or not request.user.is_authenticated %}
{% include 'ostatus/error.html' with error=error user=user account=account %}
{% else %}
<div class="block card">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">
{{ user.display_name }}
{% if user.manually_approves_followers %}
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
<span class="is-sr-only">{% trans 'Locked account' %}</span>
</span>
{% endif %}
</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
<form name="follow" method="post" action="{% url 'follow' %}/?next={% url 'ostatus-success' %}?following={{ user.username }}">
{% csrf_token %}
<input name="user" value="{{ user.username }}" hidden>
<button class="button is-link" type="submit">{% blocktrans with username=user.display_name %}Follow {{ username }}{% endblocktrans %}</button>
</form>
</div>
</div>
<div>
{% if user.summary %}
{{ user.summary|to_markdown|safe|truncatechars_html:120 }}
{% else %}&nbsp;{% endif %}
</div>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends 'ostatus/template.html' %}
{% load i18n %}
{% load utilities %}
{% block content %}
<div class="block card">
<div class="card-content">
<div class="media">
<a href="{{ user.local_path }}" class="media-left">
{% include 'snippets/avatar.html' with user=user large=True %}
</a>
<div class="media-content">
<a href="{{ user.local_path }}" class="is-block mb-2">
<span class="title is-4 is-block">
{{ user.display_name }}
{% if user.manually_approves_followers %}
<span class="icon icon-lock is-size-7" title="{% trans 'Locked account' %}">
<span class="is-sr-only">{% trans "Locked account" %}</span>
</span>
{% endif %}
</span>
<span class="subtitle is-7 is-block">@{{ user|username }}</span>
</a>
</div>
</div>
<p class="notification is-success">
<span class="icon icon-check m-0-mobile" aria-hidden="true"></span>
<span>{% blocktrans with display_name=user.display_name %}You are now following {{ display_name }}!{% endblocktrans %}</span>
</p>
</div>
</div>
<div class="block is-pulled-right">
<button type="button" class="button" onclick="closeWindow()">Close window</button>
</div>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% load layout %}
{% load i18n %}
{% load static %}
{% load utilities %}
{% load markdown %}
<!DOCTYPE html>
<html lang="{% get_lang %}">
<head>
<title>{% block title %}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{% static 'css/vendor/bulma.min.css' %}">
<link rel="stylesheet" href="{% static 'css/vendor/icons.css' %}">
<link rel="stylesheet" href="{% static 'css/bookwyrm.css' %}">
<script>
function closeWindow() {
window.close();
}
</script>
</head>
<body>
<nav class="navbar" aria-label="main navigation">
<div class="container">
<div class="navbar-brand">
<img class="image logo navbar-item" src="{% if site.logo_small %}{% get_media_prefix %}{{ site.logo_small }}{% else %}{% static 'images/logo-small.png' %}{% endif %}" alt="Home page">
<h2 class="navbar-item subtitle">{% block heading %}{% endblock %}</h2>
</div>
</div>
</nav>
<div class="section is-flex-grow-1 columns is-centered">
<div class="block column is-one-third">
{% block content%}{% endblock %}
</div>
</div>
<script>
var csrf_token = '{{ csrf_token }}';
</script>
<script src="{% static 'js/bookwyrm.js' %}?v={{ js_cache }}"></script>
</body>
</html>

View file

@ -18,10 +18,9 @@
{% csrf_token %}
<div class="field">
<label class="label" for="id_password">{% trans "Confirm password:" %}</label>
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required>
{% for error in form.password.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
</div>
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
</form>

View file

@ -33,31 +33,27 @@
{% endif %}
<div class="column">
{{ form.avatar }}
{% for error in form.avatar.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.avatar.errors id="desc_avatar" %}
</div>
</div>
<div class="field">
<label class="label" for="id_name">{% trans "Display name:" %}</label>
{{ form.name }}
{% for error in form.name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}
</div>
<div class="field">
<label class="label" for="id_summary">{% trans "Summary:" %}</label>
{{ form.summary }}
{% for error in form.summary.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.summary.errors id="desc_summary" %}
</div>
<div class="field">
<label class="label" for="id_email">{% trans "Email address:" %}</label>
{{ form.email }}
{% for error in form.email.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.email.errors id="desc_email" %}
</div>
</div>
</section>
@ -69,19 +65,23 @@
<div class="box">
<div class="field">
<label class="checkbox label" for="id_show_goal">
{% trans "Show reading goal prompt in feed:" %}
{{ form.show_goal }}
{% trans "Show reading goal prompt in feed" %}
</label>
</div>
<div class="field">
<label class="checkbox label" for="id_show_suggested_users">
{% trans "Show suggested users:" %}
{{ form.show_suggested_users }}
{% trans "Show suggested users" %}
</label>
</div>
<div class="field">
<label class="checkbox label" for="id_discoverable">
{% trans "Show this account in suggested users:" %}
{{ form.discoverable }}
{% trans "Show this account in suggested users" %}
</label>
{% url 'directory' as path %}
<p class="help">
<p class="help" id="desc_discoverable">
{% blocktrans %}Your account will show up in the <a href="{{ path }}">directory</a>, and may be recommended to other BookWyrm users.{% endblocktrans %}
</p>
</div>
@ -107,8 +107,8 @@
<div class="box">
<div class="field">
<label class="checkbox label" for="id_manually_approves_followers">
{% trans "Manually approve followers:" %}
{{ form.manually_approves_followers }}
{% trans "Manually approve followers" %}
</label>
</div>
<div class="field">

View file

@ -39,7 +39,7 @@
<header class="columns is-mobile">
<div class="column">
<h3 class="title is-5">
Results from
{% trans 'Results from' %}
<a href="{{ result_set.connector.base_url }}" target="_blank">{{ result_set.connector.name|default:result_set.connector.identifier }}</a>
</h3>
</div>

View file

@ -13,60 +13,68 @@
{% csrf_token %}
<input type="hidden" name="user" value="{{ request.user.id }}">
<p>
<label class="label" for="id_preview">{% trans "Preview:" %}</label>
<label class="label" for="id_preview">
{% trans "Preview:" %}
</label>
{{ form.preview }}
{% for error in form.preview.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.preview.errors id="desc_preview" %}
</p>
<p>
<label class="label" for="id_content">{% trans "Content:" %}</label>
<label class="label" for="id_content">
{% trans "Content:" %}
</label>
{{ form.content }}
{% for error in form.content.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.content.errors id="desc_content" %}
</p>
<p>
<label class="label" for="id_event_date">{% trans "Event date:" %}</label>
<label class="label" for="id_event_date">
{% trans "Event date:" %}
</label>
<input type="date" name="event_date" value="{{ form.event_date.value|date:'Y-m-d' }}" class="input" id="id_event_date">
{% for error in form.event_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.event_date.errors id="desc_event_date" %}
</p>
<hr aria-hidden="true">
<div class="columns">
<div class="column">
<p>
<label class="label" for="id_start_date">{% trans "Start date:" %}</label>
<label class="label" for="id_start_date">
{% trans "Start date:" %}
</label>
<input type="date" name="start_date" class="input" value="{{ form.start_date.value|date:'Y-m-d' }}" id="id_start_date">
{% for error in form.start_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.start_date.errors id="desc_start_date" %}
</p>
</div>
<div class="column">
<p>
<label class="label" for="id_end_date">{% trans "End date:" %}</label>
<label class="label" for="id_end_date">
{% trans "End date:" %}
</label>
<input type="date" name="end_date" class="input" id="id_end_date" value="{{ form.end_date.value|date:'Y-m-d' }}">
{% for error in form.end_date.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.end_date.errors id="desc_end_date" %}
</p>
</div>
<div class="column is-narrow">
<p>
<label class="label" for="id_active">{% trans "Active:" %}</label>
<label class="label" for="id_active">
{% trans "Active:" %}
</label>
{{ form.active }}
{% for error in form.active.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.active.errors id="desc_active" %}
</p>
</div>
</div>
<div class="field has-addons">
<div class="control">
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
<button type="submit" class="button is-primary">
{% trans "Save" %}
</button>
</div>
</div>
{% endblock %}

View file

@ -17,10 +17,8 @@
{{ form.domain }}
</div>
</div>
{% for error in form.domain.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.domain.errors id="desc_domain" %}
<div class="field">
<div class="control">
<button type="submit" class="button is-primary">{% trans "Add" %}</button>

View file

@ -27,11 +27,12 @@
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="id_server_name">{% trans "Instance:" %}</label>
<input type="text" name="server_name" maxlength="255" class="input" required id="id_server_name" value="{{ form.server_name.value|default:'' }}" placeholder="domain.com">
{% for error in form.server_name.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<label class="label" for="id_server_name">
{% trans "Instance:" %}
</label>
<input type="text" name="server_name" maxlength="255" class="input" required id="id_server_name" value="{{ form.server_name.value|default:'' }}" placeholder="domain.com" aria-describedby="desc_server_name">
{% include 'snippets/form_errors.html' with errors_list=form.server_name.errors id="desc_server_name" %}
</div>
</div>
<div class="column is-half">
@ -49,29 +50,37 @@
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="id_application_type">{% trans "Software:" %}</label>
<input type="text" name="application_type" maxlength="255" class="input" id="id_application_type" value="{{ form.application_type.value|default:'' }}">
{% for error in form.application_type.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<label class="label" for="id_application_type">
{% trans "Software:" %}
</label>
<input type="text" name="application_type" maxlength="255" class="input" id="id_application_type" value="{{ form.application_type.value|default:'' }}" aria-describedby="desc_application_type">
{% include 'snippets/form_errors.html' with errors_list=form.application_type.errors id="desc_application_type" %}
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label" for="id_application_version">{% trans "Version:" %}</label>
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}">
{% for error in form.application_version.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<label class="label" for="id_application_version">
{% trans "Version:" %}
</label>
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}" aria-describedby="desc_application_version">
{% include 'snippets/form_errors.html' with errors_list=form.application_version.errors id="desc_application_version" %}
</div>
</div>
</div>
<div class="field">
<label class="label" for="id_notes">{% trans "Notes:" %}</label>
<textarea name="notes" cols="40" rows="5" class="textarea" id="id_notes">{{ form.notes.value|default:'' }}</textarea>
<label class="label" for="id_notes">
{% trans "Notes:" %}
</label>
<textarea name="notes" cols="40" rows="5" class="textarea" id="id_notes">
{{ form.notes.value|default:'' }}
</textarea>
</div>
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
<button type="submit" class="button is-primary">
{% trans "Save" %}
</button>
</form>
{% endblock %}

View file

@ -20,16 +20,16 @@
</div>
<div class="field">
<input type="text" name="address" maxlength="255" class="input" required="" id="id_address" placeholder="190.0.2.0/24">
<input type="text" name="address" maxlength="255" class="input" required="" id="id_address" placeholder="190.0.2.0/24" aria-describedby="desc_address">
</div>
{% for error in form.address.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=form.address.errors id="desc_address" %}
<div class="field">
<div class="control">
<button type="submit" class="button is-primary">{% trans "Add" %}</button>
<button type="submit" class="button is-primary">
{% trans "Add" %}
</button>
</div>
</div>
</form>

View file

@ -33,8 +33,8 @@
{{ site_form.instance_description }}
</div>
<div class="field">
<label class="label mb-0" for="id_short_description">{% trans "Short description:" %}</label>
<p class="help">{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support HTML or Markdown." %}</p>
<label class="label mb-0" for="id_instance_short_description">{% trans "Short description:" %}</label>
<p class="help" id="desc_instance_short_description">{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support HTML or Markdown." %}</p>
{{ site_form.instance_short_description }}
</div>
<div class="field">
@ -114,7 +114,7 @@
{{ site_form.require_confirm_email }}
{% trans "Require users to confirm email address" %}
</label>
<p class="help">{% trans "(Recommended if registration is open)" %}</p>
<p class="help" id="desc_require_confirm_email">{% trans "(Recommended if registration is open)" %}</p>
</div>
<div class="field">
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
@ -123,9 +123,8 @@
<div class="field">
<label class="label" for="id_invite_request_text">{% trans "Invite request text:" %}</label>
{{ site_form.invite_request_text }}
{% for error in site_form.invite_request_text.errors %}
<p class="help is-danger">{{ error|escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=site_form.invite_request_text.errors id="desc_invite_request_text" %}
</div>
</div>
</section>

View file

@ -15,10 +15,9 @@
</p>
<div class="field">
<label class="label" for="id_password">{% trans "Your password:" %}</label>
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required>
{% for error in form.password.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<input class="input {% if form.password.errors %}is-danger{% endif %}" type="password" name="password" id="id_password" required aria-describedby="desc_password">
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
</div>
<button type="submit" class="button is-danger">{% trans "Delete Account" %}</button>
</form>

View file

@ -50,18 +50,23 @@
{% endif %}
{% with group=user.groups.first %}
<div class="select">
<select name="groups" id="id_user_group">
<select name="groups" id="id_user_group" aria-describedby="desc_user_group">
{% for value, name in group_form.fields.groups.choices %}
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>{{ name|title }}</option>
<option value="{{ value }}" {% if name == group.name %}selected{% endif %}>
{{ name|title }}
</option>
{% endfor %}
<option value="" {% if not group %}selected{% endif %}>User</option>
<option value="" {% if not group %}selected{% endif %}>
User
</option>
</select>
</div>
{% for error in group_form.groups.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
{% include 'snippets/form_errors.html' with errors_list=group_form.groups.errors id="desc_user_group" %}
{% endwith %}
<button class="button">{% trans "Save" %}</button>
<button class="button">
{% trans "Save" %}
</button>
</form>
</div>
{% endif %}

View file

@ -80,7 +80,10 @@
<div class="block columns is-mobile">
<div class="column">
<h2 class="title is-3">
{{ shelf.name }}
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
{% else %}{{ shelf.name }}{% endif %}
<span class="subtitle">
{% include 'snippets/privacy-icons.html' with item=shelf %}
</span>

View file

@ -5,11 +5,9 @@
{% if book.authors.exists %}
{% blocktrans trimmed with path=book.local_path title=book|book_title %}
<a href="{{ path }}">{{ title }}</a> by
{% endblocktrans %}
{% include 'snippets/authors.html' with book=book limit=3 %}
{% endblocktrans %}&nbsp;{% include 'snippets/authors.html' with book=book limit=3 %}
{% else %}
<a href="{{ book.local_path }}">{{ book|book_title }}</a>
{% endif %}
{% endspaceless %}

View file

@ -0,0 +1,9 @@
{% if errors_list %}
<div id="{{ id }}">
{% for error in errors_list %}
<p class="help is-danger">
{{ error | escape }}
</p>
{% endfor %}
</div>
{% endif %}

View file

@ -9,10 +9,11 @@ Finish "<em>{{ book_title }}</em>"
{% endblock %}
{% block modal-form-open %}
<form name="finish-reading" action="{% url 'reading-status' 'finish' book.id %}" method="post" class="submit-status">
<form name="finish-reading" action="{% url 'reading-status' 'finish' book.id %}" method="post" {% if not refresh %}class="submit-status"{% endif %}>
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<input type="hidden" name="reading_status" value="read">
<input type="hidden" name="shelf" value="{{ move_from }}">
{% endblock %}
{% block reading-dates %}

View file

@ -9,8 +9,9 @@ Start "<em>{{ book_title }}</em>"
{% endblock %}
{% block modal-form-open %}
<form name="start-reading" action="{% url 'reading-status' 'start' book.id %}" method="post" class="submit-status">
<form name="start-reading" action="{% url 'reading-status' 'start' book.id %}" method="post" {% if not refresh %}class="submit-status"{% endif %}>
<input type="hidden" name="reading_status" value="reading">
<input type="hidden" name="shelf" value="{{ move_from }}">
{% csrf_token %}
{% endblock %}

View file

@ -9,8 +9,9 @@ Want to Read "<em>{{ book_title }}</em>"
{% endblock %}
{% block modal-form-open %}
<form name="shelve" action="{% url 'reading-status' 'want' book.id %}" method="post" class="submit-status">
<form name="shelve" action="{% url 'reading-status' 'want' book.id %}" method="post" {% if not refresh %}class="submit-status"{% endif %}>
<input type="hidden" name="reading_status" value="to-read">
<input type="hidden" name="shelf" value="{{ move_from }}">
{% csrf_token %}
{% endblock %}

View file

@ -3,32 +3,31 @@
<div class="field">
<label class="label" for="id_localname_register">{% trans "Username:" %}</label>
<div class="control">
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname_register" value="{% if register_form.localname.value %}{{ register_form.localname.value }}{% endif %}">
<input type="text" name="localname" maxlength="150" class="input" required="" id="id_localname_register" value="{% if register_form.localname.value %}{{ register_form.localname.value }}{% endif %}" aria-describedby="desc_localname_register">
{% include 'snippets/form_errors.html' with errors_list=register_form.localname.errors id="desc_localname_register" %}
</div>
{% for error in register_form.localname.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
</div>
<div class="field">
<label class="label" for="id_email_register">{% trans "Email address:" %}</label>
<div class="control">
<input type="email" name="email" maxlength="254" class="input" id="id_email_register" value="{% if register_form.email.value %}{{ register_form.email.value }}{% endif %}" required>
{% for error in register_form.email.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<input type="email" name="email" maxlength="254" class="input" id="id_email_register" value="{% if register_form.email.value %}{{ register_form.email.value }}{% endif %}" required aria-describedby="desc_email_register">
{% include 'snippets/form_errors.html' with errors_list=register_form.email.errors id="desc_email_register" %}
</div>
</div>
<div class="field">
<label class="label" for="id_password_register">{% trans "Password:" %}</label>
<div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_register">
{% for error in register_form.password.errors %}
<p class="help is-danger">{{ error | escape }}</p>
{% endfor %}
<input type="password" name="password" maxlength="128" class="input" required="" id="id_password_register" aria-describedby="desc_password_register">
{% include 'snippets/form_errors.html' with errors_list=register_form.password.errors id="desc_password_register" %}
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" type="submit">{% trans "Sign Up" %}</button>
<button class="button is-primary" type="submit">
{% trans "Sign Up" %}
</button>
</div>
</div>

View file

@ -1,29 +1,96 @@
{% extends 'components/dropdown.html' %}
{% load i18n %}
{% load bookwyrm_tags %}
{% load utilities %}
{% block dropdown-trigger %}
<span>{% trans "Move book" %}</span>
<span class="icon icon-arrow-down" aria-hidden="true"></span>
{% endblock %}
{% block dropdown-list %}
{% with book.id|uuid as uuid %}
{% active_shelf book as active_shelf %}
{% latest_read_through book request.user as readthrough %}
{% for shelf in user_shelves %}
{% if shelf.editable %}
<li role="menuitem" class="dropdown-item p-0">
<form name="shelve" action="/shelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="change-shelf-from" value="{{ current.identifier }}">
<input type="hidden" name="shelf" value="{{ shelf.identifier }}">
<button class="button is-fullwidth is-small shelf-option is-radiusless is-white" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}><span>{{ shelf.name }}</span></button>
<button class="button is-fullwidth is-small shelf-option is-radiusless is-white" type="submit" {% if shelf.identifier == current.identifier %}disabled{% endif %}>
<span>
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
{% else %}{{ shelf.name }}{% endif %}
</span>
</button>
</form>
</li>
{% else%}
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
{% with button_class="is-fullwidth is-small shelf-option is-radiusless is-white" %}
<li role="menuitem" class="dropdown-item p-0">
{% if shelf.identifier == 'reading' %}
{% trans "Start reading" as button_text %}
{% url 'reading-status' 'start' book.id as fallback_url %}
{% include 'snippets/toggle/toggle_button.html' with class=button_class text=button_text controls_text="start_reading" controls_uid=uuid focus="modal_title_start_reading" disabled=is_current fallback_url=fallback_url %}
{% elif shelf.identifier == 'read' %}
{% trans "Read" as button_text %}
{% url 'reading-status' 'finish' book.id as fallback_url %}
{% include 'snippets/toggle/toggle_button.html' with class=button_class text=button_text controls_text="finish_reading" controls_uid=uuid focus="modal_title_finish_reading" disabled=is_current fallback_url=fallback_url %}
{% elif shelf.identifier == 'to-read' %}
{% trans "Want to read" as button_text %}
{% url 'reading-status' 'want' book.id as fallback_url %}
{% include 'snippets/toggle/toggle_button.html' with class=button_class text=button_text controls_text="want_to_read" controls_uid=uuid focus="modal_title_want_to_read" disabled=is_current fallback_url=fallback_url %}
{% endif %}
</li>
{% endwith %}
{% endif %}
{% endfor %}
<li class="navbar-divider" role="separator"></li>
{% if shelf.identifier == 'all' %}
{% for shelved_in in book.shelves.all %}
<li class="navbar-divider m-0" role="separator" ></li>
<li role="menuitem" class="dropdown-item p-0">
<form name="shelve" action="/unshelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="shelf" value="{{ current.id }}">
<button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove" %}</button>
<input type="hidden" name="shelf" value="{{ shelved_in.id }}">
<button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove from" %} {{ shelved_in.name }}</button>
</form>
</li>
{% endfor %}
{% else %}
<li class="navbar-divider" role="separator" ></li>
<li role="menuitem" class="dropdown-item p-0">
<form name="shelve" action="/unshelve/" method="post">
{% csrf_token %}
<input type="hidden" name="book" value="{{ book.id }}">
<input type="hidden" name="shelf" value="{{ shelf.id }}">
<button class="button is-fullwidth is-small is-radiusless is-danger is-light" type="submit">{% trans "Remove from" %} {{ shelf.name }}</button>
</form>
</li>
{% endif %}
{% include 'snippets/reading_modals/want_to_read_modal.html' with book=active_shelf.book controls_text="want_to_read" controls_uid=uuid move_from=current.id refresh=True class="" %}
{% include 'snippets/reading_modals/start_reading_modal.html' with book=active_shelf.book controls_text="start_reading" controls_uid=uuid move_from=current.id refresh=True class="" %}
{% include 'snippets/reading_modals/finish_reading_modal.html' with book=active_shelf.book controls_text="finish_reading" controls_uid=uuid move_from=current.id readthrough=readthrough refresh=True class="" %}
{% endwith %}
{% endblock %}

View file

@ -32,7 +32,7 @@
{% elif shelf.editable %}
<form name="shelve" action="/shelve/" method="post">
<form name="shelve" action="/shelve/" method="post" autocomplete="off">
{% csrf_token %}
<input type="hidden" name="book" value="{{ active_shelf.book.id }}">
<button class="button {{ class }}" name="shelf" type="submit" value="{{ shelf.identifier }}" {% if shelf in book.shelf_set.all %} disabled {% endif %}>

View file

@ -1,6 +1,6 @@
{% load utilities %}
{% if fallback_url %}
<form name="fallback_form_{{ 0|uuid }}" method="GET" action="{{ fallback_url }}">
<form name="fallback_form_{{ 0|uuid }}" method="GET" action="{{ fallback_url }}" autocomplete="off">
{% endif %}
<button
{% if not fallback_url %}

View file

@ -0,0 +1,34 @@
{% spaceless %}
{% load i18n %}
{% load humanize %}
{% firstof limit 3 as limit %}
{% with subtraction_value='-'|add:limit %}
{% with remainder_count=items|length|add:subtraction_value %}
{% with remainder_count_display=remainder_count|intcomma %}
<details>
<summary>
{% for item in items|slice:limit %}
<span
{% if itemprop %}itemprop="{{ itemprop }}"{% endif %}
>{{ item }}</span>{% if not forloop.last %}, {% elif remainder_count > 0 %}, {% blocktrans trimmed count counter=remainder_count %}
and {{ remainder_count_display }} other
{% plural %}
and {{ remainder_count_display }} others
{% endblocktrans %}
{% endif %}
{% endfor %}
</summary>
{% for item in items|slice:"3:" %}
<span
{% if itemprop %}itemprop="{{ itemprop }}"{% endif %}
>{{ item }}</span>{% if not forloop.last %}, {% endif %}
{% endfor %}
</details>
{% endwith %}
{% endwith %}
{% endwith %}
{% endspaceless %}

View file

@ -39,6 +39,9 @@
{% if not is_self and request.user.is_authenticated %}
{% include 'snippets/follow_button.html' with user=user %}
{% endif %}
{% if not is_self %}
{% include 'ostatus/remote_follow_button.html' with user=user %}
{% endif %}
{% if is_self and user.follower_requests.all %}
<div class="follow-requests">

View file

@ -29,8 +29,13 @@
<div class="columns is-mobile scroll-x">
{% for shelf in shelves %}
<div class="column is-narrow">
<h3>{{ shelf.name }}
{% if shelf.size > 3 %}<small>(<a href="{{ shelf.local_path }}">{% blocktrans with size=shelf.size %}View all {{ size }}{% endblocktrans %}</a>)</small>{% endif %}</h3>
<h3>
{% if shelf.name == 'To Read' %}{% trans "To Read" %}
{% elif shelf.name == 'Currently Reading' %}{% trans "Currently Reading" %}
{% elif shelf.name == 'Read' %}{% trans "Read" %}
{% else %}{{ shelf.name }}{% endif %}
{% if shelf.size > 3 %}<small>(<a href="{{ shelf.local_path }}">{% blocktrans with size=shelf.size %}View all {{ size }}{% endblocktrans %}</a>)</small>{% endif %}
</h3>
<div class="is-mobile field is-grouped">
{% for book in shelf.books %}
<div class="control">
@ -49,7 +54,8 @@
{% if goal %}
<div class="block">
<h2 class="title">{% now 'Y' %} Reading Goal</h2>
{% now 'Y' as current_year%}
<h2 class="title">{% blocktrans %}{{ current_year }} Reading Goal{% endblocktrans %}</h2>
{% include 'snippets/goal_progress.html' with goal=goal %}
</div>
{% endif %}

View file

@ -77,7 +77,12 @@ def related_status(notification):
def active_shelf(context, book):
"""check what shelf a user has a book on, if any"""
if hasattr(book, "current_shelves"):
return book.current_shelves[0] if len(book.current_shelves) else {"book": book}
read_shelves = [
s
for s in book.current_shelves
if s.shelf.identifier in models.Shelf.READ_STATUS_IDENTIFIERS
]
return read_shelves[0] if len(read_shelves) else {"book": book}
shelf = (
models.ShelfBook.objects.filter(

View file

@ -1,7 +1,9 @@
""" template filters for really common utilities """
import os
import re
from uuid import uuid4
from django import template
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from django.templatetags.static import static
@ -66,3 +68,32 @@ def get_book_cover_thumbnail(book, size="medium", ext="jpg"):
return cover_thumbnail.url
except OSError:
return static("images/no_cover.jpg")
@register.filter(name="get_isni_bio")
def get_isni_bio(existing, author):
"""Returns the isni bio string if an existing author has an isni listed"""
auth_isni = re.sub(r"\D", "", str(author.isni))
if len(existing) == 0:
return ""
for value in existing:
if hasattr(value, "bio") and auth_isni == re.sub(r"\D", "", str(value.isni)):
return mark_safe(f"Author of <em>{value.bio}</em>")
return ""
# pylint: disable=unused-argument
@register.filter(name="get_isni", needs_autoescape=True)
def get_isni(existing, author, autoescape=True):
"""Returns the isni ID if an existing author has an ISNI listing"""
auth_isni = re.sub(r"\D", "", str(author.isni))
if len(existing) == 0:
return ""
for value in existing:
if hasattr(value, "isni") and auth_isni == re.sub(r"\D", "", str(value.isni)):
isni = value.isni
return mark_safe(
f'<input type="text" name="isni-for-{author.id}" value="{isni}" hidden>'
)
return ""

View file

@ -40,6 +40,8 @@ class AbstractConnector(TestCase):
class TestConnector(abstract_connector.AbstractConnector):
"""nothing added here"""
generated_remote_link_field = "openlibrary_link"
def format_search_result(self, search_result):
return search_result
@ -87,9 +89,7 @@ class AbstractConnector(TestCase):
def test_get_or_create_book_existing(self):
"""find an existing book by remote/origin id"""
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(
self.book.remote_id, "https://%s/book/%d" % (DOMAIN, self.book.id)
)
self.assertEqual(self.book.remote_id, f"https://{DOMAIN}/book/{self.book.id}")
self.assertEqual(self.book.origin_id, "https://example.com/book/1234")
# dedupe by origin id
@ -99,7 +99,7 @@ class AbstractConnector(TestCase):
# dedupe by remote id
result = self.connector.get_or_create_book(
"https://%s/book/%d" % (DOMAIN, self.book.id)
f"https://{DOMAIN}/book/{self.book.id}"
)
self.assertEqual(models.Book.objects.count(), 1)
self.assertEqual(result, self.book)
@ -119,7 +119,8 @@ class AbstractConnector(TestCase):
@responses.activate
def test_get_or_create_author(self):
"""load an author"""
self.connector.author_mappings = [ # pylint: disable=attribute-defined-outside-init # pylint: disable=attribute-defined-outside-init
# pylint: disable=attribute-defined-outside-init
self.connector.author_mappings = [
Mapping("id"),
Mapping("name"),
]
@ -139,3 +140,26 @@ class AbstractConnector(TestCase):
author = models.Author.objects.create(name="Test Author")
result = self.connector.get_or_create_author(author.remote_id)
self.assertEqual(author, result)
@responses.activate
def test_update_author_from_remote(self):
"""trigger the function that looks up the remote data"""
author = models.Author.objects.create(name="Test", openlibrary_key="OL123A")
# pylint: disable=attribute-defined-outside-init
self.connector.author_mappings = [
Mapping("id"),
Mapping("name"),
Mapping("isni"),
]
responses.add(
responses.GET,
"https://openlibrary.org/authors/OL123A",
json={"id": "https://www.example.com/author", "name": "Beep", "isni": "hi"},
)
self.connector.update_author_from_remote(author)
author.refresh_from_db()
self.assertEqual(author.name, "Test")
self.assertEqual(author.isni, "hi")

View file

@ -1,11 +1,14 @@
""" testing book data connectors """
import json
import pathlib
from unittest.mock import patch
from django.test import TestCase
import responses
from bookwyrm import models
from bookwyrm.connectors.inventaire import Connector, get_language_code
from bookwyrm.connectors.connector_manager import ConnectorException
class Inventaire(TestCase):
@ -48,6 +51,44 @@ class Inventaire(TestCase):
self.assertEqual(result["wdt:P31"], ["wd:Q3331189"])
self.assertEqual(result["uri"], "isbn:9780375757853")
@responses.activate
def test_get_book_data_invalid(self):
"""error if there isn't any entity data"""
responses.add(
responses.GET,
"https://test.url/ok",
json={
"entities": {},
"redirects": {},
},
)
with self.assertRaises(ConnectorException):
self.connector.get_book_data("https://test.url/ok")
@responses.activate
def test_search(self):
"""min confidence filtering"""
responses.add(
responses.GET,
"https://inventaire.io/search?q=hi",
json={
"results": [
{
"_score": 200,
"label": "hello",
},
{
"_score": 100,
"label": "hi",
},
],
},
)
results = self.connector.search("hi", min_confidence=0.5)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].title, "hello")
def test_format_search_result(self):
"""json to search result objs"""
search_file = pathlib.Path(__file__).parent.joinpath(
@ -157,6 +198,88 @@ class Inventaire(TestCase):
"https://covers.inventaire.io/img/entities/12345",
)
def test_isbn_search_empty(self):
"""another search type"""
search_results = {}
results = self.connector.parse_isbn_search_data(search_results)
self.assertEqual(results, [])
def test_isbn_search_no_title(self):
"""another search type"""
search_file = pathlib.Path(__file__).parent.joinpath(
"../data/inventaire_isbn_search.json"
)
search_results = json.loads(search_file.read_bytes())
search_results["entities"]["isbn:9782290349229"]["claims"]["wdt:P1476"] = None
result = self.connector.format_isbn_search_result(
search_results.get("entities")
)
self.assertIsNone(result)
def test_is_work_data(self):
"""is it a work"""
work_file = pathlib.Path(__file__).parent.joinpath(
"../data/inventaire_work.json"
)
work_data = json.loads(work_file.read_bytes())
with patch("bookwyrm.connectors.inventaire.get_data") as get_data_mock:
get_data_mock.return_value = work_data
formatted = self.connector.get_book_data("hi")
self.assertTrue(self.connector.is_work_data(formatted))
edition_file = pathlib.Path(__file__).parent.joinpath(
"../data/inventaire_edition.json"
)
edition_data = json.loads(edition_file.read_bytes())
with patch("bookwyrm.connectors.inventaire.get_data") as get_data_mock:
get_data_mock.return_value = edition_data
formatted = self.connector.get_book_data("hi")
self.assertFalse(self.connector.is_work_data(formatted))
@responses.activate
def test_get_edition_from_work_data(self):
"""load edition"""
responses.add(
responses.GET,
"https://inventaire.io/?action=by-uris&uris=hello",
json={"entities": {}},
)
data = {"uri": "blah"}
with patch(
"bookwyrm.connectors.inventaire.Connector.load_edition_data"
) as loader_mock, patch(
"bookwyrm.connectors.inventaire.Connector.get_book_data"
) as getter_mock:
loader_mock.return_value = {"uris": ["hello"]}
self.connector.get_edition_from_work_data(data)
self.assertTrue(getter_mock.called)
with patch(
"bookwyrm.connectors.inventaire.Connector.load_edition_data"
) as loader_mock:
loader_mock.return_value = {"uris": []}
with self.assertRaises(ConnectorException):
self.connector.get_edition_from_work_data(data)
@responses.activate
def test_get_work_from_edition_data(self):
"""load work"""
responses.add(
responses.GET,
"https://inventaire.io/?action=by-uris&uris=hello",
)
data = {"wdt:P629": ["hello"]}
with patch("bookwyrm.connectors.inventaire.Connector.get_book_data") as mock:
self.connector.get_work_from_edition_data(data)
self.assertEqual(mock.call_count, 1)
args = mock.call_args[0]
self.assertEqual(args[0], "https://inventaire.io?action=by-uris&uris=hello")
data = {"wdt:P629": [None]}
with self.assertRaises(ConnectorException):
self.connector.get_work_from_edition_data(data)
def test_get_language_code(self):
"""get english or whatever is in reach"""
options = {
@ -183,3 +306,11 @@ class Inventaire(TestCase):
extract = self.connector.get_description({"enwiki": "test_path"})
self.assertEqual(extract, "hi hi")
def test_remote_id_from_model(self):
"""figure out a url from an id"""
obj = models.Author.objects.create(name="hello", inventaire_id="123")
self.assertEqual(
self.connector.get_remote_id_from_model(obj),
"https://inventaire.io?action=by-uris&uris=123",
)

View file

@ -98,6 +98,9 @@ class Openlibrary(TestCase):
"type": "/type/datetime",
"value": "2008-08-31 10:09:33.413686",
},
"remote_ids": {
"isni": "000111",
},
"key": "/authors/OL453734A",
"type": {"key": "/type/author"},
"id": 1259965,
@ -110,6 +113,7 @@ class Openlibrary(TestCase):
self.assertIsInstance(result, models.Author)
self.assertEqual(result.name, "George Elliott")
self.assertEqual(result.openlibrary_key, "OL453734A")
self.assertEqual(result.isni, "000111")
def test_get_cover_url(self):
"""formats a url that should contain the cover image"""

View file

@ -19,7 +19,7 @@ from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.activitypub.base_activity import ActivityObject
from bookwyrm.models import fields, User, Status
from bookwyrm.models import fields, User, Status, Edition
from bookwyrm.models.base_model import BookWyrmModel
from bookwyrm.models.activitypub_mixin import ActivitypubMixin
from bookwyrm.settings import DOMAIN
@ -215,7 +215,7 @@ class ModelFields(TestCase):
"rat", "rat@rat.rat", "ratword", local=True, localname="rat"
)
public = "https://www.w3.org/ns/activitystreams#Public"
followers = "%s/followers" % user.remote_id
followers = f"{user.remote_id}/followers"
instance = fields.PrivacyField()
instance.name = "privacy_field"
@ -409,11 +409,10 @@ class ModelFields(TestCase):
"""loadin' a list of items from Links"""
# TODO
@responses.activate
@patch("bookwyrm.models.activitypub_mixin.ObjectMixin.broadcast")
@patch("bookwyrm.suggested_users.remove_user_task.delay")
def test_image_field(self, *_):
"""storing images"""
def test_image_field_to_activity(self, *_):
"""serialize an image field to activitypub"""
user = User.objects.create_user(
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
)
@ -437,16 +436,155 @@ class ModelFields(TestCase):
self.assertEqual(output.name, "")
self.assertEqual(output.type, "Document")
@responses.activate
def test_image_field_from_activity(self, *_):
"""load an image from activitypub"""
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/default_avi.jpg"
)
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
instance = fields.ImageField()
responses.add(
responses.GET,
"http://www.example.com/image.jpg",
body=user.avatar.file.read(),
body=image.tobytes(),
status=200,
)
loaded_image = instance.field_from_activity("http://www.example.com/image.jpg")
self.assertIsInstance(loaded_image, list)
self.assertIsInstance(loaded_image[1], ContentFile)
@responses.activate
def test_image_field_set_field_from_activity(self, *_):
"""update a model instance from an activitypub object"""
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/default_avi.jpg"
)
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
instance = fields.ImageField(activitypub_field="cover", name="cover")
responses.add(
responses.GET,
"http://www.example.com/image.jpg",
body=image.tobytes(),
status=200,
)
book = Edition.objects.create(title="hello")
MockActivity = namedtuple("MockActivity", ("cover"))
mock_activity = MockActivity("http://www.example.com/image.jpg")
instance.set_field_from_activity(book, mock_activity)
self.assertIsNotNone(book.cover.name)
self.assertEqual(book.cover.size, 43200)
@responses.activate
def test_image_field_set_field_from_activity_no_overwrite_no_cover(self, *_):
"""update a model instance from an activitypub object"""
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/default_avi.jpg"
)
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
instance = fields.ImageField(activitypub_field="cover", name="cover")
responses.add(
responses.GET,
"http://www.example.com/image.jpg",
body=image.tobytes(),
status=200,
)
book = Edition.objects.create(title="hello")
MockActivity = namedtuple("MockActivity", ("cover"))
mock_activity = MockActivity("http://www.example.com/image.jpg")
instance.set_field_from_activity(book, mock_activity, overwrite=False)
self.assertIsNotNone(book.cover.name)
self.assertEqual(book.cover.size, 43200)
@responses.activate
def test_image_field_set_field_from_activity_no_overwrite_with_cover(self, *_):
"""update a model instance from an activitypub object"""
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/default_avi.jpg"
)
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
another_image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/logo.png"
)
another_image = Image.open(another_image_file)
another_output = BytesIO()
another_image.save(another_output, format=another_image.format)
instance = fields.ImageField(activitypub_field="cover", name="cover")
responses.add(
responses.GET,
"http://www.example.com/image.jpg",
body=another_image.tobytes(),
status=200,
)
book = Edition.objects.create(title="hello")
book.cover.save("test.jpg", ContentFile(output.getvalue()))
self.assertEqual(book.cover.size, 2136)
MockActivity = namedtuple("MockActivity", ("cover"))
mock_activity = MockActivity("http://www.example.com/image.jpg")
instance.set_field_from_activity(book, mock_activity, overwrite=False)
# same cover as before
self.assertEqual(book.cover.size, 2136)
@responses.activate
def test_image_field_set_field_from_activity_with_overwrite_with_cover(self, *_):
"""update a model instance from an activitypub object"""
image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/default_avi.jpg"
)
image = Image.open(image_file)
output = BytesIO()
image.save(output, format=image.format)
book = Edition.objects.create(title="hello")
book.cover.save("test.jpg", ContentFile(output.getvalue()))
self.assertEqual(book.cover.size, 2136)
another_image_file = pathlib.Path(__file__).parent.joinpath(
"../../static/images/logo.png"
)
another_image = Image.open(another_image_file)
another_output = BytesIO()
another_image.save(another_output, format=another_image.format)
instance = fields.ImageField(activitypub_field="cover", name="cover")
responses.add(
responses.GET,
"http://www.example.com/image.jpg",
body=another_image.tobytes(),
status=200,
)
MockActivity = namedtuple("MockActivity", ("cover"))
mock_activity = MockActivity("http://www.example.com/image.jpg")
instance.set_field_from_activity(book, mock_activity, overwrite=True)
# new cover
self.assertIsNotNone(book.cover.name)
self.assertEqual(book.cover.size, 376800)
def test_datetime_field(self, *_):
"""this one is pretty simple, it just has to use isoformat"""
instance = fields.DateTimeField()

View file

@ -1,6 +1,7 @@
""" testing models """
from unittest.mock import patch
from django.test import TestCase
from uuid import UUID
from bookwyrm import models, settings
@ -80,3 +81,12 @@ class List(TestCase):
self.assertEqual(item.book_list.privacy, "public")
self.assertEqual(item.privacy, "direct")
self.assertEqual(item.recipients, [])
def test_embed_key(self, _):
"""embed_key should never be empty"""
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
book_list = models.List.objects.create(
name="Test List", user=self.local_user
)
self.assertIsInstance(book_list.embed_key, UUID)

View file

@ -209,6 +209,28 @@ class BookViews(TestCase):
self.assertEqual(self.book.description, "new description hi")
self.assertEqual(self.book.last_edited_by, self.local_user)
def test_update_book_from_remote(self):
"""call out to sync with remote connector"""
models.Connector.objects.create(
identifier="openlibrary.org",
name="OpenLibrary",
connector_file="openlibrary",
base_url="https://openlibrary.org",
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.local_user.groups.add(self.group)
request = self.factory.post("")
request.user = self.local_user
with patch(
"bookwyrm.connectors.openlibrary.Connector.update_book_from_remote"
) as mock:
views.update_book_from_remote(request, self.book.id, "openlibrary.org")
self.assertEqual(mock.call_count, 1)
def _setup_cover_url():
"""creates cover url mock"""

View file

@ -148,3 +148,26 @@ class AuthorViews(TestCase):
self.assertEqual(author.name, "Test Author")
validate_html(resp.render())
self.assertEqual(resp.status_code, 200)
def test_update_author_from_remote(self):
"""call out to sync with remote connector"""
author = models.Author.objects.create(name="Test Author")
models.Connector.objects.create(
identifier="openlibrary.org",
name="OpenLibrary",
connector_file="openlibrary",
base_url="https://openlibrary.org",
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.local_user.groups.add(self.group)
request = self.factory.post("")
request.user = self.local_user
with patch(
"bookwyrm.connectors.openlibrary.Connector.update_author_from_remote"
) as mock:
views.update_author_from_remote(request, author.id, "openlibrary.org")
self.assertEqual(mock.call_count, 1)

View file

@ -4,10 +4,12 @@ from unittest.mock import patch
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
from bookwyrm import models, views
from bookwyrm.tests.validate_html import validate_html
@patch("bookwyrm.activitystreams.add_user_statuses_task.delay")
@ -16,6 +18,7 @@ class FollowViews(TestCase):
def setUp(self):
"""we need basic test data and mocks"""
models.SiteSettings.objects.create()
self.factory = RequestFactory()
with patch("bookwyrm.suggested_users.rerank_suggestions_task.delay"), patch(
"bookwyrm.activitystreams.populate_stream_task.delay"
@ -174,3 +177,43 @@ class FollowViews(TestCase):
self.assertEqual(models.UserFollowRequest.objects.filter(id=rel.id).count(), 0)
# follow relationship should not exist
self.assertEqual(models.UserFollows.objects.filter(id=rel.id).count(), 0)
def test_ostatus_follow_request(self, _):
"""check ostatus subscribe template loads"""
request = self.factory.get(
"", {"acct": "https%3A%2F%2Fexample.com%2Fusers%2Frat"}
)
request.user = self.local_user
result = views.ostatus_follow_request(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_remote_follow_page(self, _):
"""check remote follow page loads"""
request = self.factory.get("", {"acct": "mouse@local.com"})
request.user = self.remote_user
result = views.remote_follow_page(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_ostatus_follow_success(self, _):
"""check remote follow success page loads"""
request = self.factory.get("")
request.user = self.remote_user
request.following = "mouse@local.com"
result = views.ostatus_follow_success(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)
def test_remote_follow(self, _):
"""check follow from remote page loads"""
request = self.factory.post("", {"user": self.remote_user.id})
request.user = self.remote_user
request.remote_user = "mouse@local.com"
result = views.remote_follow(request)
self.assertIsInstance(result, TemplateResponse)
validate_html(result.render())
self.assertEqual(result.status_code, 200)

View file

@ -3,6 +3,7 @@ import json
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
from django.http.response import Http404
from django.template.response import TemplateResponse
from django.test import TestCase
from django.test.client import RequestFactory
@ -385,3 +386,46 @@ class ListViews(TestCase):
result = view(request, self.local_user.username)
self.assertEqual(result.status_code, 302)
def test_embed_call_without_key(self):
"""there are so many views, this just makes sure it DOESNT load"""
view = views.unsafe_embed_list
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=True,
order=1,
)
with patch("bookwyrm.views.list.is_api_request") as is_api:
is_api.return_value = False
with self.assertRaises(Http404):
result = view(request, self.list.id, "")
def test_embed_call_with_key(self):
"""there are so many views, this just makes sure it LOADS"""
view = views.unsafe_embed_list
request = self.factory.get("")
request.user = self.anonymous_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.apply_async"):
models.ListItem.objects.create(
book_list=self.list,
user=self.local_user,
book=self.book,
approved=True,
order=1,
)
embed_key = str(self.list.embed_key.hex)
with patch("bookwyrm.views.list.is_api_request") as is_api:
is_api.return_value = False
result = view(request, self.list.id, embed_key)
self.assertIsInstance(result, TemplateResponse)
result.render()
self.assertEqual(result.status_code, 200)

View file

@ -50,10 +50,17 @@ class UpdateViews(TestCase):
request = self.factory.get("")
request.user = self.local_user
with patch("bookwyrm.activitystreams.ActivityStream.get_unread_count") as mock:
mock.return_value = 3
result = views.get_unread_status_count(request, "home")
with patch(
"bookwyrm.activitystreams.ActivityStream.get_unread_count"
) as mock_count:
with patch(
"bookwyrm.activitystreams.ActivityStream.get_unread_count_by_status_type"
) as mock_count_by_status:
mock_count.return_value = 3
mock_count_by_status.return_value = {"review": 5}
result = views.get_unread_status_count(request, "home")
self.assertIsInstance(result, JsonResponse)
data = json.loads(result.getvalue())
self.assertEqual(data["count"], 3)
self.assertEqual(data["count_by_type"]["review"], 5)

View file

@ -44,6 +44,7 @@ urlpatterns = [
re_path(r"^api/v1/instance/?$", views.instance_info),
re_path(r"^api/v1/instance/peers/?$", views.peers),
re_path(r"^opensearch.xml$", views.opensearch, name="opensearch"),
re_path(r"^ostatus_subscribe/?$", views.ostatus_follow_request),
# polling updates
re_path("^api/updates/notifications/?$", views.get_notification_count),
re_path("^api/updates/stream/(?P<stream>[a-z]+)/?$", views.get_unread_status_count),
@ -336,6 +337,11 @@ urlpatterns = [
),
re_path(r"^save-list/(?P<list_id>\d+)/?$", views.save_list, name="list-save"),
re_path(r"^unsave-list/(?P<list_id>\d+)/?$", views.unsave_list, name="list-unsave"),
re_path(
r"^list/(?P<list_id>\d+)/embed/(?P<list_key>[0-9a-f]+)?$",
views.unsafe_embed_list,
name="embed-list",
),
# User books
re_path(rf"{USER_PATH}/books/?$", views.Shelf.as_view(), name="user-shelves"),
re_path(
@ -422,13 +428,29 @@ urlpatterns = [
r"^upload-cover/(?P<book_id>\d+)/?$", views.upload_cover, name="upload-cover"
),
re_path(r"^add-description/(?P<book_id>\d+)/?$", views.add_description),
re_path(r"^resolve-book/?$", views.resolve_book),
re_path(r"^switch-edition/?$", views.switch_edition),
re_path(r"^resolve-book/?$", views.resolve_book, name="resolve-book"),
re_path(r"^switch-edition/?$", views.switch_edition, name="switch-edition"),
re_path(
rf"{BOOK_PATH}/update/(?P<connector_identifier>[\w\.]+)/?$",
views.update_book_from_remote,
name="book-update-remote",
),
re_path(
r"^author/(?P<author_id>\d+)/update/(?P<connector_identifier>[\w\.]+)/?$",
views.update_author_from_remote,
name="author-update-remote",
),
# 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()),
re_path(
r"^author/(?P<author_id>\d+)(.json)?/?$", views.Author.as_view(), name="author"
),
re_path(
r"^author/(?P<author_id>\d+)/edit/?$",
views.EditAuthor.as_view(),
name="edit-author",
),
# reading progress
re_path(r"^edit-readthrough/?$", views.edit_readthrough, name="edit-readthrough"),
re_path(r"^delete-readthrough/?$", views.delete_readthrough),
@ -450,4 +472,9 @@ urlpatterns = [
re_path(r"^unfollow/?$", views.unfollow, name="unfollow"),
re_path(r"^accept-follow-request/?$", views.accept_follow_request),
re_path(r"^delete-follow-request/?$", views.delete_follow_request),
re_path(r"^ostatus_follow/?$", views.remote_follow, name="remote-follow"),
re_path(r"^remote_follow/?$", views.remote_follow_page, name="remote-follow-page"),
re_path(
r"^ostatus_success/?$", views.ostatus_follow_success, name="ostatus-success"
),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

185
bookwyrm/utils/isni.py Normal file
View file

@ -0,0 +1,185 @@
"""ISNI author checking utilities"""
import xml.etree.ElementTree as ET
import requests
from bookwyrm import activitypub, models
def request_isni_data(search_index, search_term, max_records=5):
"""Request data from the ISNI API"""
search_string = f'{search_index}="{search_term}"'
query_params = {
"query": search_string,
"version": "1.1",
"operation": "searchRetrieve",
"recordSchema": "isni-b",
"maximumRecords": max_records,
"startRecord": "1",
"recordPacking": "xml",
"sortKeys": "RLV,pica,0,,",
}
result = requests.get("http://isni.oclc.org/sru/", params=query_params, timeout=15)
# the OCLC ISNI server asserts the payload is encoded
# in latin1, but we know better
result.encoding = "utf-8"
return result.text
def make_name_string(element):
"""create a string of form 'personal_name surname'"""
# NOTE: this will often be incorrect, many naming systems
# list "surname" before personal name
forename = element.find(".//forename")
surname = element.find(".//surname")
if forename is not None:
return "".join([forename.text, " ", surname.text])
return surname.text
def get_other_identifier(element, code):
"""Get other identifiers associated with an author from their ISNI record"""
identifiers = element.findall(".//otherIdentifierOfIdentity")
for section_head in identifiers:
if (
section_head.find(".//type") is not None
and section_head.find(".//type").text == code
and section_head.find(".//identifier") is not None
):
return section_head.find(".//identifier").text
# if we can't find it in otherIdentifierOfIdentity,
# try sources
for source in element.findall(".//sources"):
code_of_source = source.find(".//codeOfSource")
if code_of_source is not None and code_of_source.text.lower() == code.lower():
return source.find(".//sourceIdentifier").text
return ""
def get_external_information_uri(element, match_string):
"""Get URLs associated with an author from their ISNI record"""
sources = element.findall(".//externalInformation")
for source in sources:
information = source.find(".//information")
uri = source.find(".//URI")
if (
uri is not None
and information is not None
and information.text.lower() == match_string.lower()
):
return uri.text
return ""
def find_authors_by_name(name_string, description=False):
"""Query the ISNI database for possible author matches by name"""
payload = request_isni_data("pica.na", name_string)
# parse xml
root = ET.fromstring(payload)
# build list of possible authors
possible_authors = []
for element in root.iter("responseRecord"):
personal_name = element.find(".//forename/..")
if not personal_name:
continue
author = get_author_from_isni(element.find(".//isniUnformatted").text)
if bool(description):
titles = []
# prefer title records from LoC+ coop, Australia, Ireland, or Singapore
# in that order
for source in ["LCNACO", "NLA", "N6I", "NLB"]:
for parent in element.findall(f'.//titleOfWork/[@source="{source}"]'):
titles.append(parent.find(".//title"))
for parent in element.findall(f'.//titleOfWork[@subsource="{source}"]'):
titles.append(parent.find(".//title"))
# otherwise just grab the first title listing
titles.append(element.find(".//title"))
if titles:
# some of the "titles" in ISNI are a little ...iffy
# '@' is used by ISNI/OCLC to index the starting point ignoring stop words
# (e.g. "The @Government of no one")
title_elements = [
e
for e in titles
if hasattr(e, "text") and not e.text.replace("@", "").isnumeric()
]
if len(title_elements):
author.bio = title_elements[0].text.replace("@", "")
else:
author.bio = None
possible_authors.append(author)
return possible_authors
def get_author_from_isni(isni):
"""Find data to populate a new author record from their ISNI"""
payload = request_isni_data("pica.isn", isni)
# parse xml
root = ET.fromstring(payload)
# there should only be a single responseRecord
# but let's use the first one just in case
element = root.find(".//responseRecord")
name = make_name_string(element.find(".//forename/.."))
viaf = get_other_identifier(element, "viaf")
# use a set to dedupe aliases in ISNI
aliases = set()
aliases_element = element.findall(".//personalNameVariant")
for entry in aliases_element:
aliases.add(make_name_string(entry))
# aliases needs to be list not set
aliases = list(aliases)
bio = element.find(".//nameTitle")
bio = bio.text if bio is not None else ""
wikipedia = get_external_information_uri(element, "Wikipedia")
author = activitypub.Author(
id=element.find(".//isniURI").text,
name=name,
isni=isni,
viafId=viaf,
aliases=aliases,
bio=bio,
wikipediaLink=wikipedia,
)
return author
def build_author_from_isni(match_value):
"""Build basic author class object from ISNI URL"""
# if it is an isni value get the data
if match_value.startswith("https://isni.org/isni/"):
isni = match_value.replace("https://isni.org/isni/", "")
return {"author": get_author_from_isni(isni)}
# otherwise it's a name string
return {}
def augment_author_metadata(author, isni):
"""Update any missing author fields from ISNI data"""
isni_author = get_author_from_isni(isni)
isni_author.to_model(model=models.Author, instance=author, overwrite=False)
# we DO want to overwrite aliases because we're adding them to the
# existing aliases and ISNI will usually have more.
# We need to dedupe because ISNI records often have lots of dupe aliases
aliases = set(isni_author.aliases)
for alias in author.aliases:
aliases.add(alias)
author.aliases = list(aliases)
author.save()

View file

@ -29,6 +29,7 @@ from .preferences.block import Block, unblock
# books
from .books.books import Book, upload_cover, add_description, resolve_book
from .books.books import update_book_from_remote
from .books.edit_book import EditBook, ConfirmEditBook
from .books.editions import Editions, switch_edition
@ -54,11 +55,18 @@ from .imports.manually_review import (
)
# misc views
from .author import Author, EditAuthor
from .author import Author, EditAuthor, update_author_from_remote
from .directory import Directory
from .discover import Discover
from .feed import DirectMessage, Feed, Replies, Status
from .follow import follow, unfollow
from .follow import (
follow,
unfollow,
ostatus_follow_request,
ostatus_follow_success,
remote_follow,
remote_follow_page,
)
from .follow import accept_follow_request, delete_follow_request
from .get_started import GetStartedBooks, GetStartedProfile, GetStartedUsers
from .goal import Goal, hide_goal
@ -76,7 +84,7 @@ from .inbox import Inbox
from .interaction import Favorite, Unfavorite, Boost, Unboost
from .isbn import Isbn
from .list import Lists, SavedLists, List, Curate, UserLists
from .list import save_list, unsave_list, delete_list
from .list import save_list, unsave_list, delete_list, unsafe_embed_list
from .notifications import Notifications
from .outbox import Outbox
from .reading import create_readthrough, delete_readthrough, delete_progressupdate

View file

@ -7,7 +7,7 @@ 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 import emailing, forms, models
# pylint: disable=no-self-use
@ -142,5 +142,6 @@ def make_report(request):
if not form.is_valid():
raise ValueError(form.errors)
form.save()
report = form.save()
emailing.moderation_report_email(report)
return redirect(request.headers.get("Referer", "/"))

View file

@ -48,4 +48,7 @@ def email_preview(request):
data["invite_link"] = "https://example.com/link"
data["confirmation_link"] = "https://example.com/link"
data["confirmation_code"] = "AKJHKDGKJSDFG"
data["reporter"] = "ConcernedUser"
data["reportee"] = "UserName"
data["report_link"] = "https://example.com/link"
return TemplateResponse(request, "email/preview.html", data)

Some files were not shown because too many files have changed in this diff Show more