diff --git a/.editorconfig b/.editorconfig index d102bc5a..f2e8a178 100644 --- a/.editorconfig +++ b/.editorconfig @@ -32,7 +32,7 @@ indent_size = 2 max_line_length = off # Computer generated files -[{package.json,*.lock,*.mo}] +[{icons.css,package.json,*.lock,*.mo}] indent_size = unset indent_style = unset max_line_length = unset diff --git a/.env.dev.example b/.env.dev.example index f42aaaae..d4476fd2 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -43,6 +43,9 @@ EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true EMAIL_USE_SSL=false +# Thumbnails Generation +ENABLE_THUMBNAIL_GENERATION=false + # S3 configuration USE_S3=false AWS_ACCESS_KEY_ID= @@ -58,6 +61,7 @@ AWS_SECRET_ACCESS_KEY= # AWS_S3_REGION_NAME=None # "fr-par" # AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud" + # Preview image generation can be computing and storage intensive # ENABLE_PREVIEW_IMAGES=True diff --git a/.env.prod.example b/.env.prod.example index 5115469c..99520916 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -43,6 +43,9 @@ EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_USE_TLS=true EMAIL_USE_SSL=false +# Thumbnails Generation +ENABLE_THUMBNAIL_GENERATION=false + # S3 configuration USE_S3=false AWS_ACCESS_KEY_ID= @@ -58,6 +61,7 @@ AWS_SECRET_ACCESS_KEY= # AWS_S3_REGION_NAME=None # "fr-par" # AWS_S3_ENDPOINT_URL=None # "https://s3.fr-par.scw.cloud" + # Preview image generation can be computing and storage intensive # ENABLE_PREVIEW_IMAGES=True diff --git a/.github/workflows/curlylint.yaml b/.github/workflows/curlylint.yaml new file mode 100644 index 00000000..e27d0b1b --- /dev/null +++ b/.github/workflows/curlylint.yaml @@ -0,0 +1,28 @@ +name: Templates validator + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Install curlylint + run: pip install curlylint + + - name: Run linter + run: > + curlylint --rule 'aria_role: true' \ + --rule 'django_forms_rendering: true' \ + --rule 'html_has_lang: true' \ + --rule 'image_alt: true' \ + --rule 'meta_viewport: true' \ + --rule 'no_autofocus: true' \ + --rule 'tabindex_no_positive: true' \ + --exclude '_modal.html|create_status/layout.html' \ + bookwyrm/templates diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml index 03147744..03875193 100644 --- a/.github/workflows/django-tests.yml +++ b/.github/workflows/django-tests.yml @@ -36,6 +36,7 @@ jobs: env: SECRET_KEY: beepbeep DEBUG: false + USE_HTTPS: true DOMAIN: your.domain.here BOOKWYRM_DATABASE_BACKEND: postgres MEDIA_ROOT: images/ diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index da32fbaf..52b1b1f2 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -48,7 +48,7 @@ class Signature: def naive_parse(activity_objects, activity_json, serializer=None): - """this navigates circular import issues""" + """this navigates circular import issues by looking up models' serializers""" if not serializer: if activity_json.get("publicKeyPem"): # ugh @@ -106,8 +106,10 @@ class ActivityObject: value = field.default setattr(self, field.name, value) - # pylint: disable=too-many-locals,too-many-branches - def to_model(self, model=None, instance=None, allow_create=True, save=True): + # pylint: disable=too-many-locals,too-many-branches,too-many-arguments + def to_model( + self, model=None, instance=None, allow_create=True, save=True, overwrite=True + ): """convert from an activity to a model instance""" model = model or get_model_from_type(self.type) @@ -129,9 +131,12 @@ class ActivityObject: # keep track of what we've changed update_fields = [] + # sets field on the model using the activity value for field in instance.simple_fields: try: - changed = field.set_field_from_activity(instance, self) + changed = field.set_field_from_activity( + instance, self, overwrite=overwrite + ) if changed: update_fields.append(field.name) except AttributeError as e: @@ -140,7 +145,9 @@ class ActivityObject: # image fields have to be set after other fields because they can save # too early and jank up users for field in instance.image_fields: - changed = field.set_field_from_activity(instance, self, save=save) + changed = field.set_field_from_activity( + instance, self, save=save, overwrite=overwrite + ) if changed: update_fields.append(field.name) @@ -268,6 +275,8 @@ def resolve_remote_id( ): """take a remote_id and return an instance, creating if necessary""" if model: # a bonus check we can do if we already know the model + if isinstance(model, str): + model = apps.get_model(f"bookwyrm.{model}", require_ready=True) result = model.find_existing_by_remote_id(remote_id) if result and not refresh: return result if not get_activity else result.to_activity_dataclass() diff --git a/bookwyrm/activitypub/note.py b/bookwyrm/activitypub/note.py index 916da2d0..d61471fe 100644 --- a/bookwyrm/activitypub/note.py +++ b/bookwyrm/activitypub/note.py @@ -30,8 +30,8 @@ class Note(ActivityObject): to: List[str] = field(default_factory=lambda: []) cc: List[str] = field(default_factory=lambda: []) replies: Dict = field(default_factory=lambda: {}) - inReplyTo: str = "" - summary: str = "" + inReplyTo: str = None + summary: str = None tag: List[Link] = field(default_factory=lambda: []) attachment: List[Document] = field(default_factory=lambda: []) sensitive: bool = False @@ -59,6 +59,9 @@ class Comment(Note): """like a note but with a book""" inReplyToBook: str + readingStatus: str = None + progress: int = None + progressMode: str = None type: str = "Comment" @@ -67,6 +70,8 @@ class Quotation(Comment): """a quote and commentary on a book""" quote: str + position: int = None + positionMode: str = None type: str = "Quotation" diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index a49a7ce4..6dedd717 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -1,14 +1,16 @@ """ access the activity streams stored in redis """ from django.dispatch import receiver +from django.db import transaction from django.db.models import signals, Q from bookwyrm import models from bookwyrm.redis_store import RedisStore, r +from bookwyrm.tasks import app from bookwyrm.views.helpers import privacy_filter class ActivityStream(RedisStore): - """a category of activity stream (like home, local, federated)""" + """a category of activity stream (like home, local, books)""" def stream_id(self, user): """the redis key for this user's instance of this stream""" @@ -22,14 +24,15 @@ class ActivityStream(RedisStore): """statuses are sorted by date published""" return obj.published_date.timestamp() - def add_status(self, status): + def add_status(self, status, increment_unread=False): """add a status to users' feeds""" # the pipeline contains all the add-to-stream activities pipeline = self.add_object_to_related_stores(status, execute=False) - for user in self.get_audience(status): - # add to the unread status count - pipeline.incr(self.unread_id(user)) + if increment_unread: + for user in self.get_audience(status): + # add to the unread status count + pipeline.incr(self.unread_id(user)) # and go! pipeline.execute() @@ -55,7 +58,13 @@ class ActivityStream(RedisStore): return ( models.Status.objects.select_subclasses() .filter(id__in=statuses) - .select_related("user", "reply_parent") + .select_related( + "user", + "reply_parent", + "comment__book", + "review__book", + "quotation__book", + ) .prefetch_related("mention_books", "mention_users") .order_by("-published_date") ) @@ -155,29 +164,89 @@ class LocalStream(ActivityStream): ) -class FederatedStream(ActivityStream): - """users you follow""" +class BooksStream(ActivityStream): + """books on your shelves""" - key = "federated" + key = "books" def get_audience(self, status): - # this stream wants no part in non-public statuses - if status.privacy != "public": + """anyone with the mentioned book on their shelves""" + # only show public statuses on the books feed, + # and only statuses that mention books + if status.privacy != "public" or not ( + status.mention_books.exists() or hasattr(status, "book") + ): return [] - return super().get_audience(status) + + work = ( + status.book.parent_work + if hasattr(status, "book") + else status.mention_books.first().parent_work + ) + + audience = super().get_audience(status) + if not audience: + return [] + return audience.filter(shelfbook__book__parent_work=work).distinct() def get_statuses_for_user(self, user): + """any public status that mentions the user's books""" + books = user.shelfbook_set.values_list( + "book__parent_work__id", flat=True + ).distinct() return privacy_filter( user, - models.Status.objects.select_subclasses(), + models.Status.objects.select_subclasses() + .filter( + Q(comment__book__parent_work__id__in=books) + | Q(quotation__book__parent_work__id__in=books) + | Q(review__book__parent_work__id__in=books) + | Q(mention_books__parent_work__id__in=books) + ) + .distinct(), privacy_levels=["public"], ) + def add_book_statuses(self, user, book): + """add statuses about a book to a user's feed""" + work = book.parent_work + statuses = privacy_filter( + user, + models.Status.objects.select_subclasses() + .filter( + Q(comment__book__parent_work=work) + | Q(quotation__book__parent_work=work) + | Q(review__book__parent_work=work) + | Q(mention_books__parent_work=work) + ) + .distinct(), + privacy_levels=["public"], + ) + self.bulk_add_objects_to_store(statuses, self.stream_id(user)) + def remove_book_statuses(self, user, book): + """add statuses about a book to a user's feed""" + work = book.parent_work + statuses = privacy_filter( + user, + models.Status.objects.select_subclasses() + .filter( + Q(comment__book__parent_work=work) + | Q(quotation__book__parent_work=work) + | Q(review__book__parent_work=work) + | Q(mention_books__parent_work=work) + ) + .distinct(), + privacy_levels=["public"], + ) + self.bulk_remove_objects_from_store(statuses, self.stream_id(user)) + + +# determine which streams are enabled in settings.py streams = { "home": HomeStream(), "local": LocalStream(), - "federated": FederatedStream(), + "books": BooksStream(), } @@ -190,41 +259,31 @@ def add_status_on_create(sender, instance, created, *args, **kwargs): return if instance.deleted: - for stream in streams.values(): - stream.remove_object_from_related_stores(instance) + remove_status_task.delay(instance.id) return - if not created: - return - - # iterates through Home, Local, Federated - 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, + # when creating new things, gotta wait on the transaction + transaction.on_commit( + lambda: add_status_on_create_command(sender, instance, created) ) - for stream in streams.values(): - stream.remove_object_from_related_stores(boosted) - for status in old_versions: - stream.remove_object_from_related_stores(status) + + +def add_status_on_create_command(sender, instance, created): + """runs this code only after the database commit completes""" + add_status_task.delay(instance.id, increment_unread=created) + + if sender == models.Boost: + handle_boost_task.delay(instance.id) @receiver(signals.post_delete, sender=models.Boost) # pylint: disable=unused-argument 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) + # remove the boost + remove_status_task.delay(instance.id) + # re-add the original status + add_status_task.delay(instance.boosted_status.id) @receiver(signals.post_save, sender=models.UserFollows) @@ -233,7 +292,9 @@ def add_statuses_on_follow(sender, instance, created, *args, **kwargs): """add a newly followed user's statuses to feeds""" if not created or not instance.user_subject.local: return - HomeStream().add_user_statuses(instance.user_subject, instance.user_object) + add_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id, stream_list=["home"] + ) @receiver(signals.post_delete, sender=models.UserFollows) @@ -242,7 +303,9 @@ def remove_statuses_on_unfollow(sender, instance, *args, **kwargs): """remove statuses from a feed on unfollow""" if not instance.user_subject.local: return - HomeStream().remove_user_statuses(instance.user_subject, instance.user_object) + remove_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id, stream_list=["home"] + ) @receiver(signals.post_save, sender=models.UserBlocks) @@ -251,29 +314,38 @@ def remove_statuses_on_block(sender, instance, *args, **kwargs): """remove statuses from all feeds on block""" # blocks apply ot all feeds if instance.user_subject.local: - for stream in streams.values(): - stream.remove_user_statuses(instance.user_subject, instance.user_object) + remove_user_statuses_task.delay( + instance.user_subject.id, instance.user_object.id + ) # and in both directions if instance.user_object.local: - for stream in streams.values(): - stream.remove_user_statuses(instance.user_object, instance.user_subject) + remove_user_statuses_task.delay( + instance.user_object.id, instance.user_subject.id + ) @receiver(signals.post_delete, sender=models.UserBlocks) # pylint: disable=unused-argument def add_statuses_on_unblock(sender, instance, *args, **kwargs): """remove statuses from all feeds on block""" - public_streams = [LocalStream(), FederatedStream()] + public_streams = [v for (k, v) in streams.items() if k != "home"] + # add statuses back to streams with statuses from anyone if instance.user_subject.local: - for stream in public_streams: - stream.add_user_statuses(instance.user_subject, instance.user_object) + add_user_statuses_task.delay( + instance.user_subject.id, + instance.user_object.id, + stream_list=public_streams, + ) # add statuses back to streams with statuses from anyone if instance.user_object.local: - for stream in public_streams: - stream.add_user_statuses(instance.user_object, instance.user_subject) + add_user_statuses_task.delay( + instance.user_object.id, + instance.user_subject.id, + stream_list=public_streams, + ) @receiver(signals.post_save, sender=models.User) @@ -284,4 +356,123 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg return for stream in streams.values(): - stream.populate_streams(instance) + populate_stream_task.delay(stream, instance.id) + + +@receiver(signals.pre_save, sender=models.ShelfBook) +# pylint: disable=unused-argument +def add_statuses_on_shelve(sender, instance, *args, **kwargs): + """update books stream when user shelves a book""" + if not instance.user.local: + return + book = instance.book + + # check if the book is already on the user's shelves + editions = book.parent_work.editions.all() + if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists(): + return + + add_book_statuses_task.delay(instance.user.id, book.id) + + +@receiver(signals.post_delete, sender=models.ShelfBook) +# pylint: disable=unused-argument +def remove_statuses_on_unshelve(sender, instance, *args, **kwargs): + """update books stream when user unshelves a book""" + if not instance.user.local: + return + + book = instance.book + + # check if the book is actually unshelved, not just moved + editions = book.parent_work.editions.all() + if models.ShelfBook.objects.filter(user=instance.user, book__in=editions).exists(): + return + + remove_book_statuses_task.delay(instance.user.id, book.id) + + +# ---- TASKS + + +@app.task +def add_book_statuses_task(user_id, book_id): + """add statuses related to a book on shelve""" + user = models.User.objects.get(id=user_id) + book = models.Edition.objects.get(id=book_id) + BooksStream().add_book_statuses(user, book) + + +@app.task +def remove_book_statuses_task(user_id, book_id): + """remove statuses about a book from a user's books feed""" + user = models.User.objects.get(id=user_id) + book = models.Edition.objects.get(id=book_id) + BooksStream().remove_book_statuses(user, book) + + +@app.task +def populate_stream_task(stream, user_id): + """background task for populating an empty activitystream""" + user = models.User.objects.get(id=user_id) + stream = streams[stream] + stream.populate_streams(user) + + +@app.task +def remove_status_task(status_ids): + """remove a status from any stream it might be in""" + # this can take an id or a list of ids + if not isinstance(status_ids, list): + status_ids = [status_ids] + statuses = models.Status.objects.filter(id__in=status_ids) + + for stream in streams.values(): + for status in statuses: + stream.remove_object_from_related_stores(status) + + +@app.task +def add_status_task(status_id, increment_unread=False): + """remove a status from any stream it might be in""" + status = models.Status.objects.get(id=status_id) + for stream in streams.values(): + stream.add_status(status, increment_unread=increment_unread) + + +@app.task +def remove_user_statuses_task(viewer_id, user_id, stream_list=None): + """remove all statuses by a user from a viewer's stream""" + stream_list = [streams[s] for s in stream_list] if stream_list else streams.values() + viewer = models.User.objects.get(id=viewer_id) + user = models.User.objects.get(id=user_id) + for stream in stream_list: + stream.remove_user_statuses(viewer, user) + + +@app.task +def add_user_statuses_task(viewer_id, user_id, stream_list=None): + """remove all statuses by a user from a viewer's stream""" + stream_list = [streams[s] for s in stream_list] if stream_list else streams.values() + viewer = models.User.objects.get(id=viewer_id) + user = models.User.objects.get(id=user_id) + for stream in stream_list: + stream.add_user_statuses(viewer, user) + + +@app.task +def handle_boost_task(boost_id): + """remove the original post and other, earlier boosts""" + instance = models.Status.objects.get(id=boost_id) + boosted = instance.boost.boosted_status + + old_versions = models.Boost.objects.filter( + boosted_status__id=boosted.id, + created_date__lt=instance.created_date, + ).values_list("id", flat=True) + + for stream in streams.values(): + audience = stream.get_stores_for_object(instance) + stream.remove_object_from_related_stores(boosted, stores=audience) + for status in old_versions: + stream.remove_object_from_related_stores(status, stores=audience) diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index fb102ea4..ffacffdf 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -139,7 +139,7 @@ class AbstractConnector(AbstractMinimalConnector): **dict_from_mappings(work_data, self.book_mappings) ) # this will dedupe automatically - work = work_activity.to_model(model=models.Work) + work = work_activity.to_model(model=models.Work, overwrite=False) for author in self.get_authors_from_data(work_data): work.authors.add(author) @@ -156,7 +156,7 @@ class AbstractConnector(AbstractMinimalConnector): 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) + edition = edition_activity.to_model(model=models.Edition, overwrite=False) edition.connector = self.connector edition.save() @@ -182,7 +182,7 @@ class AbstractConnector(AbstractMinimalConnector): return None # this will dedupe - return activity.to_model(model=models.Author) + return activity.to_model(model=models.Author, overwrite=False) @abstractmethod def is_work_data(self, data): diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py index 116aa5c1..d2a7b9fa 100644 --- a/bookwyrm/connectors/inventaire.py +++ b/bookwyrm/connectors/inventaire.py @@ -71,7 +71,7 @@ class Connector(AbstractConnector): # flatten the data so that images, uri, and claims are on the same level return { **data.get("claims", {}), - **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks"]}, + **{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]}, } def search(self, query, min_confidence=None): # pylint: disable=arguments-differ @@ -145,8 +145,8 @@ class Connector(AbstractConnector): def get_edition_from_work_data(self, data): data = self.load_edition_data(data.get("uri")) try: - uri = data["uris"][0] - except KeyError: + uri = data.get("uris", [])[0] + except IndexError: raise ConnectorException("Invalid book data") return self.get_book_data(self.get_remote_id(uri)) diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py index 1f0387fe..0610a8b9 100644 --- a/bookwyrm/context_processors.py +++ b/bookwyrm/context_processors.py @@ -11,6 +11,7 @@ def site_settings(request): # pylint: disable=unused-argument return { "site": models.SiteSettings.objects.get(), "active_announcements": models.Announcement.active_announcements(), + "thumbnail_generation_enabled": settings.ENABLE_THUMBNAIL_GENERATION, "media_full_url": settings.MEDIA_FULL_URL, "preview_images_enabled": settings.ENABLE_PREVIEW_IMAGES, "request_protocol": request_protocol, diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index 657310b0..fff3985e 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -23,6 +23,14 @@ def email_data(): } +def email_confirmation_email(user): + """newly registered users confirm email address""" + data = email_data() + data["confirmation_code"] = user.confirmation_code + data["confirmation_link"] = user.confirmation_link + send_email.delay(user.email, *format_email("confirm", data)) + + def invite_email(invite_request): """send out an invite code""" data = email_data() diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index c9e795c3..69052605 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -86,6 +86,7 @@ class CommentForm(CustomForm): "privacy", "progress", "progress_mode", + "reading_status", ] @@ -100,6 +101,8 @@ class QuotationForm(CustomForm): "content_warning", "sensitive", "privacy", + "position", + "position_mode", ] diff --git a/bookwyrm/imagegenerators.py b/bookwyrm/imagegenerators.py new file mode 100644 index 00000000..1d065192 --- /dev/null +++ b/bookwyrm/imagegenerators.py @@ -0,0 +1,113 @@ +"""Generators for all the different thumbnail sizes""" +from imagekit import ImageSpec, register +from imagekit.processors import ResizeToFit + + +class BookXSmallWebp(ImageSpec): + """Handles XSmall size in Webp format""" + + processors = [ResizeToFit(80, 80)] + format = "WEBP" + options = {"quality": 95} + + +class BookXSmallJpg(ImageSpec): + """Handles XSmall size in Jpeg format""" + + processors = [ResizeToFit(80, 80)] + format = "JPEG" + options = {"quality": 95} + + +class BookSmallWebp(ImageSpec): + """Handles Small size in Webp format""" + + processors = [ResizeToFit(100, 100)] + format = "WEBP" + options = {"quality": 95} + + +class BookSmallJpg(ImageSpec): + """Handles Small size in Jpeg format""" + + processors = [ResizeToFit(100, 100)] + format = "JPEG" + options = {"quality": 95} + + +class BookMediumWebp(ImageSpec): + """Handles Medium size in Webp format""" + + processors = [ResizeToFit(150, 150)] + format = "WEBP" + options = {"quality": 95} + + +class BookMediumJpg(ImageSpec): + """Handles Medium size in Jpeg format""" + + processors = [ResizeToFit(150, 150)] + format = "JPEG" + options = {"quality": 95} + + +class BookLargeWebp(ImageSpec): + """Handles Large size in Webp format""" + + processors = [ResizeToFit(200, 200)] + format = "WEBP" + options = {"quality": 95} + + +class BookLargeJpg(ImageSpec): + """Handles Large size in Jpeg format""" + + processors = [ResizeToFit(200, 200)] + format = "JPEG" + options = {"quality": 95} + + +class BookXLargeWebp(ImageSpec): + """Handles XLarge size in Webp format""" + + processors = [ResizeToFit(250, 250)] + format = "WEBP" + options = {"quality": 95} + + +class BookXLargeJpg(ImageSpec): + """Handles XLarge size in Jpeg format""" + + processors = [ResizeToFit(250, 250)] + format = "JPEG" + options = {"quality": 95} + + +class BookXxLargeWebp(ImageSpec): + """Handles XxLarge size in Webp format""" + + processors = [ResizeToFit(500, 500)] + format = "WEBP" + options = {"quality": 95} + + +class BookXxLargeJpg(ImageSpec): + """Handles XxLarge size in Jpeg format""" + + processors = [ResizeToFit(500, 500)] + format = "JPEG" + options = {"quality": 95} + + +register.generator("bw:book:xsmall:webp", BookXSmallWebp) +register.generator("bw:book:xsmall:jpg", BookXSmallJpg) +register.generator("bw:book:small:webp", BookSmallWebp) +register.generator("bw:book:small:jpg", BookSmallJpg) +register.generator("bw:book:medium:webp", BookMediumWebp) +register.generator("bw:book:medium:jpg", BookMediumJpg) +register.generator("bw:book:large:webp", BookLargeWebp) +register.generator("bw:book:large:jpg", BookLargeJpg) +register.generator("bw:book:xlarge:webp", BookXLargeWebp) +register.generator("bw:book:xlarge:jpg", BookXLargeJpg) +register.generator("bw:book:xxlarge:webp", BookXxLargeWebp) +register.generator("bw:book:xxlarge:jpg", BookXxLargeJpg) diff --git a/bookwyrm/management/commands/populate_streams.py b/bookwyrm/management/commands/populate_streams.py index f8aa21a5..a04d7f5a 100644 --- a/bookwyrm/management/commands/populate_streams.py +++ b/bookwyrm/management/commands/populate_streams.py @@ -3,22 +3,35 @@ from django.core.management.base import BaseCommand from bookwyrm import activitystreams, models -def populate_streams(): +def populate_streams(stream=None): """build all the streams for all the users""" + streams = [stream] if stream else activitystreams.streams.keys() + print("Populations streams", streams) users = models.User.objects.filter( local=True, is_active=True, - ) + ).order_by("-last_active_date") + print("This may take a long time! Please be patient.") for user in users: - for stream in activitystreams.streams.values(): - stream.populate_streams(user) + for stream_key in streams: + print(".", end="") + activitystreams.populate_stream_task.delay(stream_key, user.id) class Command(BaseCommand): """start all over with user streams""" help = "Populate streams for all users" + + def add_arguments(self, parser): + parser.add_argument( + "--stream", + default=None, + help="Specifies which time of stream to populate", + ) + # pylint: disable=no-self-use,unused-argument def handle(self, *args, **options): """run feed builder""" - populate_streams() + stream = options.get("stream") + populate_streams(stream=stream) diff --git a/bookwyrm/migrations/0080_alter_shelfbook_options.py b/bookwyrm/migrations/0080_alter_shelfbook_options.py new file mode 100644 index 00000000..b5ee7e67 --- /dev/null +++ b/bookwyrm/migrations/0080_alter_shelfbook_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.4 on 2021-08-05 00:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0079_merge_20210804_1746"), + ] + + operations = [ + migrations.AlterModelOptions( + name="shelfbook", + options={"ordering": ("-shelved_date", "-created_date", "-updated_date")}, + ), + ] diff --git a/bookwyrm/migrations/0081_alter_user_last_active_date.py b/bookwyrm/migrations/0081_alter_user_last_active_date.py new file mode 100644 index 00000000..dc6b640f --- /dev/null +++ b/bookwyrm/migrations/0081_alter_user_last_active_date.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.4 on 2021-08-06 02:51 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0080_alter_shelfbook_options"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="last_active_date", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/bookwyrm/migrations/0082_auto_20210806_2324.py b/bookwyrm/migrations/0082_auto_20210806_2324.py new file mode 100644 index 00000000..ab0aa158 --- /dev/null +++ b/bookwyrm/migrations/0082_auto_20210806_2324.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2.4 on 2021-08-06 23:24 + +import bookwyrm.models.base_model +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0081_alter_user_last_active_date"), + ] + + operations = [ + migrations.AddField( + model_name="sitesettings", + name="require_confirm_email", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="user", + name="confirmation_code", + field=models.CharField( + default=bookwyrm.models.base_model.new_access_code, max_length=32 + ), + ), + migrations.AlterField( + model_name="connector", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("pending", "Pending"), + ("self_deletion", "Self Deletion"), + ("moderator_deletion", "Moderator Deletion"), + ("domain_block", "Domain Block"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="user", + name="deactivation_reason", + field=models.CharField( + blank=True, + choices=[ + ("pending", "Pending"), + ("self_deletion", "Self Deletion"), + ("moderator_deletion", "Moderator Deletion"), + ("domain_block", "Domain Block"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0083_auto_20210816_2022.py b/bookwyrm/migrations/0083_auto_20210816_2022.py new file mode 100644 index 00000000..ecf2778b --- /dev/null +++ b/bookwyrm/migrations/0083_auto_20210816_2022.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2.4 on 2021-08-16 20:22 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0082_auto_20210806_2324"), + ] + + operations = [ + migrations.AddField( + model_name="comment", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "Toread"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="quotation", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "Toread"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + migrations.AddField( + model_name="review", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "Toread"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0084_auto_20210817_1916.py b/bookwyrm/migrations/0084_auto_20210817_1916.py new file mode 100644 index 00000000..6e826f99 --- /dev/null +++ b/bookwyrm/migrations/0084_auto_20210817_1916.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2.4 on 2021-08-17 19:16 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0083_auto_20210816_2022"), + ] + + operations = [ + migrations.AlterField( + model_name="comment", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "To-Read"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="quotation", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "To-Read"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="review", + name="reading_status", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("to-read", "To-Read"), + ("reading", "Reading"), + ("read", "Read"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0085_user_saved_lists.py b/bookwyrm/migrations/0085_user_saved_lists.py new file mode 100644 index 00000000..d4d9278c --- /dev/null +++ b/bookwyrm/migrations/0085_user_saved_lists.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.4 on 2021-08-23 18:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0084_auto_20210817_1916"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="saved_lists", + field=models.ManyToManyField( + related_name="saved_lists", to="bookwyrm.List" + ), + ), + ] diff --git a/bookwyrm/migrations/0086_auto_20210827_1727.py b/bookwyrm/migrations/0086_auto_20210827_1727.py new file mode 100644 index 00000000..ef6af206 --- /dev/null +++ b/bookwyrm/migrations/0086_auto_20210827_1727.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2.4 on 2021-08-27 17:27 + +from django.db import migrations, models +import django.db.models.expressions + + +def normalize_readthrough_dates(app_registry, schema_editor): + """Find any invalid dates and reset them""" + db_alias = schema_editor.connection.alias + app_registry.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter( + start_date__gt=models.F("finish_date") + ).update(start_date=models.F("finish_date")) + + +def reverse_func(apps, schema_editor): + """nothing to do here""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0085_user_saved_lists"), + ] + + operations = [ + migrations.RunPython(normalize_readthrough_dates, reverse_func), + migrations.AlterModelOptions( + name="readthrough", + options={"ordering": ("-start_date",)}, + ), + migrations.AddConstraint( + model_name="readthrough", + constraint=models.CheckConstraint( + check=models.Q( + ("finish_date__gte", django.db.models.expressions.F("start_date")) + ), + name="chronology", + ), + ), + ] diff --git a/bookwyrm/migrations/0086_auto_20210828_1724.py b/bookwyrm/migrations/0086_auto_20210828_1724.py new file mode 100644 index 00000000..21247711 --- /dev/null +++ b/bookwyrm/migrations/0086_auto_20210828_1724.py @@ -0,0 +1,49 @@ +# Generated by Django 3.2.4 on 2021-08-28 17:24 + +import bookwyrm.models.fields +from django.conf import settings +from django.db import migrations, models +from django.db.models import F, Value, CharField +from django.db.models.functions import Concat + + +def forwards_func(apps, schema_editor): + """generate followers url""" + db_alias = schema_editor.connection.alias + apps.get_model("bookwyrm", "User").objects.using(db_alias).annotate( + generated_url=Concat( + F("remote_id"), Value("/followers"), output_field=CharField() + ) + ).update(followers_url=models.F("generated_url")) + + +def reverse_func(apps, schema_editor): + """noop""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0085_user_saved_lists"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="followers_url", + field=bookwyrm.models.fields.CharField( + default="/followers", max_length=255 + ), + preserve_default=False, + ), + migrations.RunPython(forwards_func, reverse_func), + migrations.AlterField( + model_name="user", + name="followers", + field=models.ManyToManyField( + related_name="following", + through="bookwyrm.UserFollows", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py b/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py new file mode 100644 index 00000000..cd531161 --- /dev/null +++ b/bookwyrm/migrations/0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.4 on 2021-08-29 18:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0086_auto_20210827_1727"), + ("bookwyrm", "0086_auto_20210828_1724"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0088_auto_20210905_2233.py b/bookwyrm/migrations/0088_auto_20210905_2233.py new file mode 100644 index 00000000..028cf7bf --- /dev/null +++ b/bookwyrm/migrations/0088_auto_20210905_2233.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.4 on 2021-09-05 22:33 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0087_merge_0086_auto_20210827_1727_0086_auto_20210828_1724"), + ] + + operations = [ + migrations.AddField( + model_name="quotation", + name="position", + field=models.IntegerField( + blank=True, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + migrations.AddField( + model_name="quotation", + name="position_mode", + field=models.CharField( + blank=True, + choices=[("PG", "page"), ("PCT", "percent")], + default="PG", + max_length=3, + null=True, + ), + ), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index 729d9cba..f287b752 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -7,7 +7,7 @@ import operator import logging from uuid import uuid4 import requests -from requests.exceptions import HTTPError, SSLError +from requests.exceptions import RequestException from Crypto.PublicKey import RSA from Crypto.Signature import pkcs1_15 @@ -43,7 +43,7 @@ class ActivitypubMixin: reverse_unfurl = False def __init__(self, *args, **kwargs): - """collect some info on model fields""" + """collect some info on model fields for later use""" self.image_fields = [] self.many_to_many_fields = [] self.simple_fields = [] # "simple" @@ -362,6 +362,13 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin): self.collection_queryset, **kwargs ).serialize() + def delete(self, *args, broadcast=True, **kwargs): + """Delete the object""" + activity = self.to_delete_activity(self.user) + super().delete(*args, **kwargs) + if self.user.local and broadcast: + self.broadcast(activity, self.user) + class CollectionItemMixin(ActivitypubMixin): """for items that are part of an (Ordered)Collection""" @@ -503,7 +510,7 @@ def broadcast_task(sender_id, activity, recipients): for recipient in recipients: try: sign_and_send(sender, activity, recipient) - except (HTTPError, SSLError, requests.exceptions.ConnectionError): + except RequestException: pass diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 2cb7c036..5b55ea50 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -1,4 +1,6 @@ """ base model with default fields """ +import base64 +from Crypto import Random from django.db import models from django.dispatch import receiver @@ -9,6 +11,7 @@ from .fields import RemoteIdField DeactivationReason = models.TextChoices( "DeactivationReason", [ + "pending", "self_deletion", "moderator_deletion", "domain_block", @@ -16,6 +19,11 @@ DeactivationReason = models.TextChoices( ) +def new_access_code(): + """the identifier for a user invite""" + return base64.b32encode(Random.get_random_bytes(5)).decode("ascii") + + class BookWyrmModel(models.Model): """shared fields""" diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index d9382807..e21d61e2 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -7,10 +7,16 @@ from django.db import models from django.dispatch import receiver from model_utils import FieldTracker from model_utils.managers import InheritanceManager +from imagekit.models import ImageSpecField from bookwyrm import activitypub from bookwyrm.preview_images import generate_edition_preview_image_task -from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE, ENABLE_PREVIEW_IMAGES +from bookwyrm.settings import ( + DOMAIN, + DEFAULT_LANGUAGE, + ENABLE_PREVIEW_IMAGES, + ENABLE_THUMBNAIL_GENERATION, +) from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin from .base_model import BookWyrmModel @@ -97,6 +103,40 @@ class Book(BookDataModel): objects = InheritanceManager() field_tracker = FieldTracker(fields=["authors", "title", "subtitle", "cover"]) + if ENABLE_THUMBNAIL_GENERATION: + cover_bw_book_xsmall_webp = ImageSpecField( + source="cover", id="bw:book:xsmall:webp" + ) + cover_bw_book_xsmall_jpg = ImageSpecField( + source="cover", id="bw:book:xsmall:jpg" + ) + cover_bw_book_small_webp = ImageSpecField( + source="cover", id="bw:book:small:webp" + ) + cover_bw_book_small_jpg = ImageSpecField(source="cover", id="bw:book:small:jpg") + cover_bw_book_medium_webp = ImageSpecField( + source="cover", id="bw:book:medium:webp" + ) + cover_bw_book_medium_jpg = ImageSpecField( + source="cover", id="bw:book:medium:jpg" + ) + cover_bw_book_large_webp = ImageSpecField( + source="cover", id="bw:book:large:webp" + ) + cover_bw_book_large_jpg = ImageSpecField(source="cover", id="bw:book:large:jpg") + cover_bw_book_xlarge_webp = ImageSpecField( + source="cover", id="bw:book:xlarge:webp" + ) + cover_bw_book_xlarge_jpg = ImageSpecField( + source="cover", id="bw:book:xlarge:jpg" + ) + cover_bw_book_xxlarge_webp = ImageSpecField( + source="cover", id="bw:book:xxlarge:webp" + ) + cover_bw_book_xxlarge_jpg = ImageSpecField( + source="cover", id="bw:book:xxlarge:jpg" + ) + @property def author_text(self): """format a list of authors""" diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index b58f8174..cc5a7bb5 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -66,7 +66,7 @@ class ActivitypubFieldMixin: self.activitypub_field = activitypub_field super().__init__(*args, **kwargs) - def set_field_from_activity(self, instance, data): + def set_field_from_activity(self, instance, data, overwrite=True): """helper function for assinging a value to the field. Returns if changed""" try: value = getattr(data, self.get_activitypub_field()) @@ -79,8 +79,15 @@ class ActivitypubFieldMixin: if formatted is None or formatted is MISSING or formatted == {}: return False + current_value = ( + getattr(instance, self.name) if hasattr(instance, self.name) else None + ) + # if we're not in overwrite mode, only continue updating the field if its unset + if current_value and not overwrite: + return False + # the field is unchanged - if hasattr(instance, self.name) and getattr(instance, self.name) == formatted: + if current_value == formatted: return False setattr(instance, self.name, formatted) @@ -210,12 +217,27 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): ) # pylint: disable=invalid-name - def set_field_from_activity(self, instance, data): + def set_field_from_activity(self, instance, data, overwrite=True): + if not overwrite: + return False + original = getattr(instance, self.name) to = data.to cc = data.cc + + # we need to figure out who this is to get their followers link + for field in ["attributedTo", "owner", "actor"]: + if hasattr(data, field): + user_field = field + break + if not user_field: + raise ValidationError("No user field found for privacy", data) + user = activitypub.resolve_remote_id(getattr(data, user_field), model="User") + if to == [self.public]: setattr(instance, self.name, "public") + elif to == [user.followers_url]: + setattr(instance, self.name, "followers") elif cc == []: setattr(instance, self.name, "direct") elif self.public in cc: @@ -231,9 +253,7 @@ class PrivacyField(ActivitypubFieldMixin, models.CharField): 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) + followers = instance.user.followers_url if instance.privacy == "public": activity["to"] = [self.public] activity["cc"] = [followers] + mentions @@ -273,8 +293,11 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField): self.link_only = link_only super().__init__(*args, **kwargs) - def set_field_from_activity(self, instance, data): + def set_field_from_activity(self, instance, data, overwrite=True): """helper function for assinging a value to the field""" + if not overwrite and getattr(instance, self.name).exists(): + return False + value = getattr(data, self.get_activitypub_field()) formatted = self.field_from_activity(value) if formatted is None or formatted is MISSING: @@ -377,13 +400,16 @@ class ImageField(ActivitypubFieldMixin, models.ImageField): super().__init__(*args, **kwargs) # pylint: disable=arguments-differ - def set_field_from_activity(self, instance, data, save=True): + def set_field_from_activity(self, instance, data, save=True, overwrite=True): """helper function for assinging a value to the field""" value = getattr(data, self.get_activitypub_field()) formatted = self.field_from_activity(value) if formatted is None or formatted is MISSING: return False + if not overwrite and hasattr(instance, self.name): + return False + getattr(instance, self.name).save(*formatted, save=save) return True diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index f2993846..05aada16 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -80,7 +80,7 @@ class ImportItem(models.Model): 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() + self.book = self.get_book_from_title_author() def get_book_from_isbn(self): """search by isbn""" diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index df341c8b..343d3c11 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -1,7 +1,8 @@ """ progress in a book """ -from django.db import models -from django.utils import timezone from django.core import validators +from django.db import models +from django.db.models import F, Q +from django.utils import timezone from .base_model import BookWyrmModel @@ -41,6 +42,16 @@ class ReadThrough(BookWyrmModel): ) return None + class Meta: + """Don't let readthroughs end before they start""" + + constraints = [ + models.CheckConstraint( + check=Q(finish_date__gte=F("start_date")), name="chronology" + ) + ] + ordering = ("-start_date",) + class ProgressUpdate(BookWyrmModel): """Store progress through a book in the database.""" diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py index 872f6b45..ef3f7c3c 100644 --- a/bookwyrm/models/site.py +++ b/bookwyrm/models/site.py @@ -1,8 +1,6 @@ """ the particulars for this instance of BookWyrm """ -import base64 import datetime -from Crypto import Random from django.db import models, IntegrityError from django.dispatch import receiver from django.utils import timezone @@ -10,7 +8,7 @@ from model_utils import FieldTracker from bookwyrm.preview_images import generate_site_preview_image_task from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES -from .base_model import BookWyrmModel +from .base_model import BookWyrmModel, new_access_code from .user import User @@ -33,6 +31,7 @@ class SiteSettings(models.Model): # registration allow_registration = models.BooleanField(default=True) allow_invite_requests = models.BooleanField(default=True) + require_confirm_email = models.BooleanField(default=True) # images logo = models.ImageField(upload_to="logos/", null=True, blank=True) @@ -61,11 +60,6 @@ class SiteSettings(models.Model): return default_settings -def new_access_code(): - """the identifier for a user invite""" - return base64.b32encode(Random.get_random_bytes(5)).decode("ascii") - - class SiteInvite(models.Model): """gives someone access to create an account on the instance""" diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 3c25f1af..d0f094d2 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -235,12 +235,31 @@ class GeneratedNote(Status): pure_type = "Note" -class Comment(Status): - """like a review but without a rating and transient""" +ReadingStatusChoices = models.TextChoices( + "ReadingStatusChoices", ["to-read", "reading", "read"] +) + + +class BookStatus(Status): + """Shared fields for comments, quotes, reviews""" book = fields.ForeignKey( "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook" ) + pure_type = "Note" + + reading_status = fields.CharField( + max_length=255, choices=ReadingStatusChoices.choices, null=True, blank=True + ) + + class Meta: + """not a real model, sorry""" + + abstract = True + + +class Comment(BookStatus): + """like a review but without a rating and transient""" # this is it's own field instead of a foreign key to the progress update # so that the update can be deleted without impacting the status @@ -265,15 +284,21 @@ class Comment(Status): ) activity_serializer = activitypub.Comment - pure_type = "Note" -class Quotation(Status): +class Quotation(BookStatus): """like a review but without a rating and transient""" quote = fields.HtmlField() - book = fields.ForeignKey( - "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook" + position = models.IntegerField( + validators=[MinValueValidator(0)], null=True, blank=True + ) + position_mode = models.CharField( + max_length=3, + choices=ProgressMode.choices, + default=ProgressMode.PAGE, + null=True, + blank=True, ) @property @@ -289,16 +314,12 @@ class Quotation(Status): ) activity_serializer = activitypub.Quotation - pure_type = "Note" -class Review(Status): +class Review(BookStatus): """a book review""" name = fields.CharField(max_length=255, null=True) - book = fields.ForeignKey( - "Edition", on_delete=models.PROTECT, activitypub_field="inReplyToBook" - ) rating = fields.DecimalField( default=None, null=True, diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 21b6bbaa..0745dffa 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -17,16 +17,22 @@ from bookwyrm.connectors import get_data, ConnectorException from bookwyrm.models.shelf import Shelf from bookwyrm.models.status import Status, Review from bookwyrm.preview_images import generate_user_preview_image_task -from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES +from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS from bookwyrm.signatures import create_key_pair from bookwyrm.tasks import app from bookwyrm.utils import regex from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin -from .base_model import BookWyrmModel, DeactivationReason +from .base_model import BookWyrmModel, DeactivationReason, new_access_code from .federated_server import FederatedServer from . import fields, Review +def site_link(): + """helper for generating links to the site""" + protocol = "https" if USE_HTTPS else "http" + return f"{protocol}://{DOMAIN}" + + class User(OrderedCollectionPageMixin, AbstractUser): """a user who wants to read books""" @@ -76,9 +82,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): preview_image = models.ImageField( upload_to="previews/avatars/", blank=True, null=True ) - followers = fields.ManyToManyField( + followers_url = fields.CharField(max_length=255, activitypub_field="followers") + followers = models.ManyToManyField( "self", - link_only=True, symmetrical=False, through="UserFollows", through_fields=("user_object", "user_subject"), @@ -98,6 +104,9 @@ class User(OrderedCollectionPageMixin, AbstractUser): through_fields=("user_subject", "user_object"), related_name="blocked_by", ) + saved_lists = models.ManyToManyField( + "List", symmetrical=False, related_name="saved_lists" + ) favorites = models.ManyToManyField( "Status", symmetrical=False, @@ -111,7 +120,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): remote_id = fields.RemoteIdField(null=True, unique=True, activitypub_field="id") created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) - last_active_date = models.DateTimeField(auto_now=True) + last_active_date = models.DateTimeField(default=timezone.now) manually_approves_followers = fields.BooleanField(default=False) show_goal = models.BooleanField(default=True) discoverable = fields.BooleanField(default=False) @@ -123,11 +132,18 @@ class User(OrderedCollectionPageMixin, AbstractUser): deactivation_reason = models.CharField( max_length=255, choices=DeactivationReason.choices, null=True, blank=True ) + confirmation_code = models.CharField(max_length=32, default=new_access_code) name_field = "username" property_fields = [("following_link", "following")] field_tracker = FieldTracker(fields=["name", "avatar"]) + @property + def confirmation_link(self): + """helper for generating confirmation links""" + link = site_link() + return f"{link}/confirm-email/{self.confirmation_code}" + @property def following_link(self): """just how to find out the following info""" @@ -207,17 +223,17 @@ class User(OrderedCollectionPageMixin, AbstractUser): self.following.order_by("-updated_date").all(), remote_id=remote_id, id_only=True, - **kwargs + **kwargs, ) def to_followers_activity(self, **kwargs): """activitypub followers list""" - remote_id = "%s/followers" % self.remote_id + remote_id = self.followers_url return self.to_ordered_collection( self.followers.order_by("-updated_date").all(), remote_id=remote_id, id_only=True, - **kwargs + **kwargs, ) def to_activity(self, **kwargs): @@ -259,10 +275,12 @@ class User(OrderedCollectionPageMixin, AbstractUser): return # populate fields for local users - self.remote_id = "https://%s/user/%s" % (DOMAIN, self.localname) - self.inbox = "%s/inbox" % self.remote_id - self.shared_inbox = "https://%s/inbox" % DOMAIN - self.outbox = "%s/outbox" % self.remote_id + link = site_link() + self.remote_id = f"{link}/user/{self.localname}" + self.followers_url = f"{self.remote_id}/followers" + self.inbox = f"{self.remote_id}/inbox" + self.shared_inbox = f"{link}/inbox" + self.outbox = f"{self.remote_id}/outbox" # an id needs to be set before we can proceed with related models super().save(*args, **kwargs) diff --git a/bookwyrm/redis_store.py b/bookwyrm/redis_store.py index fa5c73a5..521e73b2 100644 --- a/bookwyrm/redis_store.py +++ b/bookwyrm/redis_store.py @@ -33,10 +33,11 @@ class RedisStore(ABC): # and go! return pipeline.execute() - def remove_object_from_related_stores(self, obj): + def remove_object_from_related_stores(self, obj, stores=None): """remove an object from all stores""" + stores = stores or self.get_stores_for_object(obj) pipeline = r.pipeline() - for store in self.get_stores_for_object(obj): + for store in stores: pipeline.zrem(store, -1, obj.id) pipeline.execute() diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 17fcfabe..c1f90079 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -75,6 +75,7 @@ INSTALLED_APPS = [ "django_rename_app", "bookwyrm", "celery", + "imagekit", "storages", ] @@ -118,7 +119,11 @@ REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379) REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None) MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200)) -STREAMS = ["home", "local", "federated"] + +STREAMS = [ + {"key": "home", "name": _("Home Timeline"), "shortname": _("Home")}, + {"key": "books", "name": _("Books Timeline"), "shortname": _("Books")}, +] # Database # https://docs.djangoproject.com/en/3.2/ref/settings/#databases @@ -187,6 +192,9 @@ USER_AGENT = "%s (BookWyrm/%s; +https://%s/)" % ( DOMAIN, ) +# Imagekit generated thumbnails +ENABLE_THUMBNAIL_GENERATION = env.bool("ENABLE_THUMBNAIL_GENERATION", False) +IMAGEKIT_CACHEFILE_DIR = "thumbnails" # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css index d10fb9b7..0724c7f1 100644 --- a/bookwyrm/static/css/bookwyrm.css +++ b/bookwyrm/static/css/bookwyrm.css @@ -29,6 +29,15 @@ body { min-width: 75% !important; } +.modal-card-body { + max-height: 70vh; +} + +.clip-text { + max-height: 35em; + overflow: hidden; +} + /** Utilities not covered by Bulma ******************************************************************************/ @@ -227,16 +236,21 @@ body { /* Cover caption * -------------------------------------------------------------------------- */ -.no-cover .cover_caption { +.no-cover .cover-caption { position: absolute; top: 0; right: 0; bottom: 0; left: 0; - padding: 0.25em; + padding: 0.5em; font-size: 0.75em; color: white; background-color: #002549; + display: flex; + align-items: center; + justify-content: center; + white-space: initial; + text-align: center; } /** Avatars diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot index 566fb13d..2c801b2b 100644 Binary files a/bookwyrm/static/css/fonts/icomoon.eot and b/bookwyrm/static/css/fonts/icomoon.eot differ diff --git a/bookwyrm/static/css/fonts/icomoon.svg b/bookwyrm/static/css/fonts/icomoon.svg index 6be97327..6327b19e 100644 --- a/bookwyrm/static/css/fonts/icomoon.svg +++ b/bookwyrm/static/css/fonts/icomoon.svg @@ -33,13 +33,12 @@ - + - - - - + + + diff --git a/bookwyrm/static/css/fonts/icomoon.ttf b/bookwyrm/static/css/fonts/icomoon.ttf index 55df6418..242ca739 100644 Binary files a/bookwyrm/static/css/fonts/icomoon.ttf and b/bookwyrm/static/css/fonts/icomoon.ttf differ diff --git a/bookwyrm/static/css/fonts/icomoon.woff b/bookwyrm/static/css/fonts/icomoon.woff index fa53e8cf..67b0f0a6 100644 Binary files a/bookwyrm/static/css/fonts/icomoon.woff and b/bookwyrm/static/css/fonts/icomoon.woff differ diff --git a/bookwyrm/static/css/vendor/icons.css b/bookwyrm/static/css/vendor/icons.css index c78af145..db783c24 100644 --- a/bookwyrm/static/css/vendor/icons.css +++ b/bookwyrm/static/css/vendor/icons.css @@ -1,156 +1,150 @@ - -/** @todo Replace icons with SVG symbols. - @see https://www.youtube.com/watch?v=9xXBYcWgCHA */ @font-face { - font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?n5x55'); - src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?n5x55') format('truetype'), - url('../fonts/icomoon.woff?n5x55') format('woff'), - url('../fonts/icomoon.svg?n5x55#icomoon') format('svg'); - font-weight: normal; - font-style: normal; - font-display: block; + font-family: 'icomoon'; + src: url('../fonts/icomoon.eot?19nagi'); + src: url('../fonts/icomoon.eot?19nagi#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?19nagi') format('truetype'), + url('../fonts/icomoon.woff?19nagi') format('woff'), + url('../fonts/icomoon.svg?19nagi#icomoon') format('svg'); + font-weight: normal; + font-style: normal; + font-display: block; } [class^="icon-"], [class*=" icon-"] { - /* use !important to prevent issues with browser extensions that change fonts */ - font-family: 'icomoon' !important; - speak: never; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: 'icomoon' !important; + speak: never; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; - /* Better Font Rendering =========== */ - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } .icon-graphic-heart:before { - content: "\e91e"; + content: "\e91e"; } .icon-graphic-paperplane:before { - content: "\e91f"; + content: "\e91f"; } .icon-graphic-banknote:before { - content: "\e920"; -} -.icon-stars:before { - content: "\e91a"; + content: "\e920"; } .icon-warning:before { - content: "\e91b"; + content: "\e91b"; } .icon-book:before { - content: "\e900"; + content: "\e900"; } .icon-bookmark:before { - content: "\e91c"; + content: "\e91a"; } .icon-rss:before { - content: "\e91d"; + content: "\e91d"; } .icon-envelope:before { - content: "\e901"; + content: "\e901"; } .icon-arrow-right:before { - content: "\e902"; + content: "\e902"; } .icon-bell:before { - content: "\e903"; + content: "\e903"; } .icon-x:before { - content: "\e904"; + content: "\e904"; } .icon-quote-close:before { - content: "\e905"; + content: "\e905"; } .icon-quote-open:before { - content: "\e906"; + content: "\e906"; } .icon-image:before { - content: "\e907"; + content: "\e907"; } .icon-pencil:before { - content: "\e908"; + content: "\e908"; } .icon-list:before { - content: "\e909"; + content: "\e909"; } .icon-unlock:before { - content: "\e90a"; + content: "\e90a"; } .icon-unlisted:before { - content: "\e90a"; + content: "\e90a"; } .icon-globe:before { - content: "\e90b"; + content: "\e90b"; } .icon-public:before { - content: "\e90b"; + content: "\e90b"; } .icon-lock:before { - content: "\e90c"; + content: "\e90c"; } .icon-followers:before { - content: "\e90c"; + content: "\e90c"; } .icon-chain-broken:before { - content: "\e90d"; + content: "\e90d"; } .icon-chain:before { - content: "\e90e"; + content: "\e90e"; } .icon-comments:before { - content: "\e90f"; + content: "\e90f"; } .icon-comment:before { - content: "\e910"; + content: "\e910"; } .icon-boost:before { - content: "\e911"; + content: "\e911"; } .icon-arrow-left:before { - content: "\e912"; + content: "\e912"; } .icon-arrow-up:before { - content: "\e913"; + content: "\e913"; } .icon-arrow-down:before { - content: "\e914"; + content: "\e914"; } .icon-home:before { - content: "\e915"; + content: "\e915"; } .icon-local:before { - content: "\e916"; + content: "\e916"; } .icon-dots-three:before { - content: "\e917"; + content: "\e917"; } .icon-check:before { - content: "\e918"; + content: "\e918"; } .icon-dots-three-vertical:before { - content: "\e919"; + content: "\e919"; } .icon-search:before { - content: "\e986"; + content: "\e986"; } .icon-star-empty:before { - content: "\e9d7"; + content: "\e9d7"; } .icon-star-half:before { - content: "\e9d8"; + content: "\e9d8"; } .icon-star-full:before { - content: "\e9d9"; + content: "\e9d9"; } .icon-heart:before { - content: "\e9da"; + content: "\e9da"; } .icon-plus:before { - content: "\ea0a"; + content: "\ea0a"; } diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index e43ed134..894b1fb6 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -138,8 +138,11 @@ let BookWyrm = new class { * @return {undefined} */ toggleAction(event) { - event.preventDefault(); let trigger = event.currentTarget; + + if (!trigger.dataset.allowDefault || event.currentTarget == event.target) { + event.preventDefault(); + } let pressed = trigger.getAttribute('aria-pressed') === 'false'; let targetId = trigger.dataset.controls; @@ -164,7 +167,7 @@ let BookWyrm = new class { } // Show/hide container. - let container = document.getElementById('hide-' + targetId); + let container = document.getElementById('hide_' + targetId); if (container) { this.toggleContainer(container, pressed); @@ -177,6 +180,13 @@ let BookWyrm = new class { this.toggleCheckbox(checkbox, pressed); } + // Toggle form disabled, if appropriate + let disable = trigger.dataset.disables; + + if (disable) { + this.toggleDisabled(disable, !pressed); + } + // Set focus, if appropriate. let focus = trigger.dataset.focusTarget; @@ -219,7 +229,7 @@ let BookWyrm = new class { /** * Check or uncheck a checbox. * - * @param {object} checkbox - DOM node + * @param {string} checkbox - id of the checkbox * @param {boolean} pressed - Is the trigger pressed? * @return {undefined} */ @@ -227,6 +237,17 @@ let BookWyrm = new class { document.getElementById(checkbox).checked = !!pressed; } + /** + * Enable or disable a form element or fieldset + * + * @param {string} form_element - id of the element + * @param {boolean} pressed - Is the trigger pressed? + * @return {undefined} + */ + toggleDisabled(form_element, pressed) { + document.getElementById(form_element).disabled = !!pressed; + } + /** * Give the focus to an element. * Only move the focus based on user interactions. diff --git a/bookwyrm/storage_backends.py b/bookwyrm/storage_backends.py index e10dfb84..4fb0feff 100644 --- a/bookwyrm/storage_backends.py +++ b/bookwyrm/storage_backends.py @@ -1,4 +1,6 @@ """Handles backends for storages""" +import os +from tempfile import SpooledTemporaryFile from storages.backends.s3boto3 import S3Boto3Storage @@ -15,3 +17,33 @@ class ImagesStorage(S3Boto3Storage): # pylint: disable=abstract-method location = "images" default_acl = "public-read" file_overwrite = False + + """ + This is our custom version of S3Boto3Storage that fixes a bug in + boto3 where the passed in file is closed upon upload. + From: + https://github.com/matthewwithanm/django-imagekit/issues/391#issuecomment-275367006 + https://github.com/boto/boto3/issues/929 + https://github.com/matthewwithanm/django-imagekit/issues/391 + """ + + def _save(self, name, content): + """ + We create a clone of the content file as when this is passed to + boto3 it wrongly closes the file upon upload where as the storage + backend expects it to still be open + """ + # Seek our content back to the start + content.seek(0, os.SEEK_SET) + + # Create a temporary file that will write to disk after a specified + # size. This file will be automatically deleted when closed by + # boto3 or after exiting the `with` statement if the boto3 is fixed + with SpooledTemporaryFile() as content_autoclose: + + # Write our original content into our copy that will be closed by boto3 + content_autoclose.write(content.read()) + + # Upload the object which will auto close the + # content_autoclose instance + return super()._save(name, content_autoclose) diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index 3a95ef7f..9c42d79d 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -19,7 +19,7 @@ class SuggestedUsers(RedisStore): def get_rank(self, obj): """get computed rank""" - return obj.mutuals + (1.0 - (1.0 / (obj.shared_books + 1))) + return obj.mutuals # + (1.0 - (1.0 / (obj.shared_books + 1))) def store_id(self, user): # pylint: disable=no-self-use """the key used to store this user's recs""" @@ -31,7 +31,7 @@ class SuggestedUsers(RedisStore): """calculate mutuals count and shared books count from rank""" return { "mutuals": math.floor(rank), - "shared_books": int(1 / (-1 * (rank % 1 - 1))) - 1, + # "shared_books": int(1 / (-1 * (rank % 1 - 1))) - 1, } def get_objects_for_store(self, store): @@ -95,7 +95,7 @@ class SuggestedUsers(RedisStore): logger.exception(err) continue user.mutuals = counts["mutuals"] - user.shared_books = counts["shared_books"] + # user.shared_books = counts["shared_books"] results.append(user) return results @@ -115,16 +115,16 @@ def get_annotated_users(viewer, *args, **kwargs): ), distinct=True, ), - shared_books=Count( - "shelfbook", - filter=Q( - ~Q(id=viewer.id), - shelfbook__book__parent_work__in=[ - s.book.parent_work for s in viewer.shelfbook_set.all() - ], - ), - distinct=True, - ), + # shared_books=Count( + # "shelfbook", + # filter=Q( + # ~Q(id=viewer.id), + # shelfbook__book__parent_work__in=[ + # s.book.parent_work for s in viewer.shelfbook_set.all() + # ], + # ), + # distinct=True, + # ), ) ) @@ -162,18 +162,18 @@ def update_suggestions_on_unfollow(sender, instance, **kwargs): rerank_user_task.delay(instance.user_object.id, update_only=False) -@receiver(signals.post_save, sender=models.ShelfBook) -@receiver(signals.post_delete, sender=models.ShelfBook) -# pylint: disable=unused-argument -def update_rank_on_shelving(sender, instance, *args, **kwargs): - """when a user shelves or unshelves a book, re-compute their rank""" - # if it's a local user, re-calculate who is rec'ed to them - if instance.user.local: - rerank_suggestions_task.delay(instance.user.id) - - # if the user is discoverable, update their rankings - if instance.user.discoverable: - rerank_user_task.delay(instance.user.id) +# @receiver(signals.post_save, sender=models.ShelfBook) +# @receiver(signals.post_delete, sender=models.ShelfBook) +# # pylint: disable=unused-argument +# def update_rank_on_shelving(sender, instance, *args, **kwargs): +# """when a user shelves or unshelves a book, re-compute their rank""" +# # if it's a local user, re-calculate who is rec'ed to them +# if instance.user.local: +# rerank_suggestions_task.delay(instance.user.id) +# +# # if the user is discoverable, update their rankings +# if instance.user.discoverable: +# rerank_user_task.delay(instance.user.id) @receiver(signals.post_save, sender=models.User) diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html index 0bc42775..77c7b901 100644 --- a/bookwyrm/templates/author/author.html +++ b/bookwyrm/templates/author/author.html @@ -57,7 +57,7 @@ {% if author.wikipedia_link %}

- + {% trans "Wikipedia" %}

@@ -70,7 +70,7 @@

{% endif %} - + {% if author.inventaire_id %}

@@ -86,7 +86,7 @@

{% endif %} - + {% if author.goodreads_key %}

@@ -109,7 +109,7 @@

diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index c5dab109..6fa2e441 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -4,7 +4,6 @@ {% load humanize %} {% load utilities %} {% load static %} -{% load layout %} {% block title %}{{ book|book_title }}{% endblock %} @@ -43,7 +42,7 @@

{% endif %} - {% if book.authors %} + {% if book.authors.exists %}
{% trans "by" %} {% include 'snippets/authors.html' with book=book %}
@@ -62,7 +61,7 @@
- {% include 'snippets/book_cover.html' with book=book cover_class='is-h-m-mobile' %} + {% include 'snippets/book_cover.html' with size='xxlarge' size_mobile='medium' book=book cover_class='is-h-m-mobile' %} {% include 'snippets/rate_action.html' with user=request.user book=book %}
@@ -72,8 +71,8 @@ {% if user_authenticated and not book.cover %}
{% trans "Add cover" as button_text %} - {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %} - {% include 'book/cover_modal.html' with book=book controls_text="add-cover" controls_uid=book.id %} + {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add_cover" controls_uid=book.id focus="modal_title_add_cover" class="is-small" %} + {% include 'book/cover_modal.html' with book=book controls_text="add_cover" controls_uid=book.id %} {% if request.GET.cover_error %}

{% trans "Failed to load cover" %}

{% endif %} @@ -128,19 +127,19 @@ {% if user_authenticated and can_edit_book and not book|book_description %} {% trans 'Add Description' as button_text %} - {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %} + {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add_description" controls_uid=book.id focus="id_description" hide_active=True id="hide_description" %} -
{% trans "Add read dates" as button_text %} - {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" class="is-small" controls_text="add-readthrough" focus="add-readthrough-focus" %} + {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="plus" class="is-small" controls_text="add_readthrough" focus="add_readthrough_focus_" %}
-
diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit_book.html index 32018a25..2f6ca324 100644 --- a/bookwyrm/templates/book/edit_book.html +++ b/bookwyrm/templates/book/edit_book.html @@ -42,11 +42,18 @@
{% endif %} -{% if book %} -
-{% else %} - -{% endif %} + {% csrf_token %} {% if confirm_mode %} @@ -220,7 +227,7 @@

{% trans "Cover" %}

- {% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' %} + {% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' size_mobile='xlarge' size='large' %}
diff --git a/bookwyrm/templates/book/edition_filters.html b/bookwyrm/templates/book/edition_filters.html index a55b72af..c41ab0c0 100644 --- a/bookwyrm/templates/book/edition_filters.html +++ b/bookwyrm/templates/book/edition_filters.html @@ -1,6 +1,7 @@ {% extends 'snippets/filters_panel/filters_panel.html' %} {% block filter_fields %} +{% include 'book/search_filter.html' %} {% include 'book/language_filter.html' %} {% include 'book/format_filter.html' %} {% endblock %} diff --git a/bookwyrm/templates/book/editions.html b/bookwyrm/templates/book/editions.html index e2a0bdda..7a4338f1 100644 --- a/bookwyrm/templates/book/editions.html +++ b/bookwyrm/templates/book/editions.html @@ -15,7 +15,7 @@
diff --git a/bookwyrm/templates/book/readthrough.html b/bookwyrm/templates/book/readthrough.html index 75140746..12430f75 100644 --- a/bookwyrm/templates/book/readthrough.html +++ b/bookwyrm/templates/book/readthrough.html @@ -2,11 +2,10 @@ {% load humanize %} {% load tz %}
-
+
{% trans "Progress Updates:" %} -
    {% if readthrough.finish_date or readthrough.progress %}
  • @@ -24,7 +23,7 @@ {% if readthrough.progress %} {% trans "Show all updates" as button_text %} {% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="updates" controls_uid=readthrough.id class="is-small" %} -
@@ -69,15 +68,15 @@
-