diff --git a/.env.dev.example b/.env.dev.example index 5e605d74..538d1611 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -7,6 +7,9 @@ DEBUG=true DOMAIN=your.domain.here #EMAIL=your@email.here +# Used for deciding which editions to prefer +DEFAULT_LANGUAGE="English" + ## Leave unset to allow all hosts # ALLOWED_HOSTS="localhost,127.0.0.1,[::1]" diff --git a/.env.prod.example b/.env.prod.example index 0013bf9d..ac9fe70f 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -7,6 +7,9 @@ DEBUG=false DOMAIN=your.domain.here EMAIL=your@email.here +# Used for deciding which editions to prefer +DEFAULT_LANGUAGE="English" + ## Leave unset to allow all hosts # ALLOWED_HOSTS="localhost,127.0.0.1,[::1]" diff --git a/LICENSE b/LICENSE.md similarity index 84% rename from LICENSE rename to LICENSE.md index 96b7cd74..f111d3fe 100644 --- a/LICENSE +++ b/LICENSE.md @@ -9,10 +9,11 @@ Permission is hereby granted, free of charge, to any person or organization (the 1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software. 2. The User is one of the following: -a. An individual person, laboring for themselves -b. A non-profit organization -c. An educational institution -d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor + + 1. An individual person, laboring for themselves + 2. A non-profit organization + 3. An educational institution + 4. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor 3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote. diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index f6ebf913..1599b408 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -7,11 +7,22 @@ from .image import Document @dataclass(init=False) -class Book(ActivityObject): +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 + lastEditedBy: str = None + + +@dataclass(init=False) +class Book(BookData): """serializes an edition or work, abstract""" title: str - lastEditedBy: str = None sortTitle: str = "" subtitle: str = "" description: str = "" @@ -25,10 +36,6 @@ class Book(ActivityObject): firstPublishedDate: str = "" publishedDate: str = "" - openlibraryKey: str = "" - librarythingKey: str = "" - goodreadsKey: str = "" - cover: Document = None type: str = "Book" @@ -55,23 +62,21 @@ class Work(Book): """work instance of a book object""" lccn: str = "" - defaultEdition: str = "" editions: List[str] = field(default_factory=lambda: []) type: str = "Work" @dataclass(init=False) -class Author(ActivityObject): +class Author(BookData): """author of a book""" name: str - lastEditedBy: str = None + isni: str = None + viafId: str = None + gutenbergId: str = None born: str = None died: str = None aliases: List[str] = field(default_factory=lambda: []) bio: str = "" - openlibraryKey: str = "" - librarythingKey: str = "" - goodreadsKey: str = "" wikipediaLink: str = "" type: str = "Author" diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 264b5a38..76718823 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -44,7 +44,7 @@ class AbstractMinimalConnector(ABC): if min_confidence: params["min_confidence"] = min_confidence - data = get_data( + data = self.get_search_data( "%s%s" % (self.search_url, query), params=params, ) @@ -57,7 +57,7 @@ class AbstractMinimalConnector(ABC): def isbn_search(self, query): """isbn search""" params = {} - data = get_data( + data = self.get_search_data( "%s%s" % (self.isbn_search_url, query), params=params, ) @@ -68,6 +68,10 @@ class AbstractMinimalConnector(ABC): results.append(self.format_isbn_search_result(doc)) return results + def get_search_data(self, remote_id, **kwargs): # pylint: disable=no-self-use + """this allows connectors to override the default behavior""" + return get_data(remote_id, **kwargs) + @abstractmethod def get_or_create_book(self, remote_id): """pull up a book record by whatever means possible""" @@ -112,12 +116,12 @@ class AbstractConnector(AbstractMinimalConnector): remote_id ) or models.Work.find_existing_by_remote_id(remote_id) if existing: - if hasattr(existing, "get_default_editon"): - return existing.get_default_editon() + if hasattr(existing, "default_edition"): + return existing.default_edition return existing # load the json - data = get_data(remote_id) + data = self.get_book_data(remote_id) mapped_data = dict_from_mappings(data, self.book_mappings) if self.is_work_data(data): try: @@ -128,12 +132,12 @@ class AbstractConnector(AbstractMinimalConnector): edition_data = data work_data = mapped_data else: + edition_data = data try: work_data = self.get_work_from_edition_data(data) work_data = dict_from_mappings(work_data, self.book_mappings) except (KeyError, ConnectorException): work_data = mapped_data - edition_data = data if not work_data or not edition_data: raise ConnectorException("Unable to load book data: %s" % remote_id) @@ -150,6 +154,10 @@ 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 + """this allows connectors to override the default behavior""" + return get_data(remote_id) + def create_edition_from_data(self, work, edition_data): """if we already have the work, we're ready""" mapped_data = dict_from_mappings(edition_data, self.book_mappings) @@ -159,10 +167,6 @@ class AbstractConnector(AbstractMinimalConnector): edition.connector = self.connector edition.save() - if not work.default_edition: - work.default_edition = edition - work.save() - for author in self.get_authors_from_data(edition_data): edition.authors.add(author) if not edition.authors.exists() and work.authors.exists(): @@ -176,7 +180,7 @@ class AbstractConnector(AbstractMinimalConnector): if existing: return existing - data = get_data(remote_id) + data = self.get_book_data(remote_id) mapped_data = dict_from_mappings(data, self.author_mappings) try: @@ -273,6 +277,7 @@ class SearchResult: title: str key: str connector: object + view_link: str = None author: str = None year: str = None cover: str = None diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index 640a0bca..10a633b2 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -7,11 +7,7 @@ class Connector(AbstractMinimalConnector): """this is basically just for search""" def get_or_create_book(self, remote_id): - edition = activitypub.resolve_remote_id(remote_id, model=models.Edition) - work = edition.parent_work - work.default_edition = work.get_default_edition() - work.save() - return edition + return activitypub.resolve_remote_id(remote_id, model=models.Edition) def parse_search_data(self, data): return data diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index 20d273e0..3a6bf13c 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -67,10 +67,12 @@ def search(query, min_confidence=0.1): return results -def local_search(query, min_confidence=0.1, raw=False): +def local_search(query, min_confidence=0.1, raw=False, filters=None): """only look at local search results""" connector = load_connector(models.Connector.objects.get(local=True)) - return connector.search(query, min_confidence=min_confidence, raw=raw) + return connector.search( + query, min_confidence=min_confidence, raw=raw, filters=filters + ) def isbn_local_search(query, raw=False): diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py new file mode 100644 index 00000000..dc27f2c0 --- /dev/null +++ b/bookwyrm/connectors/inventaire.py @@ -0,0 +1,214 @@ +""" inventaire data connector """ +import re + +from bookwyrm import models +from .abstract_connector import AbstractConnector, SearchResult, Mapping +from .abstract_connector import get_data +from .connector_manager import ConnectorException + + +class Connector(AbstractConnector): + """instantiate a connector for OL""" + + def __init__(self, identifier): + super().__init__(identifier) + + get_first = lambda a: a[0] + shared_mappings = [ + Mapping("id", remote_field="uri", formatter=self.get_remote_id), + Mapping("bnfId", remote_field="wdt:P268", formatter=get_first), + Mapping("openlibraryKey", remote_field="wdt:P648", formatter=get_first), + ] + self.book_mappings = [ + Mapping("title", remote_field="wdt:P1476", formatter=get_first), + Mapping("subtitle", remote_field="wdt:P1680", formatter=get_first), + Mapping("inventaireId", remote_field="uri"), + Mapping( + "description", remote_field="sitelinks", formatter=self.get_description + ), + Mapping("cover", remote_field="image", formatter=self.get_cover_url), + Mapping("isbn13", remote_field="wdt:P212", formatter=get_first), + Mapping("isbn10", remote_field="wdt:P957", formatter=get_first), + Mapping("oclcNumber", remote_field="wdt:P5331", formatter=get_first), + Mapping("goodreadsKey", remote_field="wdt:P2969", formatter=get_first), + Mapping("librarythingKey", remote_field="wdt:P1085", formatter=get_first), + Mapping("languages", remote_field="wdt:P407", formatter=self.resolve_keys), + Mapping("publishers", remote_field="wdt:P123", formatter=self.resolve_keys), + Mapping("publishedDate", remote_field="wdt:P577", formatter=get_first), + Mapping("pages", remote_field="wdt:P1104", formatter=get_first), + Mapping( + "subjectPlaces", remote_field="wdt:P840", formatter=self.resolve_keys + ), + Mapping("subjects", remote_field="wdt:P921", formatter=self.resolve_keys), + Mapping("asin", remote_field="wdt:P5749", formatter=get_first), + ] + shared_mappings + # TODO: P136: genre, P674 characters, P950 bne + + self.author_mappings = [ + Mapping("id", remote_field="uri", formatter=self.get_remote_id), + Mapping("name", remote_field="labels", formatter=get_language_code), + Mapping("bio", remote_field="sitelinks", formatter=self.get_description), + Mapping("goodreadsKey", remote_field="wdt:P2963", formatter=get_first), + Mapping("isni", remote_field="wdt:P213", formatter=get_first), + Mapping("viafId", remote_field="wdt:P214", formatter=get_first), + Mapping("gutenberg_id", remote_field="wdt:P1938", formatter=get_first), + Mapping("born", remote_field="wdt:P569", formatter=get_first), + Mapping("died", remote_field="wdt:P570", formatter=get_first), + ] + shared_mappings + + def get_remote_id(self, value): + """convert an id/uri into a url""" + return "{:s}?action=by-uris&uris={:s}".format(self.books_url, value) + + def get_book_data(self, remote_id): + data = get_data(remote_id) + extracted = list(data.get("entities").values()) + try: + data = extracted[0] + except KeyError: + raise ConnectorException("Invalid book data") + # 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"]}, + } + + def parse_search_data(self, data): + return data.get("results") + + def format_search_result(self, search_result): + images = search_result.get("image") + cover = ( + "{:s}/img/entities/{:s}".format(self.covers_url, images[0]) + if images + else None + ) + return SearchResult( + title=search_result.get("label"), + key=self.get_remote_id(search_result.get("uri")), + author=search_result.get("description"), + view_link="{:s}/entity/{:s}".format( + self.base_url, search_result.get("uri") + ), + cover=cover, + connector=self, + ) + + def parse_isbn_search_data(self, data): + """got some daaaata""" + results = data.get("entities") + if not results: + return [] + return list(results.values()) + + def format_isbn_search_result(self, search_result): + """totally different format than a regular search result""" + title = search_result.get("claims", {}).get("wdt:P1476", []) + if not title: + return None + return SearchResult( + title=title[0], + key=self.get_remote_id(search_result.get("uri")), + author=search_result.get("description"), + view_link="{:s}/entity/{:s}".format( + self.base_url, search_result.get("uri") + ), + cover=self.get_cover_url(search_result.get("image")), + connector=self, + ) + + def is_work_data(self, data): + return data.get("type") == "work" + + def load_edition_data(self, work_uri): + """get a list of editions for a work""" + url = "{:s}?action=reverse-claims&property=wdt:P629&value={:s}".format( + self.books_url, work_uri + ) + return get_data(url) + + def get_edition_from_work_data(self, data): + data = self.load_edition_data(data.get("uri")) + try: + uri = data["uris"][0] + except KeyError: + raise ConnectorException("Invalid book data") + return self.get_book_data(self.get_remote_id(uri)) + + def get_work_from_edition_data(self, data): + try: + uri = data["claims"]["wdt:P629"] + except KeyError: + raise ConnectorException("Invalid book data") + return self.get_book_data(self.get_remote_id(uri)) + + def get_authors_from_data(self, data): + authors = data.get("wdt:P50", []) + for author in authors: + yield self.get_or_create_author(self.get_remote_id(author)) + + def expand_book_data(self, book): + work = book + # go from the edition to the work, if necessary + if isinstance(book, models.Edition): + work = book.parent_work + + try: + edition_options = self.load_edition_data(work.inventaire_id) + except ConnectorException: + # who knows, man + return + + for edition_uri in edition_options.get("uris"): + remote_id = self.get_remote_id(edition_uri) + try: + data = self.get_book_data(remote_id) + except ConnectorException: + # who, indeed, knows + continue + self.create_edition_from_data(work, data) + + def get_cover_url(self, cover_blob, *_): + """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: + cover_blob = cover_blob[0] + cover_id = cover_blob.get("url") + if not cover_id: + return None + # cover may or may not be an absolute url already + if re.match(r"^http", cover_id): + return cover_id + return "%s%s" % (self.covers_url, cover_id) + + def resolve_keys(self, keys): + """cool, it's "wd:Q3156592" now what the heck does that mean""" + results = [] + for uri in keys: + try: + data = self.get_book_data(self.get_remote_id(uri)) + except ConnectorException: + continue + results.append(get_language_code(data.get("labels"))) + return results + + def get_description(self, links): + """grab an extracted excerpt from wikipedia""" + link = links.get("enwiki") + if not link: + return "" + url = "{:s}/api/data?action=wp-extract&lang=en&title={:s}".format( + self.base_url, link + ) + try: + data = get_data(url) + except ConnectorException: + return "" + return data.get("extract") + + +def get_language_code(options, code="en"): + """when there are a bunch of translation but we need a single field""" + return options.get(code) diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index a7c30b66..69d498b8 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -14,8 +14,8 @@ class Connector(AbstractConnector): def __init__(self, identifier): super().__init__(identifier) - get_first = lambda a: a[0] - get_remote_id = lambda a: self.base_url + a + get_first = lambda a, *args: a[0] + get_remote_id = lambda a, *args: self.base_url + a self.book_mappings = [ Mapping("title"), Mapping("id", remote_field="key", formatter=get_remote_id), diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py index 22835941..a8f85834 100644 --- a/bookwyrm/connectors/self_connector.py +++ b/bookwyrm/connectors/self_connector.py @@ -3,7 +3,7 @@ from functools import reduce import operator from django.contrib.postgres.search import SearchRank, SearchVector -from django.db.models import Count, F, Q +from django.db.models import Count, OuterRef, Subquery, F, Q from bookwyrm import models from .abstract_connector import AbstractConnector, SearchResult @@ -13,15 +13,16 @@ class Connector(AbstractConnector): """instantiate a connector""" # pylint: disable=arguments-differ - def search(self, query, min_confidence=0.1, raw=False): + def search(self, query, min_confidence=0.1, raw=False, filters=None): """search your local database""" + filters = filters or [] if not query: return [] # first, try searching unqiue identifiers - results = search_identifiers(query) + results = search_identifiers(query, *filters) if not results: # then try searching title/author - results = search_title_author(query, min_confidence) + results = search_title_author(query, min_confidence, *filters) search_results = [] for result in results: if raw: @@ -46,7 +47,16 @@ class Connector(AbstractConnector): # when there are multiple editions of the same work, pick the default. # it would be odd for this to happen. - results = results.filter(parent_work__default_edition__id=F("id")) or results + + default_editions = models.Edition.objects.filter( + parent_work=OuterRef("parent_work") + ).order_by("-edition_rank") + results = ( + results.annotate( + default_id=Subquery(default_editions.values("id")[:1]) + ).filter(default_id=F("id")) + or results + ) search_results = [] for result in results: @@ -59,6 +69,10 @@ class Connector(AbstractConnector): return search_results def format_search_result(self, search_result): + cover = None + if search_result.cover: + cover = "%s%s" % (self.covers_url, search_result.cover) + return SearchResult( title=search_result.title, key=search_result.remote_id, @@ -67,7 +81,7 @@ class Connector(AbstractConnector): if search_result.published_date else None, connector=self, - cover="%s%s" % (self.covers_url, search_result.cover), + cover=cover, confidence=search_result.rank if hasattr(search_result, "rank") else 1, ) @@ -98,23 +112,31 @@ class Connector(AbstractConnector): pass -def search_identifiers(query): +def search_identifiers(query, *filters): """tries remote_id, isbn; defined as dedupe fields on the model""" - filters = [ + or_filters = [ {f.name: query} for f in models.Edition._meta.get_fields() if hasattr(f, "deduplication_field") and f.deduplication_field ] results = models.Edition.objects.filter( - reduce(operator.or_, (Q(**f) for f in filters)) + *filters, reduce(operator.or_, (Q(**f) for f in or_filters)) ).distinct() # when there are multiple editions of the same work, pick the default. # it would be odd for this to happen. - return results.filter(parent_work__default_edition__id=F("id")) or results + default_editions = models.Edition.objects.filter( + parent_work=OuterRef("parent_work") + ).order_by("-edition_rank") + return ( + results.annotate(default_id=Subquery(default_editions.values("id")[:1])).filter( + default_id=F("id") + ) + or results + ) -def search_title_author(query, min_confidence): +def search_title_author(query, min_confidence, *filters): """searches for title and author""" vector = ( SearchVector("title", weight="A") @@ -126,7 +148,7 @@ def search_title_author(query, min_confidence): results = ( models.Edition.objects.annotate(search=vector) .annotate(rank=SearchRank(vector, query)) - .filter(rank__gt=min_confidence) + .filter(*filters, rank__gt=min_confidence) .order_by("-rank") ) @@ -139,10 +161,10 @@ def search_title_author(query, min_confidence): for work_id in set(editions_of_work): editions = results.filter(parent_work=work_id) - default = editions.filter(parent_work__default_edition=F("id")) - default_rank = default.first().rank if default.exists() else 0 + default = editions.order_by("-edition_rank").first() + default_rank = default.rank if default else 0 # if mutliple books have the top rank, pick the default edition if default_rank == editions.first().rank: - yield default.first() + yield default else: yield editions.first() diff --git a/bookwyrm/connectors/settings.py b/bookwyrm/connectors/settings.py index f1674cf7..4cc98da7 100644 --- a/bookwyrm/connectors/settings.py +++ b/bookwyrm/connectors/settings.py @@ -1,3 +1,3 @@ """ settings book data connectors """ -CONNECTORS = ["openlibrary", "self_connector", "bookwyrm_connector"] +CONNECTORS = ["openlibrary", "inventaire", "self_connector", "bookwyrm_connector"] diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py index c1e41897..89c62e73 100644 --- a/bookwyrm/importers/importer.py +++ b/bookwyrm/importers/importer.py @@ -116,24 +116,33 @@ def handle_imported_book(source, user, item, include_reviews, privacy): read.save() if include_reviews and (item.rating or item.review): - review_title = ( - "Review of {!r} on {!r}".format( - item.book.title, - source, - ) - if item.review - else "" - ) - # we don't know the publication date of the review, # but "now" is a bad guess published_date_guess = item.date_read or item.date_added - models.Review.objects.create( - user=user, - book=item.book, - name=review_title, - content=item.review, - rating=item.rating, - published_date=published_date_guess, - privacy=privacy, - ) + if item.review: + review_title = ( + "Review of {!r} on {!r}".format( + item.book.title, + source, + ) + if item.review + else "" + ) + models.Review.objects.create( + user=user, + book=item.book, + name=review_title, + content=item.review, + rating=item.rating, + published_date=published_date_guess, + privacy=privacy, + ) + else: + # just a rating + models.ReviewRating.objects.create( + user=user, + book=item.book, + rating=item.rating, + published_date=published_date_guess, + privacy=privacy, + ) diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py index 0c0cc61f..9033249d 100644 --- a/bookwyrm/management/commands/initdb.py +++ b/bookwyrm/management/commands/initdb.py @@ -94,6 +94,18 @@ def init_connectors(): priority=2, ) + Connector.objects.create( + identifier="inventaire.io", + name="Inventaire", + connector_file="inventaire", + base_url="https://inventaire.io", + books_url="https://inventaire.io/api/entities", + covers_url="https://inventaire.io", + search_url="https://inventaire.io/api/search?types=works&types=works&search=", + isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A", + priority=3, + ) + Connector.objects.create( identifier="openlibrary.org", name="OpenLibrary", diff --git a/bookwyrm/migrations/0062_auto_20210406_1731.py b/bookwyrm/migrations/0062_auto_20210406_1731.py new file mode 100644 index 00000000..5db176ec --- /dev/null +++ b/bookwyrm/migrations/0062_auto_20210406_1731.py @@ -0,0 +1,30 @@ +# Generated by Django 3.1.6 on 2021-04-06 17:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0061_auto_20210402_1435"), + ] + + operations = [ + migrations.RemoveConstraint( + model_name="connector", + name="connector_file_valid", + ), + migrations.AlterField( + model_name="connector", + name="connector_file", + field=models.CharField( + choices=[ + ("openlibrary", "Openlibrary"), + ("inventaire", "Inventaire"), + ("self_connector", "Self Connector"), + ("bookwyrm_connector", "Bookwyrm Connector"), + ], + max_length=255, + ), + ), + ] diff --git a/bookwyrm/migrations/0063_auto_20210407_0045.py b/bookwyrm/migrations/0063_auto_20210407_0045.py new file mode 100644 index 00000000..cd87dd97 --- /dev/null +++ b/bookwyrm/migrations/0063_auto_20210407_0045.py @@ -0,0 +1,63 @@ +# Generated by Django 3.1.6 on 2021-04-07 00:45 + +import bookwyrm.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0062_auto_20210406_1731"), + ] + + operations = [ + migrations.AddField( + model_name="author", + name="bnf_id", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="author", + name="gutenberg_id", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="author", + name="inventaire_id", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="author", + name="isni", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="author", + name="viaf_id", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="book", + name="bnf_id", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + migrations.AddField( + model_name="book", + name="inventaire_id", + field=bookwyrm.models.fields.CharField( + blank=True, max_length=255, null=True + ), + ), + ] diff --git a/bookwyrm/migrations/0071_merge_0063_auto_20210407_0045_0070_auto_20210423_0121.py b/bookwyrm/migrations/0071_merge_0063_auto_20210407_0045_0070_auto_20210423_0121.py new file mode 100644 index 00000000..b6489b80 --- /dev/null +++ b/bookwyrm/migrations/0071_merge_0063_auto_20210407_0045_0070_auto_20210423_0121.py @@ -0,0 +1,13 @@ +# Generated by Django 3.2 on 2021-04-26 21:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0063_auto_20210407_0045"), + ("bookwyrm", "0070_auto_20210423_0121"), + ] + + operations = [] diff --git a/bookwyrm/migrations/0072_remove_work_default_edition.py b/bookwyrm/migrations/0072_remove_work_default_edition.py new file mode 100644 index 00000000..1c05c95e --- /dev/null +++ b/bookwyrm/migrations/0072_remove_work_default_edition.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2 on 2021-04-28 22:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0071_merge_0063_auto_20210407_0045_0070_auto_20210423_0121"), + ] + + operations = [ + migrations.RemoveField( + model_name="work", + name="default_edition", + ), + ] diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py index b9a4b146..c4e26c5a 100644 --- a/bookwyrm/models/author.py +++ b/bookwyrm/models/author.py @@ -14,6 +14,15 @@ class Author(BookDataModel): wikipedia_link = fields.CharField( max_length=255, blank=True, null=True, deduplication_field=True ) + isni = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True + ) + viaf_id = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True + ) + gutenberg_id = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True + ) # idk probably other keys would be useful here? born = fields.DateTimeField(blank=True, null=True) died = fields.DateTimeField(blank=True, null=True) diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index dd098e56..869ff04d 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -1,11 +1,11 @@ """ database schema for books and shelves """ import re -from django.db import models, transaction +from django.db import models from model_utils.managers import InheritanceManager from bookwyrm import activitypub -from bookwyrm.settings import DOMAIN +from bookwyrm.settings import DOMAIN, DEFAULT_LANGUAGE from .activitypub_mixin import OrderedCollectionPageMixin, ObjectMixin from .base_model import BookWyrmModel @@ -19,12 +19,18 @@ class BookDataModel(ObjectMixin, BookWyrmModel): openlibrary_key = fields.CharField( max_length=255, blank=True, null=True, deduplication_field=True ) + inventaire_id = fields.CharField( + max_length=255, blank=True, null=True, deduplication_field=True + ) librarything_key = fields.CharField( max_length=255, blank=True, null=True, deduplication_field=True ) goodreads_key = fields.CharField( max_length=255, blank=True, null=True, deduplication_field=True ) + bnf_id = fields.CharField( # Bibliothèque nationale de France + max_length=255, blank=True, null=True, deduplication_field=True + ) last_edited_by = fields.ForeignKey( "User", @@ -137,10 +143,6 @@ class Work(OrderedCollectionPageMixin, Book): lccn = fields.CharField( max_length=255, blank=True, null=True, deduplication_field=True ) - # this has to be nullable but should never be null - default_edition = fields.ForeignKey( - "Edition", on_delete=models.PROTECT, null=True, load_remote=False - ) def save(self, *args, **kwargs): """set some fields on the edition object""" @@ -149,18 +151,10 @@ class Work(OrderedCollectionPageMixin, Book): edition.save() return super().save(*args, **kwargs) - def get_default_edition(self): + @property + def default_edition(self): """in case the default edition is not set""" - return self.default_edition or self.editions.order_by("-edition_rank").first() - - @transaction.atomic() - def reset_default_edition(self): - """sets a new default edition based on computed rank""" - self.default_edition = None - # editions are re-ranked implicitly - self.save() - self.default_edition = self.get_default_edition() - self.save() + return self.editions.order_by("-edition_rank").first() def to_edition_list(self, **kwargs): """an ordered collection of editions""" @@ -214,17 +208,20 @@ class Edition(Book): activity_serializer = activitypub.Edition name_field = "title" - def get_rank(self, ignore_default=False): + def get_rank(self): """calculate how complete the data is on this edition""" - if ( - not ignore_default - and self.parent_work - and self.parent_work.default_edition == self - ): - # default edition has the highest rank - return 20 rank = 0 + # big ups for havinga cover rank += int(bool(self.cover)) * 3 + # is it in the instance's preferred language? + rank += int(bool(DEFAULT_LANGUAGE in self.languages)) + # prefer print editions + if self.physical_format: + rank += int( + bool(self.physical_format.lower() in ["paperback", "hardcover"]) + ) + + # does it have metadata? rank += int(bool(self.isbn_13)) rank += int(bool(self.isbn_10)) rank += int(bool(self.oclc_number)) @@ -242,6 +239,12 @@ class Edition(Book): if self.isbn_10 and not self.isbn_13: self.isbn_13 = isbn_10_to_13(self.isbn_10) + # normalize isbn format + if self.isbn_10: + self.isbn_10 = re.sub(r"[^0-9X]", "", self.isbn_10) + if self.isbn_13: + self.isbn_13 = re.sub(r"[^0-9X]", "", self.isbn_13) + # set rank self.edition_rank = self.get_rank() diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py index 6043fc02..625cdbed 100644 --- a/bookwyrm/models/connector.py +++ b/bookwyrm/models/connector.py @@ -31,16 +31,6 @@ class Connector(BookWyrmModel): # when to reset the query count back to 0 (ie, after 1 day) query_count_expiry = models.DateTimeField(auto_now_add=True, blank=True) - class Meta: - """check that there's code to actually use this connector""" - - constraints = [ - models.CheckConstraint( - check=models.Q(connector_file__in=ConnectorFiles), - name="connector_file_valid", - ) - ] - def __str__(self): return "{} ({})".format( self.identifier, diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index fb5488e7..1bc3c587 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -11,6 +11,7 @@ DOMAIN = env("DOMAIN") VERSION = "0.0.1" PAGE_LENGTH = env("PAGE_LENGTH", 15) +DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") # celery CELERY_BROKER = env("CELERY_BROKER") @@ -34,6 +35,8 @@ LOCALE_PATHS = [ os.path.join(BASE_DIR, "locale"), ] +DEFAULT_AUTO_FIELD = "django.db.models.AutoField" + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 97f105bf..abe50145 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -81,6 +81,9 @@ {% if book.openlibrary_key %}
{% trans "View on OpenLibrary" %}
{% endif %} + {% if book.inventaire_id %} +{% trans "View on Inventaire" %}
+ {% endif %} diff --git a/bookwyrm/templates/lists/list.html b/bookwyrm/templates/lists/list.html index a9f8e5c0..641038d0 100644 --- a/bookwyrm/templates/lists/list.html +++ b/bookwyrm/templates/lists/list.html @@ -13,6 +13,16 @@{% trans "This list is currently empty" %}
{% else %} @@ -116,7 +126,7 @@{% include 'snippets/book_titleby.html' with book=book %}
-