diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 6401bb89..90101a85 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -74,6 +74,9 @@ class ActivityObject: model.activity_serializer) ) + if hasattr(model, 'ignore_activity') and model.ignore_activity(self): + return instance + # check for an existing instance, if we're not updating a known obj instance = instance or model.find_existing(self.serialize()) or model() diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 93cd384f..ee4b8851 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -50,7 +50,7 @@ class Work(Book): ''' work instance of a book object ''' lccn: str = '' defaultEdition: str = '' - editions: List[str] + editions: List[str] = field(default_factory=lambda: []) type: str = 'Work' @@ -58,9 +58,9 @@ class Work(Book): class Author(ActivityObject): ''' author of a book ''' name: str - born: str = '' - died: str = '' - aliases: str = '' + born: str = None + died: str = None + aliases: List[str] = field(default_factory=lambda: []) bio: str = '' openlibraryKey: str = '' wikipediaLink: str = '' diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index c9f1ad2e..5afd1089 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -1,16 +1,14 @@ ''' functionality outline for a book data connector ''' from abc import ABC, abstractmethod from dataclasses import dataclass -import pytz from urllib3.exceptions import RequestError from django.db import transaction -from dateutil import parser import requests from requests import HTTPError from requests.exceptions import SSLError -from bookwyrm import models +from bookwyrm import activitypub, models class ConnectorException(HTTPError): @@ -38,7 +36,7 @@ class AbstractMinimalConnector(ABC): for field in self_fields: setattr(self, field, getattr(info, field)) - def search(self, query, min_confidence=None): + def search(self, query, min_confidence=None):# pylint: disable=unused-argument ''' free text search ''' resp = requests.get( '%s%s' % (self.search_url, query), @@ -72,9 +70,6 @@ class AbstractConnector(AbstractMinimalConnector): ''' generic book data connector ''' def __init__(self, identifier): super().__init__(identifier) - - self.key_mappings = [] - # fields we want to look for in book data to copy over # title we handle separately. self.book_mappings = [] @@ -88,217 +83,110 @@ class AbstractConnector(AbstractMinimalConnector): return True + @transaction.atomic def get_or_create_book(self, remote_id): - # try to load the book - book = models.Book.objects.select_subclasses().filter( - origin_id=remote_id - ).first() - if book: - if isinstance(book, models.Work): - return book.default_edition - return book + ''' translate arbitrary json into an Activitypub dataclass ''' + # first, check if we have the origin_id saved + existing = models.Edition.find_existing_by_remote_id(remote_id) or \ + models.Work.find_existing_by_remote_id(remote_id) + if existing: + if hasattr(existing, 'get_default_editon'): + return existing.get_default_editon() + return existing - # no book was found, so we start creating a new one + # load the json data = get_data(remote_id) - - work = None - edition = None + mapped_data = dict_from_mappings(data, self.book_mappings) if self.is_work_data(data): - work_data = data - # if we requested a work and there's already an edition, we're set - work = self.match_from_mappings(work_data, models.Work) - if work and work.default_edition: - return work.default_edition - - # no such luck, we need more information. try: - edition_data = self.get_edition_from_work_data(work_data) + edition_data = self.get_edition_from_work_data(data) except KeyError: # hack: re-use the work data as the edition data # this is why remote ids aren't necessarily unique edition_data = data + work_data = mapped_data else: - edition_data = data - edition = self.match_from_mappings(edition_data, models.Edition) - # no need to figure out about the work if we already know about it - if edition and edition.parent_work: - return edition - - # no such luck, we need more information. try: - work_data = self.get_work_from_edition_date(edition_data) + work_data = self.get_work_from_edition_data(data) + work_data = dict_from_mappings(work_data, self.book_mappings) except KeyError: - # remember this hack: re-use the work data as the edition data - work_data = data + 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) - # at this point, we need to figure out the work, edition, or both - # atomic so that we don't save a work with no edition for vice versa - with transaction.atomic(): - if not work: - work_key = self.get_remote_id_from_data(work_data) - work = self.create_book(work_key, work_data, models.Work) + # create activitypub object + work_activity = activitypub.Work(**work_data) + # this will dedupe automatically + work = work_activity.to_model(models.Work) + for author in self.get_authors_from_data(data): + work.authors.add(author) + return self.create_edition_from_data(work, edition_data) - if not edition: - ed_key = self.get_remote_id_from_data(edition_data) - edition = self.create_book(ed_key, edition_data, models.Edition) - edition.parent_work = work - edition.save() - work.default_edition = edition - work.save() - # now's our change to fill in author gaps + 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) + mapped_data['work'] = work.remote_id + edition_activity = activitypub.Edition(**mapped_data) + edition = edition_activity.to_model(models.Edition) + edition.connector = self.connector + edition.save() + + 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(): - edition.authors.set(work.authors.all()) - edition.author_text = work.author_text - edition.save() - - if not edition: - raise ConnectorException('Unable to create book: %s' % remote_id) + edition.authors.add(work.authors.all()) return edition - def create_book(self, remote_id, data, model): - ''' create a work or edition from data ''' - book = model.objects.create( - origin_id=remote_id, - title=data['title'], - connector=self.connector, - ) - return self.update_book_from_data(book, data) + def get_or_create_author(self, remote_id): + ''' load that author ''' + existing = models.Author.find_existing_by_remote_id(remote_id) + if existing: + return existing + data = get_data(remote_id) - def update_book_from_data(self, book, data, update_cover=True): - ''' for creating a new book or syncing with data ''' - book = update_from_mappings(book, data, self.book_mappings) - - author_text = [] - for author in self.get_authors_from_data(data): - book.authors.add(author) - author_text.append(author.name) - book.author_text = ', '.join(author_text) - book.save() - - if not update_cover: - return book - - cover = self.get_cover_from_data(data) - if cover: - book.cover.save(*cover, save=True) - return book - - - def update_book(self, book, data=None): - ''' load new data ''' - if not book.sync and not book.sync_cover: - return book - - if not data: - key = getattr(book, self.key_name) - data = self.load_book_data(key) - - if book.sync: - book = self.update_book_from_data( - book, data, update_cover=book.sync_cover) - else: - cover = self.get_cover_from_data(data) - if cover: - book.cover.save(*cover, save=True) - - return book - - - def match_from_mappings(self, data, model): - ''' try to find existing copies of this book using various keys ''' - relevent_mappings = [m for m in self.key_mappings if \ - not m.model or model == m.model] - for mapping in relevent_mappings: - # check if this field is present in the data - value = data.get(mapping.remote_field) - if not value: - continue - - # extract the value in the right format - value = mapping.formatter(value) - - # search our database for a matching book - kwargs = {mapping.local_field: value} - match = model.objects.filter(**kwargs).first() - if match: - return match - return None - - - @abstractmethod - def get_remote_id_from_data(self, data): - ''' otherwise we won't properly set the remote_id in the db ''' + mapped_data = dict_from_mappings(data, self.author_mappings) + activity = activitypub.Author(**mapped_data) + # this will dedupe + return activity.to_model(models.Author) @abstractmethod def is_work_data(self, data): ''' differentiate works and editions ''' - @abstractmethod def get_edition_from_work_data(self, data): ''' every work needs at least one edition ''' - @abstractmethod def get_work_from_edition_date(self, data): ''' every edition needs a work ''' - @abstractmethod def get_authors_from_data(self, data): ''' load author data ''' - - @abstractmethod - def get_cover_from_data(self, data): - ''' load cover ''' - @abstractmethod def expand_book_data(self, book): ''' get more info on a book ''' -def update_from_mappings(obj, data, mappings): - ''' assign data to model with mappings ''' +def dict_from_mappings(data, mappings): + ''' create a dict in Activitypub format, using mappings supplies by + the subclass ''' + result = {} for mapping in mappings: - # check if this field is present in the data - value = data.get(mapping.remote_field) - if not value: - continue - - # extract the value in the right format - try: - value = mapping.formatter(value) - except: - continue - - # assign the formatted value to the model - obj.__setattr__(mapping.local_field, value) - return obj - - -def get_date(date_string): - ''' helper function to try to interpret dates ''' - if not date_string: - return None - - try: - return pytz.utc.localize(parser.parse(date_string)) - except ValueError: - pass - - try: - return parser.parse(date_string) - except ValueError: - return None + result[mapping.local_field] = mapping.get_value(data) + return result def get_data(url): @@ -349,11 +237,19 @@ class SearchResult: class Mapping: ''' associate a local database field with a field in an external dataset ''' - def __init__( - self, local_field, remote_field=None, formatter=None, model=None): + def __init__(self, local_field, remote_field=None, formatter=None): noop = lambda x: x self.local_field = local_field self.remote_field = remote_field or local_field self.formatter = formatter or noop - self.model = model + + def get_value(self, data): + ''' pull a field from incoming json and return the formatted version ''' + value = data.get(self.remote_field) + if not value: + return None + try: + return self.formatter(value) + except:# pylint: disable=bare-except + return None diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index 28eb1ea0..74f76668 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -1,13 +1,9 @@ ''' openlibrary data connector ''' import re -import requests - -from django.core.files.base import ContentFile from bookwyrm import models from .abstract_connector import AbstractConnector, SearchResult, Mapping -from .abstract_connector import ConnectorException -from .abstract_connector import get_date, get_data, update_from_mappings +from .abstract_connector import ConnectorException, get_data from .openlibrary_languages import languages @@ -17,62 +13,57 @@ class Connector(AbstractConnector): super().__init__(identifier) get_first = lambda a: a[0] - self.key_mappings = [ - Mapping('isbn_13', model=models.Edition, formatter=get_first), - Mapping('isbn_10', model=models.Edition, formatter=get_first), - Mapping('lccn', model=models.Work, formatter=get_first), + get_remote_id = lambda a: self.base_url + a + self.book_mappings = [ + Mapping('title'), + Mapping('id', remote_field='key', formatter=get_remote_id), Mapping( - 'oclc_number', - remote_field='oclc_numbers', - model=models.Edition, - formatter=get_first - ), - Mapping( - 'openlibrary_key', - remote_field='key', - formatter=get_openlibrary_key - ), - Mapping('goodreads_key'), - Mapping('asin'), - ] - - self.book_mappings = self.key_mappings + [ - Mapping('sort_title'), + 'cover', remote_field='covers', formatter=self.get_cover_url), + Mapping('sortTitle', remote_field='sort_title'), Mapping('subtitle'), Mapping('description', formatter=get_description), Mapping('languages', formatter=get_languages), Mapping('series', formatter=get_first), - Mapping('series_number'), + Mapping('seriesNumber', remote_field='series_number'), Mapping('subjects'), - Mapping('subject_places'), + Mapping('subjectPlaces'), + Mapping('isbn13', formatter=get_first), + Mapping('isbn10', formatter=get_first), + Mapping('lccn', formatter=get_first), Mapping( - 'first_published_date', - remote_field='first_publish_date', - formatter=get_date + 'oclcNumber', remote_field='oclc_numbers', + formatter=get_first ), Mapping( - 'published_date', - remote_field='publish_date', - formatter=get_date + 'openlibraryKey', remote_field='key', + formatter=get_openlibrary_key ), + Mapping('goodreadsKey', remote_field='goodreads_key'), + Mapping('asin'), Mapping( - 'pages', - model=models.Edition, - remote_field='number_of_pages' + 'firstPublishedDate', remote_field='first_publish_date', ), - Mapping('physical_format', model=models.Edition), + Mapping('publishedDate', remote_field='publish_date'), + Mapping('pages', remote_field='number_of_pages'), + Mapping('physicalFormat', remote_field='physical_format'), Mapping('publishers'), ] self.author_mappings = [ + Mapping('id', remote_field='key', formatter=get_remote_id), Mapping('name'), - Mapping('born', remote_field='birth_date', formatter=get_date), - Mapping('died', remote_field='death_date', formatter=get_date), + Mapping( + 'openlibraryKey', remote_field='key', + formatter=get_openlibrary_key + ), + Mapping('born', remote_field='birth_date'), + Mapping('died', remote_field='death_date'), Mapping('bio', formatter=get_description), ] def get_remote_id_from_data(self, data): + ''' format a url from an openlibrary id field ''' try: key = data['key'] except KeyError: @@ -107,24 +98,17 @@ class Connector(AbstractConnector): ''' parse author json and load or create authors ''' for author_blob in data.get('authors', []): author_blob = author_blob.get('author', author_blob) - # this id is "/authors/OL1234567A" and we want just "OL1234567A" - author_id = author_blob['key'].split('/')[-1] - yield self.get_or_create_author(author_id) + # this id is "/authors/OL1234567A" + author_id = author_blob['key'] + url = '%s/%s.json' % (self.base_url, author_id) + yield self.get_or_create_author(url) - def get_cover_from_data(self, data): + def get_cover_url(self, cover_blob): ''' ask openlibrary for the cover ''' - if not data.get('covers'): - return None - - cover_id = data.get('covers')[0] + cover_id = cover_blob[0] image_name = '%s-M.jpg' % cover_id - url = '%s/b/id/%s' % (self.covers_url, image_name) - response = requests.get(url) - if not response.ok: - response.raise_for_status() - image_content = ContentFile(response.content) - return [image_name, image_content] + return '%s/b/id/%s' % (self.covers_url, image_name) def parse_search_data(self, data): @@ -158,37 +142,7 @@ class Connector(AbstractConnector): # we can mass download edition data from OL to avoid repeatedly querying edition_options = self.load_edition_data(work.openlibrary_key) for edition_data in edition_options.get('entries'): - olkey = edition_data.get('key').split('/')[-1] - # make sure the edition isn't already in the database - if models.Edition.objects.filter(openlibrary_key=olkey).count(): - continue - - # creates and populates the book from the data - edition = self.create_book(olkey, edition_data, models.Edition) - # ensures that the edition is associated with the work - edition.parent_work = work - edition.save() - # get author data from the work if it's missing from the edition - if not edition.authors and work.authors: - edition.authors.set(work.authors.all()) - - - def get_or_create_author(self, olkey): - ''' load that author ''' - if not re.match(r'^OL\d+A$', olkey): - raise ValueError('Invalid OpenLibrary author ID') - author = models.Author.objects.filter(openlibrary_key=olkey).first() - if author: - return author - - url = '%s/authors/%s.json' % (self.base_url, olkey) - data = get_data(url) - - author = models.Author(openlibrary_key=olkey) - author = update_from_mappings(author, data, self.author_mappings) - author.save() - - return author + self.create_edition_from_data(work, edition_data) def get_description(description_blob): diff --git a/bookwyrm/incoming.py b/bookwyrm/incoming.py index c1c15ca9..ddf99f97 100644 --- a/bookwyrm/incoming.py +++ b/bookwyrm/incoming.py @@ -197,26 +197,11 @@ def handle_create(activity): # not a type of status we are prepared to deserialize return - if activity.type == 'Note': - # keep notes if they are replies to existing statuses - reply = models.Status.objects.filter( - remote_id=activity.inReplyTo - ).first() - - if not reply: - discard = True - # keep notes if they mention local users - tags = [l['href'] for l in activity.tag if l['type'] == 'Mention'] - for tag in tags: - if models.User.objects.filter( - remote_id=tag, local=True).exists(): - # we found a mention of a known use boost - discard = False - break - if discard: - return - status = activity.to_model(model) + if not status: + # it was discarded because it's not a bookwyrm type + return + # create a notification if this is a reply notified = [] if status.reply_parent and status.reply_parent.user.local: diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index 5dece504..e4f1f29b 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -73,7 +73,10 @@ class Book(ActivitypubMixin, BookWyrmModel): @property def alt_text(self): ''' image alt test ''' - return '%s cover (%s)' % (self.title, self.edition_info) + text = '%s cover' % self.title + if self.edition_info: + text += ' (%s)' % self.edition_info + return text def save(self, *args, **kwargs): ''' can't be abstract for query reasons, but you shouldn't USE it ''' diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py index b3011e00..87d22085 100644 --- a/bookwyrm/models/fields.py +++ b/bookwyrm/models/fields.py @@ -11,6 +11,7 @@ from django.core.files.base import ContentFile from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from markdown import markdown from bookwyrm import activitypub from bookwyrm.sanitize_html import InputHtmlParser from bookwyrm.settings import DOMAIN @@ -25,6 +26,7 @@ def validate_remote_id(value): params={'value': value}, ) + def validate_username(value): ''' make sure usernames look okay ''' if not re.match(r'^[A-Za-z\-_\.]+$', value): @@ -71,6 +73,9 @@ class ActivitypubFieldMixin: return key = self.get_activitypub_field() + # TODO: surely there's a better way + if instance.__class__.__name__ == 'Boost' and key == 'attributedTo': + key = 'actor' if isinstance(activity.get(key), list): activity[key] += formatted else: @@ -396,6 +401,16 @@ class HtmlField(ActivitypubFieldMixin, models.TextField): sanitizer.feed(value) return sanitizer.get_output() + def to_python(self, value):# pylint: disable=no-self-use + ''' process markdown before save ''' + if not value: + return value + content = markdown(value) + # sanitize resulting html + sanitizer = InputHtmlParser() + sanitizer.feed(content) + return sanitizer.get_output() + class ArrayField(ActivitypubFieldMixin, DjangoArrayField): ''' activitypub-aware array field ''' def field_to_activity(self, value): diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py index b358554c..3162c22b 100644 --- a/bookwyrm/models/status.py +++ b/bookwyrm/models/status.py @@ -1,7 +1,9 @@ ''' models for storing different kinds of Activities ''' -from django.utils import timezone +from dataclasses import MISSING +from django.apps import apps from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.utils import timezone from model_utils.managers import InheritanceManager from bookwyrm import activitypub @@ -46,6 +48,27 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): serialize_reverse_fields = [('attachments', 'attachment')] deserialize_reverse_fields = [('attachments', 'attachment')] + @classmethod + def ignore_activity(cls, activity): + ''' keep notes if they are replies to existing statuses ''' + if activity.type != 'Note': + return False + if cls.objects.filter( + remote_id=activity.inReplyTo).exists(): + return False + + # keep notes if they mention local users + if activity.tag == MISSING or activity.tag is None: + return True + tags = [l['href'] for l in activity.tag if l['type'] == 'Mention'] + for tag in tags: + user_model = apps.get_model('bookwyrm.User', require_ready=True) + if user_model.objects.filter( + remote_id=tag, local=True).exists(): + # we found a mention of a known use boost + return False + return True + @classmethod def replies(cls, status): ''' load all replies to a status. idk if there's a better way @@ -59,6 +82,11 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel): ''' expose the type of status for the ui using activity type ''' return self.activity_serializer.__name__ + @property + def boostable(self): + ''' you can't boost dms ''' + return self.privacy in ['unlisted', 'public'] + def to_replies(self, **kwargs): ''' helper function for loading AP serialized replies to a status ''' return self.to_ordered_collection( @@ -127,8 +155,8 @@ class Comment(Status): @property def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' - return self.content + '

(comment on "%s")' % \ - (self.book.remote_id, self.book.title) + return '

%s

(comment on "%s")

' % \ + (self.content, self.book.remote_id, self.book.title) activity_serializer = activitypub.Comment pure_type = 'Note' @@ -143,7 +171,7 @@ class Quotation(Status): @property def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' - return '"%s"
-- "%s"

%s' % ( + return '

"%s"
-- "%s"

%s

' % ( self.quote, self.book.remote_id, self.book.title, @@ -184,8 +212,7 @@ class Review(Status): @property def pure_content(self): ''' indicate the book in question for mastodon (or w/e) users ''' - return self.content + '

("%s")' % \ - (self.book.remote_id, self.book.title) + return self.content activity_serializer = activitypub.Review pure_type = 'Article' diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index d5db9949..ce50fc09 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -91,6 +91,7 @@ class User(OrderedCollectionPageMixin, AbstractUser): last_active_date = models.DateTimeField(auto_now=True) manually_approves_followers = fields.BooleanField(default=False) + name_field = 'username' @property def alt_text(self): ''' alt text with username ''' diff --git a/bookwyrm/outgoing.py b/bookwyrm/outgoing.py index 65a253e9..7ac2d0d6 100644 --- a/bookwyrm/outgoing.py +++ b/bookwyrm/outgoing.py @@ -211,18 +211,16 @@ def handle_status(user, form): ''' generic handler for statuses ''' status = form.save(commit=False) if not status.sensitive and status.content_warning: - # the cw text field remains populated hen you click "remove" + # the cw text field remains populated when you click "remove" status.content_warning = None status.save() # inspect the text for user tags - text = status.content - matches = re.finditer( - regex.username, - text - ) - for match in matches: + matches = [] + for match in re.finditer(regex.username, status.content): username = match.group().strip().split('@')[1:] + print(match.group()) + print(len(username)) if len(username) == 1: # this looks like a local user (@user), fill in the domain username.append(DOMAIN) @@ -232,6 +230,7 @@ def handle_status(user, form): if not mention_user: # we can ignore users we don't know about continue + matches.append((match.group(), mention_user.remote_id)) # add them to status mentions fk status.mention_users.add(mention_user) # create notification if the mentioned user is local @@ -242,6 +241,20 @@ def handle_status(user, form): related_user=user, related_status=status ) + # add links + content = status.content + content = re.sub( + r'([^(href=")])(https?:\/\/([A-Za-z\.\-_\/]+' \ + r'\.[A-Za-z]{2,}[A-Za-z\.\-_\/]+))', + r'\g<1>\g<3>', + content) + for (username, url) in matches: + content = re.sub( + r'%s([^@])' % username, + r'%s\g<1>' % (url, username), + content) + + status.content = content status.save() # notify reply parent or tagged users @@ -315,15 +328,19 @@ def handle_unfavorite(user, status): def handle_boost(user, status): ''' a user wishes to boost a status ''' + # is it boostable? + if not status.boostable: + return + if models.Boost.objects.filter( boosted_status=status, user=user).exists(): # you already boosted that. return boost = models.Boost.objects.create( boosted_status=status, + privacy=status.privacy, user=user, ) - boost.save() boost_activity = boost.to_activity() broadcast(user, boost_activity) diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py index 933fc43c..de13ede8 100644 --- a/bookwyrm/sanitize_html.py +++ b/bookwyrm/sanitize_html.py @@ -6,7 +6,11 @@ class InputHtmlParser(HTMLParser):#pylint: disable=abstract-method def __init__(self): HTMLParser.__init__(self) - self.allowed_tags = ['p', 'b', 'i', 'pre', 'a', 'span'] + self.allowed_tags = [ + 'p', 'br', + 'b', 'i', 'strong', 'em', 'pre', + 'a', 'span', 'ul', 'ol', 'li' + ] self.tag_stack = [] self.output = [] # if the html appears invalid, we just won't allow any at all diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index dd5c45eb..5b5ff08d 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -137,8 +137,3 @@ input.toggle-control:checked ~ .modal.toggle-content { content: "\e904"; right: 0; } - -/* --- BLOCKQUOTE --- */ -blockquote { - white-space: pre-line; -} diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index abed3598..506ee3db 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -103,14 +103,14 @@
{% for shelf in user_shelves %}

- This edition is on your {{ shelf.shelf.name }} shelf. + This edition is on your {{ shelf.shelf.name }} shelf. {% include 'snippets/shelf_selector.html' with current=shelf.shelf %}

{% endfor %} {% for shelf in other_edition_shelves %}

- A different edition of this book is on your {{ shelf.shelf.name }} shelf. + A different edition of this book is on your {{ shelf.shelf.name }} shelf. {% include 'snippets/switch_edition_button.html' with edition=book %}

{% endfor %} diff --git a/bookwyrm/templates/snippets/boost_button.html b/bookwyrm/templates/snippets/boost_button.html index bf06cef7..02b55794 100644 --- a/bookwyrm/templates/snippets/boost_button.html +++ b/bookwyrm/templates/snippets/boost_button.html @@ -1,8 +1,9 @@ {% load bookwyrm_tags %} + {% with status.id|uuid as uuid %}
{% csrf_token %} -