diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 000000000..1a641edd2 --- /dev/null +++ b/.github/workflows/mypy.yml @@ -0,0 +1,50 @@ +name: Mypy + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.9 + uses: actions/setup-python@v4 + with: + python-version: 3.9 + - name: Install Dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Analysing the code with mypy + env: + SECRET_KEY: beepbeep + DEBUG: false + USE_HTTPS: true + DOMAIN: your.domain.here + BOOKWYRM_DATABASE_BACKEND: postgres + MEDIA_ROOT: images/ + POSTGRES_PASSWORD: hunter2 + POSTGRES_USER: postgres + POSTGRES_DB: github_actions + POSTGRES_HOST: 127.0.0.1 + CELERY_BROKER: "" + REDIS_BROKER_PORT: 6379 + REDIS_BROKER_PASSWORD: beep + USE_DUMMY_CACHE: true + FLOWER_PORT: 8888 + EMAIL_HOST: "smtp.mailgun.org" + EMAIL_PORT: 587 + EMAIL_HOST_USER: "" + EMAIL_HOST_PASSWORD: "" + EMAIL_USE_TLS: true + ENABLE_PREVIEW_IMAGES: false + ENABLE_THUMBNAIL_GENERATION: true + HTTP_X_FORWARDED_PROTO: false + run: | + mypy bookwyrm celerywyrm diff --git a/.github/workflows/prettier.yaml b/.github/workflows/prettier.yaml index a081e1a7a..501516ae1 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 000000000..dd0c917e2 --- /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 9b7897eba..615db440a 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -2,6 +2,8 @@ from dataclasses import dataclass, fields, MISSING from json import JSONEncoder import logging +from typing import Optional, Union, TypeVar, overload, Any + import requests from django.apps import apps @@ -10,12 +12,15 @@ from django.utils.http import http_date from bookwyrm import models from bookwyrm.connectors import ConnectorException, get_data +from bookwyrm.models import base_model 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__) +TBookWyrmModel = TypeVar("TBookWyrmModel", bound=base_model.BookWyrmModel) + class ActivitySerializerError(ValueError): """routine problems serializing activitypub json""" @@ -65,7 +70,11 @@ class ActivityObject: id: str type: str - def __init__(self, activity_objects=None, **kwargs): + def __init__( + self, + activity_objects: Optional[list[str, base_model.BookWyrmModel]] = None, + **kwargs: dict[str, Any], + ): """this lets you pass in an object with fields that aren't in the dataclass, which it ignores. Any field in the dataclass is required or has a default value""" @@ -101,13 +110,13 @@ class ActivityObject: # 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, - allow_external_connections=True, - ): + model: Optional[type[TBookWyrmModel]] = None, + instance: Optional[TBookWyrmModel] = None, + allow_create: bool = True, + save: bool = True, + overwrite: bool = True, + allow_external_connections: bool = True, + ) -> Optional[TBookWyrmModel]: """convert from an activity to a model instance. Args: model: the django model that this object is being converted to (will guess if not known) @@ -241,7 +250,7 @@ class ActivityObject: 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 @@ -296,14 +305,40 @@ def get_model_from_type(activity_type): # pylint: disable=too-many-arguments +@overload def resolve_remote_id( - remote_id, - model=None, - refresh=False, - save=True, - get_activity=False, - allow_external_connections=True, -): + remote_id: str, + model: type[TBookWyrmModel], + refresh: bool = False, + save: bool = True, + get_activity: bool = False, + allow_external_connections: bool = True, +) -> TBookWyrmModel: + ... + + +# pylint: disable=too-many-arguments +@overload +def resolve_remote_id( + remote_id: str, + model: Optional[str] = None, + refresh: bool = False, + save: bool = True, + get_activity: bool = False, + allow_external_connections: bool = True, +) -> base_model.BookWyrmModel: + ... + + +# pylint: disable=too-many-arguments +def resolve_remote_id( + remote_id: str, + model: Optional[Union[str, type[base_model.BookWyrmModel]]] = None, + refresh: bool = False, + save: bool = True, + get_activity: bool = False, + allow_external_connections: bool = True, +) -> base_model.BookWyrmModel: """take a remote_id and return an instance, creating if necessary. Args: remote_id: the unique url for looking up the object in the db or by http model: a string or object representing the model that corresponds to the object diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index d3aca4471..5db0dc3ac 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -1,6 +1,6 @@ """ book and author data """ from dataclasses import dataclass, field -from typing import List +from typing import Optional from .base_activity import ActivityObject from .image import Document @@ -11,19 +11,19 @@ from .image import Document class BookData(ActivityObject): """shared fields for all book data and authors""" - openlibraryKey: str = None - inventaireId: str = None - librarythingKey: str = None - goodreadsKey: str = None - bnfId: str = None - viaf: str = None - wikidata: str = None - asin: str = None - aasin: str = None - isfdb: str = None - lastEditedBy: str = None - links: List[str] = field(default_factory=lambda: []) - fileLinks: List[str] = field(default_factory=lambda: []) + openlibraryKey: Optional[str] = None + inventaireId: Optional[str] = None + librarythingKey: Optional[str] = None + goodreadsKey: Optional[str] = None + bnfId: Optional[str] = None + viaf: Optional[str] = None + wikidata: Optional[str] = None + asin: Optional[str] = None + aasin: Optional[str] = None + isfdb: Optional[str] = None + lastEditedBy: Optional[str] = None + links: list[str] = field(default_factory=list) + fileLinks: list[str] = field(default_factory=list) # pylint: disable=invalid-name @@ -35,17 +35,17 @@ class Book(BookData): sortTitle: str = None subtitle: str = None description: str = "" - languages: List[str] = field(default_factory=lambda: []) + languages: list[str] = field(default_factory=list) series: str = "" seriesNumber: str = "" - subjects: List[str] = field(default_factory=lambda: []) - subjectPlaces: List[str] = field(default_factory=lambda: []) + subjects: list[str] = field(default_factory=list) + subjectPlaces: list[str] = field(default_factory=list) - authors: List[str] = field(default_factory=lambda: []) + authors: list[str] = field(default_factory=list) firstPublishedDate: str = "" publishedDate: str = "" - cover: Document = None + cover: Optional[Document] = None type: str = "Book" @@ -58,10 +58,10 @@ class Edition(Book): isbn10: str = "" isbn13: str = "" oclcNumber: str = "" - pages: int = None + pages: Optional[int] = None physicalFormat: str = "" physicalFormatDetail: str = "" - publishers: List[str] = field(default_factory=lambda: []) + publishers: list[str] = field(default_factory=list) editionRank: int = 0 type: str = "Edition" @@ -73,7 +73,7 @@ class Work(Book): """work instance of a book object""" lccn: str = "" - editions: List[str] = field(default_factory=lambda: []) + editions: list[str] = field(default_factory=list) type: str = "Work" @@ -83,12 +83,12 @@ class Author(BookData): """author of a book""" name: str - isni: str = None - viafId: str = None - gutenbergId: str = None - born: str = None - died: str = None - aliases: List[str] = field(default_factory=lambda: []) + isni: Optional[str] = None + viafId: Optional[str] = None + gutenbergId: Optional[str] = None + born: Optional[str] = None + died: Optional[str] = None + aliases: list[str] = field(default_factory=list) bio: str = "" wikipediaLink: str = "" type: str = "Author" diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py index 5d581d564..71f9e42d7 100644 --- a/bookwyrm/activitystreams.py +++ b/bookwyrm/activitystreams.py @@ -8,7 +8,7 @@ from opentelemetry import trace 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 @@ -329,10 +329,9 @@ def add_status_on_create(sender, instance, created, *args, **kwargs): remove_status_task.delay(instance.id) return - # To avoid creating a zillion unnecessary tasks caused by re-saving the model, - # check if it's actually ready to send before we go. We're trusting this was - # set correctly by the inbox or view - if not instance.ready: + # We don't want to create multiple add_status_tasks for each status, and because + # the transactions are atomic, on_commit won't run until the status is ready to add. + if not created: return # when creating new things, gotta wait on the transaction @@ -343,7 +342,11 @@ 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 + # boosts trigger 'saves" twice, so don't bother duplicating the task + if sender == models.Boost and not created: + return + + 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 +356,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 +500,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 +508,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 +516,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 +524,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 +539,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 +551,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 +561,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 +571,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/book_search.py b/bookwyrm/book_search.py index 822c87f01..ceb228f40 100644 --- a/bookwyrm/book_search.py +++ b/bookwyrm/book_search.py @@ -1,22 +1,53 @@ """ using a bookwyrm instance as a source of book data """ +from __future__ import annotations from dataclasses import asdict, dataclass from functools import reduce import operator +from typing import Optional, Union, Any, Literal, overload from django.contrib.postgres.search import SearchRank, SearchQuery from django.db.models import F, Q +from django.db.models.query import QuerySet from bookwyrm import models from bookwyrm import connectors from bookwyrm.settings import MEDIA_FULL_URL +@overload +def search( + query: str, + *, + min_confidence: float = 0, + filters: Optional[list[Any]] = None, + return_first: Literal[False], +) -> QuerySet[models.Edition]: + ... + + +@overload +def search( + query: str, + *, + min_confidence: float = 0, + filters: Optional[list[Any]] = None, + return_first: Literal[True], +) -> Optional[models.Edition]: + ... + + # pylint: disable=arguments-differ -def search(query, min_confidence=0, filters=None, return_first=False): +def search( + query: str, + *, + min_confidence: float = 0, + filters: Optional[list[Any]] = None, + return_first: bool = False, +) -> Union[Optional[models.Edition], QuerySet[models.Edition]]: """search your local database""" filters = filters or [] if not query: - return [] + return None if return_first else [] query = query.strip() results = None @@ -66,7 +97,9 @@ def format_search_result(search_result): ).json() -def search_identifiers(query, *filters, return_first=False): +def search_identifiers( + query, *filters, return_first=False +) -> Union[Optional[models.Edition], QuerySet[models.Edition]]: """tries remote_id, isbn; defined as dedupe fields on the model""" if connectors.maybe_isbn(query): # Oh did you think the 'S' in ISBN stood for 'standard'? @@ -87,7 +120,9 @@ def search_identifiers(query, *filters, return_first=False): return results -def search_title_author(query, min_confidence, *filters, return_first=False): +def search_title_author( + query, min_confidence, *filters, return_first=False +) -> QuerySet[models.Edition]: """searches for title and author""" query = SearchQuery(query, config="simple") | SearchQuery(query, config="english") results = ( @@ -122,11 +157,11 @@ class SearchResult: title: str key: str connector: object - view_link: str = None - author: str = None - year: str = None - cover: str = None - confidence: int = 1 + view_link: Optional[str] = None + author: Optional[str] = None + year: Optional[str] = None + cover: Optional[str] = None + confidence: float = 1.0 def __repr__(self): # pylint: disable=consider-using-f-string diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 950bb11f9..8b6dcb885 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -1,5 +1,7 @@ """ functionality outline for a book data connector """ +from __future__ import annotations from abc import ABC, abstractmethod +from typing import Optional, TypedDict, Any, Callable, Union, Iterator from urllib.parse import quote_plus import imghdr import logging @@ -16,33 +18,38 @@ from bookwyrm import activitypub, models, settings from bookwyrm.settings import USER_AGENT from .connector_manager import load_more_data, ConnectorException, raise_not_valid_url from .format_mappings import format_mappings - +from ..book_search import SearchResult logger = logging.getLogger(__name__) +JsonDict = dict[str, Any] + + +class ConnectorResults(TypedDict): + """TypedDict for results returned by connector""" + + connector: AbstractMinimalConnector + results: list[SearchResult] + class AbstractMinimalConnector(ABC): """just the bare bones, for other bookwyrm instances""" - def __init__(self, identifier): + def __init__(self, identifier: str): # load connector settings info = models.Connector.objects.get(identifier=identifier) self.connector = info # the things in the connector model to copy over - self_fields = [ - "base_url", - "books_url", - "covers_url", - "search_url", - "isbn_search_url", - "name", - "identifier", - ] - for field in self_fields: - setattr(self, field, getattr(info, field)) + self.base_url = info.base_url + self.books_url = info.books_url + self.covers_url = info.covers_url + self.search_url = info.search_url + self.isbn_search_url = info.isbn_search_url + self.name = info.name + self.identifier = info.identifier - def get_search_url(self, query): + def get_search_url(self, query: str) -> str: """format the query url""" # Check if the query resembles an ISBN if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "": @@ -54,13 +61,21 @@ class AbstractMinimalConnector(ABC): # searched as free text. This, instead, only searches isbn if it's isbn-y return f"{self.search_url}{quote_plus(query)}" - def process_search_response(self, query, data, min_confidence): + def process_search_response( + self, query: str, data: Any, min_confidence: float + ) -> list[SearchResult]: """Format the search results based on the format of the query""" if maybe_isbn(query): return list(self.parse_isbn_search_data(data))[:10] return list(self.parse_search_data(data, min_confidence))[:10] - async def get_results(self, session, url, min_confidence, query): + async def get_results( + self, + session: aiohttp.ClientSession, + url: str, + min_confidence: float, + query: str, + ) -> Optional[ConnectorResults]: """try this specific connector""" # pylint: disable=line-too-long headers = { @@ -74,55 +89,63 @@ class AbstractMinimalConnector(ABC): async with session.get(url, headers=headers, params=params) as response: if not response.ok: logger.info("Unable to connect to %s: %s", url, response.reason) - return + return None try: raw_data = await response.json() except aiohttp.client_exceptions.ContentTypeError as err: logger.exception(err) - return + return None - return { - "connector": self, - "results": self.process_search_response( + return ConnectorResults( + connector=self, + results=self.process_search_response( query, raw_data, min_confidence ), - } + ) except asyncio.TimeoutError: logger.info("Connection timed out for url: %s", url) except aiohttp.ClientError as err: logger.info(err) + return None @abstractmethod - def get_or_create_book(self, remote_id): + def get_or_create_book(self, remote_id: str) -> Optional[models.Book]: """pull up a book record by whatever means possible""" @abstractmethod - def parse_search_data(self, data, min_confidence): + def parse_search_data( + self, data: Any, min_confidence: float + ) -> Iterator[SearchResult]: """turn the result json from a search into a list""" @abstractmethod - def parse_isbn_search_data(self, data): + def parse_isbn_search_data(self, data: Any) -> Iterator[SearchResult]: """turn the result json from a search into a list""" class AbstractConnector(AbstractMinimalConnector): """generic book data connector""" - def __init__(self, identifier): + generated_remote_link_field = "" + + def __init__(self, identifier: str): super().__init__(identifier) # fields we want to look for in book data to copy over # title we handle separately. - self.book_mappings = [] + self.book_mappings: list[Mapping] = [] + self.author_mappings: list[Mapping] = [] - def get_or_create_book(self, remote_id): + def get_or_create_book(self, remote_id: str) -> Optional[models.Book]: """translate arbitrary json into an Activitypub dataclass""" # first, check if we have the origin_id saved existing = models.Edition.find_existing_by_remote_id( remote_id ) or models.Work.find_existing_by_remote_id(remote_id) if existing: - if hasattr(existing, "default_edition"): + if hasattr(existing, "default_edition") and isinstance( + existing.default_edition, models.Edition + ): return existing.default_edition return existing @@ -154,6 +177,9 @@ class AbstractConnector(AbstractMinimalConnector): ) # this will dedupe automatically work = work_activity.to_model(model=models.Work, overwrite=False) + if not work: + return None + for author in self.get_authors_from_data(work_data): work.authors.add(author) @@ -161,12 +187,21 @@ class AbstractConnector(AbstractMinimalConnector): load_more_data.delay(self.connector.id, work.id) return edition - def get_book_data(self, remote_id): # pylint: disable=no-self-use + def get_book_data(self, remote_id: str) -> JsonDict: # pylint: disable=no-self-use """this allows connectors to override the default behavior""" return get_data(remote_id) - def create_edition_from_data(self, work, edition_data, instance=None): + def create_edition_from_data( + self, + work: models.Work, + edition_data: Union[str, JsonDict], + instance: Optional[models.Edition] = None, + ) -> Optional[models.Edition]: """if we already have the work, we're ready""" + if isinstance(edition_data, str): + # We don't expect a string here + return None + mapped_data = dict_from_mappings(edition_data, self.book_mappings) mapped_data["work"] = work.remote_id edition_activity = activitypub.Edition(**mapped_data) @@ -174,6 +209,9 @@ class AbstractConnector(AbstractMinimalConnector): model=models.Edition, overwrite=False, instance=instance ) + if not edition: + return None + # if we're updating an existing instance, we don't need to load authors if instance: return edition @@ -190,7 +228,9 @@ class AbstractConnector(AbstractMinimalConnector): return edition - def get_or_create_author(self, remote_id, instance=None): + def get_or_create_author( + self, remote_id: str, instance: Optional[models.Author] = None + ) -> Optional[models.Author]: """load that author""" if not instance: existing = models.Author.find_existing_by_remote_id(remote_id) @@ -210,46 +250,51 @@ class AbstractConnector(AbstractMinimalConnector): model=models.Author, overwrite=False, instance=instance ) - def get_remote_id_from_model(self, obj): + def get_remote_id_from_model(self, obj: models.BookDataModel) -> Optional[str]: """given the data stored, how can we look this up""" - return getattr(obj, getattr(self, "generated_remote_link_field")) + remote_id: Optional[str] = getattr(obj, self.generated_remote_link_field) + return remote_id - def update_author_from_remote(self, obj): + def update_author_from_remote(self, obj: models.Author) -> Optional[models.Author]: """load the remote data from this connector and add it to an existing author""" remote_id = self.get_remote_id_from_model(obj) + if not remote_id: + return None return self.get_or_create_author(remote_id, instance=obj) - def update_book_from_remote(self, obj): + def update_book_from_remote(self, obj: models.Edition) -> Optional[models.Edition]: """load the remote data from this connector and add it to an existing book""" remote_id = self.get_remote_id_from_model(obj) + if not remote_id: + return None data = self.get_book_data(remote_id) return self.create_edition_from_data(obj.parent_work, data, instance=obj) @abstractmethod - def is_work_data(self, data): + def is_work_data(self, data: JsonDict) -> bool: """differentiate works and editions""" @abstractmethod - def get_edition_from_work_data(self, data): + def get_edition_from_work_data(self, data: JsonDict) -> JsonDict: """every work needs at least one edition""" @abstractmethod - def get_work_from_edition_data(self, data): + def get_work_from_edition_data(self, data: JsonDict) -> JsonDict: """every edition needs a work""" @abstractmethod - def get_authors_from_data(self, data): + def get_authors_from_data(self, data: JsonDict) -> Iterator[models.Author]: """load author data""" @abstractmethod - def expand_book_data(self, book): + def expand_book_data(self, book: models.Book) -> None: """get more info on a book""" -def dict_from_mappings(data, mappings): +def dict_from_mappings(data: JsonDict, mappings: list[Mapping]) -> JsonDict: """create a dict in Activitypub format, using mappings supplies by the subclass""" - result = {} + result: JsonDict = {} for mapping in mappings: # sometimes there are multiple mappings for one field, don't # overwrite earlier writes in that case @@ -259,7 +304,11 @@ def dict_from_mappings(data, mappings): return result -def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT): +def get_data( + url: str, + params: Optional[dict[str, str]] = None, + timeout: int = settings.QUERY_TIMEOUT, +) -> JsonDict: """wrapper for request.get""" # check if the url is blocked raise_not_valid_url(url) @@ -292,10 +341,15 @@ def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT): logger.info(err) raise ConnectorException(err) + if not isinstance(data, dict): + raise ConnectorException("Unexpected data format") + return data -def get_image(url, timeout=10): +def get_image( + url: str, timeout: int = 10 +) -> Union[tuple[ContentFile[bytes], str], tuple[None, None]]: """wrapper for requesting an image""" raise_not_valid_url(url) try: @@ -325,14 +379,19 @@ def get_image(url, timeout=10): class Mapping: """associate a local database field with a field in an external dataset""" - def __init__(self, local_field, remote_field=None, formatter=None): + def __init__( + self, + local_field: str, + remote_field: Optional[str] = None, + formatter: Optional[Callable[[Any], Any]] = None, + ): noop = lambda x: x self.local_field = local_field self.remote_field = remote_field or local_field self.formatter = formatter or noop - def get_value(self, data): + def get_value(self, data: JsonDict) -> Optional[Any]: """pull a field from incoming json and return the formatted version""" value = data.get(self.remote_field) if not value: @@ -343,7 +402,7 @@ class Mapping: return None -def infer_physical_format(format_text): +def infer_physical_format(format_text: str) -> Optional[str]: """try to figure out what the standardized format is from the free value""" format_text = format_text.lower() if format_text in format_mappings: @@ -356,7 +415,7 @@ def infer_physical_format(format_text): return matches[0] -def unique_physical_format(format_text): +def unique_physical_format(format_text: str) -> Optional[str]: """only store the format if it isn't directly in the format mappings""" format_text = format_text.lower() if format_text in format_mappings: @@ -365,7 +424,7 @@ def unique_physical_format(format_text): return format_text -def maybe_isbn(query): +def maybe_isbn(query: str) -> bool: """check if a query looks like an isbn""" isbn = re.sub(r"[\W_]", "", query) # removes filler characters # ISBNs must be numeric except an ISBN10 checkdigit can be 'X' diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index e07a0b281..4064f4b4c 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -1,4 +1,7 @@ """ using another bookwyrm instance as a source of book data """ +from __future__ import annotations +from typing import Any, Iterator + from bookwyrm import activitypub, models from bookwyrm.book_search import SearchResult from .abstract_connector import AbstractMinimalConnector @@ -7,15 +10,19 @@ from .abstract_connector import AbstractMinimalConnector class Connector(AbstractMinimalConnector): """this is basically just for search""" - def get_or_create_book(self, remote_id): + def get_or_create_book(self, remote_id: str) -> models.Edition: return activitypub.resolve_remote_id(remote_id, model=models.Edition) - def parse_search_data(self, data, min_confidence): + def parse_search_data( + self, data: list[dict[str, Any]], min_confidence: float + ) -> Iterator[SearchResult]: for search_result in data: search_result["connector"] = self yield SearchResult(**search_result) - def parse_isbn_search_data(self, data): + def parse_isbn_search_data( + self, data: list[dict[str, Any]] + ) -> Iterator[SearchResult]: for search_result in data: search_result["connector"] = self yield SearchResult(**search_result) diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 7e823c0af..444a626ba 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -1,8 +1,11 @@ """ interface with whatever connectors the app has """ +from __future__ import annotations import asyncio import importlib import ipaddress import logging +from asyncio import Future +from typing import Iterator, Any, Optional, Union, overload, Literal from urllib.parse import urlparse import aiohttp @@ -12,8 +15,10 @@ from django.db.models import signals from requests import HTTPError from bookwyrm import book_search, models +from bookwyrm.book_search import SearchResult +from bookwyrm.connectors import abstract_connector from bookwyrm.settings import SEARCH_TIMEOUT -from bookwyrm.tasks import app, LOW +from bookwyrm.tasks import app, CONNECTORS logger = logging.getLogger(__name__) @@ -22,11 +27,15 @@ class ConnectorException(HTTPError): """when the connector can't do what was asked""" -async def async_connector_search(query, items, min_confidence): +async def async_connector_search( + query: str, + items: list[tuple[str, abstract_connector.AbstractConnector]], + min_confidence: float, +) -> list[Optional[abstract_connector.ConnectorResults]]: """Try a number of requests simultaneously""" timeout = aiohttp.ClientTimeout(total=SEARCH_TIMEOUT) async with aiohttp.ClientSession(timeout=timeout) as session: - tasks = [] + tasks: list[Future[Optional[abstract_connector.ConnectorResults]]] = [] for url, connector in items: tasks.append( asyncio.ensure_future( @@ -35,14 +44,29 @@ async def async_connector_search(query, items, min_confidence): ) results = await asyncio.gather(*tasks) - return results + return list(results) -def search(query, min_confidence=0.1, return_first=False): +@overload +def search( + query: str, *, min_confidence: float = 0.1, return_first: Literal[False] +) -> list[abstract_connector.ConnectorResults]: + ... + + +@overload +def search( + query: str, *, min_confidence: float = 0.1, return_first: Literal[True] +) -> Optional[SearchResult]: + ... + + +def search( + query: str, *, min_confidence: float = 0.1, return_first: bool = False +) -> Union[list[abstract_connector.ConnectorResults], Optional[SearchResult]]: """find books based on arbitrary keywords""" if not query: - return [] - results = [] + return None if return_first else [] items = [] for connector in get_connectors(): @@ -57,8 +81,12 @@ def search(query, min_confidence=0.1, return_first=False): items.append((url, connector)) # load as many results as we can - results = asyncio.run(async_connector_search(query, items, min_confidence)) - results = [r for r in results if r] + # failed requests will return None, so filter those out + results = [ + r + for r in asyncio.run(async_connector_search(query, items, min_confidence)) + if r + ] if return_first: # find the best result from all the responses and return that @@ -66,11 +94,12 @@ def search(query, min_confidence=0.1, return_first=False): all_results = sorted(all_results, key=lambda r: r.confidence, reverse=True) return all_results[0] if all_results else None - # failed requests will return None, so filter those out return results -def first_search_result(query, min_confidence=0.1): +def first_search_result( + query: str, min_confidence: float = 0.1 +) -> Union[models.Edition, SearchResult, None]: """search until you find a result that fits""" # try local search first result = book_search.search(query, min_confidence=min_confidence, return_first=True) @@ -80,13 +109,13 @@ def first_search_result(query, min_confidence=0.1): return search(query, min_confidence=min_confidence, return_first=True) or None -def get_connectors(): +def get_connectors() -> Iterator[abstract_connector.AbstractConnector]: """load all connectors""" for info in models.Connector.objects.filter(active=True).order_by("priority").all(): yield load_connector(info) -def get_or_create_connector(remote_id): +def get_or_create_connector(remote_id: str) -> abstract_connector.AbstractConnector: """get the connector related to the object's server""" url = urlparse(remote_id) identifier = url.netloc @@ -109,8 +138,8 @@ def get_or_create_connector(remote_id): return load_connector(connector_info) -@app.task(queue=LOW) -def load_more_data(connector_id, book_id): +@app.task(queue=CONNECTORS) +def load_more_data(connector_id: str, book_id: str) -> None: """background the work of getting all 10,000 editions of LoTR""" connector_info = models.Connector.objects.get(id=connector_id) connector = load_connector(connector_info) @@ -118,8 +147,10 @@ def load_more_data(connector_id, book_id): connector.expand_book_data(book) -@app.task(queue=LOW) -def create_edition_task(connector_id, work_id, data): +@app.task(queue=CONNECTORS) +def create_edition_task( + connector_id: int, work_id: int, data: Union[str, abstract_connector.JsonDict] +) -> None: """separate task for each of the 10,000 editions of LoTR""" connector_info = models.Connector.objects.get(id=connector_id) connector = load_connector(connector_info) @@ -127,23 +158,31 @@ def create_edition_task(connector_id, work_id, data): connector.create_edition_from_data(work, data) -def load_connector(connector_info): +def load_connector( + connector_info: models.Connector, +) -> abstract_connector.AbstractConnector: """instantiate the connector class""" connector = importlib.import_module( f"bookwyrm.connectors.{connector_info.connector_file}" ) - return connector.Connector(connector_info.identifier) + return connector.Connector(connector_info.identifier) # type: ignore[no-any-return] @receiver(signals.post_save, sender="bookwyrm.FederatedServer") # pylint: disable=unused-argument -def create_connector(sender, instance, created, *args, **kwargs): +def create_connector( + sender: Any, + instance: models.FederatedServer, + created: Any, + *args: Any, + **kwargs: Any, +) -> None: """create a connector to an external bookwyrm server""" if instance.application_type == "bookwyrm": get_or_create_connector(f"https://{instance.server_name}") -def raise_not_valid_url(url): +def raise_not_valid_url(url: str) -> None: """do some basic reality checks on the url""" parsed = urlparse(url) if not parsed.scheme in ["http", "https"]: diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py index f3e24c0ec..c08bcdee1 100644 --- a/bookwyrm/connectors/inventaire.py +++ b/bookwyrm/connectors/inventaire.py @@ -1,9 +1,10 @@ """ inventaire data connector """ import re +from typing import Any, Union, Optional, Iterator, Iterable from bookwyrm import models from bookwyrm.book_search import SearchResult -from .abstract_connector import AbstractConnector, Mapping +from .abstract_connector import AbstractConnector, Mapping, JsonDict from .abstract_connector import get_data from .connector_manager import ConnectorException, create_edition_task @@ -13,7 +14,7 @@ class Connector(AbstractConnector): generated_remote_link_field = "inventaire_id" - def __init__(self, identifier): + def __init__(self, identifier: str): super().__init__(identifier) get_first = lambda a: a[0] @@ -60,13 +61,13 @@ class Connector(AbstractConnector): Mapping("died", remote_field="wdt:P570", formatter=get_first), ] + shared_mappings - def get_remote_id(self, value): + def get_remote_id(self, value: str) -> str: """convert an id/uri into a url""" return f"{self.books_url}?action=by-uris&uris={value}" - def get_book_data(self, remote_id): + def get_book_data(self, remote_id: str) -> JsonDict: data = get_data(remote_id) - extracted = list(data.get("entities").values()) + extracted = list(data.get("entities", {}).values()) try: data = extracted[0] except (KeyError, IndexError): @@ -74,10 +75,16 @@ 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", "type"]}, + **{ + k: data.get(k) + for k in ["uri", "image", "labels", "sitelinks", "type"] + if k in data + }, } - def parse_search_data(self, data, min_confidence): + def parse_search_data( + self, data: JsonDict, min_confidence: float + ) -> Iterator[SearchResult]: for search_result in data.get("results", []): images = search_result.get("image") cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None @@ -96,7 +103,7 @@ class Connector(AbstractConnector): connector=self, ) - def parse_isbn_search_data(self, data): + def parse_isbn_search_data(self, data: JsonDict) -> Iterator[SearchResult]: """got some data""" results = data.get("entities") if not results: @@ -114,35 +121,44 @@ class Connector(AbstractConnector): connector=self, ) - def is_work_data(self, data): + def is_work_data(self, data: JsonDict) -> bool: return data.get("type") == "work" - def load_edition_data(self, work_uri): + def load_edition_data(self, work_uri: str) -> JsonDict: """get a list of editions for a work""" # pylint: disable=line-too-long url = f"{self.books_url}?action=reverse-claims&property=wdt:P629&value={work_uri}&sort=true" return get_data(url) - def get_edition_from_work_data(self, data): - data = self.load_edition_data(data.get("uri")) + def get_edition_from_work_data(self, data: JsonDict) -> JsonDict: + work_uri = data.get("uri") + if not work_uri: + raise ConnectorException("Invalid URI") + data = self.load_edition_data(work_uri) try: uri = data.get("uris", [])[0] except IndexError: raise ConnectorException("Invalid book data") return self.get_book_data(self.get_remote_id(uri)) - def get_work_from_edition_data(self, data): - uri = data.get("wdt:P629", [None])[0] + def get_work_from_edition_data(self, data: JsonDict) -> JsonDict: + try: + uri = data.get("wdt:P629", [])[0] + except IndexError: + raise ConnectorException("Invalid book data") + if not uri: raise ConnectorException("Invalid book data") return self.get_book_data(self.get_remote_id(uri)) - def get_authors_from_data(self, data): + def get_authors_from_data(self, data: JsonDict) -> Iterator[models.Author]: authors = data.get("wdt:P50", []) for author in authors: - yield self.get_or_create_author(self.get_remote_id(author)) + model = self.get_or_create_author(self.get_remote_id(author)) + if model: + yield model - def expand_book_data(self, book): + def expand_book_data(self, book: models.Book) -> None: work = book # go from the edition to the work, if necessary if isinstance(book, models.Edition): @@ -154,11 +170,16 @@ class Connector(AbstractConnector): # who knows, man return - for edition_uri in edition_options.get("uris"): + for edition_uri in edition_options.get("uris", []): remote_id = self.get_remote_id(edition_uri) create_edition_task.delay(self.connector.id, work.id, remote_id) - def create_edition_from_data(self, work, edition_data, instance=None): + def create_edition_from_data( + self, + work: models.Work, + edition_data: Union[str, JsonDict], + instance: Optional[models.Edition] = None, + ) -> Optional[models.Edition]: """pass in the url as data and then call the version in abstract connector""" if isinstance(edition_data, str): try: @@ -168,22 +189,26 @@ class Connector(AbstractConnector): return None return super().create_edition_from_data(work, edition_data, instance=instance) - def get_cover_url(self, cover_blob, *_): + def get_cover_url( + self, cover_blob: Union[list[JsonDict], JsonDict], *_: Any + ) -> Optional[str]: """format the relative cover url into an absolute one: {"url": "/img/entities/e794783f01b9d4f897a1ea9820b96e00d346994f"} """ # covers may or may not be a list - if isinstance(cover_blob, list) and len(cover_blob) > 0: + if isinstance(cover_blob, list): + if len(cover_blob) == 0: + return None cover_blob = cover_blob[0] cover_id = cover_blob.get("url") - if not cover_id: + if not isinstance(cover_id, str): return None # cover may or may not be an absolute url already if re.match(r"^http", cover_id): return cover_id return f"{self.covers_url}{cover_id}" - def resolve_keys(self, keys): + def resolve_keys(self, keys: Iterable[str]) -> list[str]: """cool, it's "wd:Q3156592" now what the heck does that mean""" results = [] for uri in keys: @@ -191,10 +216,10 @@ class Connector(AbstractConnector): data = self.get_book_data(self.get_remote_id(uri)) except ConnectorException: continue - results.append(get_language_code(data.get("labels"))) + results.append(get_language_code(data.get("labels", {}))) return results - def get_description(self, links): + def get_description(self, links: JsonDict) -> str: """grab an extracted excerpt from wikipedia""" link = links.get("enwiki") if not link: @@ -204,15 +229,15 @@ class Connector(AbstractConnector): data = get_data(url) except ConnectorException: return "" - return data.get("extract") + return data.get("extract", "") - def get_remote_id_from_model(self, obj): + def get_remote_id_from_model(self, obj: models.BookDataModel) -> str: """use get_remote_id to figure out the link from a model obj""" remote_id_value = obj.inventaire_id return self.get_remote_id(remote_id_value) -def get_language_code(options, code="en"): +def get_language_code(options: JsonDict, code: str = "en") -> Any: """when there are a bunch of translation but we need a single field""" result = options.get(code) if result: diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index 0fd786660..4dc6d6ac1 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -1,9 +1,13 @@ """ openlibrary data connector """ import re +from typing import Any, Optional, Union, Iterator, Iterable + +from markdown import markdown from bookwyrm import models from bookwyrm.book_search import SearchResult -from .abstract_connector import AbstractConnector, Mapping +from bookwyrm.utils.sanitizer import clean +from .abstract_connector import AbstractConnector, Mapping, JsonDict from .abstract_connector import get_data, infer_physical_format, unique_physical_format from .connector_manager import ConnectorException, create_edition_task from .openlibrary_languages import languages @@ -14,7 +18,7 @@ class Connector(AbstractConnector): generated_remote_link_field = "openlibrary_link" - def __init__(self, identifier): + def __init__(self, identifier: str): super().__init__(identifier) get_first = lambda a, *args: a[0] @@ -94,14 +98,14 @@ class Connector(AbstractConnector): Mapping("inventaire_id", remote_field="links", formatter=get_inventaire_id), ] - def get_book_data(self, remote_id): + def get_book_data(self, remote_id: str) -> JsonDict: data = get_data(remote_id) if data.get("type", {}).get("key") == "/type/redirect": - remote_id = self.base_url + data.get("location") + remote_id = self.base_url + data.get("location", "") return get_data(remote_id) return data - def get_remote_id_from_data(self, data): + def get_remote_id_from_data(self, data: JsonDict) -> str: """format a url from an openlibrary id field""" try: key = data["key"] @@ -109,10 +113,10 @@ class Connector(AbstractConnector): raise ConnectorException("Invalid book data") return f"{self.books_url}{key}" - def is_work_data(self, data): + def is_work_data(self, data: JsonDict) -> bool: return bool(re.match(r"^[\/\w]+OL\d+W$", data["key"])) - def get_edition_from_work_data(self, data): + def get_edition_from_work_data(self, data: JsonDict) -> JsonDict: try: key = data["key"] except KeyError: @@ -124,7 +128,7 @@ class Connector(AbstractConnector): raise ConnectorException("No editions for work") return edition - def get_work_from_edition_data(self, data): + def get_work_from_edition_data(self, data: JsonDict) -> JsonDict: try: key = data["works"][0]["key"] except (IndexError, KeyError): @@ -132,7 +136,7 @@ class Connector(AbstractConnector): url = f"{self.books_url}{key}" return self.get_book_data(url) - def get_authors_from_data(self, data): + def get_authors_from_data(self, data: JsonDict) -> Iterator[models.Author]: """parse author json and load or create authors""" for author_blob in data.get("authors", []): author_blob = author_blob.get("author", author_blob) @@ -144,7 +148,7 @@ class Connector(AbstractConnector): continue yield author - def get_cover_url(self, cover_blob, size="L"): + def get_cover_url(self, cover_blob: list[str], size: str = "L") -> Optional[str]: """ask openlibrary for the cover""" if not cover_blob: return None @@ -152,8 +156,10 @@ class Connector(AbstractConnector): image_name = f"{cover_id}-{size}.jpg" return f"{self.covers_url}/b/id/{image_name}" - def parse_search_data(self, data, min_confidence): - for idx, search_result in enumerate(data.get("docs")): + def parse_search_data( + self, data: JsonDict, min_confidence: float + ) -> Iterator[SearchResult]: + for idx, search_result in enumerate(data.get("docs", [])): # build the remote id from the openlibrary key key = self.books_url + search_result["key"] author = search_result.get("author_name") or ["Unknown"] @@ -174,7 +180,7 @@ class Connector(AbstractConnector): confidence=confidence, ) - def parse_isbn_search_data(self, data): + def parse_isbn_search_data(self, data: JsonDict) -> Iterator[SearchResult]: for search_result in list(data.values()): # build the remote id from the openlibrary key key = self.books_url + search_result["key"] @@ -188,12 +194,12 @@ class Connector(AbstractConnector): year=search_result.get("publish_date"), ) - def load_edition_data(self, olkey): + def load_edition_data(self, olkey: str) -> JsonDict: """query openlibrary for editions of a work""" url = f"{self.books_url}/works/{olkey}/editions" return self.get_book_data(url) - def expand_book_data(self, book): + def expand_book_data(self, book: models.Book) -> None: work = book # go from the edition to the work, if necessary if isinstance(book, models.Edition): @@ -206,14 +212,14 @@ class Connector(AbstractConnector): # who knows, man return - for edition_data in edition_options.get("entries"): + for edition_data in edition_options.get("entries", []): # does this edition have ANY interesting data? if ignore_edition(edition_data): continue create_edition_task.delay(self.connector.id, work.id, edition_data) -def ignore_edition(edition_data): +def ignore_edition(edition_data: JsonDict) -> bool: """don't load a million editions that have no metadata""" # an isbn, we love to see it if edition_data.get("isbn_13") or edition_data.get("isbn_10"): @@ -232,19 +238,30 @@ def ignore_edition(edition_data): return True -def get_description(description_blob): +def get_description(description_blob: Union[JsonDict, str]) -> str: """descriptions can be a string or a dict""" if isinstance(description_blob, dict): - return description_blob.get("value") - return description_blob + description = markdown(description_blob.get("value", "")) + else: + description = markdown(description_blob) + + if ( + description.startswith("

") + and description.endswith("

") + and description.count("

") == 1 + ): + # If there is just one

tag and it is around the text remove it + return description[len("

") : -len("

")].strip() + + return clean(description) -def get_openlibrary_key(key): +def get_openlibrary_key(key: str) -> str: """convert /books/OL27320736M into OL27320736M""" return key.split("/")[-1] -def get_languages(language_blob): +def get_languages(language_blob: Iterable[JsonDict]) -> list[Optional[str]]: """/language/eng -> English""" langs = [] for lang in language_blob: @@ -252,14 +269,14 @@ def get_languages(language_blob): return langs -def get_dict_field(blob, field_name): +def get_dict_field(blob: Optional[JsonDict], field_name: str) -> Optional[Any]: """extract the isni from the remote id data for the author""" if not blob or not isinstance(blob, dict): return None return blob.get(field_name) -def get_wikipedia_link(links): +def get_wikipedia_link(links: list[Any]) -> Optional[str]: """extract wikipedia links""" if not isinstance(links, list): return None @@ -272,7 +289,7 @@ def get_wikipedia_link(links): return None -def get_inventaire_id(links): +def get_inventaire_id(links: list[Any]) -> Optional[str]: """extract and format inventaire ids""" if not isinstance(links, list): return None @@ -282,11 +299,13 @@ def get_inventaire_id(links): continue if link.get("title") == "inventaire.io": iv_link = link.get("url") + if not isinstance(iv_link, str): + return None return iv_link.split("/")[-1] return None -def pick_default_edition(options): +def pick_default_edition(options: list[JsonDict]) -> Optional[JsonDict]: """favor physical copies with covers in english""" if not options: return None diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py index 2271077b1..5e08ebba1 100644 --- a/bookwyrm/emailing.py +++ b/bookwyrm/emailing.py @@ -3,7 +3,7 @@ from django.core.mail import EmailMultiAlternatives 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 623beaa04..4885dc063 100644 --- a/bookwyrm/forms/books.py +++ b/bookwyrm/forms/books.py @@ -20,6 +20,7 @@ class EditionForm(CustomForm): model = models.Edition fields = [ "title", + "sort_title", "subtitle", "description", "series", @@ -45,6 +46,9 @@ class EditionForm(CustomForm): ] 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"} @@ -107,6 +111,7 @@ class EditionFromWorkForm(CustomForm): model = models.Work fields = [ "title", + "sort_title", "subtitle", "authors", "description", diff --git a/bookwyrm/forms/lists.py b/bookwyrm/forms/lists.py index 647db3bfe..f5008baa3 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/isbn/RangeMessage.xml b/bookwyrm/isbn/RangeMessage.xml new file mode 100644 index 000000000..619cf1ff7 --- /dev/null +++ b/bookwyrm/isbn/RangeMessage.xml @@ -0,0 +1,7904 @@ + + + + + + + + + + + + + + + +]> + + International ISBN Agency + fa1a5bb4-9703-4910-bd34-2ffe0ae46c45 + Sat, 22 Jul 2023 02:00:37 BST + + + 978 + International ISBN Agency + + + 0000000-5999999 + 1 + + + 6000000-6499999 + 3 + + + 6500000-6599999 + 2 + + + 6600000-6999999 + 0 + + + 7000000-7999999 + 1 + + + 8000000-9499999 + 2 + + + 9500000-9899999 + 3 + + + 9900000-9989999 + 4 + + + 9990000-9999999 + 5 + + + + + 979 + International ISBN Agency + + + 0000000-0999999 + 0 + + + 1000000-1299999 + 2 + + + 1300000-7999999 + 0 + + + 8000000-8999999 + 1 + + + 9000000-9999999 + 0 + + + + + + + 978-0 + English language + + + 0000000-1999999 + 2 + + + 2000000-2279999 + 3 + + + 2280000-2289999 + 4 + + + 2290000-3689999 + 3 + + + 3690000-3699999 + 4 + + + 3700000-6389999 + 3 + + + 6390000-6397999 + 4 + + + 6398000-6399999 + 7 + + + 6400000-6449999 + 3 + + + 6450000-6459999 + 7 + + + 6460000-6479999 + 3 + + + 6480000-6489999 + 7 + + + 6490000-6549999 + 3 + + + 6550000-6559999 + 4 + + + 6560000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9499999 + 6 + + + 9500000-9999999 + 7 + + + + + 978-1 + English language + + + 0000000-0099999 + 3 + + + 0100000-0299999 + 2 + + + 0300000-0349999 + 3 + + + 0350000-0399999 + 4 + + + 0400000-0499999 + 3 + + + 0500000-0699999 + 2 + + + 0700000-0999999 + 4 + + + 1000000-3979999 + 3 + + + 3980000-5499999 + 4 + + + 5500000-6499999 + 5 + + + 6500000-6799999 + 4 + + + 6800000-6859999 + 5 + + + 6860000-7139999 + 4 + + + 7140000-7169999 + 3 + + + 7170000-7319999 + 4 + + + 7320000-7399999 + 7 + + + 7400000-7749999 + 5 + + + 7750000-7753999 + 7 + + + 7754000-7763999 + 5 + + + 7764000-7764999 + 7 + + + 7765000-7769999 + 5 + + + 7770000-7782999 + 7 + + + 7783000-7899999 + 5 + + + 7900000-7999999 + 4 + + + 8000000-8004999 + 5 + + + 8005000-8049999 + 5 + + + 8050000-8379999 + 5 + + + 8380000-8384999 + 7 + + + 8385000-8671999 + 5 + + + 8672000-8675999 + 4 + + + 8676000-8697999 + 5 + + + 8698000-9159999 + 6 + + + 9160000-9165059 + 7 + + + 9165060-9168699 + 6 + + + 9168700-9169079 + 7 + + + 9169080-9195999 + 6 + + + 9196000-9196549 + 7 + + + 9196550-9729999 + 6 + + + 9730000-9877999 + 4 + + + 9878000-9911499 + 6 + + + 9911500-9911999 + 7 + + + 9912000-9989899 + 6 + + + 9989900-9999999 + 7 + + + + + 978-2 + French language + + + 0000000-1999999 + 2 + + + 2000000-3499999 + 3 + + + 3500000-3999999 + 5 + + + 4000000-4869999 + 3 + + + 4870000-4949999 + 6 + + + 4950000-4959999 + 3 + + + 4960000-4966999 + 4 + + + 4967000-4969999 + 5 + + + 4970000-5279999 + 3 + + + 5280000-5299999 + 4 + + + 5300000-6999999 + 3 + + + 7000000-8399999 + 4 + + + 8400000-8999999 + 5 + + + 9000000-9197999 + 6 + + + 9198000-9198099 + 5 + + + 9198100-9199429 + 6 + + + 9199430-9199689 + 7 + + + 9199690-9499999 + 6 + + + 9500000-9999999 + 7 + + + + + 978-3 + German language + + + 0000000-0299999 + 2 + + + 0300000-0339999 + 3 + + + 0340000-0369999 + 4 + + + 0370000-0399999 + 5 + + + 0400000-1999999 + 2 + + + 2000000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9499999 + 6 + + + 9500000-9539999 + 7 + + + 9540000-9699999 + 5 + + + 9700000-9849999 + 7 + + + 9850000-9999999 + 5 + + + + + 978-4 + Japan + + + 0000000-1999999 + 2 + + + 2000000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9499999 + 6 + + + 9500000-9999999 + 7 + + + + + 978-5 + former U.S.S.R + + + 0000000-0049999 + 5 + + + 0050000-0099999 + 4 + + + 0100000-1999999 + 2 + + + 2000000-3619999 + 3 + + + 3620000-3623999 + 4 + + + 3624000-3629999 + 5 + + + 3630000-4209999 + 3 + + + 4210000-4299999 + 4 + + + 4300000-4309999 + 3 + + + 4310000-4399999 + 4 + + + 4400000-4409999 + 3 + + + 4410000-4499999 + 4 + + + 4500000-6039999 + 3 + + + 6040000-6049999 + 7 + + + 6050000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9099999 + 6 + + + 9100000-9199999 + 5 + + + 9200000-9299999 + 4 + + + 9300000-9499999 + 5 + + + 9500000-9500999 + 7 + + + 9501000-9799999 + 4 + + + 9800000-9899999 + 5 + + + 9900000-9909999 + 7 + + + 9910000-9999999 + 4 + + + + + 978-600 + Iran + + + 0000000-0999999 + 2 + + + 1000000-4999999 + 3 + + + 5000000-8999999 + 4 + + + 9000000-9867999 + 5 + + + 9868000-9929999 + 4 + + + 9930000-9959999 + 3 + + + 9960000-9999999 + 5 + + + + + 978-601 + Kazakhstan + + + 0000000-1999999 + 2 + + + 2000000-6999999 + 3 + + + 7000000-7999999 + 4 + + + 8000000-8499999 + 5 + + + 8500000-9999999 + 2 + + + + + 978-602 + Indonesia + + + 0000000-0699999 + 2 + + + 0700000-1399999 + 4 + + + 1400000-1499999 + 5 + + + 1500000-1699999 + 4 + + + 1700000-1999999 + 5 + + + 2000000-4999999 + 3 + + + 5000000-5399999 + 5 + + + 5400000-5999999 + 4 + + + 6000000-6199999 + 5 + + + 6200000-6999999 + 4 + + + 7000000-7499999 + 5 + + + 7500000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-603 + Saudi Arabia + + + 0000000-0499999 + 2 + + + 0500000-4999999 + 2 + + + 5000000-7999999 + 3 + + + 8000000-8999999 + 4 + + + 9000000-9999999 + 5 + + + + + 978-604 + Vietnam + + + 0000000-2999999 + 1 + + + 3000000-3999999 + 3 + + + 4000000-4699999 + 2 + + + 4700000-4979999 + 3 + + + 4980000-4999999 + 4 + + + 5000000-8999999 + 2 + + + 9000000-9799999 + 3 + + + 9800000-9999999 + 4 + + + + + 978-605 + Turkey + + + 0000000-0299999 + 2 + + + 0300000-0399999 + 3 + + + 0400000-0599999 + 2 + + + 0600000-0699999 + 5 + + + 0700000-0999999 + 2 + + + 1000000-1999999 + 3 + + + 2000000-2399999 + 4 + + + 2400000-3999999 + 3 + + + 4000000-5999999 + 4 + + + 6000000-7499999 + 5 + + + 7500000-7999999 + 4 + + + 8000000-8999999 + 5 + + + 9000000-9999999 + 4 + + + + + 978-606 + Romania + + + 0000000-0999999 + 3 + + + 1000000-4999999 + 2 + + + 5000000-7999999 + 3 + + + 8000000-9099999 + 4 + + + 9100000-9199999 + 3 + + + 9200000-9599999 + 5 + + + 9600000-9749999 + 4 + + + 9750000-9999999 + 3 + + + + + 978-607 + Mexico + + + 0000000-3999999 + 2 + + + 4000000-5929999 + 3 + + + 5930000-5999999 + 5 + + + 6000000-7499999 + 3 + + + 7500000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-608 + North Macedonia + + + 0000000-0999999 + 1 + + + 1000000-1999999 + 2 + + + 2000000-4499999 + 3 + + + 4500000-6499999 + 4 + + + 6500000-6999999 + 5 + + + 7000000-9999999 + 1 + + + + + 978-609 + Lithuania + + + 0000000-3999999 + 2 + + + 4000000-7999999 + 3 + + + 8000000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-611 + Thailand + + + 0000000-9999999 + 0 + + + + + 978-612 + Peru + + + 0000000-2999999 + 2 + + + 3000000-3999999 + 3 + + + 4000000-4499999 + 4 + + + 4500000-4999999 + 5 + + + 5000000-5149999 + 4 + + + 5150000-9999999 + 0 + + + + + 978-613 + Mauritius + + + 0000000-9999999 + 1 + + + + + 978-614 + Lebanon + + + 0000000-3999999 + 2 + + + 4000000-7999999 + 3 + + + 8000000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-615 + Hungary + + + 0000000-0999999 + 2 + + + 1000000-4999999 + 3 + + + 5000000-7999999 + 4 + + + 8000000-8999999 + 5 + + + 9000000-9999999 + 0 + + + + + 978-616 + Thailand + + + 0000000-1999999 + 2 + + + 2000000-6999999 + 3 + + + 7000000-8999999 + 4 + + + 9000000-9999999 + 5 + + + + + 978-617 + Ukraine + + + 0000000-4999999 + 2 + + + 5000000-6999999 + 3 + + + 7000000-8999999 + 4 + + + 9000000-9999999 + 5 + + + + + 978-618 + Greece + + + 0000000-1999999 + 2 + + + 2000000-4999999 + 3 + + + 5000000-7999999 + 4 + + + 8000000-9999999 + 5 + + + + + 978-619 + Bulgaria + + + 0000000-1499999 + 2 + + + 1500000-6999999 + 3 + + + 7000000-8999999 + 4 + + + 9000000-9999999 + 5 + + + + + 978-620 + Mauritius + + + 0000000-9999999 + 1 + + + + + 978-621 + Philippines + + + 0000000-2999999 + 2 + + + 3000000-3999999 + 0 + + + 4000000-5999999 + 3 + + + 6000000-7999999 + 0 + + + 8000000-8999999 + 4 + + + 9000000-9499999 + 0 + + + 9500000-9999999 + 5 + + + + + 978-622 + Iran + + + 0000000-1099999 + 2 + + + 1100000-1999999 + 0 + + + 2000000-4249999 + 3 + + + 4250000-5199999 + 0 + + + 5200000-8499999 + 4 + + + 8500000-8999999 + 0 + + + 9000000-9999999 + 5 + + + + + 978-623 + Indonesia + + + 0000000-0999999 + 2 + + + 1000000-1299999 + 0 + + + 1300000-4999999 + 3 + + + 5000000-5249999 + 0 + + + 5250000-8799999 + 4 + + + 8800000-9999999 + 5 + + + + + 978-624 + Sri Lanka + + + 0000000-0499999 + 2 + + + 0500000-1999999 + 0 + + + 2000000-2499999 + 3 + + + 2500000-4999999 + 0 + + + 5000000-6449999 + 4 + + + 6450000-9449999 + 0 + + + 9450000-9999999 + 5 + + + + + 978-625 + Turkey + + + 0000000-0099999 + 2 + + + 0100000-3649999 + 0 + + + 3650000-4429999 + 3 + + + 4430000-4449999 + 5 + + + 4450000-4499999 + 3 + + + 4500000-6349999 + 0 + + + 6350000-7793999 + 4 + + + 7794000-7794999 + 5 + + + 7795000-8499999 + 4 + + + 8500000-9899999 + 0 + + + 9900000-9999999 + 5 + + + + + 978-626 + Taiwan + + + 0000000-0499999 + 2 + + + 0500000-2999999 + 0 + + + 3000000-4999999 + 3 + + + 5000000-6999999 + 0 + + + 7000000-7999999 + 4 + + + 8000000-9499999 + 0 + + + 9500000-9999999 + 5 + + + + + 978-627 + Pakistan + + + 0000000-2999999 + 0 + + + 3000000-3199999 + 2 + + + 3200000-4999999 + 0 + + + 5000000-5249999 + 3 + + + 5250000-7499999 + 0 + + + 7500000-7999999 + 4 + + + 8000000-9999999 + 0 + + + + + 978-628 + Colombia + + + 0000000-0999999 + 2 + + + 1000000-4999999 + 0 + + + 5000000-5499999 + 3 + + + 5500000-7499999 + 0 + + + 7500000-8499999 + 4 + + + 8500000-9499999 + 0 + + + 9500000-9999999 + 5 + + + + + 978-629 + Malaysia + + + 0000000-0299999 + 2 + + + 0300000-4699999 + 0 + + + 4700000-4999999 + 3 + + + 5000000-7499999 + 0 + + + 7500000-7999999 + 4 + + + 8000000-9649999 + 0 + + + 9650000-9999999 + 5 + + + + + 978-630 + Romania + + + 0000000-2999999 + 0 + + + 3000000-3499999 + 3 + + + 3500000-6499999 + 0 + + + 6500000-6849999 + 4 + + + 6850000-9999999 + 0 + + + + + 978-631 + Argentina + + + 0000000-0999999 + 2 + + + 1000000-2999999 + 0 + + + 3000000-3999999 + 3 + + + 4000000-6499999 + 0 + + + 6500000-7499999 + 4 + + + 7500000-8999999 + 0 + + + 9000000-9999999 + 5 + + + + + 978-65 + Brazil + + + 0000000-0199999 + 2 + + + 0200000-2499999 + 0 + + + 2500000-2999999 + 3 + + + 3000000-3029999 + 3 + + + 3030000-4999999 + 0 + + + 5000000-5129999 + 4 + + + 5130000-5349999 + 0 + + + 5350000-6149999 + 4 + + + 6150000-7999999 + 0 + + + 8000000-8182499 + 5 + + + 8182500-8449999 + 0 + + + 8450000-8999999 + 5 + + + 9000000-9024499 + 6 + + + 9024500-9799999 + 0 + + + 9800000-9999999 + 6 + + + + + 978-7 + China, People's Republic + + + 0000000-0999999 + 2 + + + 1000000-4999999 + 3 + + + 5000000-7999999 + 4 + + + 8000000-8999999 + 5 + + + 9000000-9999999 + 6 + + + + + 978-80 + former Czechoslovakia + + + 0000000-1999999 + 2 + + + 2000000-5299999 + 3 + + + 5300000-5499999 + 5 + + + 5500000-6899999 + 3 + + + 6900000-6999999 + 5 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9989999 + 6 + + + 9990000-9999999 + 5 + + + + + 978-81 + India + + + 0000000-1899999 + 2 + + + 1900000-1999999 + 5 + + + 2000000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9999999 + 6 + + + + + 978-82 + Norway + + + 0000000-1999999 + 2 + + + 2000000-6899999 + 3 + + + 6900000-6999999 + 6 + + + 7000000-8999999 + 4 + + + 9000000-9899999 + 5 + + + 9900000-9999999 + 6 + + + + + 978-83 + Poland + + + 0000000-1999999 + 2 + + + 2000000-5999999 + 3 + + + 6000000-6999999 + 5 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9999999 + 6 + + + + + 978-84 + Spain + + + 0000000-0999999 + 2 + + + 1000000-1049999 + 5 + + + 1050000-1199999 + 4 + + + 1200000-1299999 + 6 + + + 1300000-1399999 + 4 + + + 1400000-1499999 + 3 + + + 1500000-1999999 + 5 + + + 2000000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9199999 + 4 + + + 9200000-9239999 + 6 + + + 9240000-9299999 + 5 + + + 9300000-9499999 + 6 + + + 9500000-9699999 + 5 + + + 9700000-9999999 + 4 + + + + + 978-85 + Brazil + + + 0000000-1999999 + 2 + + + 2000000-4549999 + 3 + + + 4550000-4552999 + 6 + + + 4553000-4559999 + 5 + + + 4560000-5289999 + 3 + + + 5290000-5319999 + 5 + + + 5320000-5339999 + 4 + + + 5340000-5399999 + 3 + + + 5400000-5402999 + 5 + + + 5403000-5403999 + 5 + + + 5404000-5404999 + 6 + + + 5405000-5408999 + 5 + + + 5409000-5409999 + 6 + + + 5410000-5439999 + 5 + + + 5440000-5479999 + 4 + + + 5480000-5499999 + 5 + + + 5500000-5999999 + 4 + + + 6000000-6999999 + 5 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9249999 + 6 + + + 9250000-9449999 + 5 + + + 9450000-9599999 + 4 + + + 9600000-9799999 + 2 + + + 9800000-9999999 + 5 + + + + + 978-86 + former Yugoslavia + + + 0000000-2999999 + 2 + + + 3000000-5999999 + 3 + + + 6000000-7999999 + 4 + + + 8000000-8999999 + 5 + + + 9000000-9999999 + 6 + + + + + 978-87 + Denmark + + + 0000000-2999999 + 2 + + + 3000000-3999999 + 0 + + + 4000000-6499999 + 3 + + + 6500000-6999999 + 0 + + + 7000000-7999999 + 4 + + + 8000000-8499999 + 0 + + + 8500000-9499999 + 5 + + + 9500000-9699999 + 0 + + + 9700000-9999999 + 6 + + + + + 978-88 + Italy + + + 0000000-1999999 + 2 + + + 2000000-3119999 + 3 + + + 3120000-3149999 + 5 + + + 3150000-3189999 + 3 + + + 3190000-3229999 + 5 + + + 3230000-3269999 + 3 + + + 3270000-3389999 + 4 + + + 3390000-3609999 + 3 + + + 3610000-3629999 + 4 + + + 3630000-5489999 + 3 + + + 5490000-5549999 + 4 + + + 5550000-5999999 + 3 + + + 6000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9099999 + 6 + + + 9100000-9269999 + 3 + + + 9270000-9399999 + 4 + + + 9400000-9479999 + 6 + + + 9480000-9999999 + 5 + + + + + 978-89 + Korea, Republic + + + 0000000-2499999 + 2 + + + 2500000-5499999 + 3 + + + 5500000-8499999 + 4 + + + 8500000-9499999 + 5 + + + 9500000-9699999 + 6 + + + 9700000-9899999 + 5 + + + 9900000-9999999 + 3 + + + + + 978-90 + Netherlands + + + 0000000-1999999 + 2 + + + 2000000-4999999 + 3 + + + 5000000-6999999 + 4 + + + 7000000-7999999 + 5 + + + 8000000-8499999 + 6 + + + 8500000-8999999 + 4 + + + 9000000-9099999 + 2 + + + 9100000-9399999 + 0 + + + 9400000-9499999 + 2 + + + 9500000-9999999 + 0 + + + + + 978-91 + Sweden + + + 0000000-1999999 + 1 + + + 2000000-4999999 + 2 + + + 5000000-6499999 + 3 + + + 6500000-6999999 + 0 + + + 7000000-8199999 + 4 + + + 8200000-8499999 + 0 + + + 8500000-9499999 + 5 + + + 9500000-9699999 + 0 + + + 9700000-9999999 + 6 + + + + + 978-92 + International NGO Publishers and EU Organizations + + + 0000000-5999999 + 1 + + + 6000000-7999999 + 2 + + + 8000000-8999999 + 3 + + + 9000000-9499999 + 4 + + + 9500000-9899999 + 5 + + + 9900000-9999999 + 6 + + + + + 978-93 + India + + + 0000000-0999999 + 2 + + + 1000000-4999999 + 3 + + + 5000000-7999999 + 4 + + + 8000000-9599999 + 5 + + + 9600000-9999999 + 6 + + + + + 978-94 + Netherlands + + + 0000000-5999999 + 3 + + + 6000000-8999999 + 4 + + + 9000000-9999999 + 5 + + + + + 978-950 + Argentina + + + 0000000-4999999 + 2 + + + 5000000-8999999 + 3 + + + 9000000-9899999 + 4 + + + 9900000-9999999 + 5 + + + + + 978-951 + Finland + + + 0000000-1999999 + 1 + + + 2000000-5499999 + 2 + + + 5500000-8899999 + 3 + + + 8900000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-952 + Finland + + + 0000000-1999999 + 2 + + + 2000000-4999999 + 3 + + + 5000000-5999999 + 4 + + + 6000000-6499999 + 2 + + + 6500000-6599999 + 5 + + + 6600000-6699999 + 4 + + + 6700000-6999999 + 5 + + + 7000000-7999999 + 4 + + + 8000000-9499999 + 2 + + + 9500000-9899999 + 4 + + + 9900000-9999999 + 5 + + + + + 978-953 + Croatia + + + 0000000-0999999 + 1 + + + 1000000-1499999 + 2 + + + 1500000-4799999 + 3 + + + 4800000-4999999 + 5 + + + 5000000-5009999 + 3 + + + 5010000-5099999 + 5 + + + 5100000-5499999 + 2 + + + 5500000-5999999 + 5 + + + 6000000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-954 + Bulgaria + + + 0000000-2899999 + 2 + + + 2900000-2999999 + 4 + + + 3000000-7999999 + 3 + + + 8000000-8999999 + 4 + + + 9000000-9299999 + 5 + + + 9300000-9999999 + 4 + + + + + 978-955 + Sri Lanka + + + 0000000-1999999 + 4 + + + 2000000-3399999 + 2 + + + 3400000-3549999 + 4 + + + 3550000-3599999 + 5 + + + 3600000-3799999 + 4 + + + 3800000-3899999 + 5 + + + 3900000-4099999 + 4 + + + 4100000-4499999 + 5 + + + 4500000-4999999 + 4 + + + 5000000-5499999 + 5 + + + 5500000-7109999 + 3 + + + 7110000-7149999 + 5 + + + 7150000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-956 + Chile + + + 0000000-0899999 + 2 + + + 0900000-0999999 + 5 + + + 1000000-1999999 + 2 + + + 2000000-5999999 + 3 + + + 6000000-6999999 + 4 + + + 7000000-9999999 + 4 + + + + + 978-957 + Taiwan + + + 0000000-0299999 + 2 + + + 0300000-0499999 + 4 + + + 0500000-1999999 + 2 + + + 2000000-2099999 + 4 + + + 2100000-2799999 + 2 + + + 2800000-3099999 + 5 + + + 3100000-4399999 + 2 + + + 4400000-8199999 + 3 + + + 8200000-9699999 + 4 + + + 9700000-9999999 + 5 + + + + + 978-958 + Colombia + + + 0000000-4999999 + 2 + + + 5000000-5099999 + 3 + + + 5100000-5199999 + 4 + + + 5200000-5399999 + 5 + + + 5400000-5599999 + 4 + + + 5600000-5999999 + 5 + + + 6000000-7999999 + 3 + + + 8000000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-959 + Cuba + + + 0000000-1999999 + 2 + + + 2000000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-9999999 + 5 + + + + + 978-960 + Greece + + + 0000000-1999999 + 2 + + + 2000000-6599999 + 3 + + + 6600000-6899999 + 4 + + + 6900000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-9299999 + 5 + + + 9300000-9399999 + 2 + + + 9400000-9799999 + 4 + + + 9800000-9999999 + 5 + + + + + 978-961 + Slovenia + + + 0000000-1999999 + 2 + + + 2000000-5999999 + 3 + + + 6000000-8999999 + 4 + + + 9000000-9799999 + 5 + + + 9800000-9999999 + 0 + + + + + 978-962 + Hong Kong, China + + + 0000000-1999999 + 2 + + + 2000000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-8699999 + 5 + + + 8700000-8999999 + 4 + + + 9000000-9999999 + 3 + + + + + 978-963 + Hungary + + + 0000000-1999999 + 2 + + + 2000000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9999999 + 4 + + + + + 978-964 + Iran + + + 0000000-1499999 + 2 + + + 1500000-2499999 + 3 + + + 2500000-2999999 + 4 + + + 3000000-5499999 + 3 + + + 5500000-8999999 + 4 + + + 9000000-9699999 + 5 + + + 9700000-9899999 + 3 + + + 9900000-9999999 + 4 + + + + + 978-965 + Israel + + + 0000000-1999999 + 2 + + + 2000000-5999999 + 3 + + + 6000000-6999999 + 0 + + + 7000000-7999999 + 4 + + + 8000000-8999999 + 0 + + + 9000000-9999999 + 5 + + + + + 978-966 + Ukraine + + + 0000000-1299999 + 2 + + + 1300000-1399999 + 3 + + + 1400000-1499999 + 2 + + + 1500000-1699999 + 4 + + + 1700000-1999999 + 3 + + + 2000000-2789999 + 4 + + + 2790000-2899999 + 3 + + + 2900000-2999999 + 4 + + + 3000000-6999999 + 3 + + + 7000000-8999999 + 4 + + + 9000000-9099999 + 5 + + + 9100000-9499999 + 3 + + + 9500000-9799999 + 5 + + + 9800000-9999999 + 3 + + + + + 978-967 + Malaysia + + + 0000000-0999999 + 4 + + + 1000000-1999999 + 5 + + + 2000000-2499999 + 4 + + + 2500000-2549999 + 3 + + + 2550000-2699999 + 5 + + + 2700000-2799999 + 4 + + + 2800000-2999999 + 4 + + + 3000000-4999999 + 3 + + + 5000000-5999999 + 4 + + + 6000000-8999999 + 2 + + + 9000000-9899999 + 3 + + + 9900000-9989999 + 4 + + + 9990000-9999999 + 5 + + + + + 978-968 + Mexico + + + 0100000-3999999 + 2 + + + 4000000-4999999 + 3 + + + 5000000-7999999 + 4 + + + 8000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-969 + Pakistan + + + 0000000-1999999 + 1 + + + 2000000-2099999 + 2 + + + 2100000-2199999 + 3 + + + 2200000-2299999 + 4 + + + 2300000-2399999 + 5 + + + 2400000-3999999 + 2 + + + 4000000-7499999 + 3 + + + 7500000-9999999 + 4 + + + + + 978-970 + Mexico + + + 0100000-5999999 + 2 + + + 6000000-8999999 + 3 + + + 9000000-9099999 + 4 + + + 9100000-9699999 + 5 + + + 9700000-9999999 + 4 + + + + + 978-971 + Philippines + + + 0000000-0159999 + 3 + + + 0160000-0199999 + 4 + + + 0200000-0299999 + 2 + + + 0300000-0599999 + 4 + + + 0600000-4999999 + 2 + + + 5000000-8499999 + 3 + + + 8500000-9099999 + 4 + + + 9100000-9599999 + 5 + + + 9600000-9699999 + 4 + + + 9700000-9899999 + 2 + + + 9900000-9999999 + 4 + + + + + 978-972 + Portugal + + + 0000000-1999999 + 1 + + + 2000000-5499999 + 2 + + + 5500000-7999999 + 3 + + + 8000000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-973 + Romania + + + 0000000-0999999 + 1 + + + 1000000-1699999 + 3 + + + 1700000-1999999 + 4 + + + 2000000-5499999 + 2 + + + 5500000-7599999 + 3 + + + 7600000-8499999 + 4 + + + 8500000-8899999 + 5 + + + 8900000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-974 + Thailand + + + 0000000-1999999 + 2 + + + 2000000-6999999 + 3 + + + 7000000-8499999 + 4 + + + 8500000-8999999 + 5 + + + 9000000-9499999 + 5 + + + 9500000-9999999 + 4 + + + + + 978-975 + Turkey + + + 0000000-0199999 + 5 + + + 0200000-2399999 + 2 + + + 2400000-2499999 + 4 + + + 2500000-5999999 + 3 + + + 6000000-9199999 + 4 + + + 9200000-9899999 + 5 + + + 9900000-9999999 + 3 + + + + + 978-976 + Caribbean Community + + + 0000000-3999999 + 1 + + + 4000000-5999999 + 2 + + + 6000000-7999999 + 3 + + + 8000000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-977 + Egypt + + + 0000000-1999999 + 2 + + + 2000000-4999999 + 3 + + + 5000000-6999999 + 4 + + + 7000000-8499999 + 3 + + + 8500000-8929999 + 5 + + + 8930000-8949999 + 3 + + + 8950000-8999999 + 4 + + + 9000000-9899999 + 2 + + + 9900000-9999999 + 3 + + + + + 978-978 + Nigeria + + + 0000000-1999999 + 3 + + + 2000000-2999999 + 4 + + + 3000000-7799999 + 5 + + + 7800000-7999999 + 3 + + + 8000000-8999999 + 4 + + + 9000000-9999999 + 3 + + + + + 978-979 + Indonesia + + + 0000000-0999999 + 3 + + + 1000000-1499999 + 4 + + + 1500000-1999999 + 5 + + + 2000000-2999999 + 2 + + + 3000000-3999999 + 4 + + + 4000000-7999999 + 3 + + + 8000000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-980 + Venezuela + + + 0000000-1999999 + 2 + + + 2000000-5999999 + 3 + + + 6000000-9999999 + 4 + + + + + 978-981 + Singapore + + + 0000000-1699999 + 2 + + + 1700000-1799999 + 5 + + + 1800000-1999999 + 2 + + + 2000000-2999999 + 3 + + + 3000000-3099999 + 4 + + + 3100000-3999999 + 3 + + + 4000000-9499999 + 4 + + + 9500000-9899999 + 0 + + + 9900000-9999999 + 2 + + + + + 978-982 + South Pacific + + + 0000000-0999999 + 2 + + + 1000000-6999999 + 3 + + + 7000000-8999999 + 2 + + + 9000000-9799999 + 4 + + + 9800000-9999999 + 5 + + + + + 978-983 + Malaysia + + + 0000000-0199999 + 2 + + + 0200000-1999999 + 3 + + + 2000000-3999999 + 4 + + + 4000000-4499999 + 5 + + + 4500000-4999999 + 2 + + + 5000000-7999999 + 2 + + + 8000000-8999999 + 3 + + + 9000000-9899999 + 4 + + + 9900000-9999999 + 5 + + + + + 978-984 + Bangladesh + + + 0000000-3999999 + 2 + + + 4000000-7999999 + 3 + + + 8000000-8999999 + 4 + + + 9000000-9999999 + 5 + + + + + 978-985 + Belarus + + + 0000000-3999999 + 2 + + + 4000000-5999999 + 3 + + + 6000000-8799999 + 4 + + + 8800000-8999999 + 3 + + + 9000000-9999999 + 5 + + + + + 978-986 + Taiwan + + + 0000000-0599999 + 2 + + + 0600000-0699999 + 5 + + + 0700000-0799999 + 4 + + + 0800000-1199999 + 2 + + + 1200000-5399999 + 3 + + + 5400000-7999999 + 4 + + + 8000000-9999999 + 5 + + + + + 978-987 + Argentina + + + 0000000-0999999 + 2 + + + 1000000-1999999 + 4 + + + 2000000-2999999 + 5 + + + 3000000-3599999 + 2 + + + 3600000-4199999 + 4 + + + 4200000-4399999 + 2 + + + 4400000-4499999 + 4 + + + 4500000-4899999 + 5 + + + 4900000-4999999 + 4 + + + 5000000-8249999 + 3 + + + 8250000-8279999 + 4 + + + 8280000-8299999 + 5 + + + 8300000-8499999 + 4 + + + 8500000-8899999 + 2 + + + 8900000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-988 + Hong Kong, China + + + 0000000-1199999 + 2 + + + 1200000-1999999 + 5 + + + 2000000-6999999 + 3 + + + 7000000-7999999 + 5 + + + 8000000-9699999 + 4 + + + 9700000-9999999 + 5 + + + + + 978-989 + Portugal + + + 0000000-1999999 + 1 + + + 2000000-3499999 + 2 + + + 3500000-3699999 + 5 + + + 3700000-5299999 + 2 + + + 5300000-5499999 + 5 + + + 5500000-7999999 + 3 + + + 8000000-9499999 + 4 + + + 9500000-9999999 + 5 + + + + + 978-9910 + Uzbekistan + + + 0000000-7299999 + 0 + + + 7300000-7499999 + 3 + + + 7500000-9649999 + 0 + + + 9650000-9999999 + 4 + + + + + 978-9911 + Montenegro + + + 0000000-1999999 + 0 + + + 2000000-2499999 + 2 + + + 2500000-5499999 + 0 + + + 5500000-7499999 + 3 + + + 7500000-9999999 + 0 + + + + + 978-9912 + Tanzania + + + 0000000-3999999 + 0 + + + 4000000-4499999 + 2 + + + 4500000-7499999 + 0 + + + 7500000-7999999 + 3 + + + 8000000-9799999 + 0 + + + 9800000-9999999 + 4 + + + + + 978-9913 + Uganda + + + 0000000-0799999 + 2 + + + 0800000-5999999 + 0 + + + 6000000-6999999 + 3 + + + 7000000-9549999 + 0 + + + 9550000-9999999 + 4 + + + + + 978-9914 + Kenya + + + 0000000-3999999 + 0 + + + 4000000-5299999 + 2 + + + 5300000-6999999 + 0 + + + 7000000-7749999 + 3 + + + 7750000-9599999 + 0 + + + 9600000-9999999 + 4 + + + + + 978-9915 + Uruguay + + + 0000000-3999999 + 0 + + + 4000000-5999999 + 2 + + + 6000000-6499999 + 0 + + + 6500000-7999999 + 3 + + + 8000000-9299999 + 0 + + + 9300000-9999999 + 4 + + + + + 978-9916 + Estonia + + + 0000000-0999999 + 1 + + + 1000000-3999999 + 2 + + + 4000000-5999999 + 1 + + + 6000000-7999999 + 3 + + + 8000000-8499999 + 2 + + + 8500000-8999999 + 3 + + + 9000000-9249999 + 0 + + + 9250000-9999999 + 4 + + + + + 978-9917 + Bolivia + + + 0000000-0999999 + 1 + + + 1000000-2999999 + 0 + + + 3000000-3499999 + 2 + + + 3500000-5999999 + 0 + + + 6000000-6999999 + 3 + + + 7000000-9799999 + 0 + + + 9800000-9999999 + 4 + + + + + 978-9918 + Malta + + + 0000000-0999999 + 1 + + + 1000000-1999999 + 0 + + + 2000000-2999999 + 2 + + + 3000000-5999999 + 0 + + + 6000000-7999999 + 3 + + + 8000000-9499999 + 0 + + + 9500000-9999999 + 4 + + + + + 978-9919 + Mongolia + + + 0000000-0999999 + 1 + + + 1000000-1999999 + 0 + + + 2000000-2999999 + 2 + + + 3000000-4999999 + 0 + + + 5000000-5999999 + 3 + + + 6000000-8999999 + 0 + + + 9000000-9999999 + 4 + + + + + 978-9920 + Morocco + + + 0000000-2999999 + 0 + + + 3000000-4299999 + 2 + + + 4300000-4999999 + 0 + + + 5000000-7999999 + 3 + + + 8000000-8749999 + 0 + + + 8750000-9999999 + 4 + + + + + 978-9921 + Kuwait + + + 0000000-0999999 + 1 + + + 1000000-2999999 + 0 + + + 3000000-3999999 + 2 + + + 4000000-6999999 + 0 + + + 7000000-8999999 + 3 + + + 9000000-9699999 + 0 + + + 9700000-9999999 + 4 + + + + + 978-9922 + Iraq + + + 0000000-1999999 + 0 + + + 2000000-2999999 + 2 + + + 3000000-5999999 + 0 + + + 6000000-7999999 + 3 + + + 8000000-8499999 + 0 + + + 8500000-9999999 + 4 + + + + + 978-9923 + Jordan + + + 0000000-0999999 + 1 + + + 1000000-5999999 + 2 + + + 6000000-6999999 + 0 + + + 7000000-8999999 + 3 + + + 9000000-9399999 + 0 + + + 9400000-9999999 + 4 + + + + + 978-9924 + Cambodia + + + 0000000-2999999 + 0 + + + 3000000-3999999 + 2 + + + 4000000-4999999 + 0 + + + 5000000-6499999 + 3 + + + 6500000-8999999 + 0 + + + 9000000-9999999 + 4 + + + + + 978-9925 + Cyprus + + + 0000000-2999999 + 1 + + + 3000000-5499999 + 2 + + + 5500000-7349999 + 3 + + + 7350000-9999999 + 4 + + + + + 978-9926 + Bosnia and Herzegovina + + + 0000000-1999999 + 1 + + + 2000000-3999999 + 2 + + + 4000000-7999999 + 3 + + + 8000000-9999999 + 4 + + + + + 978-9927 + Qatar + + + 0000000-0999999 + 2 + + + 1000000-3999999 + 3 + + + 4000000-4999999 + 4 + + + 5000000-9999999 + 0 + + + + + 978-9928 + Albania + + + 0000000-0999999 + 2 + + + 1000000-3999999 + 3 + + + 4000000-4999999 + 4 + + + 5000000-7999999 + 0 + + + 8000000-8999999 + 3 + + + 9000000-9999999 + 2 + + + + + 978-9929 + Guatemala + + + 0000000-3999999 + 1 + + + 4000000-5499999 + 2 + + + 5500000-7999999 + 3 + + + 8000000-9999999 + 4 + + + + + 978-9930 + Costa Rica + + + 0000000-4999999 + 2 + + + 5000000-9399999 + 3 + + + 9400000-9999999 + 4 + + + + + 978-9931 + Algeria + + + 0000000-2399999 + 2 + + + 2400000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9932 + Lao People's Democratic Republic + + + 0000000-3999999 + 2 + + + 4000000-8499999 + 3 + + + 8500000-9999999 + 4 + + + + + 978-9933 + Syria + + + 0000000-0999999 + 1 + + + 1000000-3999999 + 2 + + + 4000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9934 + Latvia + + + 0000000-0999999 + 1 + + + 1000000-4999999 + 2 + + + 5000000-7999999 + 3 + + + 8000000-9999999 + 4 + + + + + 978-9935 + Iceland + + + 0000000-0999999 + 1 + + + 1000000-3999999 + 2 + + + 4000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9936 + Afghanistan + + + 0000000-1999999 + 1 + + + 2000000-3999999 + 2 + + + 4000000-7999999 + 3 + + + 8000000-9999999 + 4 + + + + + 978-9937 + Nepal + + + 0000000-2999999 + 1 + + + 3000000-4999999 + 2 + + + 5000000-7999999 + 3 + + + 8000000-9999999 + 4 + + + + + 978-9938 + Tunisia + + + 0000000-7999999 + 2 + + + 8000000-9499999 + 3 + + + 9500000-9749999 + 4 + + + 9750000-9909999 + 3 + + + 9910000-9999999 + 4 + + + + + 978-9939 + Armenia + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-8999999 + 3 + + + 9000000-9599999 + 4 + + + 9600000-9799999 + 3 + + + 9800000-9999999 + 2 + + + + + 978-9940 + Montenegro + + + 0000000-1999999 + 1 + + + 2000000-4999999 + 2 + + + 5000000-8399999 + 3 + + + 8400000-8699999 + 2 + + + 8700000-9999999 + 4 + + + + + 978-9941 + Georgia + + + 0000000-0999999 + 1 + + + 1000000-3999999 + 2 + + + 4000000-7999999 + 3 + + + 8000000-8999999 + 1 + + + 9000000-9999999 + 4 + + + + + 978-9942 + Ecuador + + + 0000000-5999999 + 2 + + + 6000000-6999999 + 3 + + + 7000000-7499999 + 4 + + + 7500000-8499999 + 3 + + + 8500000-8999999 + 4 + + + 9000000-9849999 + 3 + + + 9850000-9999999 + 4 + + + + + 978-9943 + Uzbekistan + + + 0000000-2999999 + 2 + + + 3000000-3999999 + 3 + + + 4000000-9749999 + 4 + + + 9750000-9999999 + 3 + + + + + 978-9944 + Turkey + + + 0000000-0999999 + 4 + + + 1000000-4999999 + 3 + + + 5000000-5999999 + 4 + + + 6000000-6999999 + 2 + + + 7000000-7999999 + 3 + + + 8000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-9945 + Dominican Republic + + + 0000000-0099999 + 2 + + + 0100000-0799999 + 3 + + + 0800000-3999999 + 2 + + + 4000000-5699999 + 3 + + + 5700000-5799999 + 2 + + + 5800000-7999999 + 3 + + + 8000000-8099999 + 2 + + + 8100000-8499999 + 3 + + + 8500000-9999999 + 4 + + + + + 978-9946 + Korea, P.D.R. + + + 0000000-1999999 + 1 + + + 2000000-3999999 + 2 + + + 4000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9947 + Algeria + + + 0000000-1999999 + 1 + + + 2000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-9948 + United Arab Emirates + + + 0000000-3999999 + 2 + + + 4000000-8499999 + 3 + + + 8500000-9999999 + 4 + + + + + 978-9949 + Estonia + + + 0000000-0899999 + 2 + + + 0900000-0999999 + 3 + + + 1000000-3999999 + 2 + + + 4000000-6999999 + 3 + + + 7000000-7199999 + 2 + + + 7200000-7499999 + 4 + + + 7500000-8999999 + 2 + + + 9000000-9999999 + 4 + + + + + 978-9950 + Palestine + + + 0000000-2999999 + 2 + + + 3000000-8499999 + 3 + + + 8500000-9999999 + 4 + + + + + 978-9951 + Kosova + + + 0000000-3899999 + 2 + + + 3900000-8499999 + 3 + + + 8500000-9799999 + 4 + + + 9800000-9999999 + 3 + + + + + 978-9952 + Azerbaijan + + + 0000000-1999999 + 1 + + + 2000000-3999999 + 2 + + + 4000000-7999999 + 3 + + + 8000000-9999999 + 4 + + + + + 978-9953 + Lebanon + + + 0000000-0999999 + 1 + + + 1000000-3999999 + 2 + + + 4000000-5999999 + 3 + + + 6000000-8999999 + 2 + + + 9000000-9299999 + 4 + + + 9300000-9699999 + 2 + + + 9700000-9999999 + 3 + + + + + 978-9954 + Morocco + + + 0000000-1999999 + 1 + + + 2000000-3999999 + 2 + + + 4000000-7999999 + 3 + + + 8000000-9899999 + 4 + + + 9900000-9999999 + 2 + + + + + 978-9955 + Lithuania + + + 0000000-3999999 + 2 + + + 4000000-9299999 + 3 + + + 9300000-9999999 + 4 + + + + + 978-9956 + Cameroon + + + 0000000-0999999 + 1 + + + 1000000-3999999 + 2 + + + 4000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9957 + Jordan + + + 0000000-3999999 + 2 + + + 4000000-6499999 + 3 + + + 6500000-6799999 + 2 + + + 6800000-6999999 + 3 + + + 7000000-8499999 + 2 + + + 8500000-8799999 + 4 + + + 8800000-9999999 + 2 + + + + + 978-9958 + Bosnia and Herzegovina + + + 0000000-0199999 + 2 + + + 0200000-0299999 + 3 + + + 0300000-0399999 + 4 + + + 0400000-0899999 + 3 + + + 0900000-0999999 + 4 + + + 1000000-1899999 + 2 + + + 1900000-1999999 + 4 + + + 2000000-4999999 + 2 + + + 5000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9959 + Libya + + + 0000000-1999999 + 1 + + + 2000000-7999999 + 2 + + + 8000000-9499999 + 3 + + + 9500000-9699999 + 4 + + + 9700000-9799999 + 3 + + + 9800000-9999999 + 2 + + + + + 978-9960 + Saudi Arabia + + + 0000000-5999999 + 2 + + + 6000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9961 + Algeria + + + 0000000-2999999 + 1 + + + 3000000-6999999 + 2 + + + 7000000-9499999 + 3 + + + 9500000-9999999 + 4 + + + + + 978-9962 + Panama + + + 0000000-5499999 + 2 + + + 5500000-5599999 + 4 + + + 5600000-5999999 + 2 + + + 6000000-8499999 + 3 + + + 8500000-9999999 + 4 + + + + + 978-9963 + Cyprus + + + 0000000-1999999 + 1 + + + 2000000-2499999 + 4 + + + 2500000-2799999 + 3 + + + 2800000-2999999 + 4 + + + 3000000-5499999 + 2 + + + 5500000-7349999 + 3 + + + 7350000-7499999 + 4 + + + 7500000-9999999 + 4 + + + + + 978-9964 + Ghana + + + 0000000-6999999 + 1 + + + 7000000-9499999 + 2 + + + 9500000-9999999 + 3 + + + + + 978-9965 + Kazakhstan + + + 0000000-3999999 + 2 + + + 4000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9966 + Kenya + + + 0000000-1399999 + 3 + + + 1400000-1499999 + 2 + + + 1500000-1999999 + 4 + + + 2000000-6999999 + 2 + + + 7000000-7499999 + 4 + + + 7500000-8209999 + 3 + + + 8210000-8249999 + 4 + + + 8250000-8259999 + 3 + + + 8260000-8289999 + 4 + + + 8290000-9599999 + 3 + + + 9600000-9999999 + 4 + + + + + 978-9967 + Kyrgyz Republic + + + 0000000-3999999 + 2 + + + 4000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9968 + Costa Rica + + + 0000000-4999999 + 2 + + + 5000000-9399999 + 3 + + + 9400000-9999999 + 4 + + + + + 978-9969 + Algeria + + + 0000000-0699999 + 2 + + + 0700000-4999999 + 0 + + + 5000000-6499999 + 3 + + + 6500000-9699999 + 0 + + + 9700000-9999999 + 4 + + + + + 978-9970 + Uganda + + + 0000000-3999999 + 2 + + + 4000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9971 + Singapore + + + 0000000-5999999 + 1 + + + 6000000-8999999 + 2 + + + 9000000-9899999 + 3 + + + 9900000-9999999 + 4 + + + + + 978-9972 + Peru + + + 0000000-0999999 + 2 + + + 1000000-1999999 + 1 + + + 2000000-2499999 + 3 + + + 2500000-2999999 + 4 + + + 3000000-5999999 + 2 + + + 6000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9973 + Tunisia + + + 0000000-0599999 + 2 + + + 0600000-0899999 + 3 + + + 0900000-0999999 + 4 + + + 1000000-6999999 + 2 + + + 7000000-9699999 + 3 + + + 9700000-9999999 + 4 + + + + + 978-9974 + Uruguay + + + 0000000-2999999 + 1 + + + 3000000-5499999 + 2 + + + 5500000-7499999 + 3 + + + 7500000-8799999 + 4 + + + 8800000-9099999 + 3 + + + 9100000-9499999 + 2 + + + 9500000-9999999 + 2 + + + + + 978-9975 + Moldova + + + 0000000-0999999 + 1 + + + 1000000-2999999 + 3 + + + 3000000-3999999 + 4 + + + 4000000-4499999 + 4 + + + 4500000-8999999 + 2 + + + 9000000-9499999 + 3 + + + 9500000-9999999 + 4 + + + + + 978-9976 + Tanzania + + + 0000000-4999999 + 1 + + + 5000000-5799999 + 4 + + + 5800000-5899999 + 3 + + + 5900000-8999999 + 2 + + + 9000000-9899999 + 3 + + + 9900000-9999999 + 4 + + + + + 978-9977 + Costa Rica + + + 0000000-8999999 + 2 + + + 9000000-9899999 + 3 + + + 9900000-9999999 + 4 + + + + + 978-9978 + Ecuador + + + 0000000-2999999 + 2 + + + 3000000-3999999 + 3 + + + 4000000-9499999 + 2 + + + 9500000-9899999 + 3 + + + 9900000-9999999 + 4 + + + + + 978-9979 + Iceland + + + 0000000-4999999 + 1 + + + 5000000-6499999 + 2 + + + 6500000-6599999 + 3 + + + 6600000-7599999 + 2 + + + 7600000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9980 + Papua New Guinea + + + 0000000-3999999 + 1 + + + 4000000-8999999 + 2 + + + 9000000-9899999 + 3 + + + 9900000-9999999 + 4 + + + + + 978-9981 + Morocco + + + 0000000-0999999 + 2 + + + 1000000-1599999 + 3 + + + 1600000-1999999 + 4 + + + 2000000-7999999 + 2 + + + 8000000-9499999 + 3 + + + 9500000-9999999 + 4 + + + + + 978-9982 + Zambia + + + 0000000-7999999 + 2 + + + 8000000-9899999 + 3 + + + 9900000-9999999 + 4 + + + + + 978-9983 + Gambia + + + 0000000-7999999 + 0 + + + 8000000-9499999 + 2 + + + 9500000-9899999 + 3 + + + 9900000-9999999 + 4 + + + + + 978-9984 + Latvia + + + 0000000-4999999 + 2 + + + 5000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9985 + Estonia + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-8999999 + 3 + + + 9000000-9999999 + 4 + + + + + 978-9986 + Lithuania + + + 0000000-3999999 + 2 + + + 4000000-8999999 + 3 + + + 9000000-9399999 + 4 + + + 9400000-9699999 + 3 + + + 9700000-9999999 + 2 + + + + + 978-9987 + Tanzania + + + 0000000-3999999 + 2 + + + 4000000-8799999 + 3 + + + 8800000-9999999 + 4 + + + + + 978-9988 + Ghana + + + 0000000-3999999 + 1 + + + 4000000-5499999 + 2 + + + 5500000-7499999 + 3 + + + 7500000-9999999 + 4 + + + + + 978-9989 + North Macedonia + + + 0000000-0999999 + 1 + + + 1000000-1999999 + 3 + + + 2000000-2999999 + 4 + + + 3000000-5999999 + 2 + + + 6000000-9499999 + 3 + + + 9500000-9999999 + 4 + + + + + 978-99901 + Bahrain + + + 0000000-4999999 + 2 + + + 5000000-7999999 + 3 + + + 8000000-9999999 + 2 + + + + + 978-99902 + Reserved Agency + + + 0000000-9999999 + 0 + + + + + 978-99903 + Mauritius + + + 0000000-1999999 + 1 + + + 2000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99904 + Curaçao + + + 0000000-5999999 + 1 + + + 6000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99905 + Bolivia + + + 0000000-3999999 + 1 + + + 4000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99906 + Kuwait + + + 0000000-2999999 + 1 + + + 3000000-5999999 + 2 + + + 6000000-6999999 + 3 + + + 7000000-8999999 + 2 + + + 9000000-9499999 + 2 + + + 9500000-9999999 + 3 + + + + + 978-99908 + Malawi + + + 0000000-0999999 + 1 + + + 1000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99909 + Malta + + + 0000000-3999999 + 1 + + + 4000000-9499999 + 2 + + + 9500000-9999999 + 3 + + + + + 978-99910 + Sierra Leone + + + 0000000-2999999 + 1 + + + 3000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99911 + Lesotho + + + 0000000-5999999 + 2 + + + 6000000-9999999 + 3 + + + + + 978-99912 + Botswana + + + 0000000-3999999 + 1 + + + 4000000-5999999 + 3 + + + 6000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99913 + Andorra + + + 0000000-2999999 + 1 + + + 3000000-3599999 + 2 + + + 3600000-5999999 + 0 + + + 6000000-6049999 + 3 + + + 6050000-9999999 + 0 + + + + + 978-99914 + International NGO Publishers + + + 0000000-4999999 + 1 + + + 5000000-6999999 + 2 + + + 7000000-7999999 + 1 + + + 8000000-8699999 + 2 + + + 8700000-8799999 + 3 + + + 8800000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99915 + Maldives + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99916 + Namibia + + + 0000000-2999999 + 1 + + + 3000000-6999999 + 2 + + + 7000000-9999999 + 3 + + + + + 978-99917 + Brunei Darussalam + + + 0000000-2999999 + 1 + + + 3000000-8899999 + 2 + + + 8900000-9999999 + 3 + + + + + 978-99918 + Faroe Islands + + + 0000000-3999999 + 1 + + + 4000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99919 + Benin + + + 0000000-2999999 + 1 + + + 3000000-3999999 + 3 + + + 4000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99920 + Andorra + + + 0000000-4999999 + 1 + + + 5000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99921 + Qatar + + + 0000000-1999999 + 1 + + + 2000000-6999999 + 2 + + + 7000000-7999999 + 3 + + + 8000000-8999999 + 1 + + + 9000000-9999999 + 2 + + + + + 978-99922 + Guatemala + + + 0000000-3999999 + 1 + + + 4000000-6999999 + 2 + + + 7000000-9999999 + 3 + + + + + 978-99923 + El Salvador + + + 0000000-1999999 + 1 + + + 2000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99924 + Nicaragua + + + 0000000-1999999 + 1 + + + 2000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99925 + Paraguay + + + 0000000-0999999 + 1 + + + 1000000-1999999 + 2 + + + 2000000-2999999 + 3 + + + 3000000-3999999 + 1 + + + 4000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99926 + Honduras + + + 0000000-0999999 + 1 + + + 1000000-5999999 + 2 + + + 6000000-8699999 + 3 + + + 8700000-8999999 + 2 + + + 9000000-9999999 + 2 + + + + + 978-99927 + Albania + + + 0000000-2999999 + 1 + + + 3000000-5999999 + 2 + + + 6000000-9999999 + 3 + + + + + 978-99928 + Georgia + + + 0000000-0999999 + 1 + + + 1000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99929 + Mongolia + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99930 + Armenia + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99931 + Seychelles + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99932 + Malta + + + 0000000-0999999 + 1 + + + 1000000-5999999 + 2 + + + 6000000-6999999 + 3 + + + 7000000-7999999 + 1 + + + 8000000-9999999 + 2 + + + + + 978-99933 + Nepal + + + 0000000-2999999 + 1 + + + 3000000-5999999 + 2 + + + 6000000-9999999 + 3 + + + + + 978-99934 + Dominican Republic + + + 0000000-1999999 + 1 + + + 2000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99935 + Haiti + + + 0000000-2999999 + 1 + + + 3000000-5999999 + 2 + + + 6000000-6999999 + 3 + + + 7000000-8999999 + 1 + + + 9000000-9999999 + 2 + + + + + 978-99936 + Bhutan + + + 0000000-0999999 + 1 + + + 1000000-5999999 + 2 + + + 6000000-9999999 + 3 + + + + + 978-99937 + Macau + + + 0000000-1999999 + 1 + + + 2000000-5999999 + 2 + + + 6000000-9999999 + 3 + + + + + 978-99938 + Srpska, Republic of + + + 0000000-1999999 + 1 + + + 2000000-5999999 + 2 + + + 6000000-8999999 + 3 + + + 9000000-9999999 + 2 + + + + + 978-99939 + Guatemala + + + 0000000-2999999 + 1 + + + 3000000-5999999 + 2 + + + 6000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99940 + Georgia + + + 0000000-0999999 + 1 + + + 1000000-6999999 + 2 + + + 7000000-9999999 + 3 + + + + + 978-99941 + Armenia + + + 0000000-2999999 + 1 + + + 3000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99942 + Sudan + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99943 + Albania + + + 0000000-2999999 + 1 + + + 3000000-5999999 + 2 + + + 6000000-9999999 + 3 + + + + + 978-99944 + Ethiopia + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99945 + Namibia + + + 0000000-4999999 + 1 + + + 5000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99946 + Nepal + + + 0000000-2999999 + 1 + + + 3000000-5999999 + 2 + + + 6000000-9999999 + 3 + + + + + 978-99947 + Tajikistan + + + 0000000-2999999 + 1 + + + 3000000-6999999 + 2 + + + 7000000-9999999 + 3 + + + + + 978-99948 + Eritrea + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99949 + Mauritius + + + 0000000-1999999 + 1 + + + 2000000-7999999 + 2 + + + 8000000-8999999 + 1 + + + 9000000-9899999 + 3 + + + 9900000-9999999 + 2 + + + + + 978-99950 + Cambodia + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99951 + Reserved Agency + + + 0000000-9999999 + 0 + + + + + 978-99952 + Mali + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99953 + Paraguay + + + 0000000-2999999 + 1 + + + 3000000-7999999 + 2 + + + 8000000-9399999 + 3 + + + 9400000-9999999 + 2 + + + + + 978-99954 + Bolivia + + + 0000000-2999999 + 1 + + + 3000000-6999999 + 2 + + + 7000000-8799999 + 3 + + + 8800000-9999999 + 2 + + + + + 978-99955 + Srpska, Republic of + + + 0000000-1999999 + 1 + + + 2000000-5999999 + 2 + + + 6000000-7999999 + 3 + + + 8000000-9999999 + 2 + + + + + 978-99956 + Albania + + + 0000000-5999999 + 2 + + + 6000000-8599999 + 3 + + + 8600000-9999999 + 2 + + + + + 978-99957 + Malta + + + 0000000-1999999 + 1 + + + 2000000-7999999 + 2 + + + 8000000-9499999 + 3 + + + 9500000-9999999 + 2 + + + + + 978-99958 + Bahrain + + + 0000000-4999999 + 1 + + + 5000000-9399999 + 2 + + + 9400000-9499999 + 3 + + + 9500000-9999999 + 3 + + + + + 978-99959 + Luxembourg + + + 0000000-2999999 + 1 + + + 3000000-5999999 + 2 + + + 6000000-9999999 + 3 + + + + + 978-99960 + Malawi + + + 0000000-0699999 + 0 + + + 0700000-0999999 + 3 + + + 1000000-9499999 + 2 + + + 9500000-9999999 + 3 + + + + + 978-99961 + El Salvador + + + 0000000-2999999 + 1 + + + 3000000-3699999 + 3 + + + 3700000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99962 + Mongolia + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99963 + Cambodia + + + 0000000-4999999 + 2 + + + 5000000-9199999 + 3 + + + 9200000-9999999 + 2 + + + + + 978-99964 + Nicaragua + + + 0000000-1999999 + 1 + + + 2000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99965 + Macau + + + 0000000-2999999 + 1 + + + 3000000-3599999 + 3 + + + 3600000-6299999 + 2 + + + 6300000-9999999 + 3 + + + + + 978-99966 + Kuwait + + + 0000000-2999999 + 1 + + + 3000000-6999999 + 2 + + + 7000000-7999999 + 3 + + + 8000000-9699999 + 2 + + + 9700000-9999999 + 3 + + + + + 978-99967 + Paraguay + + + 0000000-0999999 + 1 + + + 1000000-5999999 + 2 + + + 6000000-9999999 + 3 + + + + + 978-99968 + Botswana + + + 0000000-3999999 + 1 + + + 4000000-5999999 + 3 + + + 6000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99969 + Oman + + + 0000000-4999999 + 1 + + + 5000000-7999999 + 2 + + + 8000000-9499999 + 3 + + + 9500000-9999999 + 2 + + + + + 978-99970 + Haiti + + + 0000000-4999999 + 1 + + + 5000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99971 + Myanmar + + + 0000000-3999999 + 1 + + + 4000000-8499999 + 2 + + + 8500000-9999999 + 3 + + + + + 978-99972 + Faroe Islands + + + 0000000-4999999 + 1 + + + 5000000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99973 + Mongolia + + + 0000000-3999999 + 1 + + + 4000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99974 + Bolivia + + + 0000000-0999999 + 1 + + + 1000000-2599999 + 2 + + + 2600000-3999999 + 3 + + + 4000000-6399999 + 2 + + + 6400000-6499999 + 3 + + + 6500000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99975 + Tajikistan + + + 0000000-2999999 + 1 + + + 3000000-3999999 + 3 + + + 4000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99976 + Srpska, Republic of + + + 0000000-0999999 + 1 + + + 1000000-1599999 + 2 + + + 1600000-1999999 + 3 + + + 2000000-5999999 + 2 + + + 6000000-8199999 + 3 + + + 8200000-8999999 + 2 + + + 9000000-9999999 + 3 + + + + + 978-99977 + Rwanda + + + 0000000-1999999 + 1 + + + 2000000-3999999 + 0 + + + 4000000-6999999 + 2 + + + 7000000-7999999 + 3 + + + 8000000-9749999 + 0 + + + 9750000-9999999 + 3 + + + + + 978-99978 + Mongolia + + + 0000000-4999999 + 1 + + + 5000000-6999999 + 2 + + + 7000000-9999999 + 3 + + + + + 978-99979 + Honduras + + + 0000000-3999999 + 1 + + + 4000000-7999999 + 2 + + + 8000000-9999999 + 3 + + + + + 978-99980 + Bhutan + + + 0000000-0999999 + 1 + + + 1000000-2999999 + 0 + + + 3000000-5999999 + 2 + + + 6000000-7499999 + 0 + + + 7500000-9999999 + 3 + + + + + 978-99981 + Macau + + + 0000000-1999999 + 1 + + + 2000000-2699999 + 0 + + + 2700000-7499999 + 2 + + + 7500000-9999999 + 3 + + + + + 978-99982 + Benin + + + 0000000-1999999 + 1 + + + 2000000-4999999 + 0 + + + 5000000-6899999 + 2 + + + 6900000-8999999 + 0 + + + 9000000-9999999 + 3 + + + + + 978-99983 + El Salvador + + + 0000000-0999999 + 1 + + + 1000000-4999999 + 0 + + + 5000000-6999999 + 2 + + + 7000000-9499999 + 0 + + + 9500000-9999999 + 3 + + + + + 978-99984 + Brunei Darussalam + + + 0000000-0999999 + 1 + + + 1000000-4999999 + 0 + + + 5000000-6999999 + 2 + + + 7000000-9499999 + 0 + + + 9500000-9999999 + 3 + + + + + 978-99985 + Tajikistan + + + 0000000-1999999 + 1 + + + 2000000-3499999 + 0 + + + 3500000-7999999 + 2 + + + 8000000-8499999 + 0 + + + 8500000-9999999 + 3 + + + + + 978-99986 + Myanmar + + + 0000000-0999999 + 1 + + + 1000000-4999999 + 0 + + + 5000000-6999999 + 2 + + + 7000000-9499999 + 0 + + + 9500000-9999999 + 3 + + + + + 978-99987 + Luxembourg + + + 0000000-6999999 + 0 + + + 7000000-9999999 + 3 + + + + + 978-99988 + Sudan + + + 0000000-0999999 + 1 + + + 1000000-4999999 + 0 + + + 5000000-5499999 + 2 + + + 5500000-7999999 + 0 + + + 8000000-8249999 + 3 + + + 8250000-9999999 + 0 + + + + + 978-99989 + Paraguay + + + 0000000-0999999 + 1 + + + 1000000-4999999 + 0 + + + 5000000-6499999 + 2 + + + 6500000-8999999 + 0 + + + 9000000-9999999 + 3 + + + + + 978-99990 + Ethiopia + + + 0000000-0999999 + 1 + + + 1000000-4999999 + 0 + + + 5000000-5499999 + 2 + + + 5500000-9749999 + 0 + + + 9750000-9999999 + 3 + + + + + 978-99992 + Oman + + + 0000000-1999999 + 1 + + + 2000000-4999999 + 0 + + + 5000000-6499999 + 2 + + + 6500000-9499999 + 0 + + + 9500000-9999999 + 3 + + + + + 978-99993 + Mauritius + + + 0000000-0999999 + 1 + + + 1000000-4999999 + 0 + + + 5000000-5499999 + 2 + + + 5500000-9799999 + 0 + + + 9800000-9999999 + 3 + + + + + 979-10 + France + + + 0000000-1999999 + 2 + + + 2000000-6999999 + 3 + + + 7000000-8999999 + 4 + + + 9000000-9759999 + 5 + + + 9760000-9999999 + 6 + + + + + 979-11 + Korea, Republic + + + 0000000-2499999 + 2 + + + 2500000-5499999 + 3 + + + 5500000-8499999 + 4 + + + 8500000-9499999 + 5 + + + 9500000-9999999 + 6 + + + + + 979-12 + Italy + + + 0000000-1999999 + 0 + + + 2000000-2999999 + 3 + + + 3000000-5449999 + 0 + + + 5450000-5999999 + 4 + + + 6000000-7999999 + 0 + + + 8000000-8499999 + 5 + + + 8500000-9999999 + 0 + + + + + 979-8 + United States + + + 0000000-1999999 + 0 + + + 2000000-2299999 + 3 + + + 2300000-3499999 + 0 + + + 3500000-3999999 + 4 + + + 4000000-8499999 + 4 + + + 8500000-8849999 + 4 + + + 8850000-8999999 + 5 + + + 9000000-9849999 + 0 + + + 9850000-9899999 + 7 + + + 9900000-9999999 + 0 + + + + + diff --git a/bookwyrm/isbn/__init__.py b/bookwyrm/isbn/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/bookwyrm/isbn/isbn.py b/bookwyrm/isbn/isbn.py new file mode 100644 index 000000000..e07d2100d --- /dev/null +++ b/bookwyrm/isbn/isbn.py @@ -0,0 +1,78 @@ +""" Use the range message from isbn-international to hyphenate ISBNs """ +import os +from xml.etree import ElementTree +import requests + +from bookwyrm import settings + + +class IsbnHyphenator: + """Class to manage the range message xml file and use it to hyphenate ISBNs""" + + __range_message_url = "https://www.isbn-international.org/export_rangemessage.xml" + __range_file_path = os.path.join( + settings.BASE_DIR, "bookwyrm", "isbn", "RangeMessage.xml" + ) + __element_tree = None + + def update_range_message(self): + """Download the range message xml file and save it locally""" + response = requests.get(self.__range_message_url) + with open(self.__range_file_path, "w", encoding="utf-8") as file: + file.write(response.text) + self.__element_tree = None + + def hyphenate(self, isbn_13): + """hyphenate the given ISBN-13 number using the range message""" + if isbn_13 is None: + return None + if self.__element_tree is None: + self.__element_tree = ElementTree.parse(self.__range_file_path) + gs1_prefix = isbn_13[:3] + reg_group = self.__find_reg_group(isbn_13, gs1_prefix) + if reg_group is None: + return isbn_13 # failed to hyphenate + registrant = self.__find_registrant(isbn_13, gs1_prefix, reg_group) + if registrant is None: + return isbn_13 # failed to hyphenate + publication = isbn_13[len(gs1_prefix) + len(reg_group) + len(registrant) : -1] + check_digit = isbn_13[-1:] + return "-".join((gs1_prefix, reg_group, registrant, publication, check_digit)) + + def __find_reg_group(self, isbn_13, gs1_prefix): + for ean_ucc_el in self.__element_tree.find("EAN.UCCPrefixes").findall( + "EAN.UCC" + ): + if ean_ucc_el.find("Prefix").text == gs1_prefix: + for rule_el in ean_ucc_el.find("Rules").findall("Rule"): + length = int(rule_el.find("Length").text) + if length == 0: + continue + reg_grp_range = [ + int(x[:length]) for x in rule_el.find("Range").text.split("-") + ] + reg_group = isbn_13[len(gs1_prefix) : len(gs1_prefix) + length] + if reg_grp_range[0] <= int(reg_group) <= reg_grp_range[1]: + return reg_group + return None + return None + + def __find_registrant(self, isbn_13, gs1_prefix, reg_group): + from_ind = len(gs1_prefix) + len(reg_group) + for group_el in self.__element_tree.find("RegistrationGroups").findall("Group"): + if group_el.find("Prefix").text == "-".join((gs1_prefix, reg_group)): + for rule_el in group_el.find("Rules").findall("Rule"): + length = int(rule_el.find("Length").text) + if length == 0: + continue + registrant_range = [ + int(x[:length]) for x in rule_el.find("Range").text.split("-") + ] + registrant = isbn_13[from_ind : from_ind + length] + if registrant_range[0] <= int(registrant) <= registrant_range[1]: + return registrant + return None + return None + + +hyphenator_singleton = IsbnHyphenator() diff --git a/bookwyrm/lists_stream.py b/bookwyrm/lists_stream.py index 2b08010b1..148b81a78 100644 --- a/bookwyrm/lists_stream.py +++ b/bookwyrm/lists_stream.py @@ -5,7 +5,7 @@ from django.db.models import signals, Count, Q 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/management/commands/repair_editions.py b/bookwyrm/management/commands/repair_editions.py new file mode 100644 index 000000000..304cd5e51 --- /dev/null +++ b/bookwyrm/management/commands/repair_editions.py @@ -0,0 +1,21 @@ +""" Repair editions with missing works """ +from django.core.management.base import BaseCommand +from bookwyrm import models + + +class Command(BaseCommand): + """command-line options""" + + help = "Repairs an edition that is in a broken state" + + # pylint: disable=unused-argument + def handle(self, *args, **options): + """Find and repair broken editions""" + # Find broken editions + editions = models.Edition.objects.filter(parent_work__isnull=True) + self.stdout.write(f"Repairing {editions.count()} edition(s):") + + # Do repair + for edition in editions: + edition.repair() + self.stdout.write(".", ending="") diff --git a/bookwyrm/migrations/0179_populate_sort_title.py b/bookwyrm/migrations/0179_populate_sort_title.py new file mode 100644 index 000000000..e238bca1d --- /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/migrations/0179_reportcomment_comment_type.py b/bookwyrm/migrations/0179_reportcomment_comment_type.py new file mode 100644 index 000000000..a8a446096 --- /dev/null +++ b/bookwyrm/migrations/0179_reportcomment_comment_type.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.18 on 2023-05-16 16:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0178_auto_20230328_2132"), + ] + + operations = [ + migrations.AddField( + model_name="reportcomment", + name="action_type", + field=models.CharField( + choices=[ + ("comment", "Comment"), + ("resolve", "Resolved report"), + ("reopen", "Re-opened report"), + ("message_reporter", "Messaged reporter"), + ("message_offender", "Messaged reported user"), + ("user_suspension", "Suspended user"), + ("user_unsuspension", "Un-suspended user"), + ("user_perms", "Changed user permission level"), + ("user_deletion", "Deleted user account"), + ("block_domain", "Blocked domain"), + ("approve_domain", "Approved domain"), + ("delete_item", "Deleted item"), + ], + default="comment", + max_length=20, + ), + ), + migrations.RenameModel("ReportComment", "ReportAction"), + ] diff --git a/bookwyrm/migrations/0180_alter_reportaction_options.py b/bookwyrm/migrations/0180_alter_reportaction_options.py new file mode 100644 index 000000000..2979d266e --- /dev/null +++ b/bookwyrm/migrations/0180_alter_reportaction_options.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.18 on 2023-06-21 22:01 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0179_reportcomment_comment_type"), + ] + + operations = [ + migrations.AlterModelOptions( + name="reportaction", + options={"ordering": ("created_date",)}, + ), + ] diff --git a/bookwyrm/migrations/0180_alter_user_preferred_language.py b/bookwyrm/migrations/0180_alter_user_preferred_language.py new file mode 100644 index 000000000..b4ab996ec --- /dev/null +++ b/bookwyrm/migrations/0180_alter_user_preferred_language.py @@ -0,0 +1,44 @@ +# Generated by Django 3.2.19 on 2023-07-23 19:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0179_populate_sort_title"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="preferred_language", + field=models.CharField( + blank=True, + choices=[ + ("en-us", "English"), + ("ca-es", "Català (Catalan)"), + ("de-de", "Deutsch (German)"), + ("eo-uy", "Esperanto (Esperanto)"), + ("es-es", "Español (Spanish)"), + ("eu-es", "Euskara (Basque)"), + ("gl-es", "Galego (Galician)"), + ("it-it", "Italiano (Italian)"), + ("fi-fi", "Suomi (Finnish)"), + ("fr-fr", "Français (French)"), + ("lt-lt", "Lietuvių (Lithuanian)"), + ("nl-nl", "Nederlands (Dutch)"), + ("no-no", "Norsk (Norwegian)"), + ("pl-pl", "Polski (Polish)"), + ("pt-br", "Português do Brasil (Brazilian Portuguese)"), + ("pt-pt", "Português Europeu (European Portuguese)"), + ("ro-ro", "Română (Romanian)"), + ("sv-se", "Svenska (Swedish)"), + ("zh-hans", "简体中文 (Simplified Chinese)"), + ("zh-hant", "繁體中文 (Traditional Chinese)"), + ], + max_length=255, + null=True, + ), + ), + ] diff --git a/bookwyrm/migrations/0181_merge_20230806_2302.py b/bookwyrm/migrations/0181_merge_20230806_2302.py new file mode 100644 index 000000000..f4f05b886 --- /dev/null +++ b/bookwyrm/migrations/0181_merge_20230806_2302.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2.20 on 2023-08-06 23:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0180_alter_reportaction_options"), + ("bookwyrm", "0180_alter_user_preferred_language"), + ] + + operations = [] diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py index f5b72f3e4..7b779190b 100644 --- a/bookwyrm/models/__init__.py +++ b/bookwyrm/models/__init__.py @@ -20,7 +20,7 @@ from .readthrough import ReadThrough, ProgressUpdate, ProgressMode from .user import User, KeyPair from .annual_goal import AnnualGoal from .relationship import UserFollows, UserFollowRequest, UserBlocks -from .report import Report, ReportComment +from .report import Report, ReportAction from .federated_server import FederatedServer from .group import Group, GroupMember, GroupMemberInvitation diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index e76433189..36317ad4e 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -6,8 +6,9 @@ from functools import reduce import json import operator import logging -from typing import List +from typing import Any, Optional from uuid import uuid4 +from typing_extensions import Self import aiohttp from Crypto.PublicKey import RSA @@ -21,7 +22,7 @@ from django.utils.http import http_date 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__) @@ -85,7 +86,7 @@ class ActivitypubMixin: super().__init__(*args, **kwargs) @classmethod - def find_existing_by_remote_id(cls, remote_id): + def find_existing_by_remote_id(cls, remote_id: str) -> Self: """look up a remote id in the db""" return cls.find_existing({"id": remote_id}) @@ -137,7 +138,7 @@ class ActivitypubMixin: queue=queue, ) - def get_recipients(self, software=None) -> List[str]: + def get_recipients(self, software=None) -> list[str]: """figure out which inbox urls to post to""" # first we have to figure out who should receive this activity privacy = self.privacy if hasattr(self, "privacy") else "public" @@ -198,7 +199,14 @@ class ActivitypubMixin: class ObjectMixin(ActivitypubMixin): """add this mixin for object models that are AP serializable""" - def save(self, *args, created=None, software=None, priority=BROADCAST, **kwargs): + def save( + self, + *args: Any, + created: Optional[bool] = None, + software: Any = None, + priority: str = BROADCAST, + **kwargs: Any, + ) -> None: """broadcast created/updated/deleted objects as appropriate""" broadcast = kwargs.get("broadcast", True) # this bonus kwarg would cause an error in the base save method @@ -379,7 +387,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 +408,7 @@ class CollectionItemMixin(ActivitypubMixin): 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 +452,7 @@ class CollectionItemMixin(ActivitypubMixin): 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 @@ -507,14 +515,14 @@ def unfurl_related_field(related_field, sort_field=None): @app.task(queue=BROADCAST) -def broadcast_task(sender_id: int, activity: str, recipients: List[str]): +def broadcast_task(sender_id: int, activity: str, recipients: list[str]): """the celery task for broadcast""" user_model = apps.get_model("bookwyrm.User", require_ready=True) sender = user_model.objects.select_related("key_pair").get(id=sender_id) asyncio.run(async_broadcast(recipients, sender, activity)) -async def async_broadcast(recipients: List[str], sender, data: str): +async def async_broadcast(recipients: list[str], sender, data: str): """Send all the broadcasts simultaneously""" timeout = aiohttp.ClientTimeout(total=10) async with aiohttp.ClientSession(timeout=timeout) as session: @@ -529,7 +537,7 @@ async def async_broadcast(recipients: List[str], sender, data: str): async def sign_and_send( - session: aiohttp.ClientSession, sender, data: str, destination: str + session: aiohttp.ClientSession, sender, data: str, destination: str, **kwargs ): """Sign the messages and send them in an asynchronous bundle""" now = http_date() @@ -539,11 +547,19 @@ async def sign_and_send( raise ValueError("No private key found for sender") digest = make_digest(data) + signature = make_signature( + "post", + sender, + destination, + now, + digest=digest, + use_legacy_key=kwargs.get("use_legacy_key"), + ) headers = { "Date": now, "Digest": digest, - "Signature": make_signature("post", sender, destination, now, digest), + "Signature": signature, "Content-Type": "application/activity+json; charset=utf-8", "User-Agent": USER_AGENT, } @@ -554,6 +570,14 @@ async def sign_and_send( logger.exception( "Failed to send broadcast to %s: %s", destination, response.reason ) + if kwargs.get("use_legacy_key") is not True: + logger.info("Trying again with legacy keyId header value") + asyncio.ensure_future( + sign_and_send( + session, sender, data, destination, use_legacy_key=True + ) + ) + return response except asyncio.TimeoutError: logger.info("Connection timed out for url: %s", destination) diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py index 1e20df340..94d978ec4 100644 --- a/bookwyrm/models/antispam.py +++ b/bookwyrm/models/antispam.py @@ -8,7 +8,7 @@ from django.db import models, transaction 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 4e7ffcad3..8cb47e5c8 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -1,5 +1,7 @@ """ database schema for books and shelves """ +from itertools import chain import re +from typing import Any from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex @@ -13,10 +15,12 @@ from model_utils.managers import InheritanceManager from imagekit.models import ImageSpecField from bookwyrm import activitypub +from bookwyrm.isbn.isbn import hyphenator_singleton as hyphenator from bookwyrm.preview_images import generate_edition_preview_image_task from bookwyrm.settings import ( DOMAIN, DEFAULT_LANGUAGE, + LANGUAGE_ARTICLES, ENABLE_PREVIEW_IMAGES, ENABLE_THUMBNAIL_GENERATION, ) @@ -88,7 +92,7 @@ class BookDataModel(ObjectMixin, BookWyrmModel): abstract = True - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: """ensure that the remote_id is within this instance""" if self.id: self.remote_id = self.get_remote_id() @@ -202,7 +206,7 @@ class Book(BookDataModel): text += f" ({self.edition_info})" return text - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: """can't be abstract for query reasons, but you shouldn't USE it""" if not isinstance(self, Edition) and not isinstance(self, Work): raise ValueError("Books should be added as Editions or Works") @@ -318,6 +322,11 @@ class Edition(Book): serialize_reverse_fields = [("file_links", "fileLinks", "-created_date")] deserialize_reverse_fields = [("file_links", "fileLinks")] + @property + def hyphenated_isbn13(self): + """generate the hyphenated version of the ISBN-13""" + return hyphenator.hyphenate(self.isbn_13) + def get_rank(self): """calculate how complete the data is on this edition""" rank = 0 @@ -341,7 +350,7 @@ class Edition(Book): # max rank is 9 return rank - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: """set some fields on the edition object""" # calculate isbn 10/13 if self.isbn_13 and self.isbn_13[:3] == "978" and not self.isbn_10: @@ -363,8 +372,34 @@ class Edition(Book): 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) + @transaction.atomic + def repair(self): + """If an edition is in a bad state (missing a work), let's fix that""" + # made sure it actually NEEDS reapir + if self.parent_work: + return + + new_work = Work.objects.create(title=self.title) + new_work.authors.set(self.authors.all()) + + self.parent_work = new_work + self.save(update_fields=["parent_work"], broadcast=False) + @classmethod def viewer_aware_objects(cls, viewer): """annotate a book query with metadata related to the user""" diff --git a/bookwyrm/models/federated_server.py b/bookwyrm/models/federated_server.py index eb03d457e..e1081ed45 100644 --- a/bookwyrm/models/federated_server.py +++ b/bookwyrm/models/federated_server.py @@ -61,7 +61,7 @@ class FederatedServer(BookWyrmModel): ).update(active=True, deactivation_reason=None) @classmethod - def is_blocked(cls, url): + def is_blocked(cls, url: str) -> bool: """look up if a domain is blocked""" url = urlparse(url) domain = url.netloc diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index df4bb2e4a..d21c9363d 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -368,10 +368,16 @@ class TagField(ManyToManyField): activity_type = item.__class__.__name__ if activity_type == "User": activity_type = "Mention" + + if activity_type == "Hashtag": + name = item.name + else: + name = f"@{getattr(item, item.name_field)}" + tags.append( activitypub.Link( href=item.remote_id, - name=getattr(item, item.name_field), + name=name, type=activity_type, ) ) @@ -379,7 +385,12 @@ class TagField(ManyToManyField): def field_from_activity(self, value, allow_external_connections=True): if not isinstance(value, list): - return None + # GoToSocial DMs and single-user mentions are + # sent as objects, not as an array of objects + if isinstance(value, dict): + value = [value] + else: + return None items = [] for link_json in value: link = activitypub.Link(**link_json) diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index a489edb7c..bb5144297 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -19,7 +19,7 @@ from bookwyrm.models import ( 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 4754bea36..7af6ad5ab 100644 --- a/bookwyrm/models/relationship.py +++ b/bookwyrm/models/relationship.py @@ -4,7 +4,6 @@ from django.db import models, transaction, IntegrityError 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 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): # 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 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): 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 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship): 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/report.py b/bookwyrm/models/report.py index f6e665053..74a9bbe41 100644 --- a/bookwyrm/models/report.py +++ b/bookwyrm/models/report.py @@ -1,11 +1,27 @@ """ flagged for moderation """ from django.core.exceptions import PermissionDenied from django.db import models +from django.utils.translation import gettext_lazy as _ from bookwyrm.settings import DOMAIN from .base_model import BookWyrmModel +# Report action enums +COMMENT = "comment" +RESOLVE = "resolve" +REOPEN = "reopen" +MESSAGE_REPORTER = "message_reporter" +MESSAGE_OFFENDER = "message_offender" +USER_SUSPENSION = "user_suspension" +USER_UNSUSPENSION = "user_unsuspension" +USER_DELETION = "user_deletion" +USER_PERMS = "user_perms" +BLOCK_DOMAIN = "block_domain" +APPROVE_DOMAIN = "approve_domain" +DELETE_ITEM = "delete_item" + + class Report(BookWyrmModel): """reported status or user""" @@ -32,20 +48,65 @@ class Report(BookWyrmModel): def get_remote_id(self): return f"https://{DOMAIN}/settings/reports/{self.id}" + def comment(self, user, note): + """comment on a report""" + ReportAction.objects.create( + action_type=COMMENT, user=user, note=note, report=self + ) + + def resolve(self, user): + """Mark a report as complete""" + self.resolved = True + self.save() + ReportAction.objects.create(action_type=RESOLVE, user=user, report=self) + + def reopen(self, user): + """Wait! This report isn't complete after all""" + self.resolved = False + self.save() + ReportAction.objects.create(action_type=REOPEN, user=user, report=self) + + @classmethod + def record_action(cls, report_id: int, action: str, user): + """Note that someone did something""" + if not report_id: + return + report = cls.objects.get(id=report_id) + ReportAction.objects.create(action_type=action, user=user, report=report) + class Meta: """set order by default""" ordering = ("-created_date",) -class ReportComment(BookWyrmModel): +ReportActionTypes = [ + (COMMENT, _("Comment")), + (RESOLVE, _("Resolved report")), + (REOPEN, _("Re-opened report")), + (MESSAGE_REPORTER, _("Messaged reporter")), + (MESSAGE_OFFENDER, _("Messaged reported user")), + (USER_SUSPENSION, _("Suspended user")), + (USER_UNSUSPENSION, _("Un-suspended user")), + (USER_PERMS, _("Changed user permission level")), + (USER_DELETION, _("Deleted user account")), + (BLOCK_DOMAIN, _("Blocked domain")), + (APPROVE_DOMAIN, _("Approved domain")), + (DELETE_ITEM, _("Deleted item")), +] + + +class ReportAction(BookWyrmModel): """updates on a report""" user = models.ForeignKey("User", on_delete=models.PROTECT) + action_type = models.CharField( + max_length=20, blank=False, default="comment", choices=ReportActionTypes + ) note = models.TextField() report = models.ForeignKey(Report, on_delete=models.PROTECT) class Meta: """sort comments""" - ordering = ("-created_date",) + ordering = ("created_date",) diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index c52cb6ab8..3d92f8d43 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -7,7 +7,7 @@ from django.utils import timezone 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/status.py b/bookwyrm/models/status.py index 047d0aba6..e51f2ba07 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -142,10 +142,17 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): # keep notes if they mention local users if activity.tag == MISSING or activity.tag is None: return True - tags = [l["href"] for l in activity.tag if l["type"] == "Mention"] + # GoToSocial sends single tags as objects + # not wrapped in a list + tags = activity.tag if isinstance(activity.tag, list) else [activity.tag] user_model = apps.get_model("bookwyrm.User", require_ready=True) for tag in tags: - if user_model.objects.filter(remote_id=tag, local=True).exists(): + if ( + tag["type"] == "Mention" + and user_model.objects.filter( + remote_id=tag["href"], local=True + ).exists() + ): # we found a mention of a known use boost return False return True diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index 85e1f0edb..6e0912aec 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -20,7 +20,7 @@ from bookwyrm.models.status import Status 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 @@ -339,7 +339,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): # this is a new remote user, we need to set their remote server field if not self.local: super().save(*args, **kwargs) - transaction.on_commit(lambda: set_remote_server.delay(self.id)) + transaction.on_commit(lambda: set_remote_server(self.id)) return with transaction.atomic(): @@ -394,6 +394,8 @@ class User(OrderedCollectionPageMixin, AbstractUser): 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,18 +471,30 @@ class KeyPair(ActivitypubMixin, BookWyrmModel): return super().save(*args, **kwargs) -@app.task(queue=LOW) -def set_remote_server(user_id): +@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) actor_parts = urlparse(user.remote_id) - user.federated_server = get_or_create_remote_server(actor_parts.netloc) + federated_server = get_or_create_remote_server( + actor_parts.netloc, allow_external_connections=allow_external_connections + ) + # if we were unable to find the server, we need to create a new entry for it + if not federated_server: + # and to do that, we will call this function asynchronously. + if not allow_external_connections: + set_remote_server.delay(user_id, allow_external_connections=True) + return + + user.federated_server = federated_server user.save(broadcast=False, update_fields=["federated_server"]) if user.bookwyrm_user and user.outbox: get_remote_reviews.delay(user.outbox) -def get_or_create_remote_server(domain, refresh=False): +def get_or_create_remote_server( + domain, allow_external_connections=False, refresh=False +): """get info on a remote server""" server = FederatedServer() try: @@ -490,6 +504,9 @@ def get_or_create_remote_server(domain, refresh=False): except FederatedServer.DoesNotExist: pass + if not allow_external_connections: + return None + try: data = get_data(f"https://{domain}/.well-known/nodeinfo") try: @@ -513,7 +530,7 @@ def get_or_create_remote_server(domain, refresh=False): 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 549e12472..aba372abc 100644 --- a/bookwyrm/preview_images.py +++ b/bookwyrm/preview_images.py @@ -16,7 +16,7 @@ from django.core.files.storage import default_storage 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 ab73115a1..829ddaef7 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured env = Env() env.read_env() DOMAIN = env("DOMAIN") -VERSION = "0.6.2" +VERSION = "0.6.4" RELEASE_API = env( "RELEASE_API", @@ -22,7 +22,7 @@ RELEASE_API = env( PAGE_LENGTH = env.int("PAGE_LENGTH", 15) DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") -JS_CACHE = "ea91d7df" +JS_CACHE = "b972a43c" # email EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") @@ -302,6 +302,7 @@ LANGUAGES = [ ("fi-fi", _("Suomi (Finnish)")), ("fr-fr", _("Français (French)")), ("lt-lt", _("Lietuvių (Lithuanian)")), + ("nl-nl", _("Nederlands (Dutch)")), ("no-no", _("Norsk (Norwegian)")), ("pl-pl", _("Polski (Polish)")), ("pt-br", _("Português do Brasil (Brazilian Portuguese)")), @@ -312,6 +313,9 @@ LANGUAGES = [ ("zh-hant", _("繁體中文 (Traditional Chinese)")), ] +LANGUAGE_ARTICLES = { + "English": {"the", "a", "an"}, +} TIME_ZONE = "UTC" diff --git a/bookwyrm/signatures.py b/bookwyrm/signatures.py index 3102f8da2..08780b731 100644 --- a/bookwyrm/signatures.py +++ b/bookwyrm/signatures.py @@ -22,7 +22,7 @@ def create_key_pair(): return private_key, public_key -def make_signature(method, sender, destination, date, digest=None): +def make_signature(method, sender, destination, date, **kwargs): """uses a private key to sign an outgoing message""" inbox_parts = urlparse(destination) signature_headers = [ @@ -31,6 +31,7 @@ def make_signature(method, sender, destination, date, digest=None): f"date: {date}", ] headers = "(request-target) host date" + digest = kwargs.get("digest") if digest is not None: signature_headers.append(f"digest: {digest}") headers = "(request-target) host date digest" @@ -38,8 +39,14 @@ def make_signature(method, sender, destination, date, digest=None): message_to_sign = "\n".join(signature_headers) signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key)) signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8"))) + # For legacy reasons we need to use an incorrect keyId for older Bookwyrm versions + key_id = ( + f"{sender.remote_id}#main-key" + if kwargs.get("use_legacy_key") + else f"{sender.remote_id}/#main-key" + ) signature = { - "keyId": f"{sender.remote_id}#main-key", + "keyId": key_id, "algorithm": "rsa-sha256", "headers": headers, "signature": b64encode(signed_message).decode("utf8"), diff --git a/bookwyrm/static/css/bookwyrm/components/_copy.scss b/bookwyrm/static/css/bookwyrm/components/_copy.scss index e0c4246e6..7a47c1dba 100644 --- a/bookwyrm/static/css/bookwyrm/components/_copy.scss +++ b/bookwyrm/static/css/bookwyrm/components/_copy.scss @@ -28,3 +28,31 @@ .vertical-copy button { width: 100%; } + +.copy-tooltip { + overflow: visible; + visibility: hidden; + width: 140px; + background-color: #555; + color: #fff; + text-align: center; + border-radius: 6px; + padding: 5px; + position: absolute; + z-index: 1; + margin-left: -30px; + margin-top: -45px; + opacity: 0; + transition: opacity 0.3s; +} + +.copy-tooltip::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -60px; + border-width: 5px; + border-style: solid; + border-color: #555 transparent transparent transparent; + } diff --git a/bookwyrm/static/css/bookwyrm/components/_stars.scss b/bookwyrm/static/css/bookwyrm/components/_stars.scss index 1a8e3680f..db2772dc0 100644 --- a/bookwyrm/static/css/bookwyrm/components/_stars.scss +++ b/bookwyrm/static/css/bookwyrm/components/_stars.scss @@ -5,6 +5,10 @@ white-space: nowrap; } +.stars .no-rating { + font-style: italic; +} + /** Stars in a review form * * Specificity makes hovering taking over checked inputs. diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot index 69628662b..b86375a90 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 c67c8b225..a3c902a07 100644 --- a/bookwyrm/static/css/fonts/icomoon.svg +++ b/bookwyrm/static/css/fonts/icomoon.svg @@ -39,6 +39,7 @@ + diff --git a/bookwyrm/static/css/fonts/icomoon.ttf b/bookwyrm/static/css/fonts/icomoon.ttf index 12c79d551..edcbd3b75 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 624b70f33..47d4bffb5 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 6477aee5c..b661ce8e7 100644 --- a/bookwyrm/static/css/vendor/icons.css +++ b/bookwyrm/static/css/vendor/icons.css @@ -1,10 +1,10 @@ @font-face { font-family: 'icomoon'; - src: url('../fonts/icomoon.eot?r7jc98'); - src: url('../fonts/icomoon.eot?r7jc98#iefix') format('embedded-opentype'), - url('../fonts/icomoon.ttf?r7jc98') format('truetype'), - url('../fonts/icomoon.woff?r7jc98') format('woff'), - url('../fonts/icomoon.svg?r7jc98#icomoon') format('svg'); + src: url('../fonts/icomoon.eot?nr4nq7'); + src: url('../fonts/icomoon.eot?nr4nq7#iefix') format('embedded-opentype'), + url('../fonts/icomoon.ttf?nr4nq7') format('truetype'), + url('../fonts/icomoon.woff?nr4nq7') format('woff'), + url('../fonts/icomoon.svg?nr4nq7#icomoon') format('svg'); font-weight: normal; font-style: normal; font-display: block; @@ -122,6 +122,9 @@ .icon-graphic-banknote:before { content: "\e920"; } +.icon-copy:before { + content: "\e92c"; +} .icon-search:before { content: "\e986"; } diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js index ceed12eba..67d64688f 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 @@ -68,6 +65,9 @@ let BookWyrm = new (class { .querySelectorAll('input[type="file"]') .forEach(bookwyrm.disableIfTooLarge.bind(bookwyrm)); document.querySelectorAll("[data-copytext]").forEach(bookwyrm.copyText.bind(bookwyrm)); + document + .querySelectorAll("[data-copywithtooltip]") + .forEach(bookwyrm.copyWithTooltip.bind(bookwyrm)); document .querySelectorAll(".modal.is-active") .forEach(bookwyrm.handleActiveModal.bind(bookwyrm)); @@ -527,6 +527,21 @@ let BookWyrm = new (class { textareaEl.parentNode.appendChild(copyButtonEl); } + copyWithTooltip(copyButtonEl) { + const text = document.getElementById(copyButtonEl.dataset.contentId).innerHTML; + const tooltipEl = document.getElementById(copyButtonEl.dataset.tooltipId); + + copyButtonEl.addEventListener("click", () => { + navigator.clipboard.writeText(text); + tooltipEl.style.visibility = "visible"; + tooltipEl.style.opacity = 1; + setTimeout(function () { + tooltipEl.style.visibility = "hidden"; + tooltipEl.style.opacity = 0; + }, 3000); + }); + } + /** * Handle the details dropdown component. * diff --git a/bookwyrm/suggested_users.py b/bookwyrm/suggested_users.py index 05e05891c..d897feff7 100644 --- a/bookwyrm/suggested_users.py +++ b/bookwyrm/suggested_users.py @@ -8,7 +8,7 @@ from opentelemetry import trace 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 91977afda..79e1b6340 100644 --- a/bookwyrm/tasks.py +++ b/bookwyrm/tasks.py @@ -10,11 +10,19 @@ app = Celery( "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/telemetry/open_telemetry.py b/bookwyrm/telemetry/open_telemetry.py index 00b24d4b0..2a0168ff3 100644 --- a/bookwyrm/telemetry/open_telemetry.py +++ b/bookwyrm/telemetry/open_telemetry.py @@ -1,6 +1,6 @@ from opentelemetry import trace from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter -from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace import TracerProvider, Tracer from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter from bookwyrm import settings @@ -16,19 +16,19 @@ elif settings.OTEL_EXPORTER_OTLP_ENDPOINT: ) -def instrumentDjango(): +def instrumentDjango() -> None: from opentelemetry.instrumentation.django import DjangoInstrumentor DjangoInstrumentor().instrument() -def instrumentPostgres(): +def instrumentPostgres() -> None: from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor Psycopg2Instrumentor().instrument() -def instrumentCelery(): +def instrumentCelery() -> None: from opentelemetry.instrumentation.celery import CeleryInstrumentor from celery.signals import worker_process_init @@ -37,5 +37,5 @@ def instrumentCelery(): CeleryInstrumentor().instrument() -def tracer(): +def tracer() -> Tracer: return trace.get_tracer(__name__) diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index e24f81d79..6dc53fba9 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -190,13 +190,15 @@ - {% include 'snippets/stars.html' with rating=rating %} + + {% include 'snippets/stars.html' with rating=rating %} - {% blocktrans count counter=review_count trimmed %} - ({{ review_count }} review) - {% plural %} - ({{ review_count }} reviews) - {% endblocktrans %} + {% blocktrans count counter=review_count trimmed %} + ({{ review_count }} review) + {% plural %} + ({{ review_count }} reviews) + {% endblocktrans %} + {% with full=book|book_description itemprop='abstract' %} diff --git a/bookwyrm/templates/book/book_identifiers.html b/bookwyrm/templates/book/book_identifiers.html index ff5aad0bb..d976f72c2 100644 --- a/bookwyrm/templates/book/book_identifiers.html +++ b/bookwyrm/templates/book/book_identifiers.html @@ -4,42 +4,50 @@ {% if book.isbn_13 or book.oclc_number or book.asin or book.aasin or book.isfdb %}
{% if book.isbn_13 %} -
+
{% trans "ISBN:" %}
-
{{ book.isbn_13 }}
+
{{ book.hyphenated_isbn13 }}
+
+ + {% trans "Copied ISBN!" %} +
{% endif %} {% if book.oclc_number %} -
+
{% trans "OCLC Number:" %}
{{ book.oclc_number }}
{% endif %} {% if book.asin %} -
+
{% trans "ASIN:" %}
{{ book.asin }}
{% endif %} {% if book.aasin %} -
+
{% trans "Audible ASIN:" %}
{{ book.aasin }}
{% endif %} {% if book.isfdb %} -
+
{% trans "ISFDB ID:" %}
{{ book.isfdb }}
{% endif %} {% if book.goodreads_key %} -
+
{% trans "Goodreads:" %}
{{ book.goodreads_key }}
diff --git a/bookwyrm/templates/book/edit/edit_book.html b/bookwyrm/templates/book/edit/edit_book.html index d4ca2165d..05f40523f 100644 --- a/bookwyrm/templates/book/edit/edit_book.html +++ b/bookwyrm/templates/book/edit/edit_book.html @@ -111,11 +111,11 @@ {% endif %} {% endfor %}
- {% else %} + {% elif add_author %}

{% blocktrans with name=add_author %}Creating a new author: {{ name }}{% endblocktrans %}

{% endif %} - {% if not book %} + {% if not book.parent_work %}
diff --git a/bookwyrm/templates/book/edit/edit_book_form.html b/bookwyrm/templates/book/edit/edit_book_form.html index e85164444..23cc6d097 100644 --- a/bookwyrm/templates/book/edit/edit_book_form.html +++ b/bookwyrm/templates/book/edit/edit_book_form.html @@ -10,7 +10,9 @@ {% csrf_token %} +{% if form.parent_work %} +{% endif %}
@@ -28,6 +30,15 @@ {% include 'snippets/form_errors.html' with errors_list=form.title.errors id="desc_title" %}
+
+ + + + {% include 'snippets/form_errors.html' with errors_list=form.sort_title.errors id="desc_sort_title" %} +
+