diff --git a/.env.dev.example b/.env.dev.example
index 2f24378c..f407c52b 100644
--- a/.env.dev.example
+++ b/.env.dev.example
@@ -13,16 +13,10 @@ DEFAULT_LANGUAGE="English"
## Leave unset to allow all hosts
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
-OL_URL=https://openlibrary.org
-
-## Database backend to use.
-## Default is postgres, sqlite is for dev quickstart only (NOT production!!!)
-BOOKWYRM_DATABASE_BACKEND=postgres
-
MEDIA_ROOT=images/
POSTGRES_PORT=5432
-POSTGRES_PASSWORD=fedireads
+POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
POSTGRES_HOST=db
@@ -34,10 +28,8 @@ REDIS_ACTIVITY_PORT=6379
#REDIS_ACTIVITY_PASSWORD=redispassword345
# Redis as celery broker
-#REDIS_BROKER_PORT=6379
+REDIS_BROKER_PORT=6379
#REDIS_BROKER_PASSWORD=redispassword123
-CELERY_BROKER=redis://redis_broker:6379/0
-CELERY_RESULT_BACKEND=redis://redis_broker:6379/0
FLOWER_PORT=8888
#FLOWER_USER=mouse
@@ -50,5 +42,14 @@ EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true
EMAIL_USE_SSL=false
-# Set this to true when initializing certbot for domain, false when not
-CERTBOT_INIT=false
+# Preview image generation can be computing and storage intensive
+# ENABLE_PREVIEW_IMAGES=True
+
+# Specify RGB tuple or RGB hex strings,
+# or use_dominant_color_light / use_dominant_color_dark
+PREVIEW_BG_COLOR=use_dominant_color_light
+# Change to #FFF if you use use_dominant_color_dark
+PREVIEW_TEXT_COLOR="#363636"
+PREVIEW_IMG_WIDTH=1200
+PREVIEW_IMG_HEIGHT=630
+PREVIEW_DEFAULT_COVER_COLOR="#002549"
diff --git a/.env.prod.example b/.env.prod.example
index a82499a3..d61c46af 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -13,16 +13,10 @@ DEFAULT_LANGUAGE="English"
## Leave unset to allow all hosts
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
-OL_URL=https://openlibrary.org
-
-## Database backend to use.
-## Default is postgres, sqlite is for dev quickstart only (NOT production!!!)
-BOOKWYRM_DATABASE_BACKEND=postgres
-
MEDIA_ROOT=images/
POSTGRES_PORT=5432
-POSTGRES_PASSWORD=securedbpassword123
+POSTGRES_PASSWORD=securedbypassword123
POSTGRES_USER=fedireads
POSTGRES_DB=fedireads
POSTGRES_HOST=db
@@ -36,8 +30,6 @@ REDIS_ACTIVITY_PASSWORD=redispassword345
# Redis as celery broker
REDIS_BROKER_PORT=6379
REDIS_BROKER_PASSWORD=redispassword123
-CELERY_BROKER=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0
-CELERY_RESULT_BACKEND=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0
FLOWER_PORT=8888
FLOWER_USER=mouse
@@ -50,5 +42,14 @@ EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true
EMAIL_USE_SSL=false
-# Set this to true when initializing certbot for domain, false when not
-CERTBOT_INIT=false
+# Preview image generation can be computing and storage intensive
+# ENABLE_PREVIEW_IMAGES=True
+
+# Specify RGB tuple or RGB hex strings,
+# or use_dominant_color_light / use_dominant_color_dark
+PREVIEW_BG_COLOR=use_dominant_color_light
+# Change to #FFF if you use use_dominant_color_dark
+PREVIEW_TEXT_COLOR="#363636"
+PREVIEW_IMG_WIDTH=1200
+PREVIEW_IMG_HEIGHT=630
+PREVIEW_DEFAULT_COVER_COLOR="#002549"
diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml
index 25af1e08..fb681dcd 100644
--- a/.github/workflows/black.yml
+++ b/.github/workflows/black.yml
@@ -1,4 +1,4 @@
-name: Lint Python
+name: Python Formatting (run ./bw-dev black to fix)
on: [push, pull_request]
diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml
index 3ce368ec..b5b319f5 100644
--- a/.github/workflows/django-tests.yml
+++ b/.github/workflows/django-tests.yml
@@ -50,7 +50,6 @@ jobs:
SECRET_KEY: beepbeep
DEBUG: true
DOMAIN: your.domain.here
- OL_URL: https://openlibrary.org
BOOKWYRM_DATABASE_BACKEND: postgres
MEDIA_ROOT: images/
POSTGRES_PASSWORD: hunter2
@@ -58,11 +57,13 @@ jobs:
POSTGRES_DB: github_actions
POSTGRES_HOST: 127.0.0.1
CELERY_BROKER: ""
- CELERY_RESULT_BACKEND: ""
+ REDIS_BROKER_PORT: 6379
+ FLOWER_PORT: 8888
EMAIL_HOST: "smtp.mailgun.org"
EMAIL_PORT: 587
EMAIL_HOST_USER: ""
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
+ ENABLE_PREVIEW_IMAGES: true
run: |
python manage.py test
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
new file mode 100644
index 00000000..1a32940f
--- /dev/null
+++ b/.github/workflows/pylint.yml
@@ -0,0 +1,24 @@
+name: Pylint
+
+on: [push, pull_request]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v2
+ - name: Set up Python 3.9
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.9
+ - name: Install Dependencies
+ run: |
+ python -m pip install --upgrade pip
+ pip install -r requirements.txt
+ pip install pylint
+ - name: Analysing the code with pylint
+ run: |
+ pylint bookwyrm/ --ignore=migrations,tests --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801
+
diff --git a/.gitignore b/.gitignore
index cf88e987..624ce100 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,6 @@
#nginx
nginx/default.conf
+
+#macOS
+**/.DS_Store
diff --git a/Dockerfile b/Dockerfile
index 0f10015c..1892ae23 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,5 +9,3 @@ WORKDIR /app
COPY requirements.txt /app/
RUN pip install -r requirements.txt --no-cache-dir
RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean
-
-COPY ./bookwyrm ./celerywyrm /app/
diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index 5349e1dd..81762388 100644
--- a/bookwyrm/activitypub/base_activity.py
+++ b/bookwyrm/activitypub/base_activity.py
@@ -37,6 +37,7 @@ class Mention(Link):
@dataclass
+# pylint: disable=invalid-name
class Signature:
"""public key block"""
@@ -56,11 +57,11 @@ def naive_parse(activity_objects, activity_json, serializer=None):
activity_type = activity_json.get("type")
try:
serializer = activity_objects[activity_type]
- except KeyError as e:
+ except KeyError as err:
# we know this exists and that we can't handle it
if activity_type in ["Question"]:
return None
- raise ActivitySerializerError(e)
+ raise ActivitySerializerError(err)
return serializer(activity_objects=activity_objects, **activity_json)
diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py
index 1599b408..bd27c4e6 100644
--- a/bookwyrm/activitypub/book.py
+++ b/bookwyrm/activitypub/book.py
@@ -6,6 +6,7 @@ from .base_activity import ActivityObject
from .image import Document
+# pylint: disable=invalid-name
@dataclass(init=False)
class BookData(ActivityObject):
"""shared fields for all book data and authors"""
@@ -18,6 +19,7 @@ class BookData(ActivityObject):
lastEditedBy: str = None
+# pylint: disable=invalid-name
@dataclass(init=False)
class Book(BookData):
"""serializes an edition or work, abstract"""
@@ -40,6 +42,7 @@ class Book(BookData):
type: str = "Book"
+# pylint: disable=invalid-name
@dataclass(init=False)
class Edition(Book):
"""Edition instance of a book object"""
@@ -57,6 +60,7 @@ class Edition(Book):
type: str = "Edition"
+# pylint: disable=invalid-name
@dataclass(init=False)
class Work(Book):
"""work instance of a book object"""
@@ -66,6 +70,7 @@ class Work(Book):
type: str = "Work"
+# pylint: disable=invalid-name
@dataclass(init=False)
class Author(BookData):
"""author of a book"""
diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py
index ea2e92b6..916da2d0 100644
--- a/bookwyrm/activitypub/note.py
+++ b/bookwyrm/activitypub/note.py
@@ -19,6 +19,7 @@ class Tombstone(ActivityObject):
return model.find_existing_by_remote_id(self.id)
+# pylint: disable=invalid-name
@dataclass(init=False)
class Note(ActivityObject):
"""Note activity"""
@@ -52,6 +53,7 @@ class GeneratedNote(Note):
type: str = "GeneratedNote"
+# pylint: disable=invalid-name
@dataclass(init=False)
class Comment(Note):
"""like a note but with a book"""
diff --git a/bookwyrm/activitypub/ordered_collection.py b/bookwyrm/activitypub/ordered_collection.py
index e3a83be8..32e37c99 100644
--- a/bookwyrm/activitypub/ordered_collection.py
+++ b/bookwyrm/activitypub/ordered_collection.py
@@ -5,6 +5,7 @@ from typing import List
from .base_activity import ActivityObject
+# pylint: disable=invalid-name
@dataclass(init=False)
class OrderedCollection(ActivityObject):
"""structure of an ordered collection activity"""
@@ -17,6 +18,7 @@ class OrderedCollection(ActivityObject):
type: str = "OrderedCollection"
+# pylint: disable=invalid-name
@dataclass(init=False)
class OrderedCollectionPrivate(OrderedCollection):
"""an ordered collection with privacy settings"""
@@ -41,6 +43,7 @@ class BookList(OrderedCollectionPrivate):
type: str = "BookList"
+# pylint: disable=invalid-name
@dataclass(init=False)
class OrderedCollectionPage(ActivityObject):
"""structure of an ordered collection activity"""
diff --git a/bookwyrm/activitypub/person.py b/bookwyrm/activitypub/person.py
index d5f37946..174ead61 100644
--- a/bookwyrm/activitypub/person.py
+++ b/bookwyrm/activitypub/person.py
@@ -6,6 +6,7 @@ from .base_activity import ActivityObject
from .image import Image
+# pylint: disable=invalid-name
@dataclass(init=False)
class PublicKey(ActivityObject):
"""public key block"""
@@ -15,6 +16,7 @@ class PublicKey(ActivityObject):
type: str = "PublicKey"
+# pylint: disable=invalid-name
@dataclass(init=False)
class Person(ActivityObject):
"""actor activitypub json"""
diff --git a/bookwyrm/activitypub/response.py b/bookwyrm/activitypub/response.py
index 07f39c7e..e480b85d 100644
--- a/bookwyrm/activitypub/response.py
+++ b/bookwyrm/activitypub/response.py
@@ -1,3 +1,4 @@
+""" ActivityPub-specific json response wrapper """
from django.http import JsonResponse
from .base_activity import ActivityEncoder
diff --git a/bookwyrm/activitypub/verbs.py b/bookwyrm/activitypub/verbs.py
index f26936d7..50a479b7 100644
--- a/bookwyrm/activitypub/verbs.py
+++ b/bookwyrm/activitypub/verbs.py
@@ -22,6 +22,7 @@ class Verb(ActivityObject):
self.object.to_model()
+# pylint: disable=invalid-name
@dataclass(init=False)
class Create(Verb):
"""Create activity"""
@@ -32,6 +33,7 @@ class Create(Verb):
type: str = "Create"
+# pylint: disable=invalid-name
@dataclass(init=False)
class Delete(Verb):
"""Create activity"""
@@ -57,6 +59,7 @@ class Delete(Verb):
# if we can't find it, we don't need to delete it because we don't have it
+# pylint: disable=invalid-name
@dataclass(init=False)
class Update(Verb):
"""Update activity"""
@@ -192,6 +195,7 @@ class Like(Verb):
self.to_model()
+# pylint: disable=invalid-name
@dataclass(init=False)
class Announce(Verb):
"""boosting a status"""
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
index 86321cd8..a49a7ce4 100644
--- a/bookwyrm/activitystreams.py
+++ b/bookwyrm/activitystreams.py
@@ -55,6 +55,8 @@ class ActivityStream(RedisStore):
return (
models.Status.objects.select_subclasses()
.filter(id__in=statuses)
+ .select_related("user", "reply_parent")
+ .prefetch_related("mention_books", "mention_users")
.order_by("-published_date")
)
@@ -199,6 +201,19 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
for stream in streams.values():
stream.add_status(instance)
+ if sender != models.Boost:
+ return
+ # remove the original post and other, earlier boosts
+ boosted = instance.boost.boosted_status
+ old_versions = models.Boost.objects.filter(
+ boosted_status__id=boosted.id,
+ created_date__lt=instance.created_date,
+ )
+ for stream in streams.values():
+ stream.remove_object_from_related_stores(boosted)
+ for status in old_versions:
+ stream.remove_object_from_related_stores(status)
+
@receiver(signals.post_delete, sender=models.Boost)
# pylint: disable=unused-argument
@@ -206,7 +221,10 @@ def remove_boost_on_delete(sender, instance, *args, **kwargs):
"""boosts are deleted"""
# we're only interested in new statuses
for stream in streams.values():
+ # remove the boost
stream.remove_object_from_related_stores(instance)
+ # re-add the original status
+ stream.add_status(instance.boosted_status)
@receiver(signals.post_save, sender=models.UserFollows)
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index 60667815..6c032b83 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -37,7 +37,7 @@ class AbstractMinimalConnector(ABC):
for field in self_fields:
setattr(self, field, getattr(info, field))
- def search(self, query, min_confidence=None):
+ def search(self, query, min_confidence=None, timeout=5):
"""free text search"""
params = {}
if min_confidence:
@@ -46,6 +46,7 @@ class AbstractMinimalConnector(ABC):
data = self.get_search_data(
"%s%s" % (self.search_url, query),
params=params,
+ timeout=timeout,
)
results = []
@@ -126,8 +127,8 @@ class AbstractConnector(AbstractMinimalConnector):
edition_data = data
try:
work_data = self.get_work_from_edition_data(data)
- except (KeyError, ConnectorException) as e:
- logger.exception(e)
+ except (KeyError, ConnectorException) as err:
+ logger.exception(err)
work_data = data
if not work_data or not edition_data:
@@ -218,7 +219,7 @@ def dict_from_mappings(data, mappings):
return result
-def get_data(url, params=None):
+def get_data(url, params=None, timeout=10):
"""wrapper for request.get"""
# check if the url is blocked
if models.FederatedServer.is_blocked(url):
@@ -234,23 +235,24 @@ def get_data(url, params=None):
"Accept": "application/json; charset=utf-8",
"User-Agent": settings.USER_AGENT,
},
+ timeout=timeout,
)
- except (RequestError, SSLError, ConnectionError) as e:
- logger.exception(e)
+ except (RequestError, SSLError, ConnectionError) as err:
+ logger.exception(err)
raise ConnectorException()
if not resp.ok:
raise ConnectorException()
try:
data = resp.json()
- except ValueError as e:
- logger.exception(e)
+ except ValueError as err:
+ logger.exception(err)
raise ConnectorException()
return data
-def get_image(url):
+def get_image(url, timeout=10):
"""wrapper for requesting an image"""
try:
resp = requests.get(
@@ -258,9 +260,10 @@ def get_image(url):
headers={
"User-Agent": settings.USER_AGENT,
},
+ timeout=timeout,
)
- except (RequestError, SSLError) as e:
- logger.exception(e)
+ except (RequestError, SSLError) as err:
+ logger.exception(err)
return None
if not resp.ok:
return None
diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py
index 95c5959d..1a615c9b 100644
--- a/bookwyrm/connectors/connector_manager.py
+++ b/bookwyrm/connectors/connector_manager.py
@@ -1,4 +1,5 @@
""" interface with whatever connectors the app has """
+from datetime import datetime
import importlib
import logging
import re
@@ -29,23 +30,25 @@ def search(query, min_confidence=0.1, return_first=False):
isbn = re.sub(r"[\W_]", "", query)
maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
+ timeout = 15
+ start_time = datetime.now()
for connector in get_connectors():
result_set = None
- if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url == "":
+ if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url != "":
# Search on ISBN
try:
result_set = connector.isbn_search(isbn)
- except Exception as e: # pylint: disable=broad-except
- logger.exception(e)
+ except Exception as err: # pylint: disable=broad-except
+ logger.exception(err)
# if this fails, we can still try regular search
# if no isbn search results, we fallback to generic search
if not result_set:
try:
result_set = connector.search(query, min_confidence=min_confidence)
- except Exception as e: # pylint: disable=broad-except
+ except Exception as err: # pylint: disable=broad-except
# we don't want *any* error to crash the whole search page
- logger.exception(e)
+ logger.exception(err)
continue
if return_first and result_set:
@@ -59,6 +62,8 @@ def search(query, min_confidence=0.1, return_first=False):
"results": result_set,
}
)
+ if (datetime.now() - start_time).seconds >= timeout:
+ break
if return_first:
return None
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
index 102c9d72..116aa5c1 100644
--- a/bookwyrm/connectors/inventaire.py
+++ b/bookwyrm/connectors/inventaire.py
@@ -74,7 +74,7 @@ class Connector(AbstractConnector):
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]},
}
- def search(self, query, min_confidence=None):
+ def search(self, query, min_confidence=None): # pylint: disable=arguments-differ
"""overrides default search function with confidence ranking"""
results = super().search(query)
if min_confidence:
diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py
index a8f85834..930b7cb3 100644
--- a/bookwyrm/connectors/self_connector.py
+++ b/bookwyrm/connectors/self_connector.py
@@ -3,7 +3,7 @@ from functools import reduce
import operator
from django.contrib.postgres.search import SearchRank, SearchVector
-from django.db.models import Count, OuterRef, Subquery, F, Q
+from django.db.models import OuterRef, Subquery, F, Q
from bookwyrm import models
from .abstract_connector import AbstractConnector, SearchResult
@@ -114,6 +114,7 @@ class Connector(AbstractConnector):
def search_identifiers(query, *filters):
"""tries remote_id, isbn; defined as dedupe fields on the model"""
+ # pylint: disable=W0212
or_filters = [
{f.name: query}
for f in models.Edition._meta.get_fields()
@@ -122,6 +123,8 @@ def search_identifiers(query, *filters):
results = models.Edition.objects.filter(
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
).distinct()
+ if results.count() <= 1:
+ return results
# when there are multiple editions of the same work, pick the default.
# it would be odd for this to happen.
@@ -146,19 +149,15 @@ def search_title_author(query, min_confidence, *filters):
)
results = (
- models.Edition.objects.annotate(search=vector)
- .annotate(rank=SearchRank(vector, query))
+ models.Edition.objects.annotate(rank=SearchRank(vector, query))
.filter(*filters, rank__gt=min_confidence)
.order_by("-rank")
)
# when there are multiple editions of the same work, pick the closest
- editions_of_work = (
- results.values("parent_work")
- .annotate(Count("parent_work"))
- .values_list("parent_work")
- )
+ editions_of_work = results.values("parent_work__id").values_list("parent_work__id")
+ # filter out multiple editions of the same work
for work_id in set(editions_of_work):
editions = results.filter(parent_work=work_id)
default = editions.order_by("-edition_rank").first()
diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py
index b77c62b0..f2661a19 100644
--- a/bookwyrm/context_processors.py
+++ b/bookwyrm/context_processors.py
@@ -1,10 +1,20 @@
""" customize the info available in context for rendering templates """
-from bookwyrm import models
+from bookwyrm import models, settings
def site_settings(request): # pylint: disable=unused-argument
"""include the custom info about the site"""
+ request_protocol = "https://"
+ if not request.is_secure():
+ request_protocol = "http://"
+
return {
"site": models.SiteSettings.objects.get(),
"active_announcements": models.Announcement.active_announcements(),
+ "static_url": settings.STATIC_URL,
+ "media_url": settings.MEDIA_URL,
+ "static_path": settings.STATIC_PATH,
+ "media_path": settings.MEDIA_PATH,
+ "preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES,
+ "request_protocol": request_protocol,
}
diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
index 25b72a11..cb55d229 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -22,6 +22,7 @@ class CustomForm(ModelForm):
css_classes["number"] = "input"
css_classes["checkbox"] = "checkbox"
css_classes["textarea"] = "textarea"
+ # pylint: disable=super-with-arguments
super(CustomForm, self).__init__(*args, **kwargs)
for visible in self.visible_fields():
if hasattr(visible.field.widget, "input_type"):
@@ -150,6 +151,12 @@ class LimitedEditUserForm(CustomForm):
help_texts = {f: None for f in fields}
+class DeleteUserForm(CustomForm):
+ class Meta:
+ model = models.User
+ fields = ["password"]
+
+
class UserGroupForm(CustomForm):
class Meta:
model = models.User
@@ -175,8 +182,6 @@ class EditionForm(CustomForm):
"authors",
"parent_work",
"shelves",
- "subjects", # TODO
- "subject_places", # TODO
"connector",
]
diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
index 89c62e73..203db034 100644
--- a/bookwyrm/importers/importer.py
+++ b/bookwyrm/importers/importer.py
@@ -67,8 +67,8 @@ def import_data(source, job_id):
for item in job.items.all():
try:
item.resolve()
- except Exception as e: # pylint: disable=broad-except
- logger.exception(e)
+ except Exception as err: # pylint: disable=broad-except
+ logger.exception(err)
item.fail_reason = "Error loading book"
item.save()
continue
diff --git a/bookwyrm/management/commands/generate_preview_images.py b/bookwyrm/management/commands/generate_preview_images.py
new file mode 100644
index 00000000..df218623
--- /dev/null
+++ b/bookwyrm/management/commands/generate_preview_images.py
@@ -0,0 +1,65 @@
+""" Generate preview images """
+from django.core.management.base import BaseCommand
+
+from bookwyrm import models, preview_images
+
+
+# pylint: disable=line-too-long
+class Command(BaseCommand):
+ """Creates previews for existing objects"""
+
+ help = "Generate preview images"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--all",
+ "-a",
+ action="store_true",
+ help="Generates images for ALL types: site, users and books. Can use a lot of computing power.",
+ )
+
+ # pylint: disable=no-self-use,unused-argument
+ def handle(self, *args, **options):
+ """generate preview images"""
+ self.stdout.write(
+ " | Hello! I will be generating preview images for your instance."
+ )
+ if options["all"]:
+ self.stdout.write(
+ "🧑🎨 ⎨ This might take quite long if your instance has a lot of books and users."
+ )
+ self.stdout.write(" | ✧ Thank you for your patience ✧")
+ else:
+ self.stdout.write("🧑🎨 ⎨ I will only generate the instance preview image.")
+ self.stdout.write(" | ✧ Be right back! ✧")
+
+ # Site
+ self.stdout.write(" → Site preview image: ", ending="")
+ preview_images.generate_site_preview_image_task.delay()
+ self.stdout.write(" OK 🖼")
+
+ if options["all"]:
+ # Users
+ users = models.User.objects.filter(
+ local=True,
+ is_active=True,
+ )
+ self.stdout.write(
+ " → User preview images ({}): ".format(len(users)), ending=""
+ )
+ for user in users:
+ preview_images.generate_user_preview_image_task.delay(user.id)
+ self.stdout.write(".", ending="")
+ self.stdout.write(" OK 🖼")
+
+ # Books
+ books = models.Book.objects.select_subclasses().filter()
+ self.stdout.write(
+ " → Book preview images ({}): ".format(len(books)), ending=""
+ )
+ for book in books:
+ preview_images.generate_edition_preview_image_task.delay(book.id)
+ self.stdout.write(".", ending="")
+ self.stdout.write(" OK 🖼")
+
+ self.stdout.write("🧑🎨 ⎨ I’m all done! ✧ Enjoy ✧")
diff --git a/bookwyrm/migrations/0076_preview_images.py b/bookwyrm/migrations/0076_preview_images.py
new file mode 100644
index 00000000..8812e9b2
--- /dev/null
+++ b/bookwyrm/migrations/0076_preview_images.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.2 on 2021-05-26 12:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0075_announcement"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="book",
+ name="preview_image",
+ field=models.ImageField(
+ blank=True, null=True, upload_to="previews/covers/"
+ ),
+ ),
+ migrations.AddField(
+ model_name="sitesettings",
+ name="preview_image",
+ field=models.ImageField(blank=True, null=True, upload_to="previews/logos/"),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="preview_image",
+ field=models.ImageField(
+ blank=True, null=True, upload_to="previews/avatars/"
+ ),
+ ),
+ ]
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index 869ff04d..d79ce206 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -2,10 +2,13 @@
import re
from django.db import models
+from django.dispatch import receiver
+from model_utils import FieldTracker
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
-from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE
+from bookwyrm.preview_images import generate_edition_preview_image_task
+from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE, ENABLE_PREVIEW_IMAGES
from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin
from .base_model import BookWyrmModel
@@ -82,10 +85,14 @@ class Book(BookDataModel):
cover = fields.ImageField(
upload_to="covers/", blank=True, null=True, alt_field="alt_text"
)
+ preview_image = models.ImageField(
+ upload_to="previews/covers/", blank=True, null=True
+ )
first_published_date = fields.DateTimeField(blank=True, null=True)
published_date = fields.DateTimeField(blank=True, null=True)
objects = InheritanceManager()
+ field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"])
@property
def author_text(self):
@@ -293,3 +300,17 @@ def isbn_13_to_10(isbn_13):
if checkdigit == 10:
checkdigit = "X"
return converted + str(checkdigit)
+
+
+# pylint: disable=unused-argument
+@receiver(models.signals.post_save, sender=Edition)
+def preview_image(instance, *args, **kwargs):
+ """create preview image on book create"""
+ if not ENABLE_PREVIEW_IMAGES:
+ return
+ changed_fields = {}
+ if instance.field_tracker:
+ changed_fields = instance.field_tracker.changed()
+
+ if len(changed_fields) > 0:
+ generate_edition_preview_image_task.delay(instance.id)
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index 123b3efa..4ec46504 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -1,5 +1,6 @@
""" activitypub-aware django model fields """
from dataclasses import MISSING
+import imghdr
import re
from uuid import uuid4
@@ -9,6 +10,7 @@ from django.contrib.postgres.fields import ArrayField as DjangoArrayField
from django.core.exceptions import ValidationError
from django.core.files.base import ContentFile
from django.db import models
+from django.forms import ClearableFileInput, ImageField as DjangoImageField
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import activitypub
@@ -200,6 +202,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
*args, max_length=255, choices=PrivacyLevels.choices, default="public"
)
+ # pylint: disable=invalid-name
def set_field_from_activity(self, instance, data):
to = data.to
cc = data.cc
@@ -218,6 +221,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField):
if hasattr(instance, "mention_users"):
mentions = [u.remote_id for u in instance.mention_users.all()]
# this is a link to the followers list
+ # pylint: disable=protected-access
followers = instance.user.__class__._meta.get_field(
"followers"
).field_to_activity(instance.user.followers)
@@ -332,6 +336,18 @@ class TagField(ManyToManyField):
return items
+class ClearableFileInputWithWarning(ClearableFileInput):
+ """max file size warning"""
+
+ template_name = "widgets/clearable_file_input_with_warning.html"
+
+
+class CustomImageField(DjangoImageField):
+ """overwrites image field for form"""
+
+ widget = ClearableFileInputWithWarning
+
+
def image_serializer(value, alt):
"""helper for serializing images"""
if value and hasattr(value, "url"):
@@ -391,10 +407,19 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
if not response:
return None
- image_name = str(uuid4()) + "." + url.split(".")[-1]
image_content = ContentFile(response.content)
+ image_name = str(uuid4()) + "." + imghdr.what(None, image_content.read())
return [image_name, image_content]
+ def formfield(self, **kwargs):
+ """special case for forms"""
+ return super().formfield(
+ **{
+ "form_class": CustomImageField,
+ **kwargs,
+ }
+ )
+
class DateTimeField(ActivitypubFieldMixin, models.DateTimeField):
"""activitypub-aware datetime field"""
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index c8130af2..f2993846 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -75,7 +75,12 @@ class ImportItem(models.Model):
def resolve(self):
"""try various ways to lookup a book"""
- self.book = self.get_book_from_isbn() or self.get_book_from_title_author()
+ if self.isbn:
+ self.book = self.get_book_from_isbn()
+ else:
+ # don't fall back on title/author search is isbn is present.
+ # you're too likely to mismatch
+ self.get_book_from_title_author()
def get_book_from_isbn(self):
"""search by isbn"""
diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py
index 2a5c3382..bbad5ba9 100644
--- a/bookwyrm/models/list.py
+++ b/bookwyrm/models/list.py
@@ -93,7 +93,8 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
)
class Meta:
- # A book may only be placed into a list once, and each order in the list may be used only
- # once
+ """A book may only be placed into a list once,
+ and each order in the list may be used only once"""
+
unique_together = (("book", "book_list"), ("order", "book_list"))
ordering = ("-created_date",)
diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py
index 12f4c51a..edb89d13 100644
--- a/bookwyrm/models/relationship.py
+++ b/bookwyrm/models/relationship.py
@@ -99,7 +99,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
status = "follow_request"
activity_serializer = activitypub.Follow
- def save(self, *args, broadcast=True, **kwargs):
+ def save(self, *args, broadcast=True, **kwargs): # pylint: disable=arguments-differ
"""make sure the follow or block relationship doesn't already exist"""
# if there's a request for a follow that already exists, accept it
# without changing the local database state
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index 2c5a2164..872f6b45 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -4,9 +4,12 @@ import datetime
from Crypto import Random
from django.db import models, IntegrityError
+from django.dispatch import receiver
from django.utils import timezone
+from model_utils import FieldTracker
-from bookwyrm.settings import DOMAIN
+from bookwyrm.preview_images import generate_site_preview_image_task
+from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
from .base_model import BookWyrmModel
from .user import User
@@ -35,6 +38,9 @@ class SiteSettings(models.Model):
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
favicon = models.ImageField(upload_to="logos/", null=True, blank=True)
+ preview_image = models.ImageField(
+ upload_to="previews/logos/", null=True, blank=True
+ )
# footer
support_link = models.CharField(max_length=255, null=True, blank=True)
@@ -42,6 +48,8 @@ class SiteSettings(models.Model):
admin_email = models.EmailField(max_length=255, null=True, blank=True)
footer_item = models.TextField(null=True, blank=True)
+ field_tracker = FieldTracker(fields=["name", "instance_tagline", "logo"])
+
@classmethod
def get(cls):
"""gets the site settings db entry or defaults"""
@@ -119,3 +127,15 @@ class PasswordReset(models.Model):
def link(self):
"""formats the invite link"""
return "https://{}/password-reset/{}".format(DOMAIN, self.code)
+
+
+# pylint: disable=unused-argument
+@receiver(models.signals.post_save, sender=SiteSettings)
+def preview_image(instance, *args, **kwargs):
+ """Update image preview for the default site image"""
+ if not ENABLE_PREVIEW_IMAGES:
+ return
+ changed_fields = instance.field_tracker.changed()
+
+ if len(changed_fields) > 0:
+ generate_site_preview_image_task.delay()
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index bd21ec56..3c25f1af 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -5,11 +5,15 @@ import re
from django.apps import apps
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
+from django.dispatch import receiver
from django.template.loader import get_template
from django.utils import timezone
+from model_utils import FieldTracker
from model_utils.managers import InheritanceManager
from bookwyrm import activitypub
+from bookwyrm.preview_images import generate_edition_preview_image_task
+from bookwyrm.settings import ENABLE_PREVIEW_IMAGES
from .activitypub_mixin import ActivitypubMixin, ActivityMixin
from .activitypub_mixin import OrderedCollectionPageMixin
from .base_model import BookWyrmModel
@@ -304,6 +308,8 @@ class Review(Status):
max_digits=3,
)
+ field_tracker = FieldTracker(fields=["rating"])
+
@property
def pure_name(self):
"""clarify review names for mastodon serialization"""
@@ -398,3 +404,17 @@ class Boost(ActivityMixin, Status):
# This constraint can't work as it would cross tables.
# class Meta:
# unique_together = ('user', 'boosted_status')
+
+
+# pylint: disable=unused-argument
+@receiver(models.signals.post_save)
+def preview_image(instance, sender, *args, **kwargs):
+ """Updates book previews if the rating has changed"""
+ if not ENABLE_PREVIEW_IMAGES or sender not in (Review, ReviewRating):
+ return
+
+ changed_fields = instance.field_tracker.changed()
+
+ if len(changed_fields) > 0:
+ edition = instance.book
+ generate_edition_preview_image_task.delay(edition.id)
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index 7d821c5b..49458a2e 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -6,15 +6,18 @@ from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group
from django.contrib.postgres.fields import CICharField
from django.core.validators import MinValueValidator
+from django.dispatch import receiver
from django.db import models
from django.utils import timezone
+from model_utils import FieldTracker
import pytz
from bookwyrm import activitypub
from bookwyrm.connectors import get_data, ConnectorException
from bookwyrm.models.shelf import Shelf
from bookwyrm.models.status import Status, Review
-from bookwyrm.settings import DOMAIN
+from bookwyrm.preview_images import generate_user_preview_image_task
+from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES
from bookwyrm.signatures import create_key_pair
from bookwyrm.tasks import app
from bookwyrm.utils import regex
@@ -70,6 +73,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
activitypub_field="icon",
alt_field="alt_text",
)
+ preview_image = models.ImageField(
+ upload_to="previews/avatars/", blank=True, null=True
+ )
followers = fields.ManyToManyField(
"self",
link_only=True,
@@ -117,6 +123,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
name_field = "username"
property_fields = [("following_link", "following")]
+ field_tracker = FieldTracker(fields=["name", "avatar"])
@property
def following_link(self):
@@ -232,7 +239,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
def save(self, *args, **kwargs):
"""populate fields for new local users"""
created = not bool(self.id)
- if not self.local and not re.match(regex.full_username, self.username):
+ if not self.local and not re.match(regex.FULL_USERNAME, self.username):
# generate a username that uses the domain (webfinger format)
actor_parts = urlparse(self.remote_id)
self.username = "%s@%s" % (self.username, actor_parts.netloc)
@@ -356,7 +363,7 @@ class AnnualGoal(BookWyrmModel):
def get_remote_id(self):
"""put the year in the path"""
- return "%s/goal/%d" % (self.user.remote_id, self.year)
+ return "{:s}/goal/{:d}".format(self.user.remote_id, self.year)
@property
def books(self):
@@ -381,17 +388,16 @@ class AnnualGoal(BookWyrmModel):
return {r.book.id: r.rating for r in reviews}
@property
- def progress_percent(self):
- """how close to your goal, in percent form"""
- return int(float(self.book_count / self.goal) * 100)
-
- @property
- def book_count(self):
+ def progress(self):
"""how many books you've read this year"""
- return self.user.readthrough_set.filter(
+ count = self.user.readthrough_set.filter(
finish_date__year__gte=self.year,
finish_date__year__lt=self.year + 1,
).count()
+ return {
+ "count": count,
+ "percent": int(float(count / self.goal) * 100),
+ }
@app.task
@@ -444,3 +450,15 @@ def get_remote_reviews(outbox):
if not activity["type"] == "Review":
continue
activitypub.Review(**activity).to_model()
+
+
+# pylint: disable=unused-argument
+@receiver(models.signals.post_save, sender=User)
+def preview_image(instance, *args, **kwargs):
+ """create preview images when user is updated"""
+ if not ENABLE_PREVIEW_IMAGES:
+ return
+ changed_fields = instance.field_tracker.changed()
+
+ if len(changed_fields) > 0:
+ generate_user_preview_image_task.delay(instance.id)
diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py
new file mode 100644
index 00000000..510625e3
--- /dev/null
+++ b/bookwyrm/preview_images.py
@@ -0,0 +1,424 @@
+""" Generate social media preview images for twitter/mastodon/etc """
+import math
+import os
+import textwrap
+from io import BytesIO
+from uuid import uuid4
+
+import colorsys
+from colorthief import ColorThief
+from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageColor
+
+from django.core.files.base import ContentFile
+from django.core.files.uploadedfile import InMemoryUploadedFile
+from django.db.models import Avg
+
+from bookwyrm import models, settings
+from bookwyrm.tasks import app
+
+
+IMG_WIDTH = settings.PREVIEW_IMG_WIDTH
+IMG_HEIGHT = settings.PREVIEW_IMG_HEIGHT
+BG_COLOR = settings.PREVIEW_BG_COLOR
+TEXT_COLOR = settings.PREVIEW_TEXT_COLOR
+DEFAULT_COVER_COLOR = settings.PREVIEW_DEFAULT_COVER_COLOR
+TRANSPARENT_COLOR = (0, 0, 0, 0)
+
+margin = math.floor(IMG_HEIGHT / 10)
+gutter = math.floor(margin / 2)
+inner_img_height = math.floor(IMG_HEIGHT * 0.8)
+inner_img_width = math.floor(inner_img_height * 0.7)
+font_dir = os.path.join(settings.STATIC_ROOT, "fonts/public_sans")
+
+
+def get_font(font_name, size=28):
+ """Loads custom font"""
+ if font_name == "light":
+ font_path = os.path.join(font_dir, "PublicSans-Light.ttf")
+ if font_name == "regular":
+ font_path = os.path.join(font_dir, "PublicSans-Regular.ttf")
+ elif font_name == "bold":
+ font_path = os.path.join(font_dir, "PublicSans-Bold.ttf")
+
+ try:
+ font = ImageFont.truetype(font_path, size)
+ except OSError:
+ font = ImageFont.load_default()
+
+ return font
+
+
+def generate_texts_layer(texts, content_width):
+ """Adds text for images"""
+ font_text_zero = get_font("bold", size=20)
+ font_text_one = get_font("bold", size=48)
+ font_text_two = get_font("bold", size=40)
+ font_text_three = get_font("regular", size=40)
+
+ text_layer = Image.new("RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR)
+ text_layer_draw = ImageDraw.Draw(text_layer)
+
+ text_y = 0
+
+ if "text_zero" in texts and texts["text_zero"]:
+ # Text one (Book title)
+ text_zero = textwrap.fill(texts["text_zero"], width=72)
+ text_layer_draw.multiline_text(
+ (0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR
+ )
+
+ try:
+ text_y = text_y + font_text_zero.getsize_multiline(text_zero)[1] + 16
+ except (AttributeError, IndexError):
+ text_y = text_y + 26
+
+ if "text_one" in texts and texts["text_one"]:
+ # Text one (Book title)
+ text_one = textwrap.fill(texts["text_one"], width=28)
+ text_layer_draw.multiline_text(
+ (0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR
+ )
+
+ try:
+ text_y = text_y + font_text_one.getsize_multiline(text_one)[1] + 16
+ except (AttributeError, IndexError):
+ text_y = text_y + 26
+
+ if "text_two" in texts and texts["text_two"]:
+ # Text one (Book subtitle)
+ text_two = textwrap.fill(texts["text_two"], width=36)
+ text_layer_draw.multiline_text(
+ (0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR
+ )
+
+ try:
+ text_y = text_y + font_text_one.getsize_multiline(text_two)[1] + 16
+ except (AttributeError, IndexError):
+ text_y = text_y + 26
+
+ if "text_three" in texts and texts["text_three"]:
+ # Text three (Book authors)
+ text_three = textwrap.fill(texts["text_three"], width=36)
+ text_layer_draw.multiline_text(
+ (0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR
+ )
+
+ text_layer_box = text_layer.getbbox()
+ return text_layer.crop(text_layer_box)
+
+
+def generate_instance_layer(content_width):
+ """Places components for instance preview"""
+ font_instance = get_font("light", size=28)
+
+ site = models.SiteSettings.objects.get()
+
+ if site.logo_small:
+ logo_img = Image.open(site.logo_small)
+ else:
+ try:
+ static_path = os.path.join(settings.STATIC_ROOT, "images/logo-small.png")
+ logo_img = Image.open(static_path)
+ except FileNotFoundError:
+ logo_img = None
+
+ instance_layer = Image.new("RGBA", (content_width, 62), color=TRANSPARENT_COLOR)
+
+ instance_text_x = 0
+
+ if logo_img:
+ logo_img.thumbnail((50, 50), Image.ANTIALIAS)
+
+ instance_layer.paste(logo_img, (0, 0))
+
+ instance_text_x = instance_text_x + 60
+
+ instance_layer_draw = ImageDraw.Draw(instance_layer)
+ instance_layer_draw.text(
+ (instance_text_x, 10), site.name, font=font_instance, fill=TEXT_COLOR
+ )
+
+ line_width = 50 + 10 + font_instance.getsize(site.name)[0]
+
+ line_layer = Image.new(
+ "RGBA", (line_width, 2), color=(*(ImageColor.getrgb(TEXT_COLOR)), 50)
+ )
+ instance_layer.alpha_composite(line_layer, (0, 60))
+
+ return instance_layer
+
+
+def generate_rating_layer(rating, content_width):
+ """Places components for rating preview"""
+ try:
+ icon_star_full = Image.open(
+ os.path.join(settings.STATIC_ROOT, "images/icons/star-full.png")
+ )
+ icon_star_empty = Image.open(
+ os.path.join(settings.STATIC_ROOT, "images/icons/star-empty.png")
+ )
+ icon_star_half = Image.open(
+ os.path.join(settings.STATIC_ROOT, "images/icons/star-half.png")
+ )
+ except FileNotFoundError:
+ return None
+
+ icon_size = 64
+ icon_margin = 10
+
+ rating_layer_base = Image.new(
+ "RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR
+ )
+ rating_layer_color = Image.new("RGBA", (content_width, icon_size), color=TEXT_COLOR)
+ rating_layer_mask = Image.new(
+ "RGBA", (content_width, icon_size), color=TRANSPARENT_COLOR
+ )
+
+ position_x = 0
+
+ for _ in range(math.floor(rating)):
+ rating_layer_mask.alpha_composite(icon_star_full, (position_x, 0))
+ position_x = position_x + icon_size + icon_margin
+
+ if math.floor(rating) != math.ceil(rating):
+ rating_layer_mask.alpha_composite(icon_star_half, (position_x, 0))
+ position_x = position_x + icon_size + icon_margin
+
+ for _ in range(5 - math.ceil(rating)):
+ rating_layer_mask.alpha_composite(icon_star_empty, (position_x, 0))
+ position_x = position_x + icon_size + icon_margin
+
+ rating_layer_mask = rating_layer_mask.getchannel("A")
+ rating_layer_mask = ImageOps.invert(rating_layer_mask)
+
+ rating_layer_composite = Image.composite(
+ rating_layer_base, rating_layer_color, rating_layer_mask
+ )
+
+ return rating_layer_composite
+
+
+def generate_default_inner_img():
+ """Adds cover image"""
+ font_cover = get_font("light", size=28)
+
+ default_cover = Image.new(
+ "RGB", (inner_img_width, inner_img_height), color=DEFAULT_COVER_COLOR
+ )
+ default_cover_draw = ImageDraw.Draw(default_cover)
+
+ text = "no image :("
+ text_dimensions = font_cover.getsize(text)
+ text_coords = (
+ math.floor((inner_img_width - text_dimensions[0]) / 2),
+ math.floor((inner_img_height - text_dimensions[1]) / 2),
+ )
+ default_cover_draw.text(text_coords, text, font=font_cover, fill="white")
+
+ return default_cover
+
+
+# pylint: disable=too-many-locals
+def generate_preview_image(
+ texts=None, picture=None, rating=None, show_instance_layer=True
+):
+ """Puts everything together"""
+ texts = texts or {}
+ # Cover
+ try:
+ inner_img_layer = Image.open(picture)
+ inner_img_layer.thumbnail((inner_img_width, inner_img_height), Image.ANTIALIAS)
+ color_thief = ColorThief(picture)
+ dominant_color = color_thief.get_color(quality=1)
+ except: # pylint: disable=bare-except
+ inner_img_layer = generate_default_inner_img()
+ dominant_color = ImageColor.getrgb(DEFAULT_COVER_COLOR)
+
+ # Color
+ if BG_COLOR in ["use_dominant_color_light", "use_dominant_color_dark"]:
+ image_bg_color = "rgb(%s, %s, %s)" % dominant_color
+
+ # Adjust color
+ image_bg_color_rgb = [x / 255.0 for x in ImageColor.getrgb(image_bg_color)]
+ image_bg_color_hls = colorsys.rgb_to_hls(*image_bg_color_rgb)
+
+ if BG_COLOR == "use_dominant_color_light":
+ lightness = max(0.9, image_bg_color_hls[1])
+ else:
+ lightness = min(0.15, image_bg_color_hls[1])
+
+ image_bg_color_hls = (
+ image_bg_color_hls[0],
+ lightness,
+ image_bg_color_hls[2],
+ )
+ image_bg_color = tuple(
+ math.ceil(x * 255) for x in colorsys.hls_to_rgb(*image_bg_color_hls)
+ )
+ else:
+ image_bg_color = BG_COLOR
+
+ # Background (using the color)
+ img = Image.new("RGBA", (IMG_WIDTH, IMG_HEIGHT), color=image_bg_color)
+
+ # Contents
+ inner_img_x = margin + inner_img_width - inner_img_layer.width
+ inner_img_y = math.floor((IMG_HEIGHT - inner_img_layer.height) / 2)
+ content_x = margin + inner_img_width + gutter
+ content_width = IMG_WIDTH - content_x - margin
+
+ contents_layer = Image.new(
+ "RGBA", (content_width, IMG_HEIGHT), color=TRANSPARENT_COLOR
+ )
+ contents_composite_y = 0
+
+ if show_instance_layer:
+ instance_layer = generate_instance_layer(content_width)
+ contents_layer.alpha_composite(instance_layer, (0, contents_composite_y))
+ contents_composite_y = contents_composite_y + instance_layer.height + gutter
+
+ texts_layer = generate_texts_layer(texts, content_width)
+ contents_layer.alpha_composite(texts_layer, (0, contents_composite_y))
+ contents_composite_y = contents_composite_y + texts_layer.height + gutter
+
+ if rating:
+ # Add some more margin
+ contents_composite_y = contents_composite_y + gutter
+ rating_layer = generate_rating_layer(rating, content_width)
+
+ if rating_layer:
+ contents_layer.alpha_composite(rating_layer, (0, contents_composite_y))
+ contents_composite_y = contents_composite_y + rating_layer.height + gutter
+
+ contents_layer_box = contents_layer.getbbox()
+ contents_layer_height = contents_layer_box[3] - contents_layer_box[1]
+
+ contents_y = math.floor((IMG_HEIGHT - contents_layer_height) / 2)
+
+ if show_instance_layer:
+ # Remove Instance Layer from centering calculations
+ contents_y = contents_y - math.floor((instance_layer.height + gutter) / 2)
+
+ contents_y = max(contents_y, margin)
+
+ # Composite layers
+ img.paste(
+ inner_img_layer, (inner_img_x, inner_img_y), inner_img_layer.convert("RGBA")
+ )
+ img.alpha_composite(contents_layer, (content_x, contents_y))
+
+ return img.convert("RGB")
+
+
+def save_and_cleanup(image, instance=None):
+ """Save and close the file"""
+ if not isinstance(instance, (models.Book, models.User, models.SiteSettings)):
+ return False
+ file_name = "%s-%s.jpg" % (str(instance.id), str(uuid4()))
+ image_buffer = BytesIO()
+
+ try:
+ try:
+ old_path = instance.preview_image.path
+ except ValueError:
+ old_path = ""
+
+ # Save
+ image.save(image_buffer, format="jpeg", quality=75)
+
+ instance.preview_image = InMemoryUploadedFile(
+ ContentFile(image_buffer.getvalue()),
+ "preview_image",
+ file_name,
+ "image/jpg",
+ image_buffer.tell(),
+ None,
+ )
+
+ save_without_broadcast = isinstance(instance, (models.Book, models.User))
+ if save_without_broadcast:
+ instance.save(broadcast=False)
+ else:
+ instance.save()
+
+ # Clean up old file after saving
+ if os.path.exists(old_path):
+ os.remove(old_path)
+
+ finally:
+ image_buffer.close()
+ return True
+
+
+# pylint: disable=invalid-name
+@app.task
+def generate_site_preview_image_task():
+ """generate preview_image for the website"""
+ if not settings.ENABLE_PREVIEW_IMAGES:
+ return
+
+ site = models.SiteSettings.objects.get()
+
+ if site.logo:
+ logo = site.logo
+ else:
+ logo = os.path.join(settings.STATIC_ROOT, "images/logo.png")
+
+ texts = {
+ "text_zero": settings.DOMAIN,
+ "text_one": site.name,
+ "text_three": site.instance_tagline,
+ }
+
+ image = generate_preview_image(texts=texts, picture=logo, show_instance_layer=False)
+
+ save_and_cleanup(image, instance=site)
+
+
+# pylint: disable=invalid-name
+@app.task
+def generate_edition_preview_image_task(book_id):
+ """generate preview_image for a book"""
+ if not settings.ENABLE_PREVIEW_IMAGES:
+ return
+
+ book = models.Book.objects.select_subclasses().get(id=book_id)
+
+ rating = models.Review.objects.filter(
+ privacy="public",
+ deleted=False,
+ book__in=[book_id],
+ ).aggregate(Avg("rating"))["rating__avg"]
+
+ texts = {
+ "text_one": book.title,
+ "text_two": book.subtitle,
+ "text_three": book.author_text,
+ }
+
+ image = generate_preview_image(texts=texts, picture=book.cover, rating=rating)
+
+ save_and_cleanup(image, instance=book)
+
+
+@app.task
+def generate_user_preview_image_task(user_id):
+ """generate preview_image for a book"""
+ if not settings.ENABLE_PREVIEW_IMAGES:
+ return
+
+ user = models.User.objects.get(id=user_id)
+
+ texts = {
+ "text_one": user.display_name,
+ "text_three": "@{}@{}".format(user.localname, settings.DOMAIN),
+ }
+
+ if user.avatar:
+ avatar = user.avatar
+ else:
+ avatar = os.path.join(settings.STATIC_ROOT, "images/default_avi.jpg")
+
+ image = generate_preview_image(texts=texts, picture=avatar)
+
+ save_and_cleanup(image, instance=user)
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index d694e33f..b928f97e 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -14,13 +14,18 @@ PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
# celery
-CELERY_BROKER = env("CELERY_BROKER")
-CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND")
+CELERY_BROKER = "redis://:{}@redis_broker:{}/0".format(
+ requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
+)
+CELERY_RESULT_BACKEND = "redis://:{}@redis_broker:{}/0".format(
+ requests.utils.quote(env("REDIS_BROKER_PASSWORD", "")), env("REDIS_BROKER_PORT")
+)
CELERY_ACCEPT_CONTENT = ["application/json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
# email
+EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = env("EMAIL_HOST")
EMAIL_PORT = env("EMAIL_PORT", 587)
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
@@ -37,6 +42,14 @@ LOCALE_PATHS = [
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
+# Preview image
+ENABLE_PREVIEW_IMAGES = env.bool("ENABLE_PREVIEW_IMAGES", False)
+PREVIEW_BG_COLOR = env.str("PREVIEW_BG_COLOR", "use_dominant_color_light")
+PREVIEW_TEXT_COLOR = env.str("PREVIEW_TEXT_COLOR", "#363636")
+PREVIEW_IMG_WIDTH = env.int("PREVIEW_IMG_WIDTH", 1200)
+PREVIEW_IMG_HEIGHT = env.int("PREVIEW_IMG_HEIGHT", 630)
+PREVIEW_DEFAULT_COVER_COLOR = env.str("PREVIEW_DEFAULT_COVER_COLOR", "#002549")
+
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
@@ -47,7 +60,6 @@ SECRET_KEY = env("SECRET_KEY")
DEBUG = env.bool("DEBUG", True)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", ["*"])
-OL_URL = env("OL_URL")
# Application definition
@@ -109,10 +121,8 @@ STREAMS = ["home", "local", "federated"]
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
-BOOKWYRM_DATABASE_BACKEND = env("BOOKWYRM_DATABASE_BACKEND", "postgres")
-
-BOOKWYRM_DBS = {
- "postgres": {
+DATABASES = {
+ "default": {
"ENGINE": "django.db.backends.postgresql_psycopg2",
"NAME": env("POSTGRES_DB", "fedireads"),
"USER": env("POSTGRES_USER", "fedireads"),
@@ -122,8 +132,6 @@ BOOKWYRM_DBS = {
},
}
-DATABASES = {"default": BOOKWYRM_DBS[BOOKWYRM_DATABASE_BACKEND]}
-
LOGIN_URL = "/login/"
AUTH_USER_MODEL = "bookwyrm.User"
@@ -131,6 +139,7 @@ AUTH_USER_MODEL = "bookwyrm.User"
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
+# pylint: disable=line-too-long
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
@@ -174,8 +183,10 @@ USE_TZ = True
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
STATIC_URL = "/static/"
+STATIC_PATH = "%s/%s" % (DOMAIN, env("STATIC_ROOT", "static"))
STATIC_ROOT = os.path.join(BASE_DIR, env("STATIC_ROOT", "static"))
MEDIA_URL = "/images/"
+MEDIA_PATH = "%s/%s" % (DOMAIN, env("MEDIA_ROOT", "images"))
MEDIA_ROOT = os.path.join(BASE_DIR, env("MEDIA_ROOT", "images"))
USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % (
diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py
index 5488cf9b..c8c90028 100644
--- a/bookwyrm/signatures.py
+++ b/bookwyrm/signatures.py
@@ -73,6 +73,7 @@ class Signature:
self.headers = headers
self.signature = signature
+ # pylint: disable=invalid-name
@classmethod
def parse(cls, request):
"""extract and parse a signature from an http request"""
diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css
index fcb32e21..3db25d1f 100644
--- a/bookwyrm/static/css/bookwyrm.css
+++ b/bookwyrm/static/css/bookwyrm.css
@@ -43,6 +43,19 @@ body {
white-space: nowrap !important;
width: 0.01em !important;
}
+
+ .m-0-mobile {
+ margin: 0 !important;
+ }
+
+ .card-footer.is-stacked-mobile {
+ flex-direction: column;
+ }
+
+ .card-footer.is-stacked-mobile .card-footer-item:not(:last-child) {
+ border-bottom: 1px solid #ededed;
+ border-right: 0;
+ }
}
.button.is-transparent {
@@ -331,6 +344,49 @@ body {
}
}
+/* Book list
+ ******************************************************************************/
+
+ol.ordered-list {
+ list-style: none;
+ counter-reset: list-counter;
+}
+
+ol.ordered-list li {
+ counter-increment: list-counter;
+}
+
+ol.ordered-list li::before {
+ content: counter(list-counter);
+ position: absolute;
+ left: -20px;
+ width: 20px;
+ height: 24px;
+ background-color: #fff;
+ border: 1px solid #dbdbdb;
+ border-right: 0;
+ border-top-left-radius: 2px;
+ border-top-right-radius: 2px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: #888;
+ font-size: 0.8em;
+ font-weight: bold;
+}
+
+@media only screen and (max-width: 768px) {
+ ol.ordered-list li::before {
+ left: 0;
+ z-index: 1;
+ border: 0;
+ border-right: 1px solid #dbdbdb;
+ border-bottom: 1px solid #dbdbdb;
+ border-radius: 0;
+ border-bottom-right-radius: 2px;
+ }
+}
+
/* Dimensions
* @todo These could be in rem.
******************************************************************************/
diff --git a/bookwyrm/static/fonts/public_sans/OFL.txt b/bookwyrm/static/fonts/public_sans/OFL.txt
new file mode 100644
index 00000000..ba4ea0b9
--- /dev/null
+++ b/bookwyrm/static/fonts/public_sans/OFL.txt
@@ -0,0 +1,94 @@
+Copyright (c) 2015, Pablo Impallari, Rodrigo Fuenzalida
+(Modified by Dan O. Williams and USWDS) (https://github.com/uswds/public-sans)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf b/bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf
new file mode 100644
index 00000000..3eb5ac24
Binary files /dev/null and b/bookwyrm/static/fonts/public_sans/PublicSans-Bold.ttf differ
diff --git a/bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf b/bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf
new file mode 100644
index 00000000..13fd7edb
Binary files /dev/null and b/bookwyrm/static/fonts/public_sans/PublicSans-Light.ttf differ
diff --git a/bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf b/bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf
new file mode 100644
index 00000000..25c1646a
Binary files /dev/null and b/bookwyrm/static/fonts/public_sans/PublicSans-Regular.ttf differ
diff --git a/bookwyrm/static/images/icons/star-empty.png b/bookwyrm/static/images/icons/star-empty.png
new file mode 100755
index 00000000..896417ef
Binary files /dev/null and b/bookwyrm/static/images/icons/star-empty.png differ
diff --git a/bookwyrm/static/images/icons/star-full.png b/bookwyrm/static/images/icons/star-full.png
new file mode 100755
index 00000000..6d78caf0
Binary files /dev/null and b/bookwyrm/static/images/icons/star-full.png differ
diff --git a/bookwyrm/static/images/icons/star-half.png b/bookwyrm/static/images/icons/star-half.png
new file mode 100755
index 00000000..75e4eadc
Binary files /dev/null and b/bookwyrm/static/images/icons/star-half.png differ
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
index 3659a20e..e43ed134 100644
--- a/bookwyrm/static/js/bookwyrm.js
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -3,6 +3,7 @@
let BookWyrm = new class {
constructor() {
+ this.MAX_FILE_SIZE_BYTES = 10 * 1000000;
this.initOnDOMLoaded();
this.initReccuringTasks();
this.initEventListeners();
@@ -32,15 +33,26 @@ let BookWyrm = new class {
'click',
this.back)
);
+
+ document.querySelectorAll('input[type="file"]')
+ .forEach(node => node.addEventListener(
+ 'change',
+ this.disableIfTooLarge.bind(this)
+ ));
}
/**
* Execute code once the DOM is loaded.
*/
initOnDOMLoaded() {
+ const bookwyrm = this;
+
window.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.tab-group')
.forEach(tabs => new TabGroup(tabs));
+ document.querySelectorAll('input[type="file"]').forEach(
+ bookwyrm.disableIfTooLarge.bind(bookwyrm)
+ );
});
}
@@ -126,6 +138,7 @@ let BookWyrm = new class {
* @return {undefined}
*/
toggleAction(event) {
+ event.preventDefault();
let trigger = event.currentTarget;
let pressed = trigger.getAttribute('aria-pressed') === 'false';
let targetId = trigger.dataset.controls;
@@ -170,6 +183,8 @@ let BookWyrm = new class {
if (focus) {
this.toggleFocus(focus);
}
+
+ return false;
}
/**
@@ -284,4 +299,27 @@ let BookWyrm = new class {
node.classList.remove(classname);
}
}
-}
+
+ disableIfTooLarge(eventOrElement) {
+ const { addRemoveClass, MAX_FILE_SIZE_BYTES } = this;
+ const element = eventOrElement.currentTarget || eventOrElement;
+
+ const submits = element.form.querySelectorAll('[type="submit"]');
+ const warns = element.parentElement.querySelectorAll('.file-too-big');
+ const isTooBig = element.files &&
+ element.files[0] &&
+ element.files[0].size > MAX_FILE_SIZE_BYTES;
+
+ if (isTooBig) {
+ submits.forEach(submitter => submitter.disabled = true);
+ warns.forEach(
+ sib => addRemoveClass(sib, 'is-hidden', false)
+ );
+ } else {
+ submits.forEach(submitter => submitter.disabled = false);
+ warns.forEach(
+ sib => addRemoveClass(sib, 'is-hidden', true)
+ );
+ }
+ }
+}();
diff --git a/bookwyrm/static/js/localstorage.js b/bookwyrm/static/js/localstorage.js
index 05955779..b485ed7e 100644
--- a/bookwyrm/static/js/localstorage.js
+++ b/bookwyrm/static/js/localstorage.js
@@ -17,7 +17,7 @@ let LocalStorageTools = new class {
* @return {undefined}
*/
updateDisplay(event) {
- // used in set reading goal
+ // Used in set reading goal
let key = event.target.dataset.id;
let value = event.target.dataset.value;
@@ -34,10 +34,10 @@ let LocalStorageTools = new class {
* @return {undefined}
*/
setDisplay(node) {
- // used in set reading goal
+ // Used in set reading goal
let key = node.dataset.hide;
let value = window.localStorage.getItem(key);
BookWyrm.addRemoveClass(node, 'is-hidden', value);
}
-}
+}();
diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html
index 67f8792c..0bc42775 100644
--- a/bookwyrm/templates/author/author.html
+++ b/bookwyrm/templates/author/author.html
@@ -15,49 +15,83 @@
{% endif %}
-
- {% if author.aliases or author.born or author.died or author.wikipedia_link %}
-
-
+
+
+
+ {% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id %}
+
+
{% if author.aliases %}
-
-
{% trans "Aliases:" %}
-
{{ author.aliases|join:', ' }}
+
+
{% trans "Aliases:" %}
+ {% for alias in author.aliases %}
+
+ {{alias}}{% if not forloop.last %}, {% endif %}
+
+ {% endfor %}
{% endif %}
+
{% if author.born %}
-
-
{% trans "Born:" %}
-
{{ author.born|naturalday }}
+
+
{% trans "Born:" %}
+ {{ author.born|naturalday }}
{% endif %}
- {% if author.aliases %}
-
diff --git a/bookwyrm/templates/author/edit_author.html b/bookwyrm/templates/author/edit_author.html
index 010d36ef..103341bf 100644
--- a/bookwyrm/templates/author/edit_author.html
+++ b/bookwyrm/templates/author/edit_author.html
@@ -29,67 +29,85 @@
{% trans "Metadata" %}
-
{% trans "Name:" %} {{ form.name }}
- {% for error in form.name.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Name:" %}
+ {{ form.name }}
+ {% for error in form.name.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
+
{% trans "Aliases:" %}
{{ form.aliases }}
{% trans "Separate multiple values with commas." %}
-
- {% for error in form.aliases.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.aliases.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "Bio:" %} {{ form.bio }}
- {% for error in form.bio.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Bio:" %}
+ {{ form.bio }}
+ {% for error in form.bio.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "Wikipedia link:" %} {{ form.wikipedia_link }}
+
{% trans "Wikipedia link:" %} {{ form.wikipedia_link }}
{% for error in form.wikipedia_link.errors %}
{{ error | escape }}
{% endfor %}
-
+
{% trans "Birth date:" %}
-
- {% for error in form.born.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.born.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
+
{% trans "Death date:" %}
-
- {% for error in form.died.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.died.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
{% trans "Author Identifiers" %}
-
{% trans "Openlibrary key:" %} {{ form.openlibrary_key }}
- {% for error in form.openlibrary_key.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Openlibrary key:" %}
+ {{ form.openlibrary_key }}
+ {% for error in form.openlibrary_key.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "Inventaire ID:" %} {{ form.inventaire_id }}
- {% for error in form.inventaire_id.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Inventaire ID:" %}
+ {{ form.inventaire_id }}
+ {% for error in form.inventaire_id.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "Librarything key:" %} {{ form.librarything_key }}
- {% for error in form.librarything_key.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Librarything key:" %}
+ {{ form.librarything_key }}
+ {% for error in form.librarything_key.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "Goodreads key:" %} {{ form.goodreads_key }}
- {% for error in form.goodreads_key.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Goodreads key:" %}
+ {{ form.goodreads_key }}
+ {% for error in form.goodreads_key.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html
index fb0a4510..26bd8322 100644
--- a/bookwyrm/templates/book/book.html
+++ b/bookwyrm/templates/book/book.html
@@ -1,35 +1,47 @@
{% extends 'layout.html' %}
-{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %}
+{% load i18n %}{% load bookwyrm_tags %}{% load humanize %}{% load utilities %}{% load layout %}
-{% block title %}{{ book.title }}{% endblock %}
+{% block title %}{{ book|book_title }}{% endblock %}
+
+{% block opengraph_images %}
+ {% include 'snippets/opengraph_images.html' with image=book.preview_image %}
+{% endblock %}
{% block content %}
{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
-
-
- {{ book.title }}{% if book.subtitle %}:
- {{ book.subtitle }}
- {% endif %}
-
-
- {% if book.series %}
-
-
-
-
- ({{ book.series }}
- {% if book.series_number %} #{{ book.series_number }}{% endif %})
-
-
- {% endif %}
+
+ {{ book.title }}
+
+ {% if book.subtitle or book.series %}
+
+ {% if book.subtitle %}
+
+
+
+ {{ book.subtitle }}
+
+ {% endif %}
+
+ {% if book.series %}
+
+
+
+ ({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})
+ {% endif %}
+
+ {% endif %}
+
{% if book.authors %}
-
- {% trans "by" %} {% include 'snippets/authors.html' with book=book %}
-
+
+ {% trans "by" %} {% include 'snippets/authors.html' with book=book %}
+
{% endif %}
@@ -37,7 +49,7 @@
{% endif %}
@@ -85,7 +97,7 @@
-
+
{% with full=book|book_description itemprop='abstract' %}
{% include 'snippets/trimmed_text.html' %}
@@ -137,7 +149,7 @@
{# user's relationship to the book #}
- {% for shelf in user_shelves %}
+ {% for shelf in user_shelfbooks %}
{% blocktrans with path=shelf.shelf.local_path shelf_name=shelf.shelf.name %}This edition is on your {{ shelf_name }} shelf.{% endblocktrans %}
{% include 'snippets/shelf_selector.html' with current=shelf.shelf %}
@@ -181,7 +193,7 @@
{% trans "You don't have any reading activity for this book." %}
{% endif %}
{% for readthrough in readthroughs %}
- {% include 'snippets/readthrough.html' with readthrough=readthrough %}
+ {% include 'book/readthrough.html' with readthrough=readthrough %}
{% endfor %}
diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit_book.html
index 8dae1d04..32018a25 100644
--- a/bookwyrm/templates/book/edit_book.html
+++ b/bookwyrm/templates/book/edit_book.html
@@ -14,11 +14,25 @@
{% endif %}
{% if book %}
-
-
{% trans "Added:" %} {{ book.created_date | naturaltime }}
-
{% trans "Updated:" %} {{ book.updated_date | naturaltime }}
-
{% trans "Last edited by:" %} {{ book.last_edited_by.display_name }}
-
+
+
+
{% trans "Added:" %}
+ {{ book.created_date | naturaltime }}
+
+
+
+
{% trans "Updated:" %}
+ {{ book.updated_date | naturaltime }}
+
+
+ {% if book.last_edited_by %}
+
+ {% endif %}
+
+
{% endif %}
@@ -38,21 +52,28 @@
{% if confirm_mode %}
{% trans "Confirm Book Info" %}
-
+
{% if author_matches %}
{% for author in author_matches %}
-
- {% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %}
+
+
+ {% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %}
+
{% with forloop.counter0 as counter %}
{% for match in author.matches %}
- {{ match.name }}
+
+
+ {{ match.name }}
+
{% blocktrans with book_title=match.book_set.first.title %}Author of {{ book_title }} {% endblocktrans %}
{% endfor %}
- {% trans "This is a new author" %}
+
+ {% trans "This is a new author" %}
+
{% endwith %}
{% endfor %}
@@ -64,11 +85,17 @@
{% if not book %}
- {% trans "Is this an edition of an existing work?" %}
+
+ {% trans "Is this an edition of an existing work?" %}
+
{% for match in book_matches %}
- {{ match.parent_work.title }}
+
+ {{ match.parent_work.title }}
+
{% endfor %}
- {% trans "This is a new work" %}
+
+ {% trans "This is a new work" %}
+
{% endif %}
@@ -89,76 +116,79 @@
{% trans "Metadata" %}
-
+
{% trans "Title:" %}
-
- {% for error in form.title.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.title.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
+
{% trans "Subtitle:" %}
-
- {% for error in form.subtitle.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.subtitle.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
- {% trans "Description:" %} {{ form.description }}
- {% for error in form.description.errors %}
- {{ error | escape }}
- {% endfor %}
+
+
{% trans "Description:" %}
+ {{ form.description }}
+ {% for error in form.description.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
+
{% trans "Series:" %}
-
- {% for error in form.series.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.series.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
+
{% trans "Series number:" %}
{{ form.series_number }}
-
- {% for error in form.series_number.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.series_number.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
+
{% trans "Languages:" %}
{{ form.languages }}
{% trans "Separate multiple values with commas." %}
-
- {% for error in form.languages.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.languages.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
+
{% trans "Publisher:" %}
{{ form.publishers }}
{% trans "Separate multiple values with commas." %}
-
- {% for error in form.publishers.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.publishers.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
+
{% trans "First published date:" %}
-
- {% for error in form.first_published_date.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.first_published_date.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
+
{% trans "Published date:" %}
-
- {% for error in form.published_date.errors %}
-
{{ error | escape }}
- {% endfor %}
+ {% for error in form.published_date.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
@@ -188,17 +225,17 @@
-
+
{% trans "Upload cover:" %}
{{ form.cover }}
-
+
{% if book %}
-
+
{% trans "Load cover from url:" %}
-
+
{% endif %}
{% for error in form.cover.errors %}
{{ error | escape }}
@@ -209,51 +246,72 @@
{% trans "Physical Properties" %}
-
{% trans "Format:" %} {{ form.physical_format }}
- {% for error in form.physical_format.errors %}
-
{{ error | escape }}
- {% endfor %}
- {% for error in form.physical_format.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Format:" %}
+ {{ form.physical_format }}
+ {% for error in form.physical_format.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "Pages:" %} {{ form.pages }}
- {% for error in form.pages.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Pages:" %}
+ {{ form.pages }}
+ {% for error in form.pages.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
{% trans "Book Identifiers" %}
-
{% trans "ISBN 13:" %} {{ form.isbn_13 }}
- {% for error in form.isbn_13.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "ISBN 13:" %}
+ {{ form.isbn_13 }}
+ {% for error in form.isbn_13.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "ISBN 10:" %} {{ form.isbn_10 }}
- {% for error in form.isbn_10.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "ISBN 10:" %}
+ {{ form.isbn_10 }}
+ {% for error in form.isbn_10.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "Openlibrary ID:" %} {{ form.openlibrary_key }}
- {% for error in form.openlibrary_key.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Openlibrary ID:" %}
+ {{ form.openlibrary_key }}
+ {% for error in form.openlibrary_key.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "Inventaire ID:" %} {{ form.inventaire_id }}
- {% for error in form.inventaire_id.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "Inventaire ID:" %}
+ {{ form.inventaire_id }}
+ {% for error in form.inventaire_id.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "OCLC Number:" %} {{ form.oclc_number }}
- {% for error in form.oclc_number.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "OCLC Number:" %}
+ {{ form.oclc_number }}
+ {% for error in form.oclc_number.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
-
{% trans "ASIN:" %} {{ form.asin }}
- {% for error in form.ASIN.errors %}
-
{{ error | escape }}
- {% endfor %}
+
+
{% trans "ASIN:" %}
+ {{ form.asin }}
+ {% for error in form.ASIN.errors %}
+
{{ error | escape }}
+ {% endfor %}
+
@@ -261,7 +319,7 @@
{% if not confirm_mode %}
{% endif %}
diff --git a/bookwyrm/templates/book/editions.html b/bookwyrm/templates/book/editions.html
index 0d5c1044..e2a0bdda 100644
--- a/bookwyrm/templates/book/editions.html
+++ b/bookwyrm/templates/book/editions.html
@@ -40,7 +40,7 @@
- {% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}
+ {% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True right=True %}
{% endfor %}
diff --git a/bookwyrm/templates/snippets/readthrough.html b/bookwyrm/templates/book/readthrough.html
similarity index 97%
rename from bookwyrm/templates/snippets/readthrough.html
rename to bookwyrm/templates/book/readthrough.html
index d5e79b86..75140746 100644
--- a/bookwyrm/templates/snippets/readthrough.html
+++ b/bookwyrm/templates/book/readthrough.html
@@ -1,8 +1,8 @@
{% load i18n %}
{% load humanize %}
{% load tz %}
-
-
+
+
{% trans "Progress Updates:" %}
diff --git a/bookwyrm/templates/components/modal.html b/bookwyrm/templates/components/modal.html
index 0a05f951..b29ff8d9 100644
--- a/bookwyrm/templates/components/modal.html
+++ b/bookwyrm/templates/components/modal.html
@@ -1,7 +1,7 @@
{% load i18n %}
-
+
{% block modal-title %}{% endblock %}
{% include 'snippets/toggle/toggle_button.html' with label=label class="delete" nonbutton=True %}
diff --git a/bookwyrm/templates/directory/directory.html b/bookwyrm/templates/directory/directory.html
index 88a7b15c..b900cb18 100644
--- a/bookwyrm/templates/directory/directory.html
+++ b/bookwyrm/templates/directory/directory.html
@@ -20,8 +20,8 @@
{% csrf_token %}
Join Directory
- {% url 'settings-profile' as path %}
- {% blocktrans %}You can opt-out at any time in your profile settings. {% endblocktrans %}
+ {% url 'prefs-profile' as path %}
+ {% blocktrans with path=path %}You can opt-out at any time in your profile settings. {% endblocktrans %}
diff --git a/bookwyrm/templates/get_started/book_preview.html b/bookwyrm/templates/get_started/book_preview.html
index 578fef70..d8941ad5 100644
--- a/bookwyrm/templates/get_started/book_preview.html
+++ b/bookwyrm/templates/get_started/book_preview.html
@@ -5,7 +5,7 @@
Add to your books
- {% for shelf in request.user.shelf_set.all %}
+ {% for shelf in user_shelves %}
{{ shelf.name }}
{% endfor %}
diff --git a/bookwyrm/templates/import.html b/bookwyrm/templates/import.html
index 2b7d69e1..d2e40748 100644
--- a/bookwyrm/templates/import.html
+++ b/bookwyrm/templates/import.html
@@ -41,8 +41,8 @@
-
- {% trans "Privacy setting for imported reviews:" %}
+
+ {% trans "Privacy setting for imported reviews:" %}
{% include 'snippets/privacy_select.html' with no_label=True %}
diff --git a/bookwyrm/templates/import_status.html b/bookwyrm/templates/import_status.html
index b1145611..11ff47f1 100644
--- a/bookwyrm/templates/import_status.html
+++ b/bookwyrm/templates/import_status.html
@@ -7,14 +7,19 @@
{% block content %}{% spaceless %}
{% trans "Import Status" %}
+
{% trans "Back to imports" %}
-
- {% trans "Import started:" %} {{ job.created_date | naturaltime }}
-
- {% if job.complete %}
-
- {% trans "Import completed:" %} {{ task.date_done | naturaltime }}
-
+
+
+
{% trans "Import started:" %}
+ {{ job.created_date | naturaltime }}
+
+ {% if job.complete %}
+
+
{% trans "Import completed:" %}
+ {{ task.date_done | naturaltime }}
+
+
{% elif task.failed %}
{% trans "TASK FAILED" %}
{% endif %}
@@ -22,8 +27,9 @@
{% if not job.complete %}
- {% trans "Import still in progress." %}
+ {% trans "Import still in progress." %}
+
{% trans "(Hit reload to update!)" %}
{% endif %}
@@ -49,16 +55,13 @@
@@ -104,7 +107,11 @@
{% endif %}
+ {% if job.complete %}
{% trans "Successfully imported" %}
+ {% else %}
+
{% trans "Import Progress" %}
+ {% endif %}
diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html
index bad1cb6e..5e66ff79 100644
--- a/bookwyrm/templates/layout.html
+++ b/bookwyrm/templates/layout.html
@@ -8,16 +8,21 @@
-
+
+ {% if preview_images_enabled is True %}
+
+ {% else %}
+ {% endif %}
-
-
+ {% block opengraph_images %}
+ {% include 'snippets/opengraph_images.html' %}
+ {% endblock %}
@@ -25,7 +30,7 @@