diff --git a/.env.dev.example b/.env.dev.example index 5e605d744..538d1611d 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 0013bf9d2..ac9fe70f8 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/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index f6ebf9131..1599b408a 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 264b5a38e..767188232 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 640a0bca7..6b1d2f8ca 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -6,12 +6,8 @@ from .abstract_connector import AbstractMinimalConnector, SearchResult 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 + def get_or_create_book(self, remote_id, work=None): + return activitypub.resolve_remote_id(remote_id, model=models.Edition) def parse_search_data(self, data): return data diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py new file mode 100644 index 000000000..dc27f2c02 --- /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 a7c30b663..69d498b85 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 0dc922a5a..6b1b349fe 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 @@ -47,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: @@ -112,7 +121,15 @@ def search_identifiers(query, *filters): # 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, *filters): @@ -140,10 +157,10 @@ def search_title_author(query, min_confidence, *filters): 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 f1674cf7c..4cc98da7f 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/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py index 0c0cc61ff..9033249d4 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 000000000..5db176ec1 --- /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 000000000..cd87dd97e --- /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 000000000..b6489b80a --- /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 000000000..1c05c95e1 --- /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 b9a4b146b..c4e26c5ab 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 dd098e560..869ff04d2 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 6043fc026..625cdbed9 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 b679e2d44..1bc3c587b 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") diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index 97f105bf7..abe501458 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/snippets/search_result_text.html b/bookwyrm/templates/snippets/search_result_text.html index 059b8e7e8..26623c05a 100644 --- a/bookwyrm/templates/snippets/search_result_text.html +++ b/bookwyrm/templates/snippets/search_result_text.html @@ -16,10 +16,15 @@- {{ result.title }} + {{ result.title }} +
+{% if result.author %} - {% blocktrans with author=result.author %}by {{ author }}{% endblocktrans %}{% endif %}{% if result.year %} ({{ result.year }}) + {{ result.author }} + {% endif %} + {% if result.year %} + ({{ result.year }}) {% endif %}
diff --git a/bookwyrm/tests/connectors/test_connector_manager.py b/bookwyrm/tests/connectors/test_connector_manager.py index feded6168..34abbeaf6 100644 --- a/bookwyrm/tests/connectors/test_connector_manager.py +++ b/bookwyrm/tests/connectors/test_connector_manager.py @@ -17,8 +17,6 @@ class ConnectorManager(TestCase): self.edition = models.Edition.objects.create( title="Example Edition", parent_work=self.work, isbn_10="0000000000" ) - self.work.default_edition = self.edition - self.work.save() self.connector = models.Connector.objects.create( identifier="test_connector", diff --git a/bookwyrm/tests/connectors/test_inventaire_connector.py b/bookwyrm/tests/connectors/test_inventaire_connector.py new file mode 100644 index 000000000..4058b0670 --- /dev/null +++ b/bookwyrm/tests/connectors/test_inventaire_connector.py @@ -0,0 +1,158 @@ +""" testing book data connectors """ +import json +import pathlib +from django.test import TestCase +import responses + +from bookwyrm import models +from bookwyrm.connectors.inventaire import Connector + + +class Inventaire(TestCase): + """test loading data from inventaire.io""" + + def setUp(self): + """creates the connector we'll use""" + models.Connector.objects.create( + identifier="inventaire.io", + name="Inventaire", + connector_file="inventaire", + base_url="https://inventaire.io", + books_url="https://inventaire.io", + covers_url="https://covers.inventaire.io", + search_url="https://inventaire.io/search?q=", + isbn_search_url="https://inventaire.io/isbn", + ) + self.connector = Connector("inventaire.io") + + @responses.activate + def test_get_book_data(self): + """flattens the default structure to make it easier to parse""" + responses.add( + responses.GET, + "https://test.url/ok", + json={ + "entities": { + "isbn:9780375757853": { + "claims": { + "wdt:P31": ["wd:Q3331189"], + }, + "uri": "isbn:9780375757853", + } + }, + "redirects": {}, + }, + ) + + result = self.connector.get_book_data("https://test.url/ok") + self.assertEqual(result["wdt:P31"], ["wd:Q3331189"]) + self.assertEqual(result["uri"], "isbn:9780375757853") + + def test_format_search_result(self): + """json to search result objs""" + search_file = pathlib.Path(__file__).parent.joinpath( + "../data/inventaire_search.json" + ) + search_results = json.loads(search_file.read_bytes()) + + results = self.connector.parse_search_data(search_results) + formatted = self.connector.format_search_result(results[0]) + + self.assertEqual(formatted.title, "The Stories of Vladimir Nabokov") + self.assertEqual( + formatted.key, "https://inventaire.io?action=by-uris&uris=wd:Q7766679" + ) + self.assertEqual( + formatted.cover, + "https://covers.inventaire.io/img/entities/ddb32e115a28dcc0465023869ba19f6868ec4042", + ) + + def test_get_cover_url(self): + """figure out where the cover image is""" + cover_blob = {"url": "/img/entities/d46a8"} + result = self.connector.get_cover_url(cover_blob) + self.assertEqual(result, "https://covers.inventaire.io/img/entities/d46a8") + + cover_blob = { + "url": "https://commons.wikimedia.org/wiki/Special:FilePath/The%20Moonstone%201st%20ed.jpg?width=1000", + "file": "The Moonstone 1st ed.jpg", + "credits": { + "text": "Wikimedia Commons", + "url": "https://commons.wikimedia.org/wiki/File:The Moonstone 1st ed.jpg", + }, + } + + result = self.connector.get_cover_url(cover_blob) + self.assertEqual( + result, + "https://commons.wikimedia.org/wiki/Special:FilePath/The%20Moonstone%201st%20ed.jpg?width=1000", + ) + + @responses.activate + def test_resolve_keys(self): + """makes an http request""" + responses.add( + responses.GET, + "https://inventaire.io?action=by-uris&uris=wd:Q465821", + json={ + "entities": { + "wd:Q465821": { + "type": "genre", + "labels": { + "nl": "briefroman", + "en": "epistolary novel", + "de-ch": "Briefroman", + "en-ca": "Epistolary novel", + "nb": "brev- og dagbokroman", + }, + "descriptions": { + "en": "novel written as a series of documents", + "es": "novela escrita como una serie de documentos", + "eo": "romano en la formo de serio de leteroj", + }, + }, + "redirects": {}, + } + }, + ) + responses.add( + responses.GET, + "https://inventaire.io?action=by-uris&uris=wd:Q208505", + json={ + "entities": { + "wd:Q208505": { + "type": "genre", + "labels": { + "en": "crime novel", + }, + }, + } + }, + ) + + keys = [ + "wd:Q465821", + "wd:Q208505", + ] + result = self.connector.resolve_keys(keys) + self.assertEqual(result, ["epistolary novel", "crime novel"]) + + def test_isbn_search(self): + """another search type""" + search_file = pathlib.Path(__file__).parent.joinpath( + "../data/inventaire_isbn_search.json" + ) + search_results = json.loads(search_file.read_bytes()) + + results = self.connector.parse_isbn_search_data(search_results) + formatted = self.connector.format_isbn_search_result(results[0]) + + self.assertEqual(formatted.title, "L'homme aux cercles bleus") + self.assertEqual( + formatted.key, + "https://inventaire.io?action=by-uris&uris=isbn:9782290349229", + ) + self.assertEqual( + formatted.cover, + "https://covers.inventaire.io/img/entities/12345", + ) diff --git a/bookwyrm/tests/connectors/test_self_connector.py b/bookwyrm/tests/connectors/test_self_connector.py index eee7d00cf..db97b65a0 100644 --- a/bookwyrm/tests/connectors/test_self_connector.py +++ b/bookwyrm/tests/connectors/test_self_connector.py @@ -84,11 +84,11 @@ class SelfConnector(TestCase): title="Edition 1 Title", parent_work=work ) edition_2 = models.Edition.objects.create( - title="Edition 2 Title", parent_work=work + title="Edition 2 Title", + parent_work=work, + edition_rank=20, # that's default babey ) edition_3 = models.Edition.objects.create(title="Fish", parent_work=work) - work.default_edition = edition_2 - work.save() # pick the best edition results = self.connector.search("Edition 1 Title") diff --git a/bookwyrm/tests/data/inventaire_edition.json b/bookwyrm/tests/data/inventaire_edition.json new file mode 100644 index 000000000..1150bc9b6 --- /dev/null +++ b/bookwyrm/tests/data/inventaire_edition.json @@ -0,0 +1,45 @@ +{ + "entities": { + "isbn:9780375757853": { + "_id": "7beee121a8d9ac345cdf4e9128577723", + "_rev": "2-ac318b04b953ca3894deb77fee28211c", + "type": "edition", + "labels": {}, + "claims": { + "wdt:P31": [ + "wd:Q3331189" + ], + "wdt:P212": [ + "978-0-375-75785-3" + ], + "wdt:P957": [ + "0-375-75785-6" + ], + "wdt:P407": [ + "wd:Q1860" + ], + "wdt:P1476": [ + "The Moonstone" + ], + "wdt:P577": [ + "2001" + ], + "wdt:P629": [ + "wd:Q2362563" + ], + "invp:P2": [ + "d46a8eac7555afa479b8bbb5149f35858e8e19c4" + ] + }, + "created": 1495452670475, + "updated": 1541032981834, + "version": 3, + "uri": "isbn:9780375757853", + "originalLang": "en", + "image": { + "url": "/img/entities/d46a8eac7555afa479b8bbb5149f35858e8e19c4" + } + } + }, + "redirects": {} +} diff --git a/bookwyrm/tests/data/inventaire_isbn_search.json b/bookwyrm/tests/data/inventaire_isbn_search.json new file mode 100644 index 000000000..7328a78fe --- /dev/null +++ b/bookwyrm/tests/data/inventaire_isbn_search.json @@ -0,0 +1,48 @@ +{ + "entities": { + "isbn:9782290349229": { + "_id": "d59e3e64f92c6340fbb10c5dcf7c0abf", + "_rev": "3-079ed51158a001dc74caafb21cff1c22", + "type": "edition", + "labels": {}, + "claims": { + "wdt:P31": [ + "wd:Q3331189" + ], + "wdt:P212": [ + "978-2-290-34922-9" + ], + "wdt:P957": [ + "2-290-34922-4" + ], + "wdt:P407": [ + "wd:Q150" + ], + "wdt:P1476": [ + "L'homme aux cercles bleus" + ], + "wdt:P629": [ + "wd:Q3203603" + ], + "wdt:P123": [ + "wd:Q3156592" + ], + "invp:P2": [ + "57883743aa7c6ad25885a63e6e94349ec4f71562" + ], + "wdt:P577": [ + "2005-05-01" + ] + }, + "created": 1485023383338, + "updated": 1609171008418, + "version": 5, + "uri": "isbn:9782290349229", + "originalLang": "fr", + "image": { + "url": "/img/entities/12345" + } + } + }, + "redirects": {} +} diff --git a/bookwyrm/tests/data/inventaire_search.json b/bookwyrm/tests/data/inventaire_search.json new file mode 100644 index 000000000..e80e593aa --- /dev/null +++ b/bookwyrm/tests/data/inventaire_search.json @@ -0,0 +1,111 @@ +{ + "results": [ + { + "id": "Q7766679", + "type": "works", + "uri": "wd:Q7766679", + "label": "The Stories of Vladimir Nabokov", + "description": "book by Vladimir Nabokov", + "image": [ + "ddb32e115a28dcc0465023869ba19f6868ec4042" + ], + "_score": 25.180836, + "_popularity": 4 + }, + { + "id": "Q47407212", + "type": "works", + "uri": "wd:Q47407212", + "label": "Conversations with Vladimir Nabokov", + "description": "book edited by Robert Golla", + "image": [], + "_score": 24.41498, + "_popularity": 2 + }, + { + "id": "Q6956987", + "type": "works", + "uri": "wd:Q6956987", + "label": "Nabokov's Congeries", + "description": "book by Vladimir Nabokov", + "image": [], + "_score": 22.343866, + "_popularity": 2 + }, + { + "id": "Q6956986", + "type": "works", + "uri": "wd:Q6956986", + "label": "Nabokov's Butterflies", + "description": "book by Brian Boyd", + "image": [], + "_score": 22.343866, + "_popularity": 2 + }, + { + "id": "Q47472170", + "type": "works", + "uri": "wd:Q47472170", + "label": "A Reader's Guide to Nabokov's \"Lolita\"", + "description": "book by Julian W. Connolly", + "image": [], + "_score": 19.482553, + "_popularity": 2 + }, + { + "id": "Q7936323", + "type": "works", + "uri": "wd:Q7936323", + "label": "Visiting Mrs Nabokov: And Other Excursions", + "description": "book by Martin Amis", + "image": [], + "_score": 18.684965, + "_popularity": 2 + }, + { + "id": "1732d81bf7376e04da27568a778561a4", + "type": "works", + "uri": "inv:1732d81bf7376e04da27568a778561a4", + "label": "Nabokov's Dark Cinema", + "image": [ + "7512805a53da569b11bf29cc3fb272c969619749" + ], + "_score": 16.56681, + "_popularity": 1 + }, + { + "id": "00f118336b02219e1bddc8fa93c56050", + "type": "works", + "uri": "inv:00f118336b02219e1bddc8fa93c56050", + "label": "The Cambridge Companion to Nabokov", + "image": [ + "0683a059fb95430cfa73334f9eff2ef377f3ae3d" + ], + "_score": 15.502292, + "_popularity": 1 + }, + { + "id": "6e59f968a1cd00dbedeb1964dec47507", + "type": "works", + "uri": "inv:6e59f968a1cd00dbedeb1964dec47507", + "label": "Vladimir Nabokov : selected letters, 1940-1977", + "image": [ + "e3ce8c0ee89d576adf2651a6e5ce55fc6d9f8cb3" + ], + "_score": 15.019735, + "_popularity": 1 + }, + { + "id": "Q127149", + "type": "works", + "uri": "wd:Q127149", + "label": "Lolita", + "description": "novel by Vladimir Nabokov", + "image": [ + "51cbfdbf7257b1a6bb3ea3fbb167dbce1fb44a0e" + ], + "_score": 13.458428, + "_popularity": 32 + } + ] +} diff --git a/bookwyrm/tests/data/inventaire_work.json b/bookwyrm/tests/data/inventaire_work.json new file mode 100644 index 000000000..635c52f3b --- /dev/null +++ b/bookwyrm/tests/data/inventaire_work.json @@ -0,0 +1,155 @@ +{ + "entities": { + "wd:Q2362563": { + "type": "work", + "labels": { + "zh-hans": "月亮宝石", + "zh-hant": "月亮寶石", + "zh-hk": "月光石", + "zh-tw": "月光石", + "cy": "The Moonstone", + "ml": "ദ മൂൺസ്റ്റോൺ", + "ja": "月長石", + "te": "ది మూన్ స్టోన్", + "ru": "Лунный камень", + "fr": "La Pierre de lune", + "en": "The Moonstone", + "es": "La piedra lunar", + "it": "La Pietra di Luna", + "zh": "月亮宝石", + "pl": "Kamień Księżycowy", + "sr": "2 Јн", + "ta": "moon stone", + "ar": "حجر القمر", + "fa": "ماهالماس", + "uk": "Місячний камінь", + "nl": "The Moonstone", + "de": "Der Monddiamant", + "sl": "Diamant", + "sv": "Månstenen", + "he": "אבן הירח", + "eu": "Ilargi-harriak", + "bg": "Лунният камък", + "ka": "მთვარის ქვა", + "eo": "La Lunŝtono", + "hy": "Լուսնաքար", + "ro": "Piatra Lunii", + "ca": "The Moonstone", + "is": "The Moonstone" + }, + "descriptions": { + "it": "romanzo scritto da Wilkie Collins", + "en": "novel by Wilkie Collins", + "de": "Buch von Wilkie Collins", + "nl": "boek van Wilkie Collins", + "ru": "роман Уилки Коллинза", + "he": "רומן מאת וילקי קולינס", + "ar": "رواية من تأليف ويلكي كولينز", + "fr": "livre de Wilkie Collins", + "es": "libro de Wilkie Collins", + "bg": "роман на Уилки Колинс", + "ka": "უილკი კოლინსის რომანი", + "eo": "angalingva romano far Wilkie Collins", + "ro": "roman de Wilkie Collins" + }, + "aliases": { + "zh": [ + "月光石" + ], + "ml": [ + "The Moonstone" + ], + "fr": [ + "The Moonstone" + ], + "it": [ + "Il diamante indiano", + "La pietra della luna", + "La maledizione del diamante indiano" + ], + "ro": [ + "The Moonstone" + ] + }, + "claims": { + "wdt:P18": [ + "The Moonstone 1st ed.jpg" + ], + "wdt:P31": [ + "wd:Q7725634" + ], + "wdt:P50": [ + "wd:Q210740" + ], + "wdt:P123": [ + "wd:Q4457856" + ], + "wdt:P136": [ + "wd:Q465821", + "wd:Q208505", + "wd:Q10992055" + ], + "wdt:P156": [ + "wd:Q7228798" + ], + "wdt:P268": [ + "12496407z" + ], + "wdt:P407": [ + "wd:Q7979" + ], + "wdt:P577": [ + "1868" + ], + "wdt:P1433": [ + "wd:Q21" + ], + "wdt:P1476": [ + "The Moonstone" + ], + "wdt:P1680": [ + "A Romance" + ], + "wdt:P2034": [ + "155" + ] + }, + "sitelinks": { + "arwiki": "حجر القمر (رواية)", + "bgwiki": "Лунният камък (роман)", + "cywiki": "The Moonstone", + "dewiki": "Der Monddiamant", + "enwiki": "The Moonstone", + "enwikisource": "The Moonstone", + "eswiki": "La piedra lunar", + "euwiki": "Ilargi-harria", + "fawiki": "ماهالماس", + "frwiki": "La Pierre de lune (roman de Wilkie Collins)", + "hewiki": "אבן הירח", + "hywiki": "Լուսնաքար", + "iswiki": "The Moonstone", + "itwiki": "La pietra di Luna", + "jawiki": "月長石 (小説)", + "mlwiki": "ദ മൂൺസ്റ്റോൺ", + "plwiki": "Kamień Księżycowy (powieść)", + "ruwiki": "Лунный камень (роман)", + "slwiki": "Diamant (roman)", + "srwikisource": "Нови завјет (Караџић) / 2. Јованова", + "svwiki": "Månstenen", + "tewiki": "ది మూన్స్టోన్", + "ukwiki": "Місячний камінь (роман)", + "zhwiki": "月亮宝石" + }, + "uri": "wd:Q2362563", + "image": { + "url": "https://commons.wikimedia.org/wiki/Special:FilePath/The%20Moonstone%201st%20ed.jpg?width=1000", + "file": "The Moonstone 1st ed.jpg", + "credits": { + "text": "Wikimedia Commons", + "url": "https://commons.wikimedia.org/wiki/File:The Moonstone 1st ed.jpg" + } + } + } + }, + "redirects": {} +} diff --git a/bookwyrm/tests/models/test_book_model.py b/bookwyrm/tests/models/test_book_model.py index c80cc4a84..cad00d43a 100644 --- a/bookwyrm/tests/models/test_book_model.py +++ b/bookwyrm/tests/models/test_book_model.py @@ -84,9 +84,3 @@ class Book(TestCase): self.first_edition.description = "hi" self.first_edition.save() self.assertEqual(self.first_edition.edition_rank, 1) - - # default edition - self.work.default_edition = self.first_edition - self.work.save() - self.first_edition.refresh_from_db() - self.assertEqual(self.first_edition.edition_rank, 20) diff --git a/bookwyrm/tests/models/test_readthrough_model.py b/bookwyrm/tests/models/test_readthrough_model.py index 93e9e654c..986b739b6 100644 --- a/bookwyrm/tests/models/test_readthrough_model.py +++ b/bookwyrm/tests/models/test_readthrough_model.py @@ -2,7 +2,7 @@ from django.test import TestCase from django.core.exceptions import ValidationError -from bookwyrm import models, settings +from bookwyrm import models class ReadThrough(TestCase): @@ -19,8 +19,6 @@ class ReadThrough(TestCase): self.edition = models.Edition.objects.create( title="Example Edition", parent_work=self.work ) - self.work.default_edition = self.edition - self.work.save() self.readthrough = models.ReadThrough.objects.create( user=self.user, book=self.edition diff --git a/bookwyrm/tests/views/test_readthrough.py b/bookwyrm/tests/views/test_readthrough.py index c9ebf2169..882c79291 100644 --- a/bookwyrm/tests/views/test_readthrough.py +++ b/bookwyrm/tests/views/test_readthrough.py @@ -20,8 +20,6 @@ class ReadThrough(TestCase): self.edition = models.Edition.objects.create( title="Example Edition", parent_work=self.work ) - self.work.default_edition = self.edition - self.work.save() self.user = models.User.objects.create_user( "cinco", "cinco@example.com", "seissiete", local=True, localname="cinco" diff --git a/bookwyrm/views/author.py b/bookwyrm/views/author.py index 0bd7b0e04..41298161c 100644 --- a/bookwyrm/views/author.py +++ b/bookwyrm/views/author.py @@ -27,7 +27,7 @@ class Author(View): ).distinct() data = { "author": author, - "books": [b.get_default_edition() for b in books], + "books": [b.default_edition for b in books], } return TemplateResponse(request, "author.html", data) diff --git a/bookwyrm/views/books.py b/bookwyrm/views/books.py index 448cf9929..6005c9fde 100644 --- a/bookwyrm/views/books.py +++ b/bookwyrm/views/books.py @@ -39,7 +39,7 @@ class Book(View): return ActivitypubResponse(book.to_activity()) if isinstance(book, models.Work): - book = book.get_default_edition() + book = book.default_edition if not book or not book.parent_work: return HttpResponseNotFound() @@ -156,7 +156,6 @@ class EditBook(View): ), } ) - print(data["author_matches"]) # we're creating a new book if not book: diff --git a/bookwyrm/views/helpers.py b/bookwyrm/views/helpers.py index 8a60b54c7..540b578f4 100644 --- a/bookwyrm/views/helpers.py +++ b/bookwyrm/views/helpers.py @@ -123,7 +123,7 @@ def get_edition(book_id): """look up a book in the db and return an edition""" book = models.Book.objects.select_subclasses().get(id=book_id) if isinstance(book, models.Work): - book = book.get_default_edition() + book = book.default_edition return book diff --git a/locale/de_DE/LC_MESSAGES/django.mo b/locale/de_DE/LC_MESSAGES/django.mo index dca726855..3f41eda57 100644 Binary files a/locale/de_DE/LC_MESSAGES/django.mo and b/locale/de_DE/LC_MESSAGES/django.mo differ diff --git a/locale/de_DE/LC_MESSAGES/django.po b/locale/de_DE/LC_MESSAGES/django.po index 7acc3f969..bbdd13275 100644 --- a/locale/de_DE/LC_MESSAGES/django.po +++ b/locale/de_DE/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.0.1\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-04-26 09:56-0700\n" +"POT-Creation-Date: 2021-04-29 11:36-0700\n" "PO-Revision-Date: 2021-03-02 17:19-0800\n" "Last-Translator: Mouse Reeve