diff --git a/.env.dev.example b/.env.dev.example
index 9a4366e0..22e12de1 100644
--- a/.env.dev.example
+++ b/.env.dev.example
@@ -69,7 +69,7 @@ AWS_SECRET_ACCESS_KEY=
# or use_dominant_color_light / use_dominant_color_dark
PREVIEW_BG_COLOR=use_dominant_color_light
# Change to #FFF if you use use_dominant_color_dark
-PREVIEW_TEXT_COLOR="#363636"
+PREVIEW_TEXT_COLOR=#363636
PREVIEW_IMG_WIDTH=1200
PREVIEW_IMG_HEIGHT=630
-PREVIEW_DEFAULT_COVER_COLOR="#002549"
+PREVIEW_DEFAULT_COVER_COLOR=#002549
diff --git a/.env.prod.example b/.env.prod.example
index 56f52a28..2e0ced5e 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -69,7 +69,7 @@ AWS_SECRET_ACCESS_KEY=
# or use_dominant_color_light / use_dominant_color_dark
PREVIEW_BG_COLOR=use_dominant_color_light
# Change to #FFF if you use use_dominant_color_dark
-PREVIEW_TEXT_COLOR="#363636"
+PREVIEW_TEXT_COLOR=#363636
PREVIEW_IMG_WIDTH=1200
PREVIEW_IMG_HEIGHT=630
-PREVIEW_DEFAULT_COVER_COLOR="#002549"
+PREVIEW_DEFAULT_COVER_COLOR=#002549
diff --git a/.gitignore b/.gitignore
index 624ce100..e5582694 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
*.swp
**/__pycache__
.local
+/nginx/nginx.conf
# VSCode
/.vscode
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
index e1a52d26..4cba9939 100644
--- a/bookwyrm/activitystreams.py
+++ b/bookwyrm/activitystreams.py
@@ -22,6 +22,11 @@ class ActivityStream(RedisStore):
stream_id = self.stream_id(user)
return f"{stream_id}-unread"
+ def unread_by_status_type_id(self, user):
+ """the redis key for this user's unread count for this stream"""
+ stream_id = self.stream_id(user)
+ return f"{stream_id}-unread-by-type"
+
def get_rank(self, obj): # pylint: disable=no-self-use
"""statuses are sorted by date published"""
return obj.published_date.timestamp()
@@ -35,6 +40,10 @@ class ActivityStream(RedisStore):
for user in self.get_audience(status):
# add to the unread status count
pipeline.incr(self.unread_id(user))
+ # add to the unread status count for status type
+ pipeline.hincrby(
+ self.unread_by_status_type_id(user), get_status_type(status), 1
+ )
# and go!
pipeline.execute()
@@ -55,6 +64,7 @@ class ActivityStream(RedisStore):
"""load the statuses to be displayed"""
# clear unreads for this feed
r.set(self.unread_id(user), 0)
+ r.delete(self.unread_by_status_type_id(user))
statuses = self.get_store(self.stream_id(user))
return (
@@ -75,6 +85,14 @@ class ActivityStream(RedisStore):
"""get the unread status count for this user's feed"""
return int(r.get(self.unread_id(user)) or 0)
+ def get_unread_count_by_status_type(self, user):
+ """get the unread status count for this user's feed's status types"""
+ status_types = r.hgetall(self.unread_by_status_type_id(user))
+ return {
+ str(key.decode("utf-8")): int(value) or 0
+ for key, value in status_types.items()
+ }
+
def populate_streams(self, user):
"""go from zero to a timeline"""
self.populate_store(self.stream_id(user))
@@ -460,7 +478,7 @@ def remove_status_task(status_ids):
@app.task(queue=HIGH)
def add_status_task(status_id, increment_unread=False):
"""add a status to any stream it should be in"""
- status = models.Status.objects.get(id=status_id)
+ status = models.Status.objects.select_subclasses().get(id=status_id)
# we don't want to tick the unread count for csv import statuses, idk how better
# to check than just to see if the states is more than a few days old
if status.created_date < timezone.now() - timedelta(days=2):
@@ -507,3 +525,20 @@ def handle_boost_task(boost_id):
stream.remove_object_from_related_stores(boosted, stores=audience)
for status in old_versions:
stream.remove_object_from_related_stores(status, stores=audience)
+
+
+def get_status_type(status):
+ """return status type even for boosted statuses"""
+ status_type = status.status_type.lower()
+
+ # Check if current status is a boost
+ if hasattr(status, "boost"):
+ # Act in accordance of your findings
+ if hasattr(status.boost.boosted_status, "review"):
+ status_type = "review"
+ if hasattr(status.boost.boosted_status, "comment"):
+ status_type = "comment"
+ if hasattr(status.boost.boosted_status, "quotation"):
+ status_type = "quotation"
+
+ return status_type
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index c032986d..20a17526 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -111,7 +111,7 @@ class AbstractConnector(AbstractMinimalConnector):
return existing.default_edition
return existing
- # load the json
+ # load the json data from the remote data source
data = self.get_book_data(remote_id)
if self.is_work_data(data):
try:
@@ -150,27 +150,37 @@ class AbstractConnector(AbstractMinimalConnector):
"""this allows connectors to override the default behavior"""
return get_data(remote_id)
- def create_edition_from_data(self, work, edition_data):
+ def create_edition_from_data(self, work, edition_data, instance=None):
"""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(model=models.Edition, overwrite=False)
- edition.connector = self.connector
- edition.save()
+ edition = edition_activity.to_model(
+ model=models.Edition, overwrite=False, instance=instance
+ )
+
+ # if we're updating an existing instance, we don't need to load authors
+ if instance:
+ return edition
+
+ if not edition.connector:
+ edition.connector = self.connector
+ edition.save(broadcast=False, update_fields=["connector"])
for author in self.get_authors_from_data(edition_data):
edition.authors.add(author)
+ # use the authors from the work if none are found for the edition
if not edition.authors.exists() and work.authors.exists():
edition.authors.set(work.authors.all())
return edition
- def get_or_create_author(self, remote_id):
+ def get_or_create_author(self, remote_id, instance=None):
"""load that author"""
- existing = models.Author.find_existing_by_remote_id(remote_id)
- if existing:
- return existing
+ if not instance:
+ existing = models.Author.find_existing_by_remote_id(remote_id)
+ if existing:
+ return existing
data = self.get_book_data(remote_id)
@@ -181,7 +191,24 @@ class AbstractConnector(AbstractMinimalConnector):
return None
# this will dedupe
- return activity.to_model(model=models.Author, overwrite=False)
+ return activity.to_model(
+ model=models.Author, overwrite=False, instance=instance
+ )
+
+ def get_remote_id_from_model(self, obj):
+ """given the data stored, how can we look this up"""
+ return getattr(obj, getattr(self, "generated_remote_link_field"))
+
+ def update_author_from_remote(self, obj):
+ """load the remote data from this connector and add it to an existing author"""
+ remote_id = self.get_remote_id_from_model(obj)
+ return self.get_or_create_author(remote_id, instance=obj)
+
+ def update_book_from_remote(self, obj):
+ """load the remote data from this connector and add it to an existing book"""
+ remote_id = self.get_remote_id_from_model(obj)
+ data = self.get_book_data(remote_id)
+ return self.create_edition_from_data(obj.parent_work, data, instance=obj)
@abstractmethod
def is_work_data(self, data):
@@ -227,15 +254,17 @@ def get_data(url, params=None, timeout=10):
resp = requests.get(
url,
params=params,
- headers={
- "Accept": "application/json; charset=utf-8",
+ headers={ # pylint: disable=line-too-long
+ "Accept": (
+ 'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'
+ ),
"User-Agent": settings.USER_AGENT,
},
timeout=timeout,
)
except RequestException as err:
logger.exception(err)
- raise ConnectorException()
+ raise ConnectorException(err)
if not resp.ok:
raise ConnectorException()
@@ -243,7 +272,7 @@ def get_data(url, params=None, timeout=10):
data = resp.json()
except ValueError as err:
logger.exception(err)
- raise ConnectorException()
+ raise ConnectorException(err)
return data
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
index e9f53856..a9aeb94f 100644
--- a/bookwyrm/connectors/inventaire.py
+++ b/bookwyrm/connectors/inventaire.py
@@ -11,6 +11,8 @@ from .connector_manager import ConnectorException
class Connector(AbstractConnector):
"""instantiate a connector for inventaire"""
+ generated_remote_link_field = "inventaire_id"
+
def __init__(self, identifier):
super().__init__(identifier)
@@ -210,6 +212,11 @@ class Connector(AbstractConnector):
return ""
return data.get("extract")
+ def get_remote_id_from_model(self, obj):
+ """use get_remote_id to figure out the link from a model obj"""
+ remote_id_value = obj.inventaire_id
+ return self.get_remote_id(remote_id_value)
+
def get_language_code(options, code="en"):
"""when there are a bunch of translation but we need a single field"""
diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py
index b8afc7ca..c15277f8 100644
--- a/bookwyrm/connectors/openlibrary.py
+++ b/bookwyrm/connectors/openlibrary.py
@@ -12,6 +12,8 @@ from .openlibrary_languages import languages
class Connector(AbstractConnector):
"""instantiate a connector for OL"""
+ generated_remote_link_field = "openlibrary_link"
+
def __init__(self, identifier):
super().__init__(identifier)
@@ -66,6 +68,7 @@ class Connector(AbstractConnector):
Mapping("born", remote_field="birth_date"),
Mapping("died", remote_field="death_date"),
Mapping("bio", formatter=get_description),
+ Mapping("isni", remote_field="remote_ids", formatter=get_isni),
]
def get_book_data(self, remote_id):
@@ -224,6 +227,13 @@ def get_languages(language_blob):
return langs
+def get_isni(remote_ids_blob):
+ """extract the isni from the remote id data for the author"""
+ if not remote_ids_blob or not isinstance(remote_ids_blob, dict):
+ return None
+ return remote_ids_blob.get("isni")
+
+
def pick_default_edition(options):
"""favor physical copies with covers in english"""
if not options:
diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
index 847ca05c..7ba7bd97 100644
--- a/bookwyrm/forms.py
+++ b/bookwyrm/forms.py
@@ -9,6 +9,8 @@ from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from bookwyrm import models
+from bookwyrm.models.fields import ClearableFileInputWithWarning
+from bookwyrm.models.user import FeedFilterChoices
class CustomForm(ModelForm):
@@ -147,6 +149,17 @@ class EditUserForm(CustomForm):
"preferred_language",
]
help_texts = {f: None for f in fields}
+ widgets = {
+ "avatar": ClearableFileInputWithWarning(
+ attrs={"aria-describedby": "desc_avatar"}
+ ),
+ "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
+ "summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
+ "email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
+ "discoverable": forms.CheckboxInput(
+ attrs={"aria-describedby": "desc_discoverable"}
+ ),
+ }
class LimitedEditUserForm(CustomForm):
@@ -160,6 +173,16 @@ class LimitedEditUserForm(CustomForm):
"discoverable",
]
help_texts = {f: None for f in fields}
+ widgets = {
+ "avatar": ClearableFileInputWithWarning(
+ attrs={"aria-describedby": "desc_avatar"}
+ ),
+ "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
+ "summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
+ "discoverable": forms.CheckboxInput(
+ attrs={"aria-describedby": "desc_discoverable"}
+ ),
+ }
class DeleteUserForm(CustomForm):
@@ -174,6 +197,18 @@ class UserGroupForm(CustomForm):
fields = ["groups"]
+class FeedStatusTypesForm(CustomForm):
+ class Meta:
+ model = models.User
+ fields = ["feed_status_types"]
+ help_texts = {f: None for f in fields}
+ widgets = {
+ "feed_status_types": widgets.CheckboxSelectMultiple(
+ choices=FeedFilterChoices,
+ ),
+ }
+
+
class CoverForm(CustomForm):
class Meta:
model = models.Book
@@ -196,6 +231,51 @@ class EditionForm(CustomForm):
"connector",
"search_vector",
]
+ widgets = {
+ "title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
+ "subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
+ "description": forms.Textarea(
+ attrs={"aria-describedby": "desc_description"}
+ ),
+ "series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
+ "series_number": forms.TextInput(
+ attrs={"aria-describedby": "desc_series_number"}
+ ),
+ "languages": forms.TextInput(
+ attrs={"aria-describedby": "desc_languages_help desc_languages"}
+ ),
+ "publishers": forms.TextInput(
+ attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
+ ),
+ "first_published_date": forms.SelectDateWidget(
+ attrs={"aria-describedby": "desc_first_published_date"}
+ ),
+ "published_date": forms.SelectDateWidget(
+ attrs={"aria-describedby": "desc_published_date"}
+ ),
+ "cover": ClearableFileInputWithWarning(
+ attrs={"aria-describedby": "desc_cover"}
+ ),
+ "physical_format": forms.Select(
+ attrs={"aria-describedby": "desc_physical_format"}
+ ),
+ "physical_format_detail": forms.TextInput(
+ attrs={"aria-describedby": "desc_physical_format_detail"}
+ ),
+ "pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
+ "isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
+ "isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
+ "openlibrary_key": forms.TextInput(
+ attrs={"aria-describedby": "desc_openlibrary_key"}
+ ),
+ "inventaire_id": forms.TextInput(
+ attrs={"aria-describedby": "desc_inventaire_id"}
+ ),
+ "oclc_number": forms.TextInput(
+ attrs={"aria-describedby": "desc_oclc_number"}
+ ),
+ "ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
+ }
class AuthorForm(CustomForm):
@@ -213,7 +293,30 @@ class AuthorForm(CustomForm):
"inventaire_id",
"librarything_key",
"goodreads_key",
+ "isni",
]
+ widgets = {
+ "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
+ "aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
+ "bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
+ "wikipedia_link": forms.TextInput(
+ attrs={"aria-describedby": "desc_wikipedia_link"}
+ ),
+ "born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
+ "died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
+ "oepnlibrary_key": forms.TextInput(
+ attrs={"aria-describedby": "desc_oepnlibrary_key"}
+ ),
+ "inventaire_id": forms.TextInput(
+ attrs={"aria-describedby": "desc_inventaire_id"}
+ ),
+ "librarything_key": forms.TextInput(
+ attrs={"aria-describedby": "desc_librarything_key"}
+ ),
+ "goodreads_key": forms.TextInput(
+ attrs={"aria-describedby": "desc_goodreads_key"}
+ ),
+ }
class ImportForm(forms.Form):
@@ -288,12 +391,37 @@ class SiteForm(CustomForm):
class Meta:
model = models.SiteSettings
exclude = []
+ widgets = {
+ "instance_short_description": forms.TextInput(
+ attrs={"aria-describedby": "desc_instance_short_description"}
+ ),
+ "require_confirm_email": forms.CheckboxInput(
+ attrs={"aria-describedby": "desc_require_confirm_email"}
+ ),
+ "invite_request_text": forms.Textarea(
+ attrs={"aria-describedby": "desc_invite_request_text"}
+ ),
+ }
class AnnouncementForm(CustomForm):
class Meta:
model = models.Announcement
exclude = ["remote_id"]
+ widgets = {
+ "preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
+ "content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
+ "event_date": forms.SelectDateWidget(
+ attrs={"aria-describedby": "desc_event_date"}
+ ),
+ "start_date": forms.SelectDateWidget(
+ attrs={"aria-describedby": "desc_start_date"}
+ ),
+ "end_date": forms.SelectDateWidget(
+ attrs={"aria-describedby": "desc_end_date"}
+ ),
+ "active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
+ }
class ListForm(CustomForm):
@@ -318,6 +446,9 @@ class EmailBlocklistForm(CustomForm):
class Meta:
model = models.EmailBlocklist
fields = ["domain"]
+ widgets = {
+ "avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
+ }
class IPBlocklistForm(CustomForm):
diff --git a/bookwyrm/importers/__init__.py b/bookwyrm/importers/__init__.py
index d4890070..dd3d62e8 100644
--- a/bookwyrm/importers/__init__.py
+++ b/bookwyrm/importers/__init__.py
@@ -3,4 +3,5 @@
from .importer import Importer
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
+from .openlibrary_import import OpenLibraryImporter
from .storygraph_import import StorygraphImporter
diff --git a/bookwyrm/importers/importer.py b/bookwyrm/importers/importer.py
index 94e6734e..32800e77 100644
--- a/bookwyrm/importers/importer.py
+++ b/bookwyrm/importers/importer.py
@@ -26,7 +26,7 @@ class Importer:
("authors", ["author", "authors", "primary author"]),
("isbn_10", ["isbn10", "isbn"]),
("isbn_13", ["isbn13", "isbn", "isbns"]),
- ("shelf", ["shelf", "exclusive shelf", "read status"]),
+ ("shelf", ["shelf", "exclusive shelf", "read status", "bookshelf"]),
("review_name", ["review name"]),
("review_body", ["my review", "review"]),
("rating", ["my rating", "rating", "star rating"]),
@@ -36,9 +36,9 @@ class Importer:
]
date_fields = ["date_added", "date_started", "date_finished"]
shelf_mapping_guesses = {
- "to-read": ["to-read"],
- "read": ["read"],
- "reading": ["currently-reading", "reading"],
+ "to-read": ["to-read", "want to read"],
+ "read": ["read", "already read"],
+ "reading": ["currently-reading", "reading", "currently reading"],
}
def create_job(self, user, csv_file, include_reviews, privacy):
@@ -90,7 +90,10 @@ class Importer:
def get_shelf(self, normalized_row):
"""determine which shelf to use"""
- shelf_name = normalized_row["shelf"]
+ shelf_name = normalized_row.get("shelf")
+ if not shelf_name:
+ return None
+ shelf_name = shelf_name.lower()
shelf = [
s for (s, gs) in self.shelf_mapping_guesses.items() if shelf_name in gs
]
@@ -106,6 +109,7 @@ class Importer:
user=user,
include_reviews=original_job.include_reviews,
privacy=original_job.privacy,
+ source=original_job.source,
# TODO: allow users to adjust mappings
mappings=original_job.mappings,
retry=True,
diff --git a/bookwyrm/importers/openlibrary_import.py b/bookwyrm/importers/openlibrary_import.py
new file mode 100644
index 00000000..ef103060
--- /dev/null
+++ b/bookwyrm/importers/openlibrary_import.py
@@ -0,0 +1,13 @@
+""" handle reading a csv from openlibrary"""
+from . import Importer
+
+
+class OpenLibraryImporter(Importer):
+ """csv downloads from OpenLibrary"""
+
+ service = "OpenLibrary"
+
+ def __init__(self, *args, **kwargs):
+ self.row_mappings_guesses.append(("openlibrary_key", ["edition id"]))
+ self.row_mappings_guesses.append(("openlibrary_work_key", ["work id"]))
+ super().__init__(*args, **kwargs)
diff --git a/bookwyrm/importers/storygraph_import.py b/bookwyrm/importers/storygraph_import.py
index 9368115d..67cbaa66 100644
--- a/bookwyrm/importers/storygraph_import.py
+++ b/bookwyrm/importers/storygraph_import.py
@@ -3,6 +3,6 @@ from . import Importer
class StorygraphImporter(Importer):
- """csv downloads from librarything"""
+ """csv downloads from Storygraph"""
service = "Storygraph"
diff --git a/bookwyrm/migrations/0119_user_feed_status_types.py b/bookwyrm/migrations/0119_user_feed_status_types.py
new file mode 100644
index 00000000..64fa9169
--- /dev/null
+++ b/bookwyrm/migrations/0119_user_feed_status_types.py
@@ -0,0 +1,32 @@
+# Generated by Django 3.2.5 on 2021-11-24 10:15
+
+import bookwyrm.models.user
+import django.contrib.postgres.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0118_alter_user_preferred_language"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="feed_status_types",
+ field=django.contrib.postgres.fields.ArrayField(
+ base_field=models.CharField(
+ choices=[
+ ("review", "Reviews"),
+ ("comment", "Comments"),
+ ("quotation", "Quotations"),
+ ("everything", "Everything else"),
+ ],
+ max_length=10,
+ ),
+ default=bookwyrm.models.user.get_feed_filter_choices,
+ size=8,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0120_list_embed_key.py b/bookwyrm/migrations/0120_list_embed_key.py
new file mode 100644
index 00000000..40db1f0f
--- /dev/null
+++ b/bookwyrm/migrations/0120_list_embed_key.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.5 on 2021-12-04 10:55
+
+from django.db import migrations, models
+import uuid
+
+
+def gen_uuid(apps, schema_editor):
+ """sets an unique UUID for embed_key"""
+ book_lists = apps.get_model("bookwyrm", "List")
+ db_alias = schema_editor.connection.alias
+ for book_list in book_lists.objects.using(db_alias).all():
+ book_list.embed_key = uuid.uuid4()
+ book_list.save(broadcast=False)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0119_user_feed_status_types"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="list",
+ name="embed_key",
+ field=models.UUIDField(editable=False, null=True, unique=True),
+ ),
+ migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/bookwyrm/models/author.py b/bookwyrm/models/author.py
index 6c29ac05..5cc11afd 100644
--- a/bookwyrm/models/author.py
+++ b/bookwyrm/models/author.py
@@ -1,4 +1,5 @@
""" database schema for info about authors """
+import re
from django.contrib.postgres.indexes import GinIndex
from django.db import models
@@ -33,6 +34,17 @@ class Author(BookDataModel):
)
bio = fields.HtmlField(null=True, blank=True)
+ @property
+ def isni_link(self):
+ """generate the url from the isni id"""
+ clean_isni = re.sub(r"\s", "", self.isni)
+ return f"https://isni.org/isni/{clean_isni}"
+
+ @property
+ def openlibrary_link(self):
+ """generate the url from the openlibrary id"""
+ return f"https://openlibrary.org/authors/{self.openlibrary_key}"
+
def get_remote_id(self):
"""editions and works both use "book" instead of model_name"""
return f"https://{DOMAIN}/author/{self.id}"
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index d97a1b8a..0a551bf2 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -52,6 +52,16 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
null=True,
)
+ @property
+ def openlibrary_link(self):
+ """generate the url from the openlibrary id"""
+ return f"https://openlibrary.org/books/{self.openlibrary_key}"
+
+ @property
+ def inventaire_link(self):
+ """generate the url from the inventaire id"""
+ return f"https://inventaire.io/entity/{self.inventaire_id}"
+
class Meta:
"""can't initialize this model, that wouldn't make sense"""
diff --git a/bookwyrm/models/group.py b/bookwyrm/models/group.py
index bd5b8d41..05ed39a2 100644
--- a/bookwyrm/models/group.py
+++ b/bookwyrm/models/group.py
@@ -73,7 +73,7 @@ class GroupMember(models.Model):
)
).exists():
raise IntegrityError()
- # accepts and requests are handled by the GroupInvitation model
+ # accepts and requests are handled by the GroupMemberInvitation model
super().save(*args, **kwargs)
@classmethod
@@ -150,31 +150,30 @@ class GroupMemberInvitation(models.Model):
notification_type=notification_type,
)
+ @transaction.atomic
def accept(self):
"""turn this request into the real deal"""
+ GroupMember.from_request(self)
- with transaction.atomic():
- GroupMember.from_request(self)
+ model = apps.get_model("bookwyrm.Notification", require_ready=True)
+ # tell the group owner
+ model.objects.create(
+ user=self.group.user,
+ related_user=self.user,
+ related_group=self.group,
+ notification_type="ACCEPT",
+ )
- model = apps.get_model("bookwyrm.Notification", require_ready=True)
- # tell the group owner
- model.objects.create(
- user=self.group.user,
- related_user=self.user,
- related_group=self.group,
- notification_type="ACCEPT",
- )
-
- # let the other members know about it
- for membership in self.group.memberships.all():
- member = membership.user
- if member not in (self.user, self.group.user):
- model.objects.create(
- user=member,
- related_user=self.user,
- related_group=self.group,
- notification_type="JOIN",
- )
+ # let the other members know about it
+ for membership in self.group.memberships.all():
+ member = membership.user
+ if member not in (self.user, self.group.user):
+ model.objects.create(
+ user=member,
+ related_user=self.user,
+ related_group=self.group,
+ notification_type="JOIN",
+ )
def reject(self):
"""generate a Reject for this membership request"""
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index c4679585..919bbf0d 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -25,7 +25,7 @@ def construct_search_term(title, author):
# Strip brackets (usually series title from search term)
title = re.sub(r"\s*\([^)]*\)\s*", "", title)
# Open library doesn't like including author initials in search term.
- author = re.sub(r"(\w\.)+\s*", "", author)
+ author = re.sub(r"(\w\.)+\s*", "", author) if author else ""
return " ".join([title, author])
@@ -88,7 +88,9 @@ class ImportItem(models.Model):
return
if self.isbn:
- self.book = self.get_book_from_isbn()
+ self.book = self.get_book_from_identifier()
+ elif self.openlibrary_key:
+ self.book = self.get_book_from_identifier(field="openlibrary_key")
else:
# don't fall back on title/author search if isbn is present.
# you're too likely to mismatch
@@ -98,10 +100,10 @@ class ImportItem(models.Model):
else:
self.book_guess = book
- def get_book_from_isbn(self):
- """search by isbn"""
+ def get_book_from_identifier(self, field="isbn"):
+ """search by isbn or other unique identifier"""
search_result = connector_manager.first_search_result(
- self.isbn, min_confidence=0.999
+ getattr(self, field), min_confidence=0.999
)
if search_result:
# it's already in the right format
@@ -114,6 +116,8 @@ class ImportItem(models.Model):
def get_book_from_title_author(self):
"""search by title and author"""
+ if not self.title:
+ return None, 0
search_term = construct_search_term(self.title, self.author)
search_result = connector_manager.first_search_result(
search_term, min_confidence=0.1
@@ -145,6 +149,13 @@ class ImportItem(models.Model):
self.normalized_data.get("isbn_10")
)
+ @property
+ def openlibrary_key(self):
+ """the edition identifier is preferable to the work key"""
+ return self.normalized_data.get("openlibrary_key") or self.normalized_data.get(
+ "openlibrary_work_key"
+ )
+
@property
def shelf(self):
"""the goodreads shelf field"""
diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py
index 978a7a9b..d159bc4a 100644
--- a/bookwyrm/models/list.py
+++ b/bookwyrm/models/list.py
@@ -1,4 +1,6 @@
""" make a list of books!! """
+import uuid
+
from django.apps import apps
from django.db import models
from django.db.models import Q
@@ -43,6 +45,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
through="ListItem",
through_fields=("book_list", "book"),
)
+ embed_key = models.UUIDField(unique=True, null=True, editable=False)
activity_serializer = activitypub.BookList
def get_remote_id(self):
@@ -105,6 +108,12 @@ class List(OrderedCollectionMixin, BookWyrmModel):
group=None, curation="closed"
)
+ def save(self, *args, **kwargs):
+ """on save, update embed_key and avoid clash with existing code"""
+ if not self.embed_key:
+ self.embed_key = uuid.uuid4()
+ return super().save(*args, **kwargs)
+
class ListItem(CollectionItemMixin, BookWyrmModel):
"""ok"""
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index d7945843..4d98f5c5 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -4,11 +4,12 @@ from urllib.parse import urlparse
from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group
-from django.contrib.postgres.fields import CICharField
+from django.contrib.postgres.fields import ArrayField, CICharField
from django.core.validators import MinValueValidator
from django.dispatch import receiver
from django.db import models, transaction
from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
from model_utils import FieldTracker
import pytz
@@ -27,6 +28,19 @@ from .federated_server import FederatedServer
from . import fields, Review
+FeedFilterChoices = [
+ ("review", _("Reviews")),
+ ("comment", _("Comments")),
+ ("quotation", _("Quotations")),
+ ("everything", _("Everything else")),
+]
+
+
+def get_feed_filter_choices():
+ """return a list of filter choice keys"""
+ return [f[0] for f in FeedFilterChoices]
+
+
def site_link():
"""helper for generating links to the site"""
protocol = "https" if USE_HTTPS else "http"
@@ -128,6 +142,13 @@ class User(OrderedCollectionPageMixin, AbstractUser):
show_suggested_users = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False)
+ # feed options
+ feed_status_types = ArrayField(
+ models.CharField(max_length=10, blank=False, choices=FeedFilterChoices),
+ size=8,
+ default=get_feed_filter_choices,
+ )
+
preferred_timezone = models.CharField(
choices=[(str(tz), str(tz)) for tz in pytz.all_timezones],
default=str(pytz.utc),
diff --git a/bookwyrm/preview_images.py b/bookwyrm/preview_images.py
index 32465d6e..a97ae2d5 100644
--- a/bookwyrm/preview_images.py
+++ b/bookwyrm/preview_images.py
@@ -49,6 +49,28 @@ def get_font(font_name, size=28):
return font
+def get_wrapped_text(text, font, content_width):
+ """text wrap length depends on the max width of the content"""
+
+ low = 0
+ high = len(text)
+
+ try:
+ # ideal length is determined via binary search
+ while low < high:
+ mid = math.floor(low + high)
+ wrapped_text = textwrap.fill(text, width=mid)
+ width = font.getsize_multiline(wrapped_text)[0]
+ if width < content_width:
+ low = mid
+ else:
+ high = mid - 1
+ except AttributeError:
+ wrapped_text = text
+
+ return wrapped_text
+
+
def generate_texts_layer(texts, content_width):
"""Adds text for images"""
font_text_zero = get_font("bold", size=20)
@@ -63,7 +85,8 @@ def generate_texts_layer(texts, content_width):
if "text_zero" in texts and texts["text_zero"]:
# Text one (Book title)
- text_zero = textwrap.fill(texts["text_zero"], width=72)
+ text_zero = get_wrapped_text(texts["text_zero"], font_text_zero, content_width)
+
text_layer_draw.multiline_text(
(0, text_y), text_zero, font=font_text_zero, fill=TEXT_COLOR
)
@@ -75,7 +98,8 @@ def generate_texts_layer(texts, content_width):
if "text_one" in texts and texts["text_one"]:
# Text one (Book title)
- text_one = textwrap.fill(texts["text_one"], width=28)
+ text_one = get_wrapped_text(texts["text_one"], font_text_one, content_width)
+
text_layer_draw.multiline_text(
(0, text_y), text_one, font=font_text_one, fill=TEXT_COLOR
)
@@ -87,7 +111,8 @@ def generate_texts_layer(texts, content_width):
if "text_two" in texts and texts["text_two"]:
# Text one (Book subtitle)
- text_two = textwrap.fill(texts["text_two"], width=36)
+ text_two = get_wrapped_text(texts["text_two"], font_text_two, content_width)
+
text_layer_draw.multiline_text(
(0, text_y), text_two, font=font_text_two, fill=TEXT_COLOR
)
@@ -99,7 +124,10 @@ def generate_texts_layer(texts, content_width):
if "text_three" in texts and texts["text_three"]:
# Text three (Book authors)
- text_three = textwrap.fill(texts["text_three"], width=36)
+ text_three = get_wrapped_text(
+ texts["text_three"], font_text_three, content_width
+ )
+
text_layer_draw.multiline_text(
(0, text_y), text_three, font=font_text_three, fill=TEXT_COLOR
)
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index d6c0b5d5..e95ff96e 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -14,7 +14,7 @@ VERSION = "0.1.0"
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
-JS_CACHE = "3eb4edb1"
+JS_CACHE = "3891b373"
# 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 0b2f98ba..5772c4b8 100644
--- a/bookwyrm/static/css/bookwyrm.css
+++ b/bookwyrm/static/css/bookwyrm.css
@@ -25,6 +25,10 @@ body {
overflow: visible;
}
+.card.has-border {
+ border: 1px solid #eee;
+}
+
.scroll-x {
overflow: hidden;
overflow-x: auto;
@@ -140,6 +144,34 @@ input[type=file]::file-selector-button:hover {
color: #363636;
}
+details .dropdown-menu {
+ display: block !important;
+}
+
+details.dropdown[open] summary.dropdown-trigger::before {
+ content: "";
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+}
+
+summary::marker {
+ content: none;
+}
+
+.detail-pinned-button summary {
+ position: absolute;
+ right: 0;
+}
+
+.detail-pinned-button form {
+ float: left;
+ width: -webkit-fill-available;
+ margin-top: 1em;
+}
+
/** Shelving
******************************************************************************/
diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot
index 8eba8692..7b1f2d9d 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.eot and b/bookwyrm/static/css/fonts/icomoon.eot differ
diff --git a/bookwyrm/static/css/fonts/icomoon.svg b/bookwyrm/static/css/fonts/icomoon.svg
index 82e41329..7dbbe0dc 100644
--- a/bookwyrm/static/css/fonts/icomoon.svg
+++ b/bookwyrm/static/css/fonts/icomoon.svg
@@ -46,4 +46,5 @@
- - {% trans "Wikipedia" %} - -
- {% endif %} + {% if links %} +- - {% trans "View ISNI record" %} - -
- {% endif %} + {% if author.isni %} + + {% endif %} - {% if author.openlibrary_key %} -- - {% trans "View on OpenLibrary" %} - -
- {% endif %} + {% trans "Load data" as button_text %} + {% if author.openlibrary_key %} +- - {% trans "View on Inventaire" %} - -
- {% endif %} + {% if author.inventaire_id %} +{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.aliases.errors id="desc_aliases" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.bio.errors id="desc_bio" %}{{ form.wikipedia_link }}
- {% for error in form.wikipedia_link.errors %} -{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.wikipedia_link.errors id="desc_wikipedia_link" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.born.errors id="desc_born" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.died.errors id="desc_died" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.oepnlibrary_key.errors id="desc_oepnlibrary_key" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.librarything_key.errors id="desc_librarything_key" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.goodreads_key.errors id="desc_goodreads_key" %} +{% trans "View on OpenLibrary" %}
++ {% trans "View on OpenLibrary" %} + {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} + {% with controls_text="ol_sync" controls_uid=book.id %} + {% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_ol_sync" class="is-small" icon_with_text="download" %} + {% include "book/sync_modal.html" with source="openlibrary.org" source_name="OpenLibrary" %} + {% endwith %} + {% endif %} +
{% endif %} {% if book.inventaire_id %} -{% trans "View on Inventaire" %}
++ {% trans "View on Inventaire" %} + {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} + {% with controls_text="iv_sync" controls_uid=book.id %} + {% include 'snippets/toggle/toggle_button.html' with text=button_text focus="modal_title_iv_sync" class="is-small" icon_with_text="download" %} + {% include "book/sync_modal.html" with source="inventaire.io" source_name="Inventaire" %} + {% endwith %} + {% endif %} +
{% endif %}{{ error | escape }}
- {% endfor %} + + + + {% include 'snippets/form_errors.html' with errors_list=form.title.errors id="desc_title" %}{{ error | escape }}
- {% endfor %} + + + + {% include 'snippets/form_errors.html' with errors_list=form.subtitle.errors id="desc_subtitle" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.description.errors id="desc_description" %}{{ error | escape }}
- {% endfor %} + + + + {% include 'snippets/form_errors.html' with errors_list=form.series.errors id="desc_series" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.series_number.errors id="desc_series_number" %}{{ error | escape }}
- {% endfor %} + + {% trans "Separate multiple values with commas." %} + + + {% include 'snippets/form_errors.html' with errors_list=form.languages.errors id="desc_languages" %}{{ error | escape }}
- {% endfor %} + + {% trans "Separate multiple values with commas." %} + + + {% include 'snippets/form_errors.html' with errors_list=form.publishers.errors id="desc_publishers" %}{{ error | escape }}
- {% endfor %} + + + + {% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}{{ error | escape }}
- {% endfor %} + + + + {% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.cover.errors id="desc_cover" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.physical_format_detail.errors id="desc_physical_format_detail" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.pages.errors id="desc_pages" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.isbn_13.errors id="desc_isbn_13" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.isbn_10.errors id="desc_isbn_10" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.openlibrary_key.errors id="desc_openlibrary_key" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.oclc_number.errors id="desc_oclc_number" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.ASIN.errors id="desc_ASIN" %}{% trans "There aren't any activities right now! Try following a user to get started" %}
+{% if user.feed_status_types|length < 4 %}{% trans "Alternatively, you can try enabling more status types" %}{% endif %}
{% if request.user.show_suggested_users and suggested_users %} {# suggested users for when things are very lonely #} diff --git a/bookwyrm/templates/feed/status.html b/bookwyrm/templates/feed/status.html index 5febf4e2..8dcad088 100644 --- a/bookwyrm/templates/feed/status.html +++ b/bookwyrm/templates/feed/status.html @@ -2,6 +2,18 @@ {% load i18n %} {% load bookwyrm_tags %} +{% block opengraph_images %} + +{% firstof status.book status.mention_books.first as book %} +{% if book %} + {% include 'snippets/opengraph_images.html' with image=preview %} +{% else %} + {% include 'snippets/opengraph_images.html' %} +{% endif %} + +{% endblock %} + + {% block panel %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.name.errors id="desc_name" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.summary.errors id="desc_summary" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=form.avatar.errors id="desc_avatar" %}{{ error|escape }}
- {% endfor %} + + + {% include 'snippets/form_errors.html' with errors_list=request_form.email.errors id="desc_request_email" %}{{ error | escape }}
- {% endfor %} + + {% include 'snippets/form_errors.html' with errors_list=login_form.password.errors id="desc_password" %}{{ error }}
- {% endfor %} + + {% if errors %} ++ {{ error }} +
+ {% endfor %} +