diff --git a/.github/workflows/prettier.yaml b/.github/workflows/prettier.yaml index a081e1a7a0..501516ae18 100644 --- a/.github/workflows/prettier.yaml +++ b/.github/workflows/prettier.yaml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v3 - name: Install modules - run: npm install prettier + run: npm install prettier@2.5.1 - name: Run Prettier run: npx prettier --check bookwyrm/static/js/*.js diff --git a/FEDERATION.md b/FEDERATION.md new file mode 100644 index 0000000000..dd0c917e2c --- /dev/null +++ b/FEDERATION.md @@ -0,0 +1,333 @@ +# Federation + +BookWyrm uses the [ActivityPub](http://activitypub.rocks/) protocol to send and receive user activity between other BookWyrm instances and other services that implement ActivityPub. To handle book data, BookWyrm has a handful of extended Activity types which are not part of the standard, but are legible to other BookWyrm instances. + +## Activities and Objects + +### Users and relationships +User relationship interactions follow the standard ActivityPub spec. + +- `Follow`: request to receive statuses from a user, and view their statuses that have followers-only privacy +- `Accept`: approves a `Follow` and finalizes the relationship +- `Reject`: denies a `Follow` +- `Block`: prevent users from seeing one another's statuses, and prevents the blocked user from viewing the actor's profile +- `Update`: updates a user's profile and settings +- `Delete`: deactivates a user +- `Undo`: reverses a `Follow` or `Block` + +### Activities +- `Create/Status`: saves a new status in the database. +- `Delete/Status`: Removes a status +- `Like/Status`: Creates a favorite on the status +- `Announce/Status`: Boosts the status into the actor's timeline +- `Undo/*`,: Reverses a `Like` or `Announce` + +### Collections +User's books and lists are represented by [`OrderedCollection`](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-orderedcollection) + +### Statuses + +BookWyrm is focused on book reading activities - it is not a general-purpose messaging application. For this reason, BookWyrm only accepts status `Create` activities if they are: + +- Direct messages (i.e., `Note`s with the privacy level `direct`, which mention a local user), +- Related to a book (of a custom status type that includes the field `inReplyToBook`), +- Replies to existing statuses saved in the database + +All other statuses will be received by the instance inbox, but by design **will not be delivered to user inboxes or displayed to users**. + +### Custom Object types + +With the exception of `Note`, the following object types are used in Bookwyrm but are not currently provided with a custom JSON-LD `@context` extension IRI. This is likely to change in future to make them true deserialisable JSON-LD objects. + +##### Note + +Within BookWyrm a `Note` is constructed according to [the ActivityStreams vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-note), however `Note`s can only be created as direct messages or as replies to other statuses. As mentioned above, this also applies to incoming `Note`s. + +##### Review + +A `Review` is a status in response to a book (indicated by the `inReplyToBook` field), which has a title, body, and numerical rating between 0 (not rated) and 5. + +Example: + +```json +{ + "id": "https://example.net/user/library_lurker/review/2", + "type": "Review", + "published": "2023-06-30T21:43:46.013132+00:00", + "attributedTo": "https://example.net/user/library_lurker", + "content": "
This is an enjoyable book with great characters.
", + "to": ["https://example.net/user/library_lurker/followers"], + "cc": [], + "replies": { + "id": "https://example.net/user/library_lurker/review/2/replies", + "type": "OrderedCollection", + "totalItems": 0, + "first": "https://example.net/user/library_lurker/review/2/replies?page=1", + "last": "https://example.net/user/library_lurker/review/2/replies?page=1", + "@context": "https://www.w3.org/ns/activitystreams" + }, + "summary": "Spoilers ahead!", + "tag": [], + "attachment": [], + "sensitive": true, + "inReplyToBook": "https://example.net/book/1", + "name": "What a cracking read", + "rating": 4.5, + "@context": "https://www.w3.org/ns/activitystreams" +} +``` + +##### Comment + +A `Comment` on a book mentions a book and has a message body, reading status, and progress indicator. + +Example: + +```json +{ + "id": "https://example.net/user/library_lurker/comment/9", + "type": "Comment", + "published": "2023-06-30T21:43:46.013132+00:00", + "attributedTo": "https://example.net/user/library_lurker", + "content": "This is a very enjoyable book so far.
", + "to": ["https://example.net/user/library_lurker/followers"], + "cc": [], + "replies": { + "id": "https://example.net/user/library_lurker/comment/9/replies", + "type": "OrderedCollection", + "totalItems": 0, + "first": "https://example.net/user/library_lurker/comment/9/replies?page=1", + "last": "https://example.net/user/library_lurker/comment/9/replies?page=1", + "@context": "https://www.w3.org/ns/activitystreams" + }, + "summary": "Spoilers ahead!", + "tag": [], + "attachment": [], + "sensitive": true, + "inReplyToBook": "https://example.net/book/1", + "readingStatus": "reading", + "progress": 25, + "progressMode": "PG", + "@context": "https://www.w3.org/ns/activitystreams" +} +``` + +##### Quotation + +A quotation (aka "quote") has a message body, an excerpt from a book including position as a page number or percentage indicator, and mentions a book. + +Example: + +```json +{ + "id": "https://example.net/user/mouse/quotation/13", + "url": "https://example.net/user/mouse/quotation/13", + "inReplyTo": null, + "published": "2020-05-10T02:38:31.150343+00:00", + "attributedTo": "https://example.net/user/mouse", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.net/user/mouse/followers" + ], + "sensitive": false, + "content": "I really like this quote", + "type": "Quotation", + "replies": { + "id": "https://example.net/user/mouse/quotation/13/replies", + "type": "Collection", + "first": { + "type": "CollectionPage", + "next": "https://example.net/user/mouse/quotation/13/replies?only_other_accounts=true&page=true", + "partOf": "https://example.net/user/mouse/quotation/13/replies", + "items": [] + } + }, + "inReplyToBook": "https://example.net/book/1", + "quote": "To be or not to be, that is the question.", + "position": 50, + "positionMode": "PCT", + "@context": "https://www.w3.org/ns/activitystreams" +} +``` + +### Custom Objects + +##### Work +A particular book, a "work" in the [FRBR](https://en.wikipedia.org/wiki/Functional_Requirements_for_Bibliographic_Records) sense. + +Example: + +```json +{ + "id": "https://bookwyrm.social/book/5988", + "type": "Work", + "authors": [ + "https://bookwyrm.social/author/417" + ], + "first_published_date": null, + "published_date": null, + "title": "Piranesi", + "sort_title": null, + "subtitle": null, + "description": "**From the *New York Times* bestselling author of *Jonathan Strange & Mr. Norrell*, an intoxicating, hypnotic new novel set in a dreamlike alternative reality.", + "languages": [], + "series": null, + "series_number": null, + "subjects": [ + "English literature" + ], + "subject_places": [], + "openlibrary_key": "OL20893680W", + "librarything_key": null, + "goodreads_key": null, + "attachment": [ + { + "url": "https://bookwyrm.social/images/covers/10226290-M.jpg", + "type": "Image" + } + ], + "lccn": null, + "editions": [ + "https://bookwyrm.social/book/5989" + ], + "@context": "https://www.w3.org/ns/activitystreams" +} +``` + +##### Edition +A particular _manifestation_ of a Work, in the [FRBR](https://en.wikipedia.org/wiki/Functional_Requirements_for_Bibliographic_Records) sense. + +Example: + +```json +{ + "id": "https://bookwyrm.social/book/5989", + "lastEditedBy": "https://example.net/users/rat", + "type": "Edition", + "authors": [ + "https://bookwyrm.social/author/417" + ], + "first_published_date": null, + "published_date": "2020-09-15T00:00:00+00:00", + "title": "Piranesi", + "sort_title": null, + "subtitle": null, + "description": "Piranesi's house is no ordinary building; its rooms are infinite, its corridors endless, its walls are lined with thousands upon thousands of statues, each one different from all the others.", + "languages": [ + "English" + ], + "series": null, + "series_number": null, + "subjects": [], + "subject_places": [], + "openlibrary_key": "OL29486417M", + "librarything_key": null, + "goodreads_key": null, + "isfdb": null, + "attachment": [ + { + "url": "https://bookwyrm.social/images/covers/50202953._SX318_.jpg", + "type": "Image" + } + ], + "isbn_10": "1526622424", + "isbn_13": "9781526622426", + "oclc_number": null, + "asin": null, + "pages": 272, + "physical_format": null, + "publishers": [ + "Bloomsbury Publishing Plc" + ], + "work": "https://bookwyrm.social/book/5988", + "@context": "https://www.w3.org/ns/activitystreams" +} +``` + +#### Shelf + +A user's book collection. By default, every user has a `to-read`, `reading`, `read`, and `stopped-reading` shelf which are used to track reading progress. Users may create an unlimited number of additional shelves with their own ids. + +Example + +```json +{ + "id": "https://example.net/user/avid_reader/books/extraspecialbooks-5", + "type": "Shelf", + "totalItems": 0, + "first": "https://example.net/user/avid_reader/books/extraspecialbooks-5?page=1", + "last": "https://example.net/user/avid_reader/books/extraspecialbooks-5?page=1", + "name": "Extra special books", + "owner": "https://example.net/user/avid_reader", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.net/user/avid_reader/followers" + ], + "@context": "https://www.w3.org/ns/activitystreams" +} +``` + +#### List + +A collection of books that may have items contributed by users other than the one who created the list. + +Example: + +```json +{ + "id": "https://example.net/list/1", + "type": "BookList", + "totalItems": 0, + "first": "https://example.net/list/1?page=1", + "last": "https://example.net/list/1?page=1", + "name": "My cool list", + "owner": "https://example.net/user/avid_reader", + "to": [ + "https://www.w3.org/ns/activitystreams#Public" + ], + "cc": [ + "https://example.net/user/avid_reader/followers" + ], + "summary": "A list of books I like.", + "curation": "curated", + "@context": "https://www.w3.org/ns/activitystreams" +} +``` + +#### Activities + +- `Create`: Adds a shelf or list to the database. +- `Delete`: Removes a shelf or list. +- `Add`: Adds a book to a shelf or list. +- `Remove`: Removes a book from a shelf or list. + +## Alternative Serialization +Because BookWyrm uses custom object types that aren't listed in [the standard ActivityStreams Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary), some statuses are transformed into standard types when sent to or viewed by non-BookWyrm services. `Review`s are converted into `Article`s, and `Comment`s and `Quotation`s are converted into `Note`s, with a link to the book and the cover image attached. + +In future this may be done with [JSON-LD type arrays](https://www.w3.org/TR/json-ld/#specifying-the-type) instead. + +## Other extensions + +### Webfinger + +Bookwyrm uses the [Webfinger](https://datatracker.ietf.org/doc/html/rfc7033) standard to identify and disambiguate fediverse actors. The [Webfinger documentation on the Mastodon project](https://docs.joinmastodon.org/spec/webfinger/) provides a good overview of how Webfinger is used. + +### HTTP Signatures + +Bookwyrm uses and requires HTTP signatures for all `POST` requests. `GET` requests are not signed by default, but if Bookwyrm receives a `403` response to a `GET` it will re-send the request, signed by the default server user. This usually will have a user id of `https://example.net/user/bookwyrm.instance.actor` + +#### publicKey id + +In older versions of Bookwyrm the `publicKey.id` was incorrectly listed in request headers as `https://example.net/user/username#main-key`. As of v0.6.3 the id is now listed correctly, as `https://example.net/user/username/#main-key`. In most ActivityPub implementations this will make no difference as the URL will usually resolve to the same place. + +### NodeInfo + +Bookwyrm uses the [NodeInfo](http://nodeinfo.diaspora.software/) standard to provide statistics and version information for each instance. + +## Further Documentation + +See [docs.joinbookwyrm.com/](https://docs.joinbookwyrm.com/) for more documentation. \ No newline at end of file diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 9b7897ebaa..c78b4f1954 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -12,7 +12,7 @@ from bookwyrm.connectors import ConnectorException, get_data from bookwyrm.signatures import make_signature from bookwyrm.settings import DOMAIN, INSTANCE_ACTOR_USERNAME -from bookwyrm.tasks import app, MEDIUM +from bookwyrm.tasks import app, MISC logger = logging.getLogger(__name__) @@ -241,7 +241,7 @@ def serialize(self, **kwargs): return data -@app.task(queue=MEDIUM) +@app.task(queue=MISC) @transaction.atomic def set_related_field( model_name, origin_model_name, related_field_name, related_remote_id, data diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index 5d581d564e..7afa69921c 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -8,7 +8,7 @@ from bookwyrm import models from bookwyrm.redis_store import RedisStore, r -from bookwyrm.tasks import app, LOW, MEDIUM, HIGH +from bookwyrm.tasks import app, STREAMS, IMPORT_TRIGGERED from bookwyrm.telemetry import open_telemetry @@ -343,7 +343,7 @@ def add_status_on_create(sender, instance, created, *args, **kwargs): def add_status_on_create_command(sender, instance, created): """runs this code only after the database commit completes""" - priority = HIGH + priority = STREAMS # check if this is an old status, de-prioritize if so # (this will happen if federation is very slow, or, more expectedly, on csv import) if instance.published_date < timezone.now() - timedelta( @@ -353,7 +353,7 @@ def add_status_on_create_command(sender, instance, created): if instance.user.local: return # an out of date remote status is a low priority but should be added - priority = LOW + priority = IMPORT_TRIGGERED add_status_task.apply_async( args=(instance.id,), @@ -497,7 +497,7 @@ def remove_statuses_on_unshelve(sender, instance, *args, **kwargs): # ---- TASKS -@app.task(queue=LOW) +@app.task(queue=STREAMS) 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) @@ -505,7 +505,7 @@ def add_book_statuses_task(user_id, book_id): BooksStream().add_book_statuses(user, book) -@app.task(queue=LOW) +@app.task(queue=STREAMS) 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) @@ -513,7 +513,7 @@ def remove_book_statuses_task(user_id, book_id): BooksStream().remove_book_statuses(user, book) -@app.task(queue=MEDIUM) +@app.task(queue=STREAMS) def populate_stream_task(stream, user_id): """background task for populating an empty activitystream""" user = models.User.objects.get(id=user_id) @@ -521,7 +521,7 @@ def populate_stream_task(stream, user_id): stream.populate_streams(user) -@app.task(queue=MEDIUM) +@app.task(queue=STREAMS) 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 @@ -536,7 +536,7 @@ def remove_status_task(status_ids): ) -@app.task(queue=HIGH) +@app.task(queue=STREAMS) def add_status_task(status_id, increment_unread=False): """add a status to any stream it should be in""" status = models.Status.objects.select_subclasses().get(id=status_id) @@ -548,7 +548,7 @@ def add_status_task(status_id, increment_unread=False): stream.add_status(status, increment_unread=increment_unread) -@app.task(queue=MEDIUM) +@app.task(queue=STREAMS) 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() @@ -558,7 +558,7 @@ def remove_user_statuses_task(viewer_id, user_id, stream_list=None): stream.remove_user_statuses(viewer, user) -@app.task(queue=MEDIUM) +@app.task(queue=STREAMS) def add_user_statuses_task(viewer_id, user_id, stream_list=None): """add all statuses by a user to a viewer's stream""" stream_list = [streams[s] for s in stream_list] if stream_list else streams.values() @@ -568,7 +568,7 @@ def add_user_statuses_task(viewer_id, user_id, stream_list=None): stream.add_user_statuses(viewer, user) -@app.task(queue=MEDIUM) +@app.task(queue=STREAMS) def handle_boost_task(boost_id): """remove the original post and other, earlier boosts""" instance = models.Status.objects.get(id=boost_id) diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 7e823c0afa..e32da7c00f 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -13,7 +13,7 @@ from bookwyrm import book_search, models from bookwyrm.settings import SEARCH_TIMEOUT -from bookwyrm.tasks import app, LOW +from bookwyrm.tasks import app, CONNECTORS logger = logging.getLogger(__name__) @@ -109,7 +109,7 @@ def get_or_create_connector(remote_id): return load_connector(connector_info) -@app.task(queue=LOW) +@app.task(queue=CONNECTORS) def load_more_data(connector_id, book_id): """background the work of getting all 10,000 editions of LoTR""" connector_info = models.Connector.objects.get(id=connector_id) @@ -118,7 +118,7 @@ def load_more_data(connector_id, book_id): connector.expand_book_data(book) -@app.task(queue=LOW) +@app.task(queue=CONNECTORS) def create_edition_task(connector_id, work_id, data): """separate task for each of the 10,000 editions of LoTR""" connector_info = models.Connector.objects.get(id=connector_id) diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index 2271077b12..5e08ebba13 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -3,7 +3,7 @@ from django.template.loader import get_template from bookwyrm import models, settings -from bookwyrm.tasks import app, HIGH +from bookwyrm.tasks import app, EMAIL from bookwyrm.settings import DOMAIN @@ -75,7 +75,7 @@ def format_email(email_name, data): return (subject, html_content, text_content) -@app.task(queue=HIGH) +@app.task(queue=EMAIL) def send_email(recipient, subject, html_content, text_content): """use a task to send the email""" email = EmailMultiAlternatives( diff --git a/bookwyrm/forms/books.py b/bookwyrm/forms/books.py index 623beaa042..3a3979e2ca 100644 --- a/bookwyrm/forms/books.py +++ b/bookwyrm/forms/books.py @@ -20,6 +20,7 @@ class Meta: model = models.Edition fields = [ "title", + "sort_title", "subtitle", "description", "series", @@ -45,6 +46,9 @@ class Meta: ] widgets = { "title": forms.TextInput(attrs={"aria-describedby": "desc_title"}), + "sort_title": forms.TextInput( + attrs={"aria-describedby": "desc_sort_title"} + ), "subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}), "description": forms.Textarea( attrs={"aria-describedby": "desc_description"} diff --git a/bookwyrm/forms/lists.py b/bookwyrm/forms/lists.py index 647db3bfe9..f5008baa3c 100644 --- a/bookwyrm/forms/lists.py +++ b/bookwyrm/forms/lists.py @@ -24,7 +24,7 @@ class SortListForm(forms.Form): sort_by = ChoiceField( choices=( ("order", _("List Order")), - ("title", _("Book Title")), + ("sort_title", _("Book Title")), ("rating", _("Rating")), ), label=_("Sort By"), diff --git a/bookwyrm/lists_stream.py b/bookwyrm/lists_stream.py index 2b08010b12..148b81a78b 100644 --- a/bookwyrm/lists_stream.py +++ b/bookwyrm/lists_stream.py @@ -5,7 +5,7 @@ from bookwyrm import models from bookwyrm.redis_store import RedisStore -from bookwyrm.tasks import app, MEDIUM, HIGH +from bookwyrm.tasks import app, LISTS class ListsStream(RedisStore): @@ -217,14 +217,14 @@ def add_list_on_account_create_command(user_id): # ---- TASKS -@app.task(queue=MEDIUM) +@app.task(queue=LISTS) def populate_lists_task(user_id): """background task for populating an empty list stream""" user = models.User.objects.get(id=user_id) ListsStream().populate_lists(user) -@app.task(queue=MEDIUM) +@app.task(queue=LISTS) def remove_list_task(list_id, re_add=False): """remove a list from any stream it might be in""" stores = models.User.objects.filter(local=True, is_active=True).values_list( @@ -239,14 +239,14 @@ def remove_list_task(list_id, re_add=False): add_list_task.delay(list_id) -@app.task(queue=HIGH) +@app.task(queue=LISTS) def add_list_task(list_id): """add a list to any stream it should be in""" book_list = models.List.objects.get(id=list_id) ListsStream().add_list(book_list) -@app.task(queue=MEDIUM) +@app.task(queue=LISTS) def remove_user_lists_task(viewer_id, user_id, exclude_privacy=None): """remove all lists by a user from a viewer's stream""" viewer = models.User.objects.get(id=viewer_id) @@ -254,7 +254,7 @@ def remove_user_lists_task(viewer_id, user_id, exclude_privacy=None): ListsStream().remove_user_lists(viewer, user, exclude_privacy=exclude_privacy) -@app.task(queue=MEDIUM) +@app.task(queue=LISTS) def add_user_lists_task(viewer_id, user_id): """add all lists by a user to a viewer's stream""" viewer = models.User.objects.get(id=viewer_id) diff --git a/bookwyrm/migrations/0179_populate_sort_title.py b/bookwyrm/migrations/0179_populate_sort_title.py new file mode 100644 index 0000000000..e238bca1d9 --- /dev/null +++ b/bookwyrm/migrations/0179_populate_sort_title.py @@ -0,0 +1,49 @@ +import re +from itertools import chain + +from django.db import migrations, transaction +from django.db.models import Q + +from bookwyrm.settings import LANGUAGE_ARTICLES + + +def set_sort_title(edition): + articles = chain( + *(LANGUAGE_ARTICLES.get(language, ()) for language in tuple(edition.languages)) + ) + edition.sort_title = re.sub( + f'^{" |^".join(articles)} ', "", str(edition.title).lower() + ) + return edition + + +@transaction.atomic +def populate_sort_title(apps, schema_editor): + Edition = apps.get_model("bookwyrm", "Edition") + db_alias = schema_editor.connection.alias + editions_wo_sort_title = Edition.objects.using(db_alias).filter( + Q(sort_title__isnull=True) | Q(sort_title__exact="") + ) + batch_size = 1000 + start = 0 + end = batch_size + while True: + batch = editions_wo_sort_title[start:end] + if not batch.exists(): + break + Edition.objects.bulk_update( + (set_sort_title(edition) for edition in batch), ["sort_title"] + ) + start = end + end += batch_size + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0178_auto_20230328_2132"), + ] + + operations = [ + migrations.RunPython(populate_sort_title), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index d1ca3747ac..4b53c6e872 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -21,7 +21,7 @@ from bookwyrm import activitypub from bookwyrm.settings import USER_AGENT, PAGE_LENGTH from bookwyrm.signatures import make_signature, make_digest -from bookwyrm.tasks import app, MEDIUM, BROADCAST +from bookwyrm.tasks import app, BROADCAST from bookwyrm.models.fields import ImageField, ManyToManyField logger = logging.getLogger(__name__) @@ -379,7 +379,7 @@ class CollectionItemMixin(ActivitypubMixin): activity_serializer = activitypub.CollectionItem - def broadcast(self, activity, sender, software="bookwyrm", queue=MEDIUM): + def broadcast(self, activity, sender, software="bookwyrm", queue=BROADCAST): """only send book collection updates to other bookwyrm instances""" super().broadcast(activity, sender, software=software, queue=queue) @@ -400,7 +400,7 @@ def recipients(self): return [] return [collection_field.user] - def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs): + def save(self, *args, broadcast=True, priority=BROADCAST, **kwargs): """broadcast updated""" # first off, we want to save normally no matter what super().save(*args, **kwargs) @@ -444,7 +444,7 @@ def to_remove_activity(self, user): class ActivityMixin(ActivitypubMixin): """add this mixin for models that are AP serializable""" - def save(self, *args, broadcast=True, priority=MEDIUM, **kwargs): + def save(self, *args, broadcast=True, priority=BROADCAST, **kwargs): """broadcast activity""" super().save(*args, **kwargs) user = self.user if hasattr(self, "user") else self.user_subject diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index 1e20df3408..94d978ec41 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -8,7 +8,7 @@ from django.db.models import Q from django.utils.translation import gettext_lazy as _ -from bookwyrm.tasks import app, LOW +from bookwyrm.tasks import app, MISC from .base_model import BookWyrmModel from .user import User @@ -65,7 +65,7 @@ class AutoMod(AdminModel): created_by = models.ForeignKey("User", on_delete=models.PROTECT) -@app.task(queue=LOW) +@app.task(queue=MISC) def automod_task(): """Create reports""" if not AutoMod.objects.exists(): diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 4e7ffcad30..c25f8fee2b 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -1,4 +1,5 @@ """ database schema for books and shelves """ +from itertools import chain import re from django.contrib.postgres.search import SearchVectorField @@ -17,6 +18,7 @@ from bookwyrm.settings import ( DOMAIN, DEFAULT_LANGUAGE, + LANGUAGE_ARTICLES, ENABLE_PREVIEW_IMAGES, ENABLE_THUMBNAIL_GENERATION, ) @@ -363,6 +365,19 @@ def save(self, *args, **kwargs): for author_id in self.authors.values_list("id", flat=True): cache.delete(f"author-books-{author_id}") + # Create sort title by removing articles from title + if self.sort_title in [None, ""]: + if self.sort_title in [None, ""]: + articles = chain( + *( + LANGUAGE_ARTICLES.get(language, ()) + for language in tuple(self.languages) + ) + ) + self.sort_title = re.sub( + f'^{" |^".join(articles)} ', "", str(self.title).lower() + ) + return super().save(*args, **kwargs) @classmethod diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index a489edb7c4..bb5144297c 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -19,7 +19,7 @@ Review, ReviewRating, ) -from bookwyrm.tasks import app, LOW, IMPORTS +from bookwyrm.tasks import app, IMPORT_TRIGGERED, IMPORTS from .fields import PrivacyLevels @@ -399,7 +399,7 @@ def handle_imported_book(item): shelved_date = item.date_added or timezone.now() ShelfBook( book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date - ).save(priority=LOW) + ).save(priority=IMPORT_TRIGGERED) for read in item.reads: # check for an existing readthrough with the same dates @@ -441,7 +441,7 @@ def handle_imported_book(item): published_date=published_date_guess, privacy=job.privacy, ) - review.save(software="bookwyrm", priority=LOW) + review.save(software="bookwyrm", priority=IMPORT_TRIGGERED) else: # just a rating review = ReviewRating.objects.filter( @@ -458,7 +458,7 @@ def handle_imported_book(item): published_date=published_date_guess, privacy=job.privacy, ) - review.save(software="bookwyrm", priority=LOW) + review.save(software="bookwyrm", priority=IMPORT_TRIGGERED) # only broadcast this review to other bookwyrm instances item.linked_review = review diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py index 4754bea36d..7af6ad5abd 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -4,7 +4,6 @@ from django.db.models import Q from bookwyrm import activitypub -from bookwyrm.tasks import HIGH from .activitypub_mixin import ActivitypubMixin, ActivityMixin from .activitypub_mixin import generate_activity from .base_model import BookWyrmModel @@ -142,7 +141,7 @@ def save(self, *args, broadcast=True, **kwargs): # pylint: disable=arguments-di # a local user is following a remote user if broadcast and self.user_subject.local and not self.user_object.local: - self.broadcast(self.to_activity(), self.user_subject, queue=HIGH) + self.broadcast(self.to_activity(), self.user_subject) if self.user_object.local: manually_approves = self.user_object.manually_approves_followers @@ -166,7 +165,7 @@ def accept(self, broadcast_only=False): actor=self.user_object.remote_id, object=self.to_activity(), ).serialize() - self.broadcast(activity, user, queue=HIGH) + self.broadcast(activity, user) if broadcast_only: return @@ -187,7 +186,7 @@ def reject(self): actor=self.user_object.remote_id, object=self.to_activity(), ).serialize() - self.broadcast(activity, self.user_object, queue=HIGH) + self.broadcast(activity, self.user_object) self.delete() diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index c52cb6ab82..3d92f8d43e 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -7,7 +7,7 @@ from bookwyrm import activitypub from bookwyrm.settings import DOMAIN -from bookwyrm.tasks import LOW +from bookwyrm.tasks import BROADCAST from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin from .base_model import BookWyrmModel from . import fields @@ -40,7 +40,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): activity_serializer = activitypub.Shelf - def save(self, *args, priority=LOW, **kwargs): + def save(self, *args, priority=BROADCAST, **kwargs): """set the identifier""" super().save(*args, priority=priority, **kwargs) if not self.identifier: @@ -100,7 +100,7 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel): activity_serializer = activitypub.ShelfItem collection_field = "shelf" - def save(self, *args, priority=LOW, **kwargs): + def save(self, *args, priority=BROADCAST, **kwargs): if not self.user: self.user = self.shelf.user if self.id and self.user.local: diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index f39468246c..6e0912aece 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -20,7 +20,7 @@ from bookwyrm.preview_images import generate_user_preview_image_task from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES from bookwyrm.signatures import create_key_pair -from bookwyrm.tasks import app, LOW +from bookwyrm.tasks import app, MISC from bookwyrm.utils import regex from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin from .base_model import BookWyrmModel, DeactivationReason, new_access_code @@ -394,6 +394,8 @@ def deactivate(self): def reactivate(self): """Now you want to come back, huh?""" # pylint: disable=attribute-defined-outside-init + if not self.allow_reactivation: + return self.is_active = True self.deactivation_reason = None self.allow_reactivation = False @@ -469,7 +471,7 @@ def save(self, *args, **kwargs): return super().save(*args, **kwargs) -@app.task(queue=LOW) +@app.task(queue=MISC) def set_remote_server(user_id, allow_external_connections=False): """figure out the user's remote server in the background""" user = User.objects.get(id=user_id) @@ -528,7 +530,7 @@ def get_or_create_remote_server( return server -@app.task(queue=LOW) +@app.task(queue=MISC) def get_remote_reviews(outbox): """ingest reviews by a new remote bookwyrm user""" outbox_page = outbox + "?page=true&type=Review" diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py index 549e124729..aba372abc1 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -16,7 +16,7 @@ from django.db.models import Avg from bookwyrm import models, settings -from bookwyrm.tasks import app, LOW +from bookwyrm.tasks import app, IMAGES logger = logging.getLogger(__name__) @@ -420,7 +420,7 @@ def save_and_cleanup(image, instance=None): # pylint: disable=invalid-name -@app.task(queue=LOW) +@app.task(queue=IMAGES) def generate_site_preview_image_task(): """generate preview_image for the website""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -445,7 +445,7 @@ def generate_site_preview_image_task(): # pylint: disable=invalid-name -@app.task(queue=LOW) +@app.task(queue=IMAGES) def generate_edition_preview_image_task(book_id): """generate preview_image for a book""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -470,7 +470,7 @@ def generate_edition_preview_image_task(book_id): save_and_cleanup(image, instance=book) -@app.task(queue=LOW) +@app.task(queue=IMAGES) def generate_user_preview_image_task(user_id): """generate preview_image for a user""" if not settings.ENABLE_PREVIEW_IMAGES: @@ -496,7 +496,7 @@ def generate_user_preview_image_task(user_id): save_and_cleanup(image, instance=user) -@app.task(queue=LOW) +@app.task(queue=IMAGES) def remove_user_preview_image_task(user_id): """remove preview_image for a user""" if not settings.ENABLE_PREVIEW_IMAGES: diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index d75b06cf8a..042cd8cf8e 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -12,7 +12,7 @@ env = Env() env.read_env() DOMAIN = env("DOMAIN") -VERSION = "0.6.3" +VERSION = "0.6.4" RELEASE_API = env( "RELEASE_API", @@ -22,7 +22,7 @@ PAGE_LENGTH = env.int("PAGE_LENGTH", 15) DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") -JS_CACHE = "d993847c" +JS_CACHE = "b972a43c" # email EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") @@ -312,6 +312,9 @@ ("zh-hant", _("繁體中文 (Traditional Chinese)")), ] +LANGUAGE_ARTICLES = { + "English": {"the", "a", "an"}, +} TIME_ZONE = "UTC" diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index ceed12eba7..0c6958f332 100644 --- a/bookwyrm/static/js/bookwyrm.js +++ b/bookwyrm/static/js/bookwyrm.js @@ -40,9 +40,6 @@ let BookWyrm = new (class { document.querySelectorAll("details.dropdown").forEach((node) => { node.addEventListener("toggle", this.handleDetailsDropdown.bind(this)); - node.querySelectorAll("[data-modal-open]").forEach((modal_node) => - modal_node.addEventListener("click", () => (node.open = false)) - ); }); document diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index 05e05891c8..d897feff7e 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -8,7 +8,7 @@ from bookwyrm import models from bookwyrm.redis_store import RedisStore, r -from bookwyrm.tasks import app, LOW, MEDIUM +from bookwyrm.tasks import app, SUGGESTED_USERS from bookwyrm.telemetry import open_telemetry @@ -244,20 +244,20 @@ def domain_level_update(sender, instance, created, update_fields=None, **kwargs) # ------------------- TASKS -@app.task(queue=LOW) +@app.task(queue=SUGGESTED_USERS) def rerank_suggestions_task(user_id): """do the hard work in celery""" suggested_users.rerank_user_suggestions(user_id) -@app.task(queue=LOW) +@app.task(queue=SUGGESTED_USERS) def rerank_user_task(user_id, update_only=False): """do the hard work in celery""" user = models.User.objects.get(id=user_id) suggested_users.rerank_obj(user, update_only=update_only) -@app.task(queue=LOW) +@app.task(queue=SUGGESTED_USERS) def remove_user_task(user_id): """do the hard work in celery""" user = models.User.objects.get(id=user_id) @@ -266,14 +266,14 @@ def remove_user_task(user_id): ) -@app.task(queue=MEDIUM) +@app.task(queue=SUGGESTED_USERS) def remove_suggestion_task(user_id, suggested_user_id): """remove a specific user from a specific user's suggestions""" suggested_user = models.User.objects.get(id=suggested_user_id) suggested_users.remove_suggestion(user_id, suggested_user) -@app.task(queue=LOW) +@app.task(queue=SUGGESTED_USERS) def bulk_remove_instance_task(instance_id): """remove a bunch of users from recs""" for user in models.User.objects.filter(federated_server__id=instance_id): @@ -282,7 +282,7 @@ def bulk_remove_instance_task(instance_id): ) -@app.task(queue=LOW) +@app.task(queue=SUGGESTED_USERS) def bulk_add_instance_task(instance_id): """remove a bunch of users from recs""" for user in models.User.objects.filter(federated_server__id=instance_id): diff --git a/bookwyrm/tasks.py b/bookwyrm/tasks.py index 91977afda8..79e1b63408 100644 --- a/bookwyrm/tasks.py +++ b/bookwyrm/tasks.py @@ -10,11 +10,19 @@ "tasks", broker=settings.CELERY_BROKER_URL, backend=settings.CELERY_RESULT_BACKEND ) -# priorities +# priorities - for backwards compatibility, will be removed next release LOW = "low_priority" MEDIUM = "medium_priority" HIGH = "high_priority" -# import items get their own queue because they're such a pain in the ass + +STREAMS = "streams" +IMAGES = "images" +SUGGESTED_USERS = "suggested_users" +EMAIL = "email" +CONNECTORS = "connectors" +LISTS = "lists" +INBOX = "inbox" IMPORTS = "imports" -# I keep making more queues?? this one broadcasting out +IMPORT_TRIGGERED = "import_triggered" BROADCAST = "broadcast" +MISC = "misc" diff --git a/bookwyrm/templates/book/edit/edit_book_form.html b/bookwyrm/templates/book/edit/edit_book_form.html index e85164444f..72d80e9cf7 100644 --- a/bookwyrm/templates/book/edit/edit_book_form.html +++ b/bookwyrm/templates/book/edit/edit_book_form.html @@ -28,6 +28,15 @@