diff --git a/.github/workflows/curlylint.yaml b/.github/workflows/curlylint.yaml index e27d0b1b..593a4283 100644 --- a/.github/workflows/curlylint.yaml +++ b/.github/workflows/curlylint.yaml @@ -24,5 +24,5 @@ jobs: --rule 'meta_viewport: true' \ --rule 'no_autofocus: true' \ --rule 'tabindex_no_positive: true' \ - --exclude '_modal.html|create_status/layout.html' \ + --exclude '_modal.html|create_status/layout.html|reading_modals/layout.html' \ bookwyrm/templates diff --git a/Dockerfile b/Dockerfile index 1892ae23..349dd82b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,4 +8,4 @@ WORKDIR /app COPY requirements.txt /app/ RUN pip install -r requirements.txt --no-cache-dir -RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean +RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index bd27c4e6..d8599c4b 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -54,6 +54,7 @@ class Edition(Book): asin: str = "" pages: int = None physicalFormat: str = "" + physicalFormatDetail: str = "" publishers: List[str] = field(default_factory=lambda: []) editionRank: int = 0 diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py new file mode 100644 index 00000000..6c89b61f --- /dev/null +++ b/bookwyrm/book_search.py @@ -0,0 +1,156 @@ +""" using a bookwyrm instance as a source of book data """ +from dataclasses import asdict, dataclass +from functools import reduce +import operator + +from django.contrib.postgres.search import SearchRank, SearchQuery +from django.db.models import OuterRef, Subquery, F, Q + +from bookwyrm import models +from bookwyrm.settings import MEDIA_FULL_URL + + +# pylint: disable=arguments-differ +def search(query, min_confidence=0, filters=None, return_first=False): + """search your local database""" + filters = filters or [] + if not query: + return [] + # first, try searching unqiue identifiers + results = search_identifiers(query, *filters, return_first=return_first) + if not results: + # then try searching title/author + results = search_title_author( + query, min_confidence, *filters, return_first=return_first + ) + return results + + +def isbn_search(query): + """search your local database""" + if not query: + return [] + + filters = [{f: query} for f in ["isbn_10", "isbn_13"]] + results = models.Edition.objects.filter( + reduce(operator.or_, (Q(**f) for f in filters)) + ).distinct() + + # when there are multiple editions of the same work, pick the default. + # it would be odd for this to happen. + + 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 + ) + return results + + +def format_search_result(search_result): + """convert a book object into a search result object""" + cover = None + if search_result.cover: + cover = f"{MEDIA_FULL_URL}{search_result.cover}" + + return SearchResult( + title=search_result.title, + key=search_result.remote_id, + author=search_result.author_text, + year=search_result.published_date.year + if search_result.published_date + else None, + cover=cover, + confidence=search_result.rank if hasattr(search_result, "rank") else 1, + connector="", + ).json() + + +def search_identifiers(query, *filters, return_first=False): + """tries remote_id, isbn; defined as dedupe fields on the model""" + # pylint: disable=W0212 + 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( + *filters, reduce(operator.or_, (Q(**f) for f in or_filters)) + ).distinct() + if results.count() <= 1: + return results + + # when there are multiple editions of the same work, pick the default. + # it would be odd for this to happen. + 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 + ) + if return_first: + return results.first() + return results + + +def search_title_author(query, min_confidence, *filters, return_first=False): + """searches for title and author""" + query = SearchQuery(query, config="simple") | SearchQuery(query, config="english") + results = ( + models.Edition.objects.filter(*filters, search_vector=query) + .annotate(rank=SearchRank(F("search_vector"), query)) + .filter(rank__gt=min_confidence) + .order_by("-rank") + ) + + # when there are multiple editions of the same work, pick the closest + editions_of_work = results.values("parent_work__id").values_list("parent_work__id") + + # filter out multiple editions of the same work + list_results = [] + for work_id in set(editions_of_work): + editions = results.filter(parent_work=work_id) + 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: + result = default + else: + result = editions.first() + if return_first: + return result + list_results.append(result) + return list_results + + +@dataclass +class SearchResult: + """standardized search result object""" + + title: str + key: str + connector: object + view_link: str = None + author: str = None + year: str = None + cover: str = None + confidence: int = 1 + + def __repr__(self): + # pylint: disable=consider-using-f-string + return "".format( + self.key, self.title, self.author + ) + + def json(self): + """serialize a connector for json response""" + serialized = asdict(self) + del serialized["connector"] + return serialized diff --git a/bookwyrm/connectors/__init__.py b/bookwyrm/connectors/__init__.py index 689f2701..efbdb166 100644 --- a/bookwyrm/connectors/__init__.py +++ b/bookwyrm/connectors/__init__.py @@ -3,4 +3,4 @@ from .settings import CONNECTORS from .abstract_connector import ConnectorException from .abstract_connector import get_data, get_image -from .connector_manager import search, local_search, first_search_result +from .connector_manager import search, first_search_result diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 455241cc..c032986d 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -1,6 +1,5 @@ """ functionality outline for a book data connector """ from abc import ABC, abstractmethod -from dataclasses import asdict, dataclass import logging from django.db import transaction @@ -9,6 +8,7 @@ from requests.exceptions import RequestException from bookwyrm import activitypub, models, settings from .connector_manager import load_more_data, ConnectorException +from .format_mappings import format_mappings logger = logging.getLogger(__name__) @@ -31,7 +31,6 @@ class AbstractMinimalConnector(ABC): "isbn_search_url", "name", "identifier", - "local", ] for field in self_fields: setattr(self, field, getattr(info, field)) @@ -267,32 +266,6 @@ def get_image(url, timeout=10): return resp -@dataclass -class SearchResult: - """standardized search result object""" - - title: str - key: str - connector: object - view_link: str = None - author: str = None - year: str = None - cover: str = None - confidence: int = 1 - - def __repr__(self): - # pylint: disable=consider-using-f-string - return "".format( - self.key, self.title, self.author - ) - - def json(self): - """serialize a connector for json response""" - serialized = asdict(self) - del serialized["connector"] - return serialized - - class Mapping: """associate a local database field with a field in an external dataset""" @@ -312,3 +285,25 @@ class Mapping: return self.formatter(value) except: # pylint: disable=bare-except return None + + +def infer_physical_format(format_text): + """try to figure out what the standardized format is from the free value""" + format_text = format_text.lower() + if format_text in format_mappings: + # try a direct match + return format_mappings[format_text] + # failing that, try substring + matches = [v for k, v in format_mappings.items() if k in format_text] + if not matches: + return None + return matches[0] + + +def unique_physical_format(format_text): + """only store the format if it isn't diretly in the format mappings""" + format_text = format_text.lower() + if format_text in format_mappings: + # try a direct match, so saving this would be redundant + return None + return format_text diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index 10a633b2..6dcba7c3 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -1,6 +1,7 @@ """ using another bookwyrm instance as a source of book data """ from bookwyrm import activitypub, models -from .abstract_connector import AbstractMinimalConnector, SearchResult +from bookwyrm.book_search import SearchResult +from .abstract_connector import AbstractMinimalConnector class Connector(AbstractMinimalConnector): diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index b676e9aa..45530cd6 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -10,7 +10,7 @@ from django.db.models import signals from requests import HTTPError -from bookwyrm import models +from bookwyrm import book_search, models from bookwyrm.tasks import app logger = logging.getLogger(__name__) @@ -55,7 +55,7 @@ def search(query, min_confidence=0.1, return_first=False): # if we found anything, return it return result_set[0] - if result_set or connector.local: + if result_set: results.append( { "connector": connector, @@ -71,22 +71,13 @@ def search(query, min_confidence=0.1, return_first=False): return results -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, filters=filters - ) - - -def isbn_local_search(query, raw=False): - """only look at local search results""" - connector = load_connector(models.Connector.objects.get(local=True)) - return connector.isbn_search(query, raw=raw) - - def first_search_result(query, min_confidence=0.1): """search until you find a result that fits""" + # try local search first + result = book_search.search(query, min_confidence=min_confidence, return_first=True) + if result: + return result + # otherwise, try remote endpoints return search(query, min_confidence=min_confidence, return_first=True) or None diff --git a/bookwyrm/connectors/format_mappings.py b/bookwyrm/connectors/format_mappings.py new file mode 100644 index 00000000..61f61efa --- /dev/null +++ b/bookwyrm/connectors/format_mappings.py @@ -0,0 +1,43 @@ +""" comparing a free text format to the standardized one """ +format_mappings = { + "paperback": "Paperback", + "soft": "Paperback", + "pamphlet": "Paperback", + "peperback": "Paperback", + "tapa blanda": "Paperback", + "turtleback": "Paperback", + "pocket": "Paperback", + "spiral": "Paperback", + "ring": "Paperback", + "平装": "Paperback", + "简装": "Paperback", + "hardcover": "Hardcover", + "hardcocer": "Hardcover", + "hardover": "Hardcover", + "hardback": "Hardcover", + "library": "Hardcover", + "tapa dura": "Hardcover", + "leather": "Hardcover", + "clothbound": "Hardcover", + "精装": "Hardcover", + "ebook": "EBook", + "e-book": "EBook", + "digital": "EBook", + "computer file": "EBook", + "epub": "EBook", + "online": "EBook", + "pdf": "EBook", + "elektronische": "EBook", + "electronic": "EBook", + "audiobook": "AudiobookFormat", + "audio": "AudiobookFormat", + "cd": "AudiobookFormat", + "dvd": "AudiobookFormat", + "mp3": "AudiobookFormat", + "cassette": "AudiobookFormat", + "kindle": "AudiobookFormat", + "talking": "AudiobookFormat", + "sound": "AudiobookFormat", + "comic": "GraphicNovel", + "graphic": "GraphicNovel", +} diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py index 70455488..faed5429 100644 --- a/bookwyrm/connectors/inventaire.py +++ b/bookwyrm/connectors/inventaire.py @@ -2,13 +2,14 @@ import re from bookwyrm import models -from .abstract_connector import AbstractConnector, SearchResult, Mapping +from bookwyrm.book_search import SearchResult +from .abstract_connector import AbstractConnector, Mapping from .abstract_connector import get_data from .connector_manager import ConnectorException class Connector(AbstractConnector): - """instantiate a connector for OL""" + """instantiate a connector for inventaire""" def __init__(self, identifier): super().__init__(identifier) diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index fca5d0f7..b8afc7ca 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -2,8 +2,9 @@ import re from bookwyrm import models -from .abstract_connector import AbstractConnector, SearchResult, Mapping -from .abstract_connector import get_data +from bookwyrm.book_search import SearchResult +from .abstract_connector import AbstractConnector, Mapping +from .abstract_connector import get_data, infer_physical_format, unique_physical_format from .connector_manager import ConnectorException from .openlibrary_languages import languages @@ -43,7 +44,16 @@ class Connector(AbstractConnector): ), Mapping("publishedDate", remote_field="publish_date"), Mapping("pages", remote_field="number_of_pages"), - Mapping("physicalFormat", remote_field="physical_format"), + Mapping( + "physicalFormat", + remote_field="physical_format", + formatter=infer_physical_format, + ), + Mapping( + "physicalFormatDetail", + remote_field="physical_format", + formatter=unique_physical_format, + ), Mapping("publishers"), ] diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py deleted file mode 100644 index cdb586cb..00000000 --- a/bookwyrm/connectors/self_connector.py +++ /dev/null @@ -1,164 +0,0 @@ -""" using a bookwyrm instance as a source of book data """ -from functools import reduce -import operator - -from django.contrib.postgres.search import SearchRank, SearchQuery -from django.db.models import OuterRef, Subquery, F, Q - -from bookwyrm import models -from .abstract_connector import AbstractConnector, SearchResult - - -class Connector(AbstractConnector): - """instantiate a connector""" - - # pylint: disable=arguments-differ - def search(self, query, min_confidence=0, raw=False, filters=None): - """search your local database""" - filters = filters or [] - if not query: - return [] - # first, try searching unqiue identifiers - results = search_identifiers(query, *filters) - if not results: - # then try searching title/author - results = search_title_author(query, min_confidence, *filters) - search_results = [] - for result in results: - if raw: - search_results.append(result) - else: - search_results.append(self.format_search_result(result)) - if len(search_results) >= 10: - break - if not raw: - search_results.sort(key=lambda r: r.confidence, reverse=True) - return search_results - - def isbn_search(self, query, raw=False): - """search your local database""" - if not query: - return [] - - filters = [{f: query} for f in ["isbn_10", "isbn_13"]] - results = models.Edition.objects.filter( - reduce(operator.or_, (Q(**f) for f in filters)) - ).distinct() - - # when there are multiple editions of the same work, pick the default. - # it would be odd for this to happen. - - 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: - if raw: - search_results.append(result) - else: - search_results.append(self.format_search_result(result)) - if len(search_results) >= 10: - break - return search_results - - def format_search_result(self, search_result): - cover = None - if search_result.cover: - cover = f"{self.covers_url}{search_result.cover}" - - return SearchResult( - title=search_result.title, - key=search_result.remote_id, - author=search_result.author_text, - year=search_result.published_date.year - if search_result.published_date - else None, - connector=self, - cover=cover, - confidence=search_result.rank if hasattr(search_result, "rank") else 1, - ) - - def format_isbn_search_result(self, search_result): - return self.format_search_result(search_result) - - def is_work_data(self, data): - pass - - def get_edition_from_work_data(self, data): - pass - - def get_work_from_edition_data(self, data): - pass - - def get_authors_from_data(self, data): - return None - - def parse_isbn_search_data(self, data): - """it's already in the right format, don't even worry about it""" - return data - - def parse_search_data(self, data): - """it's already in the right format, don't even worry about it""" - return data - - def expand_book_data(self, book): - pass - - -def search_identifiers(query, *filters): - """tries remote_id, isbn; defined as dedupe fields on the model""" - # pylint: disable=W0212 - 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( - *filters, reduce(operator.or_, (Q(**f) for f in or_filters)) - ).distinct() - if results.count() <= 1: - return results - - # when there are multiple editions of the same work, pick the default. - # it would be odd for this to happen. - 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): - """searches for title and author""" - query = SearchQuery(query, config="simple") | SearchQuery(query, config="english") - results = ( - models.Edition.objects.filter(*filters, search_vector=query) - .annotate(rank=SearchRank(F("search_vector"), query)) - .filter(rank__gt=min_confidence) - .order_by("-rank") - ) - - # when there are multiple editions of the same work, pick the closest - editions_of_work = results.values("parent_work__id").values_list("parent_work__id") - - # filter out multiple editions of the same work - for work_id in set(editions_of_work): - editions = results.filter(parent_work=work_id) - 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 - else: - yield editions.first() diff --git a/bookwyrm/connectors/settings.py b/bookwyrm/connectors/settings.py index 4cc98da7..927e39b2 100644 --- a/bookwyrm/connectors/settings.py +++ b/bookwyrm/connectors/settings.py @@ -1,3 +1,3 @@ """ settings book data connectors """ -CONNECTORS = ["openlibrary", "inventaire", "self_connector", "bookwyrm_connector"] +CONNECTORS = ["openlibrary", "inventaire", "bookwyrm_connector"] diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py index ffb7581e..7ab923fd 100644 --- a/bookwyrm/forms.py +++ b/bookwyrm/forms.py @@ -29,8 +29,7 @@ class CustomForm(ModelForm): input_type = visible.field.widget.input_type if isinstance(visible.field.widget, Textarea): input_type = "textarea" - visible.field.widget.attrs["cols"] = None - visible.field.widget.attrs["rows"] = None + visible.field.widget.attrs["rows"] = 5 visible.field.widget.attrs["class"] = css_classes[input_type] @@ -228,7 +227,7 @@ class ExpiryWidget(widgets.Select): elif selected_string == "forever": return None else: - return selected_string # "This will raise + return selected_string # This will raise return timezone.now() + interval @@ -269,7 +268,7 @@ class CreateInviteForm(CustomForm): class ShelfForm(CustomForm): class Meta: model = models.Shelf - fields = ["user", "name", "privacy"] + fields = ["user", "name", "privacy", "description"] class GoalForm(CustomForm): diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py index 71ac511a..d0ab648e 100644 --- a/bookwyrm/management/commands/initdb.py +++ b/bookwyrm/management/commands/initdb.py @@ -4,7 +4,6 @@ from django.contrib.auth.models import Group, Permission from django.contrib.contenttypes.models import ContentType from bookwyrm.models import Connector, FederatedServer, SiteSettings, User -from bookwyrm.settings import DOMAIN def init_groups(): @@ -73,19 +72,6 @@ def init_permissions(): def init_connectors(): """access book data sources""" - Connector.objects.create( - identifier=DOMAIN, - name="Local", - local=True, - connector_file="self_connector", - base_url="https://%s" % DOMAIN, - books_url="https://%s/book" % DOMAIN, - covers_url="https://%s/images/" % DOMAIN, - search_url="https://%s/search?q=" % DOMAIN, - isbn_search_url="https://%s/isbn/" % DOMAIN, - priority=1, - ) - Connector.objects.create( identifier="bookwyrm.social", name="BookWyrm dot Social", diff --git a/bookwyrm/migrations/0099_readthrough_is_active.py b/bookwyrm/migrations/0099_readthrough_is_active.py new file mode 100644 index 00000000..e7b177ba --- /dev/null +++ b/bookwyrm/migrations/0099_readthrough_is_active.py @@ -0,0 +1,37 @@ +# Generated by Django 3.2.4 on 2021-09-22 16:53 + +from django.db import migrations, models + + +def set_active_readthrough(apps, schema_editor): + """best-guess for deactivation date""" + db_alias = schema_editor.connection.alias + apps.get_model("bookwyrm", "ReadThrough").objects.using(db_alias).filter( + start_date__isnull=False, + finish_date__isnull=True, + ).update(is_active=True) + + +def reverse_func(apps, schema_editor): + """noop""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0098_auto_20210918_2238"), + ] + + operations = [ + migrations.AddField( + model_name="readthrough", + name="is_active", + field=models.BooleanField(default=False), + ), + migrations.RunPython(set_active_readthrough, reverse_func), + migrations.AlterField( + model_name="readthrough", + name="is_active", + field=models.BooleanField(default=True), + ), + ] diff --git a/bookwyrm/migrations/0100_shelf_description.py b/bookwyrm/migrations/0100_shelf_description.py new file mode 100644 index 00000000..18185b17 --- /dev/null +++ b/bookwyrm/migrations/0100_shelf_description.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-09-28 23:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0099_readthrough_is_active"), + ] + + operations = [ + migrations.AddField( + model_name="shelf", + name="description", + field=models.TextField(blank=True, max_length=500, null=True), + ), + ] diff --git a/bookwyrm/migrations/0101_auto_20210929_1847.py b/bookwyrm/migrations/0101_auto_20210929_1847.py new file mode 100644 index 00000000..3fca28ea --- /dev/null +++ b/bookwyrm/migrations/0101_auto_20210929_1847.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2 on 2021-05-21 00:17 + +from django.db import migrations +import bookwyrm +from bookwyrm.connectors.abstract_connector import infer_physical_format + + +def infer_format(app_registry, schema_editor): + """set the new phsyical format field based on existing format data""" + db_alias = schema_editor.connection.alias + + editions = ( + app_registry.get_model("bookwyrm", "Edition") + .objects.using(db_alias) + .filter(physical_format_detail__isnull=False) + ) + for edition in editions: + free_format = edition.physical_format_detail.lower() + edition.physical_format = infer_physical_format(free_format) + edition.save() + + +def reverse(app_registry, schema_editor): + """doesn't need to do anything""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0100_shelf_description"), + ] + + operations = [ + migrations.RenameField( + model_name="edition", + old_name="physical_format", + new_name="physical_format_detail", + ), + migrations.AddField( + model_name="edition", + name="physical_format", + field=bookwyrm.models.fields.CharField( + blank=True, + choices=[ + ("AudiobookFormat", "Audiobook"), + ("EBook", "eBook"), + ("GraphicNovel", "Graphic novel"), + ("Hardcover", "Hardcover"), + ("Paperback", "Paperback"), + ], + max_length=255, + null=True, + ), + ), + migrations.RunPython(infer_format, reverse), + ] diff --git a/bookwyrm/migrations/0102_remove_connector_local.py b/bookwyrm/migrations/0102_remove_connector_local.py new file mode 100644 index 00000000..857f0f58 --- /dev/null +++ b/bookwyrm/migrations/0102_remove_connector_local.py @@ -0,0 +1,41 @@ +# Generated by Django 3.2.5 on 2021-09-30 17:46 + +from django.db import migrations +from bookwyrm.settings import DOMAIN + + +def remove_self_connector(app_registry, schema_editor): + """set the new phsyical format field based on existing format data""" + db_alias = schema_editor.connection.alias + app_registry.get_model("bookwyrm", "Connector").objects.using(db_alias).filter( + connector_file="self_connector" + ).delete() + + +def reverse(app_registry, schema_editor): + """doesn't need to do anything""" + db_alias = schema_editor.connection.alias + model = app_registry.get_model("bookwyrm", "Connector") + model.objects.using(db_alias).create( + identifier=DOMAIN, + name="Local", + local=True, + connector_file="self_connector", + base_url=f"https://{DOMAIN}", + books_url=f"https://{DOMAIN}/book", + covers_url=f"https://{DOMAIN}/images/", + search_url=f"https://{DOMAIN}/search?q=", + isbn_search_url=f"https://{DOMAIN}/isbn/", + priority=1, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0101_auto_20210929_1847"), + ] + + operations = [ + migrations.RunPython(remove_self_connector, reverse), + ] diff --git a/bookwyrm/migrations/0103_remove_connector_local.py b/bookwyrm/migrations/0103_remove_connector_local.py new file mode 100644 index 00000000..788ce5f8 --- /dev/null +++ b/bookwyrm/migrations/0103_remove_connector_local.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.5 on 2021-09-30 18:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0102_remove_connector_local"), + ] + + operations = [ + migrations.RemoveField( + model_name="connector", + name="local", + ), + ] diff --git a/bookwyrm/migrations/0104_auto_20211001_2012.py b/bookwyrm/migrations/0104_auto_20211001_2012.py new file mode 100644 index 00000000..8d429040 --- /dev/null +++ b/bookwyrm/migrations/0104_auto_20211001_2012.py @@ -0,0 +1,53 @@ +# Generated by Django 3.2.5 on 2021-10-01 20:12 + +from django.db import migrations, models + + +def set_thread_id(app_registry, schema_editor): + """set thread ids""" + db_alias = schema_editor.connection.alias + # set the thread id on parent nodes + model = app_registry.get_model("bookwyrm", "Status") + model.objects.using(db_alias).filter(reply_parent__isnull=True).update( + thread_id=models.F("id") + ) + + queryset = model.objects.using(db_alias).filter( + reply_parent__isnull=False, + reply_parent__thread_id__isnull=False, + thread_id__isnull=True, + ) + iters = 0 + while queryset.exists(): + queryset.update( + thread_id=models.Subquery( + model.objects.filter(id=models.OuterRef("reply_parent")).values_list( + "thread_id" + )[:1] + ) + ) + print(iters) + iters += 1 + if iters > 50: + print("exceeded query depth") + break + + +def reverse(*_): + """do nothing""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookwyrm", "0103_remove_connector_local"), + ] + + operations = [ + migrations.AddField( + model_name="status", + name="thread_id", + field=models.IntegerField(blank=True, null=True), + ), + migrations.RunPython(set_thread_id, reverse), + ] diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py index 61652620..5921d773 100644 --- a/bookwyrm/models/base_model.py +++ b/bookwyrm/models/base_model.py @@ -1,8 +1,11 @@ """ base model with default fields """ import base64 from Crypto import Random + +from django.core.exceptions import PermissionDenied from django.db import models from django.dispatch import receiver +from django.http import Http404 from django.utils.translation import gettext_lazy as _ from bookwyrm.settings import DOMAIN @@ -48,26 +51,26 @@ class BookWyrmModel(models.Model): """how to link to this object in the local app""" return self.get_remote_id().replace(f"https://{DOMAIN}", "") - def visible_to_user(self, viewer): + def raise_visible_to_user(self, viewer): """is a user authorized to view an object?""" # make sure this is an object with privacy owned by a user if not hasattr(self, "user") or not hasattr(self, "privacy"): - return None + return # viewer can't see it if the object's owner blocked them if viewer in self.user.blocks.all(): - return False + raise Http404() # you can see your own posts and any public or unlisted posts if viewer == self.user or self.privacy in ["public", "unlisted"]: - return True + return # you can see the followers only posts of people you follow if ( self.privacy == "followers" and self.user.followers.filter(id=viewer.id).first() ): - return True + return # you can see dms you are tagged in if hasattr(self, "mention_users"): @@ -75,6 +78,7 @@ class BookWyrmModel(models.Model): self.privacy == "direct" and self.mention_users.filter(id=viewer.id).first() ): + return True # you can see groups of which you are a member @@ -89,7 +93,31 @@ class BookWyrmModel(models.Model): ): return True - return False + raise Http404() + + def raise_not_editable(self, viewer): + """does this user have permission to edit this object? liable to be overwritten + by models that inherit this base model class""" + if not hasattr(self, "user"): + return + + # generally moderators shouldn't be able to edit other people's stuff + if self.user == viewer: + return + + raise PermissionDenied() + + def raise_not_deletable(self, viewer): + """does this user have permission to delete this object? liable to be + overwritten by models that inherit this base model class""" + if not hasattr(self, "user"): + return + + # but generally moderators can delete other people's stuff + if self.user == viewer or viewer.has_perm("moderate_post"): + return + + raise PermissionDenied() @receiver(models.signals.post_save) diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index ac7c42f6..8ae75baf 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -3,9 +3,10 @@ import re from django.contrib.postgres.search import SearchVectorField from django.contrib.postgres.indexes import GinIndex -from django.db import models -from django.db import transaction +from django.db import models, transaction +from django.db.models import Prefetch from django.dispatch import receiver +from django.utils.translation import gettext_lazy as _ from model_utils import FieldTracker from model_utils.managers import InheritanceManager from imagekit.models import ImageSpecField @@ -226,6 +227,16 @@ class Work(OrderedCollectionPageMixin, Book): deserialize_reverse_fields = [("editions", "editions")] +# https://schema.org/BookFormatType +FormatChoices = [ + ("AudiobookFormat", _("Audiobook")), + ("EBook", _("eBook")), + ("GraphicNovel", _("Graphic novel")), + ("Hardcover", _("Hardcover")), + ("Paperback", _("Paperback")), +] + + class Edition(Book): """an edition of a book""" @@ -243,7 +254,10 @@ class Edition(Book): max_length=255, blank=True, null=True, deduplication_field=True ) pages = fields.IntegerField(blank=True, null=True) - physical_format = fields.CharField(max_length=255, blank=True, null=True) + physical_format = fields.CharField( + max_length=255, choices=FormatChoices, null=True, blank=True + ) + physical_format_detail = fields.CharField(max_length=255, blank=True, null=True) publishers = fields.ArrayField( models.CharField(max_length=255), blank=True, default=list ) @@ -307,6 +321,27 @@ class Edition(Book): return super().save(*args, **kwargs) + @classmethod + def viewer_aware_objects(cls, viewer): + """annotate a book query with metadata related to the user""" + queryset = cls.objects + if not viewer or not viewer.is_authenticated: + return queryset + + queryset = queryset.prefetch_related( + Prefetch( + "shelfbook_set", + queryset=viewer.shelfbook_set.all(), + to_attr="current_shelves", + ), + Prefetch( + "readthrough_set", + queryset=viewer.readthrough_set.filter(is_active=True).all(), + to_attr="active_readthroughs", + ), + ) + return queryset + def isbn_10_to_13(isbn_10): """convert an isbn 10 into an isbn 13""" diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py index 9d2c6aeb..99e73ab3 100644 --- a/bookwyrm/models/connector.py +++ b/bookwyrm/models/connector.py @@ -14,7 +14,6 @@ class Connector(BookWyrmModel): identifier = models.CharField(max_length=255, unique=True) priority = models.IntegerField(default=2) name = models.CharField(max_length=255, null=True, blank=True) - local = models.BooleanField(default=False) connector_file = models.CharField(max_length=255, choices=ConnectorFiles.choices) api_key = models.CharField(max_length=255, null=True, blank=True) active = models.BooleanField(default=True) diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py index b891a229..bb2a22d1 100644 --- a/bookwyrm/models/list.py +++ b/bookwyrm/models/list.py @@ -101,6 +101,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel): notification_type="ADD", ) +<<<<<<< HEAD if self.book_list.group: for membership in self.book_list.group.memberships.all(): if membership.user != self.user: @@ -110,6 +111,14 @@ class ListItem(CollectionItemMixin, BookWyrmModel): related_list_item=self, notification_type="ADD" ) +======= + def raise_not_deletable(self, viewer): + """the associated user OR the list owner can delete""" + if self.book_list.user == viewer: + return + super().raise_not_deletable(viewer) + +>>>>>>> main class Meta: """A book may only be placed into a list once, and each order in the list may be used only once""" diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py index e1090f41..f75918ac 100644 --- a/bookwyrm/models/readthrough.py +++ b/bookwyrm/models/readthrough.py @@ -26,10 +26,14 @@ class ReadThrough(BookWyrmModel): ) start_date = models.DateTimeField(blank=True, null=True) finish_date = models.DateTimeField(blank=True, null=True) + is_active = models.BooleanField(default=True) def save(self, *args, **kwargs): """update user active time""" self.user.update_active_date() + # an active readthrough must have an unset finish date + if self.finish_date: + self.is_active = False super().save(*args, **kwargs) def create_update(self): diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py index 6f134402..c578f082 100644 --- a/bookwyrm/models/shelf.py +++ b/bookwyrm/models/shelf.py @@ -1,5 +1,6 @@ """ puttin' books on shelves """ import re +from django.core.exceptions import PermissionDenied from django.db import models from django.utils import timezone @@ -20,6 +21,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): name = fields.CharField(max_length=100) identifier = models.CharField(max_length=100) + description = models.TextField(blank=True, null=True, max_length=500) user = fields.ForeignKey( "User", on_delete=models.PROTECT, activitypub_field="owner" ) @@ -51,12 +53,23 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel): """list of books for this shelf, overrides OrderedCollectionMixin""" return self.books.order_by("shelfbook") + @property + def deletable(self): + """can the shelf be safely deleted?""" + return self.editable and not self.shelfbook_set.exists() + def get_remote_id(self): """shelf identifier instead of id""" base_path = self.user.remote_id identifier = self.identifier or self.get_identifier() return f"{base_path}/books/{identifier}" + def raise_not_deletable(self, viewer): + """don't let anyone delete a default shelf""" + super().raise_not_deletable(viewer) + if not self.deletable: + raise PermissionDenied() + class Meta: """user/shelf unqiueness""" diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index 3a0fad5e..58488123 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -3,6 +3,7 @@ from dataclasses import MISSING import re from django.apps import apps +from django.core.exceptions import PermissionDenied from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.dispatch import receiver @@ -56,6 +57,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): on_delete=models.PROTECT, activitypub_field="inReplyTo", ) + thread_id = models.IntegerField(blank=True, null=True) objects = InheritanceManager() activity_serializer = activitypub.Note @@ -67,6 +69,17 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): ordering = ("-published_date",) + def save(self, *args, **kwargs): + """save and notify""" + if self.reply_parent: + self.thread_id = self.reply_parent.thread_id or self.reply_parent.id + + super().save(*args, **kwargs) + + if not self.reply_parent: + self.thread_id = self.id + super().save(broadcast=False, update_fields=["thread_id"]) + def delete(self, *args, **kwargs): # pylint: disable=unused-argument """ "delete" a status""" if hasattr(self, "boosted_status"): @@ -187,6 +200,13 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): """json serialized activitypub class""" return self.to_activity_dataclass(pure=pure).serialize() + def raise_not_editable(self, viewer): + """certain types of status aren't editable""" + # first, the standard raise + super().raise_not_editable(viewer) + if isinstance(self, (GeneratedNote, ReviewRating)): + raise PermissionDenied() + class GeneratedNote(Status): """these are app-generated messages about user activity""" diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 31c2edf8..39469a7a 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -13,7 +13,7 @@ VERSION = "0.0.1" PAGE_LENGTH = env("PAGE_LENGTH", 15) DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English") -JS_CACHE = "7f2343cf" +JS_CACHE = "c02929b1" # email EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") diff --git a/bookwyrm/static/css/bookwyrm.css b/bookwyrm/static/css/bookwyrm.css index e1012c2f..eca7914a 100644 --- a/bookwyrm/static/css/bookwyrm.css +++ b/bookwyrm/static/css/bookwyrm.css @@ -492,6 +492,23 @@ ol.ordered-list li::before { } } +/* Threads + ******************************************************************************/ + +.thread .is-main .card { + box-shadow: 0 0.5em 1em -0.125em rgb(50 115 220 / 35%), 0 0 0 1px rgb(50 115 220 / 2%); +} + +.thread::after { + content: ""; + position: absolute; + z-index: -1; + top: 0; + bottom: 0; + left: 2.5em; + border-left: 2px solid #e0e0e0; +} + /* Dimensions * @todo These could be in rem. ******************************************************************************/ diff --git a/bookwyrm/static/js/block_href.js b/bookwyrm/static/js/block_href.js new file mode 100644 index 00000000..fc20a6ab --- /dev/null +++ b/bookwyrm/static/js/block_href.js @@ -0,0 +1,21 @@ +/* exported BlockHref */ + +let BlockHref = new class { + constructor() { + document.querySelectorAll('[data-href]') + .forEach(t => t.addEventListener('click', this.followLink.bind(this))); + } + + /** + * Follow a fake link + * + * @param {Event} event + * @return {undefined} + */ + followLink(event) { + const url = event.currentTarget.dataset.href; + + window.location.href = url; + } +}(); + diff --git a/bookwyrm/static/js/status_cache.js b/bookwyrm/static/js/status_cache.js index b3e345b1..2a50bfcb 100644 --- a/bookwyrm/static/js/status_cache.js +++ b/bookwyrm/static/js/status_cache.js @@ -141,8 +141,10 @@ let StatusCache = new class { modal.getElementsByClassName("modal-close")[0].click(); // Update shelve buttons - document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']") - .forEach(button => this.cycleShelveButtons(button, form.reading_status.value)); + if (form.reading_status) { + document.querySelectorAll("[data-shelve-button-book='" + form.book.value +"']") + .forEach(button => this.cycleShelveButtons(button, form.reading_status.value)); + } return; } diff --git a/bookwyrm/templates/author/author.html b/bookwyrm/templates/author/author.html index 77c7b901..8a15cd0f 100644 --- a/bookwyrm/templates/author/author.html +++ b/bookwyrm/templates/author/author.html @@ -22,7 +22,7 @@ -
+
{% if author.aliases or author.born or author.died or author.wikipedia_link or author.openlibrary_key or author.inventaire_id %} diff --git a/bookwyrm/templates/book/book.html b/bookwyrm/templates/book/book.html index fc94c7de..36241ee2 100644 --- a/bookwyrm/templates/book/book.html +++ b/bookwyrm/templates/book/book.html @@ -203,7 +203,9 @@
+ {% with 0|uuid as controls_uid %} {% include 'snippets/create_status.html' with book=book hide_cover=True %} + {% endwith %}
{% endif %}
diff --git a/bookwyrm/templates/book/book_identifiers.html b/bookwyrm/templates/book/book_identifiers.html index 6021d243..8c8313f3 100644 --- a/bookwyrm/templates/book/book_identifiers.html +++ b/bookwyrm/templates/book/book_identifiers.html @@ -1,6 +1,7 @@ {% spaceless %} {% load i18n %} +{% if book.isbn13 or book.oclc_number or book.asin %}
{% if book.isbn_13 %}
@@ -23,4 +24,5 @@
{% endif %}
+{% endif %} {% endspaceless %} diff --git a/bookwyrm/templates/book/edit/edit_book.html b/bookwyrm/templates/book/edit/edit_book.html new file mode 100644 index 00000000..ec7b0858 --- /dev/null +++ b/bookwyrm/templates/book/edit/edit_book.html @@ -0,0 +1,116 @@ +{% extends 'layout.html' %} +{% load i18n %} +{% load humanize %} + +{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %} + +{% block content %} +
+

+ {% if book %} + {% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %} + {% else %} + {% trans "Add Book" %} + {% endif %} +

+ {% if book %} +
+
{% trans "Added:" %}
+
{{ book.created_date | naturaltime }}
+ +
{% trans "Updated:" %}
+
{{ book.updated_date | naturaltime }}
+ + {% if book.last_edited_by %} +
{% trans "Last edited by:" %}
+
{{ book.last_edited_by.display_name }}
+ {% endif %} + +
+ {% endif %} +
+ +
+ {% if confirm_mode %} +
+

{% trans "Confirm Book Info" %}

+
+ {% if author_matches %} + +
+ {% for author in author_matches %} +
+ + {% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %} + + {% with forloop.counter0 as counter %} + {% for match in author.matches %} + +

+ {% blocktrans with book_title=match.book_set.first.title %}Author of {{ book_title }}{% endblocktrans %} +

+ {% endfor %} + + {% endwith %} +
+ {% endfor %} +
+ {% else %} +

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

+ {% endif %} + + {% if not book %} +
+
+ + {% trans "Is this an edition of an existing work?" %} + + {% for match in book_matches %} + + {% endfor %} + +
+
+ {% endif %} +
+ + + + {% trans "Back" %} + +
+ +
+ {% endif %} + + {% include "book/edit/edit_book_form.html" %} + + {% if not confirm_mode %} +
+ + {% trans "Cancel" %} +
+ {% endif %} +
+ +{% endblock %} diff --git a/bookwyrm/templates/book/edit_book.html b/bookwyrm/templates/book/edit/edit_book_form.html similarity index 54% rename from bookwyrm/templates/book/edit_book.html rename to bookwyrm/templates/book/edit/edit_book_form.html index 2f6ca324..982bb56d 100644 --- a/bookwyrm/templates/book/edit_book.html +++ b/bookwyrm/templates/book/edit/edit_book_form.html @@ -1,40 +1,4 @@ -{% extends 'layout.html' %} {% load i18n %} -{% load humanize %} - -{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %} - -{% block content %} -
-

- {% if book %} - {% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %} - {% else %} - {% trans "Add Book" %} - {% endif %} -

- {% if book %} -
-
-
{% trans "Added:" %}
-
{{ book.created_date | naturaltime }}
-
- -
-
{% trans "Updated:" %}
-
{{ book.updated_date | naturaltime }}
-
- - {% if book.last_edited_by %} -
-
{% trans "Last edited by:" %}
-
{{ book.last_edited_by.display_name }}
-
- {% endif %} - -
- {% endif %} -
{% if form.non_field_errors %}
@@ -42,87 +6,14 @@
{% endif %} -
- - {% csrf_token %} - {% if confirm_mode %} -
-

{% trans "Confirm Book Info" %}

-
- {% if author_matches %} - -
- {% for author in author_matches %} -
- - {% blocktrans with name=author.name %}Is "{{ name }}" an existing author?{% endblocktrans %} - - {% with forloop.counter0 as counter %} - {% for match in author.matches %} - -

- {% blocktrans with book_title=match.book_set.first.title %}Author of {{ book_title }}{% endblocktrans %} -

- {% endfor %} - - {% endwith %} -
- {% endfor %} -
- {% else %} -

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

- {% endif %} - - {% if not book %} -
-
- - {% trans "Is this an edition of an existing work?" %} - - {% for match in book_matches %} - - {% endfor %} - -
-
- {% endif %} -
- - - - {% trans "Back" %} - -
- -
- {% endif %} - - -
-
-
-

{% trans "Metadata" %}

+{% csrf_token %} + +
+
+
+

{% trans "Metadata" %}

+
@@ -147,20 +38,25 @@ {% endfor %}
-
- - - {% for error in form.series.errors %} -

{{ error | escape }}

- {% endfor %} -
- -
- - {{ form.series_number }} - {% for error in form.series_number.errors %} -

{{ error | escape }}

- {% endfor %} +
+
+
+ + + {% for error in form.series.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+
+
+ + {{ form.series_number }} + {% for error in form.series_number.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
@@ -171,7 +67,12 @@

{{ error | escape }}

{% endfor %}
+
+
+
+

{% trans "Publication" %}

+
{{ form.publishers }} @@ -196,10 +97,12 @@

{{ error | escape }}

{% endfor %}
-
+
+
-
-

{% trans "Authors" %}

+
+

{% trans "Authors" %}

+
{% if book.authors.exists %}
{% for author in book.authors.all %} @@ -220,45 +123,64 @@ {% trans "Separate multiple values with commas." %}
-
-
+
+ +
-
+
+

{% trans "Cover" %}

-
-
- {% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' size_mobile='xlarge' size='large' %} -
+
+
+ {% if book.cover %} +
+ {% include 'snippets/book_cover.html' with book=book cover_class='is-h-xl-mobile is-w-auto-tablet' size_mobile='xlarge' size='large' %} +
+ {% endif %} -
-
+
{{ form.cover }}
- {% if book %}
- +
- {% endif %} {% for error in form.cover.errors %}

{{ error | escape }}

{% endfor %}
+
-
-

{% trans "Physical Properties" %}

-
- - {{ form.physical_format }} - {% for error in form.physical_format.errors %} -

{{ error | escape }}

- {% endfor %} +
+

{% trans "Physical Properties" %}

+
+
+
+
+ +
+ {{ form.physical_format }} +
+ {% for error in form.physical_format.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
+
+
+ + {{ form.physical_format_detail }} + {% for error in form.physical_format_detail.errors %} +

{{ error | escape }}

+ {% endfor %} +
+
@@ -269,9 +191,11 @@ {% endfor %}
+
-
-

{% trans "Book Identifiers" %}

+
+

{% trans "Book Identifiers" %}

+
{{ form.isbn_13 }} @@ -320,15 +244,6 @@ {% endfor %}
-
+
- - {% if not confirm_mode %} -
- - {% trans "Cancel" %} -
- {% endif %} - - -{% endblock %} +
diff --git a/bookwyrm/templates/book/edition_filters.html b/bookwyrm/templates/book/edition_filters.html deleted file mode 100644 index c41ab0c0..00000000 --- a/bookwyrm/templates/book/edition_filters.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'snippets/filters_panel/filters_panel.html' %} - -{% block filter_fields %} -{% include 'book/search_filter.html' %} -{% include 'book/language_filter.html' %} -{% include 'book/format_filter.html' %} -{% endblock %} diff --git a/bookwyrm/templates/book/editions/edition_filters.html b/bookwyrm/templates/book/editions/edition_filters.html new file mode 100644 index 00000000..c6702a5c --- /dev/null +++ b/bookwyrm/templates/book/editions/edition_filters.html @@ -0,0 +1,7 @@ +{% extends 'snippets/filters_panel/filters_panel.html' %} + +{% block filter_fields %} +{% include 'book/editions/search_filter.html' %} +{% include 'book/editions/language_filter.html' %} +{% include 'book/editions/format_filter.html' %} +{% endblock %} diff --git a/bookwyrm/templates/book/editions.html b/bookwyrm/templates/book/editions/editions.html similarity index 97% rename from bookwyrm/templates/book/editions.html rename to bookwyrm/templates/book/editions/editions.html index 7a4338f1..a3ff0802 100644 --- a/bookwyrm/templates/book/editions.html +++ b/bookwyrm/templates/book/editions/editions.html @@ -8,7 +8,7 @@

{% blocktrans with work_path=work.local_path work_title=work|book_title %}Editions of "{{ work_title }}"{% endblocktrans %}

-{% include 'book/edition_filters.html' %} +{% include 'book/editions/edition_filters.html' %}
{% for book in editions %} diff --git a/bookwyrm/templates/book/format_filter.html b/bookwyrm/templates/book/editions/format_filter.html similarity index 100% rename from bookwyrm/templates/book/format_filter.html rename to bookwyrm/templates/book/editions/format_filter.html diff --git a/bookwyrm/templates/book/language_filter.html b/bookwyrm/templates/book/editions/language_filter.html similarity index 100% rename from bookwyrm/templates/book/language_filter.html rename to bookwyrm/templates/book/editions/language_filter.html diff --git a/bookwyrm/templates/book/search_filter.html b/bookwyrm/templates/book/editions/search_filter.html similarity index 100% rename from bookwyrm/templates/book/search_filter.html rename to bookwyrm/templates/book/editions/search_filter.html diff --git a/bookwyrm/templates/book/publisher_info.html b/bookwyrm/templates/book/publisher_info.html index b7975a62..e0711fa8 100644 --- a/bookwyrm/templates/book/publisher_info.html +++ b/bookwyrm/templates/book/publisher_info.html @@ -3,29 +3,30 @@ {% load i18n %} {% load humanize %} +{% firstof book.physical_format_detail book.physical_format as format %} +{% firstof book.physical_format book.physical_format_detail as format_property %} +{% with pages=book.pages %} +{% if format or pages %} + +{% if format_property %} + +{% endif %} + +{% if pages %} + +{% endif %} +

- {% with format=book.physical_format pages=book.pages %} - {% if format %} - {% comment %} - @todo The bookFormat property is limited to a list of values whereas the book edition is free text. - @see https://schema.org/bookFormat - {% endcomment %} - - {% endif %} - - {% if pages %} - - {% endif %} - - {% if format and not pages %} - {% blocktrans %}{{ format }}{% endblocktrans %} - {% elif format and pages %} - {% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %} - {% elif pages %} - {% blocktrans %}{{ pages }} pages{% endblocktrans %} - {% endif %} - {% endwith %} + {% if format and not pages %} + {% blocktrans %}{{ format }}{% endblocktrans %} + {% elif format and pages %} + {% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %} + {% elif pages %} + {% blocktrans %}{{ pages }} pages{% endblocktrans %} + {% endif %}

+{% endif %} +{% endwith %} {% if book.languages %} {% for language in book.languages %} @@ -39,32 +40,34 @@

{% endif %} +{% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %} +{% if date or book.first_published_date or book.publishers %} +{% if date or book.first_published_date %} + +{% endif %}

- {% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %} - {% if date or book.first_published_date %} - - {% endif %} - {% comment %} - @todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor. - @see https://schema.org/Publisher - {% endcomment %} - {% if book.publishers %} - {% for publisher in book.publishers %} - - {% endfor %} - {% endif %} + {% comment %} + @todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor. + @see https://schema.org/Publisher + {% endcomment %} + {% if book.publishers %} + {% for publisher in book.publishers %} + + {% endfor %} + {% endif %} - {% if date and publisher %} - {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} - {% elif date %} - {% blocktrans %}Published {{ date }}{% endblocktrans %} - {% elif publisher %} - {% blocktrans %}Published by {{ publisher }}.{% endblocktrans %} - {% endif %} - {% endwith %} + {% if date and publisher %} + {% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} + {% elif date %} + {% blocktrans %}Published {{ date }}{% endblocktrans %} + {% elif publisher %} + {% blocktrans %}Published by {{ publisher }}.{% endblocktrans %} + {% endif %}

+{% endif %} +{% endwith %} {% endspaceless %} diff --git a/bookwyrm/templates/components/inline_form.html b/bookwyrm/templates/components/inline_form.html index a8924ef2..37f9f556 100644 --- a/bookwyrm/templates/components/inline_form.html +++ b/bookwyrm/templates/components/inline_form.html @@ -1,5 +1,5 @@ {% load i18n %} -
-
+ + + + +
+

{% trans "Privacy" %}

+
+
+ +
+
+ +
+ {{ form.default_post_privacy }} +
+
+
+
+
{% endblock %} diff --git a/bookwyrm/templates/preferences/layout.html b/bookwyrm/templates/preferences/layout.html index 758be9e5..bf4fed7d 100644 --- a/bookwyrm/templates/preferences/layout.html +++ b/bookwyrm/templates/preferences/layout.html @@ -12,7 +12,8 @@ {% endif %} diff --git a/bookwyrm/templates/moderation/report.html b/bookwyrm/templates/settings/reports/report.html similarity index 78% rename from bookwyrm/templates/moderation/report.html rename to bookwyrm/templates/settings/reports/report.html index d0e4026a..37593f3c 100644 --- a/bookwyrm/templates/moderation/report.html +++ b/bookwyrm/templates/settings/reports/report.html @@ -3,20 +3,21 @@ {% load humanize %} {% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %} -{% block header %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %} + +{% block header %} +{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %} +{% trans "Back to reports" %} +{% endblock %} {% block panel %} -
- {% include 'moderation/report_preview.html' with report=report %} + {% include 'settings/reports/report_preview.html' with report=report %}
-{% include 'user_admin/user_info.html' with user=report.user %} +{% include 'settings/users/user_info.html' with user=report.user %} -{% include 'user_admin/user_moderation_actions.html' with user=report.user %} +{% include 'settings/users/user_moderation_actions.html' with user=report.user %}

{% trans "Moderator Comments" %}

diff --git a/bookwyrm/templates/moderation/report_preview.html b/bookwyrm/templates/settings/reports/report_preview.html similarity index 100% rename from bookwyrm/templates/moderation/report_preview.html rename to bookwyrm/templates/settings/reports/report_preview.html diff --git a/bookwyrm/templates/moderation/reports.html b/bookwyrm/templates/settings/reports/reports.html similarity index 90% rename from bookwyrm/templates/moderation/reports.html rename to bookwyrm/templates/settings/reports/reports.html index c83f626f..c72fd03d 100644 --- a/bookwyrm/templates/moderation/reports.html +++ b/bookwyrm/templates/settings/reports/reports.html @@ -30,7 +30,7 @@
-{% include 'user_admin/user_admin_filters.html' %} +{% include 'settings/users/user_admin_filters.html' %}
{% if not reports %} @@ -39,7 +39,7 @@ {% for report in reports %}
- {% include 'moderation/report_preview.html' with report=report %} + {% include 'settings/reports/report_preview.html' with report=report %}
{% endfor %}
diff --git a/bookwyrm/templates/settings/site.html b/bookwyrm/templates/settings/site.html index 50895485..da5b7705 100644 --- a/bookwyrm/templates/settings/site.html +++ b/bookwyrm/templates/settings/site.html @@ -5,36 +5,46 @@ {% block header %}{% trans "Site Settings" %}{% endblock %} -{% block panel %} +{% block site-subtabs %} + +{% endblock %} +{% block panel %} {% csrf_token %}

{% trans "Instance Info" %}

-
- - {{ site_form.name }} -
-
- - {{ site_form.instance_tagline }} -
-
- - {{ site_form.instance_description }} -
-
- -

{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support html or markdown." %}

- {{ site_form.instance_short_description }} -
-
- - {{ site_form.code_of_conduct }} -
-
- - {{ site_form.privacy_policy }} +
+
+ + {{ site_form.name }} +
+
+ + {{ site_form.instance_tagline }} +
+
+ + {{ site_form.instance_description }} +
+
+ +

{% trans "Used when the instance is previewed on joinbookwyrm.com. Does not support html or markdown." %}

+ {{ site_form.instance_short_description }} +
+
+ + {{ site_form.code_of_conduct }} +
+
+ + {{ site_form.privacy_policy }} +
@@ -42,16 +52,16 @@

{% trans "Images" %}

-
-
+
+
{{ site_form.logo }}
-
+
{{ site_form.logo_small }}
-
+
{{ site_form.favicon }}
@@ -62,21 +72,23 @@ @@ -84,35 +96,37 @@

{% trans "Registration" %}

-
- -
-
- -
-
- -

{% trans "(Recommended if registration is open)" %}

-
-
- - {{ site_form.registration_closed_text }} -
-
- - {{ site_form.invite_request_text }} - {% for error in site_form.invite_request_text.errors %} -

{{ error|escape }}

- {% endfor %} +
+
+ +
+
+ +
+
+ +

{% trans "(Recommended if registration is open)" %}

+
+
+ + {{ site_form.registration_closed_text }} +
+
+ + {{ site_form.invite_request_text }} + {% for error in site_form.invite_request_text.errors %} +

{{ error|escape }}

+ {% endfor %} +
diff --git a/bookwyrm/templates/user_admin/delete_user_form.html b/bookwyrm/templates/settings/users/delete_user_form.html similarity index 100% rename from bookwyrm/templates/user_admin/delete_user_form.html rename to bookwyrm/templates/settings/users/delete_user_form.html diff --git a/bookwyrm/templates/user_admin/server_filter.html b/bookwyrm/templates/settings/users/server_filter.html similarity index 100% rename from bookwyrm/templates/user_admin/server_filter.html rename to bookwyrm/templates/settings/users/server_filter.html diff --git a/bookwyrm/templates/settings/users/user.html b/bookwyrm/templates/settings/users/user.html new file mode 100644 index 00000000..676502e6 --- /dev/null +++ b/bookwyrm/templates/settings/users/user.html @@ -0,0 +1,16 @@ +{% extends 'settings/layout.html' %} +{% load i18n %} + +{% block title %}{{ user.username }}{% endblock %} +{% block header %} +{{ user.username }} +{% trans "Back to users" %} +{% endblock %} + +{% block panel %} +{% include 'settings/users/user_info.html' with user=user %} + +{% include 'settings/users/user_moderation_actions.html' with user=user %} + +{% endblock %} + diff --git a/bookwyrm/templates/user_admin/user_admin.html b/bookwyrm/templates/settings/users/user_admin.html similarity index 97% rename from bookwyrm/templates/user_admin/user_admin.html rename to bookwyrm/templates/settings/users/user_admin.html index 024ebfec..874ce818 100644 --- a/bookwyrm/templates/user_admin/user_admin.html +++ b/bookwyrm/templates/settings/users/user_admin.html @@ -13,7 +13,7 @@ {% block panel %} -{% include 'user_admin/user_admin_filters.html' %} +{% include 'settings/users/user_admin_filters.html' %} diff --git a/bookwyrm/templates/user_admin/user_admin_filters.html b/bookwyrm/templates/settings/users/user_admin_filters.html similarity index 59% rename from bookwyrm/templates/user_admin/user_admin_filters.html rename to bookwyrm/templates/settings/users/user_admin_filters.html index c9c7a93f..48a3b7c8 100644 --- a/bookwyrm/templates/user_admin/user_admin_filters.html +++ b/bookwyrm/templates/settings/users/user_admin_filters.html @@ -1,7 +1,7 @@ {% extends 'snippets/filters_panel/filters_panel.html' %} {% block filter_fields %} -{% include 'user_admin/username_filter.html' %} +{% include 'settings/users/username_filter.html' %} {% include 'directory/community_filter.html' %} -{% include 'user_admin/server_filter.html' %} +{% include 'settings/users/server_filter.html' %} {% endblock %} diff --git a/bookwyrm/templates/user_admin/user_info.html b/bookwyrm/templates/settings/users/user_info.html similarity index 57% rename from bookwyrm/templates/user_admin/user_info.html rename to bookwyrm/templates/settings/users/user_info.html index 7ad57e0e..8d332b1a 100644 --- a/bookwyrm/templates/user_admin/user_info.html +++ b/bookwyrm/templates/settings/users/user_info.html @@ -48,58 +48,42 @@
{% if user.local %} -
-
{% trans "Email:" %}
-
{{ user.email }}
-
+
{% trans "Email:" %}
+
{{ user.email }}
{% endif %} {% with report_count=user.report_set.count %} -
-
{% trans "Reports:" %}
-
- {{ report_count|intcomma }} - {% if report_count > 0 %} - - {% trans "(View reports)" %} - - {% endif %} -
-
+
{% trans "Reports:" %}
+
+ {{ report_count|intcomma }} + {% if report_count > 0 %} + + {% trans "(View reports)" %} + + {% endif %} +
{% endwith %} -
-
{% trans "Blocked by count:" %}
-
{{ user.blocked_by.count }}
-
+
{% trans "Blocked by count:" %}
+
{{ user.blocked_by.count }}
-
-
{% trans "Last active date:" %}
-
{{ user.last_active_date }}
-
+
{% trans "Last active date:" %}
+
{{ user.last_active_date }}
-
-
{% trans "Manually approved followers:" %}
-
{{ user.manually_approves_followers }}
-
+
{% trans "Manually approved followers:" %}
+
{{ user.manually_approves_followers }}
-
-
{% trans "Discoverable:" %}
-
{{ user.discoverable }}
-
+
{% trans "Discoverable:" %}
+
{{ user.discoverable }}
{% if not user.is_active %} -
-
{% trans "Deactivation reason:" %}
-
{{ user.deactivation_reason }}
-
+
{% trans "Deactivation reason:" %}
+
{{ user.deactivation_reason }}
{% endif %} {% if not user.is_active and user.deactivation_reason == "pending" %} -
-
{% trans "Confirmation code:" %}
-
{{ user.confirmation_code }}
-
+
{% trans "Confirmation code:" %}
+
{{ user.confirmation_code }}
{% endif %}
@@ -113,18 +97,14 @@ {% if server %}
{{ server.server_name }}
-
-
{% trans "Software:" %}
-
{{ server.application_type }}
-
-
-
{% trans "Version:" %}
-
{{ server.application_version }}
-
-
-
{% trans "Status:" %}
-
{{ server.status }}
-
+
{% trans "Software:" %}
+
{{ server.application_type }}
+ +
{% trans "Version:" %}
+
{{ server.application_version }}
+ +
{% trans "Status:" %}
+
{{ server.status }}
{% if server.notes %}
{% trans "Notes" %}
diff --git a/bookwyrm/templates/user_admin/user_moderation_actions.html b/bookwyrm/templates/settings/users/user_moderation_actions.html similarity index 95% rename from bookwyrm/templates/user_admin/user_moderation_actions.html rename to bookwyrm/templates/settings/users/user_moderation_actions.html index 12b70d3c..a976359f 100644 --- a/bookwyrm/templates/user_admin/user_moderation_actions.html +++ b/bookwyrm/templates/settings/users/user_moderation_actions.html @@ -36,7 +36,7 @@ {% if user.local %}
- {% include "user_admin/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %} + {% include "settings/users/delete_user_form.html" with controls_text="delete_user" class="mt-2 mb-2" %}
{% endif %} diff --git a/bookwyrm/templates/user_admin/username_filter.html b/bookwyrm/templates/settings/users/username_filter.html similarity index 100% rename from bookwyrm/templates/user_admin/username_filter.html rename to bookwyrm/templates/settings/users/username_filter.html diff --git a/bookwyrm/templates/shelf/create_shelf_form.html b/bookwyrm/templates/shelf/create_shelf_form.html new file mode 100644 index 00000000..e15e1cc1 --- /dev/null +++ b/bookwyrm/templates/shelf/create_shelf_form.html @@ -0,0 +1,13 @@ +{% extends 'components/inline_form.html' %} +{% load i18n %} + +{% block header %} +{% trans "Create Shelf" %} +{% endblock %} + +{% block form %} + + {% include "shelf/form.html" with editable=shelf.editable form=create_form %} + +{% endblock %} + diff --git a/bookwyrm/templates/shelf/edit_shelf_form.html b/bookwyrm/templates/shelf/edit_shelf_form.html new file mode 100644 index 00000000..5951b6da --- /dev/null +++ b/bookwyrm/templates/shelf/edit_shelf_form.html @@ -0,0 +1,13 @@ +{% extends 'components/inline_form.html' %} +{% load i18n %} + +{% block header %} +{% trans "Edit Shelf" %} +{% endblock %} + +{% block form %} + + {% include "shelf/form.html" with editable=shelf.editable form=edit_form privacy=shelf.privacy %} + +{% endblock %} + diff --git a/bookwyrm/templates/shelf/form.html b/bookwyrm/templates/shelf/form.html new file mode 100644 index 00000000..ff7f8b5e --- /dev/null +++ b/bookwyrm/templates/shelf/form.html @@ -0,0 +1,28 @@ +{% load i18n %} +{% load utilities %} +{% with 0|uuid as uuid %} +{% csrf_token %} + + +{% if editable %} +
+ + +
+{% else %} + +{% endif %} + +
+ + +
+
+
+ {% include 'snippets/privacy_select.html' with current=privacy %} +
+
+ +
+
+{% endwith %} diff --git a/bookwyrm/templates/user/shelf/shelf.html b/bookwyrm/templates/shelf/shelf.html similarity index 70% rename from bookwyrm/templates/user/shelf/shelf.html rename to bookwyrm/templates/shelf/shelf.html index 06507d3e..88f4b2bb 100644 --- a/bookwyrm/templates/user/shelf/shelf.html +++ b/bookwyrm/templates/shelf/shelf.html @@ -5,7 +5,7 @@ {% load i18n %} {% block title %} -{% include 'user/shelf/books_header.html' %} +{% include 'user/books_header.html' %} {% endblock %} {% block opengraph_images %} @@ -15,7 +15,7 @@ {% block content %}

- {% include 'user/shelf/books_header.html' %} + {% include 'user/books_header.html' %}

@@ -60,45 +60,62 @@
- {% include 'user/shelf/create_shelf_form.html' with controls_text='create_shelf_form' %} + {% include 'shelf/create_shelf_form.html' with controls_text='create_shelf_form' %}
-
-
-

- {{ shelf.name }} - - {% include 'snippets/privacy-icons.html' with item=shelf %} - - {% with count=books.paginator.count %} - {% if count %} -

- {% blocktrans trimmed count counter=count with formatted_count=count|intcomma %} - {{ formatted_count }} book - {% plural %} - {{ formatted_count }} books - {% endblocktrans %} - - {% if books.has_other_pages %} - {% blocktrans trimmed with start=books.start_index end=books.end_index %} - (showing {{ start }}-{{ end }}) +

+
+
+

+ {{ shelf.name }} + + {% include 'snippets/privacy-icons.html' with item=shelf %} + + {% with count=books.paginator.count %} + {% if count %} +

+ {% blocktrans trimmed count counter=count with formatted_count=count|intcomma %} + {{ formatted_count }} book + {% plural %} + {{ formatted_count }} books {% endblocktrans %} + + {% if books.has_other_pages %} + {% blocktrans trimmed with start=books.start_index end=books.end_index %} + (showing {{ start }}-{{ end }}) + {% endblocktrans %} + {% endif %} +

{% endif %} -

- {% endif %} - {% endwith %} -

-
- {% if is_self and shelf.id %} -
- {% trans "Edit shelf" as button_text %} - {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_shelf_form" focus="edit_shelf_form_header" %} + {% endwith %} +

+
+ {% if is_self and shelf.id %} +
+
+ {% trans "Edit shelf" as button_text %} + {% include 'snippets/toggle/open_button.html' with text=button_text icon_with_text="pencil" controls_text="edit_shelf_form" focus="edit_shelf_form_header" %} + + {% if shelf.deletable %} +
+ {% csrf_token %} + + + + {% endif %} +
+
+ {% endif %}
+ {% if shelf.description %} +

{{ shelf.description }}

{% endif %}
- {% include 'user/shelf/edit_shelf_form.html' with controls_text="edit_shelf_form" %} + {% include 'shelf/edit_shelf_form.html' with controls_text="edit_shelf_form" %}
@@ -167,17 +184,7 @@
{% else %} -

{% trans "This shelf is empty." %}

- {% if shelf.id and shelf.editable %} -
- {% csrf_token %} - - -
- {% endif %} - +

{% trans "This shelf is empty." %}

{% endif %}
diff --git a/bookwyrm/templates/snippets/create_status/content_field.html b/bookwyrm/templates/snippets/create_status/content_field.html index c2b383b9..c9afecc7 100644 --- a/bookwyrm/templates/snippets/create_status/content_field.html +++ b/bookwyrm/templates/snippets/create_status/content_field.html @@ -12,7 +12,7 @@ draft: an existing Status object that is providing default values for input fiel name="content" class="textarea save-draft" data-cache-draft="id_content_{{ type }}_{{ book.id }}{{ reply_parent.id }}" - id="id_content_{{ type }}_{{ book.id }}{{ reply_parent.id }}" + id="id_content_{{ type }}_{{ book.id }}{{ reply_parent.id }}{{ uuid }}" placeholder="{{ placeholder }}" aria-label="{% if reply_parent %}{% trans 'Reply' %}{% else %}{% trans 'Content' %}{% endif %}" {% if not optional and type != "quotation" and type != "review" %}required{% endif %} diff --git a/bookwyrm/templates/snippets/create_status/layout.html b/bookwyrm/templates/snippets/create_status/layout.html index 1e10085c..4dded200 100644 --- a/bookwyrm/templates/snippets/create_status/layout.html +++ b/bookwyrm/templates/snippets/create_status/layout.html @@ -19,7 +19,7 @@ reply_parent: the Status object this post will be in reply to, if applicable name="{{ type }}" action="/post/{{ type }}" method="post" - id="tab_{{ type }}_{{ book.id }}{{ reply_parent.id }}" + id="form_{{ type }}_{{ book.id }}{{ reply_parent.id }}" > {% endblock %} @@ -36,7 +36,7 @@ reply_parent: the Status object this post will be in reply to, if applicable {# fields that go between the content warnings and the content field (ie, quote) #} {% block pre_content_additions %}{% endblock %} -
{% endblock %} + +{% block form %} +{% include "snippets/reading_modals/form.html" with optional=True type="start_modal" %} +{% endblock %} diff --git a/bookwyrm/templates/snippets/reading_modals/want_to_read_modal.html b/bookwyrm/templates/snippets/reading_modals/want_to_read_modal.html index 5dec637b..d1f06d8f 100644 --- a/bookwyrm/templates/snippets/reading_modals/want_to_read_modal.html +++ b/bookwyrm/templates/snippets/reading_modals/want_to_read_modal.html @@ -13,3 +13,7 @@ Want to Read "{{ book_title }}" {% csrf_token %} {% endblock %} + +{% block form %} +{% include "snippets/reading_modals/form.html" with optional=True type="want_modal" %} +{% endblock %} diff --git a/bookwyrm/templates/snippets/readthrough_form.html b/bookwyrm/templates/snippets/readthrough_form.html index 132472d2..a68306a3 100644 --- a/bookwyrm/templates/snippets/readthrough_form.html +++ b/bookwyrm/templates/snippets/readthrough_form.html @@ -10,20 +10,10 @@
{# Only show progress for editing existing readthroughs #} {% if readthrough.id and not readthrough.finish_date %} -