diff --git a/.css-config-sample/_instance-settings.scss b/.css-config-sample/_instance-settings.scss
deleted file mode 100644
index e86c1ce00..000000000
--- a/.css-config-sample/_instance-settings.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-@charset "utf-8";
-
-// Copy this file to bookwyrm/static/css/ and set your instance custom styles.
diff --git a/.github/workflows/django-tests.yml b/.github/workflows/django-tests.yml
index 00e08dadb..97a744813 100644
--- a/.github/workflows/django-tests.yml
+++ b/.github/workflows/django-tests.yml
@@ -55,5 +55,6 @@ jobs:
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
ENABLE_PREVIEW_IMAGES: false
+ ENABLE_THUMBNAIL_GENERATION: true
run: |
pytest -n 3
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
index 1b14149f2..a3117f7cb 100644
--- a/.github/workflows/pylint.yml
+++ b/.github/workflows/pylint.yml
@@ -21,8 +21,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- pip install pylint
- name: Analysing the code with pylint
run: |
- pylint bookwyrm/ --ignore=migrations,tests --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801
+ pylint bookwyrm/
diff --git a/.gitignore b/.gitignore
index 92fc85bc0..ec2a08f80 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,7 +17,8 @@
.env
/images/
bookwyrm/static/css/bookwyrm.css
-bookwyrm/static/css/_instance-settings.scss
+bookwyrm/static/css/themes/
+!bookwyrm/static/css/themes/bookwyrm-*.scss
# Testing
.coverage
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 000000000..9fa808eed
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1 @@
+**/vendor/*
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 000000000..464638853
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,9 @@
+[MAIN]
+ignore=migrations
+load-plugins=pylint.extensions.no_self_use
+
+[MESSAGES CONTROL]
+disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C3001,import-error
+
+[FORMAT]
+max-line-length=88
diff --git a/Dockerfile b/Dockerfile
index 349dd82b1..b3cd26e88 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -6,6 +6,7 @@ RUN mkdir /app /app/static /app/images
WORKDIR /app
+RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean
+
COPY requirements.txt /app/
RUN pip install -r requirements.txt --no-cache-dir
-RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean
diff --git a/README.md b/README.md
index bd7344df9..558d42d45 100644
--- a/README.md
+++ b/README.md
@@ -1,63 +1,45 @@
# BookWyrm
-Social reading and reviewing, decentralized with ActivityPub
+[](https://github.com/bookwyrm-social/bookwyrm/releases)
+[](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/django-tests.yml)
+[](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/pylint.yml)
-## Contents
-- [Joining BookWyrm](#joining-bookwyrm)
-- [Contributing](#contributing)
-- [About BookWyrm](#about-bookwyrm)
- - [What it is and isn't](#what-it-is-and-isnt)
- - [The role of federation](#the-role-of-federation)
- - [Features](#features)
-- [Book data](#book-data)
-- [Set up Bookwyrm](#set-up-bookwyrm)
-
-## Joining BookWyrm
-BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://joinbookwyrm.com/instances/) list.
-
-You can request an invite by entering your email address at https://bookwyrm.social.
+BookWyrm is a social network for tracking your reading, talking about books, writing reviews, and discovering what to read next. Federation allows BookWyrm users to join small, trusted communities that can connect with one another, and with other ActivityPub services like [Mastodon](https://joinmastodon.org/) and [Pleroma](http://pleroma.social/).
-## Contributing
-See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions.
+## Links
+
+[](https://tech.lgbt/@bookwyrm)
+[](https://twitter.com/BookWyrmSocial)
+
+ - [Project homepage](https://joinbookwyrm.com/)
+ - [Support](https://patreon.com/bookwyrm)
+ - [Documentation](https://docs.joinbookwyrm.com/)
+
## About BookWyrm
-### What it is and isn't
-BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree.
+BookWyrm is a platform for social reading. You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree.
-### The role of federation
+## Federation
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
-### Features
-Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/bookwyrm-social/bookwyrm/issues) to get the conversation going!
-- Posting about books
- - Compose reviews, with or without ratings, which are aggregated in the book page
- - Compose other kinds of statuses about books, such as:
- - Comments on a book
- - Quotes or excerpts
- - Reply to statuses
- - View aggregate reviews of a book across connected BookWyrm instances
- - Differentiate local and federated reviews and rating in your activity feed
-- Track reading activity
- - Shelve books on default "to-read," "currently reading," and "read" shelves
- - Create custom shelves
- - Store started reading/finished reading dates, as well as progress updates along the way
- - Update followers about reading activity (optionally, and with granular privacy controls)
- - Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator
-- Federation with ActivityPub
- - Broadcast and receive user statuses and activity
- - Share book data between instances to create a networked database of metadata
- - Identify shared books across instances and aggregate related content
- - Follow and interact with users across BookWyrm instances
- - Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported)
-- Granular privacy controls
- - Private, followers-only, and public privacy levels for posting, shelves, and lists
- - Option for users to manually approve followers
- - Allow blocking and flagging for moderation
+## Features
-### The Tech Stack
+### Post about books
+Compose reviews, comment on what you're reading, and post quotes from books. You can converse with other BookWyrm users across the network about what they're reading.
+
+### Track reading activity
+Keep track of what books you've read, and what books you'd like to read in the future.
+
+### Federation with ActivityPub
+Federation allows you to interact with users on other instances and services, and also shares metadata about books and authors, which collaboratively builds a decentralized database of books.
+
+### Privacy and moderation
+Users and administrators can control who can see thier posts and what other instances to federate with.
+
+## Tech Stack
Web backend
- [Django](https://www.djangoproject.com/) web server
- [PostgreSQL](https://www.postgresql.org/) database
@@ -78,8 +60,5 @@ Deployment
- [Nginx](https://nginx.org/en/) HTTP server
-## Book data
-The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
-
-## Set up Bookwyrm
-The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up Bookwyrm in a [developer environment](https://docs.joinbookwyrm.com/developer-environment.html) or [production](https://docs.joinbookwyrm.com/installing-in-production.html).
+## Set up BookWyrm
+The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up BookWyrm in a [developer environment](https://docs.joinbookwyrm.com/install-dev.html) or [production](https://docs.joinbookwyrm.com/install-prod.html).
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 000000000..c4e5e9cf9
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,5 @@
+# Security Policy
+
+## Reporting a Vulnerability
+
+Please report security issues to `mousereeve@riseup.net`
\ No newline at end of file
diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py
index 179eeaab5..0833a692c 100644
--- a/bookwyrm/activitypub/base_activity.py
+++ b/bookwyrm/activitypub/base_activity.py
@@ -1,18 +1,21 @@
""" basics for an activitypub serializer """
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
+import requests
+import logging
from django.apps import apps
from django.db import IntegrityError, transaction
+from django.utils.http import http_date
+from bookwyrm import models
from bookwyrm.connectors import ConnectorException, get_data
+from bookwyrm.signatures import make_signature
+from bookwyrm.settings import DOMAIN
from bookwyrm.tasks import app
-import requests
-from django.utils.http import http_date
-from bookwyrm import models
-from bookwyrm.signatures import make_signature
-from bookwyrm.settings import DOMAIN
+logger = logging.getLogger(__name__)
+
class ActivitySerializerError(ValueError):
"""routine problems serializing activitypub json"""
@@ -44,12 +47,12 @@ def naive_parse(activity_objects, activity_json, serializer=None):
activity_json["type"] = "PublicKey"
activity_type = activity_json.get("type")
+ if activity_type in ["Question", "Article"]:
+ return None
try:
serializer = activity_objects[activity_type]
except KeyError as err:
# we know this exists and that we can't handle it
- if activity_type in ["Question"]:
- return None
raise ActivitySerializerError(err)
return serializer(activity_objects=activity_objects, **activity_json)
@@ -70,7 +73,7 @@ class ActivityObject:
try:
value = kwargs[field.name]
if value in (None, MISSING, {}):
- raise KeyError()
+ raise KeyError("Missing required field", field.name)
try:
is_subclass = issubclass(field.type, ActivityObject)
except TypeError:
@@ -279,9 +282,9 @@ def resolve_remote_id(
else:
raise e
except ConnectorException:
- raise ActivitySerializerError(
- f"Could not connect to host for remote_id: {remote_id}"
- )
+ logger.exception("Could not connect to host for remote_id: %s", remote_id)
+ return None
+
# determine the model implicitly, if not provided
# or if it's a model with subclasses like Status, check again
if not model or hasattr(model.objects, "select_subclasses"):
diff --git a/bookwyrm/activitystreams.py b/bookwyrm/activitystreams.py
index f2dd43fb2..a90d7943b 100644
--- a/bookwyrm/activitystreams.py
+++ b/bookwyrm/activitystreams.py
@@ -298,8 +298,9 @@ def add_status_on_create_command(sender, instance, created):
priority = HIGH
# check if this is an old status, de-prioritize if so
# (this will happen if federation is very slow, or, more expectedly, on csv import)
- one_day = 60 * 60 * 24
- if (instance.created_date - instance.published_date).seconds > one_day:
+ if instance.published_date < timezone.now() - timedelta(
+ days=1
+ ) or instance.created_date < instance.published_date - timedelta(days=1):
priority = LOW
add_status_task.apply_async(
diff --git a/bookwyrm/apps.py b/bookwyrm/apps.py
index d494877d6..786f86e1c 100644
--- a/bookwyrm/apps.py
+++ b/bookwyrm/apps.py
@@ -19,11 +19,11 @@ def download_file(url, destination):
with open(destination, "b+w") as outfile:
outfile.write(stream.read())
except (urllib.error.HTTPError, urllib.error.URLError):
- logger.error("Failed to download file %s", url)
+ logger.info("Failed to download file %s", url)
except OSError:
- logger.error("Couldn't open font file %s for writing", destination)
+ logger.info("Couldn't open font file %s for writing", destination)
except: # pylint: disable=bare-except
- logger.exception("Unknown error in file download")
+ logger.info("Unknown error in file download")
class BookwyrmConfig(AppConfig):
diff --git a/bookwyrm/book_search.py b/bookwyrm/book_search.py
index e42a6d8c3..4b0a6eab9 100644
--- a/bookwyrm/book_search.py
+++ b/bookwyrm/book_search.py
@@ -148,8 +148,8 @@ class SearchResult:
def __repr__(self):
# pylint: disable=consider-using-f-string
- return "".format(
- self.key, self.title, self.author
+ return "".format(
+ self.key, self.title, self.author, self.confidence
)
def json(self):
diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py
index d8b9c6300..dc4be4b3d 100644
--- a/bookwyrm/connectors/abstract_connector.py
+++ b/bookwyrm/connectors/abstract_connector.py
@@ -1,9 +1,8 @@
""" functionality outline for a book data connector """
from abc import ABC, abstractmethod
import imghdr
-import ipaddress
import logging
-from urllib.parse import urlparse
+import re
from django.core.files.base import ContentFile
from django.db import transaction
@@ -11,7 +10,7 @@ import requests
from requests.exceptions import RequestException
from bookwyrm import activitypub, models, settings
-from .connector_manager import load_more_data, ConnectorException
+from .connector_manager import load_more_data, ConnectorException, raise_not_valid_url
from .format_mappings import format_mappings
@@ -39,62 +38,34 @@ class AbstractMinimalConnector(ABC):
for field in self_fields:
setattr(self, field, getattr(info, field))
- def search(self, query, min_confidence=None, timeout=settings.QUERY_TIMEOUT):
- """free text search"""
- params = {}
- if min_confidence:
- params["min_confidence"] = min_confidence
+ def get_search_url(self, query):
+ """format the query url"""
+ # Check if the query resembles an ISBN
+ if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
+ return f"{self.isbn_search_url}{query}"
- data = self.get_search_data(
- f"{self.search_url}{query}",
- params=params,
- timeout=timeout,
- )
- results = []
+ # NOTE: previously, we tried searching isbn and if that produces no results,
+ # searched as free text. This, instead, only searches isbn if it's isbn-y
+ return f"{self.search_url}{query}"
- for doc in self.parse_search_data(data)[:10]:
- results.append(self.format_search_result(doc))
- return results
-
- def isbn_search(self, query, timeout=settings.QUERY_TIMEOUT):
- """isbn search"""
- params = {}
- data = self.get_search_data(
- f"{self.isbn_search_url}{query}",
- params=params,
- timeout=timeout,
- )
- results = []
-
- # this shouldn't be returning mutliple results, but just in case
- for doc in self.parse_isbn_search_data(data)[:10]:
- results.append(self.format_isbn_search_result(doc))
- return results
-
- def get_search_data(self, remote_id, **kwargs): # pylint: disable=no-self-use
- """this allows connectors to override the default behavior"""
- return get_data(remote_id, **kwargs)
+ def process_search_response(self, query, data, min_confidence):
+ """Format the search results based on the formt of the query"""
+ if maybe_isbn(query):
+ return list(self.parse_isbn_search_data(data))[:10]
+ return list(self.parse_search_data(data, min_confidence))[:10]
@abstractmethod
def get_or_create_book(self, remote_id):
"""pull up a book record by whatever means possible"""
@abstractmethod
- def parse_search_data(self, data):
+ def parse_search_data(self, data, min_confidence):
"""turn the result json from a search into a list"""
- @abstractmethod
- def format_search_result(self, search_result):
- """create a SearchResult obj from json"""
-
@abstractmethod
def parse_isbn_search_data(self, data):
"""turn the result json from a search into a list"""
- @abstractmethod
- def format_isbn_search_result(self, search_result):
- """create a SearchResult obj from json"""
-
class AbstractConnector(AbstractMinimalConnector):
"""generic book data connector"""
@@ -131,7 +102,7 @@ class AbstractConnector(AbstractMinimalConnector):
try:
work_data = self.get_work_from_edition_data(data)
except (KeyError, ConnectorException) as err:
- logger.exception(err)
+ logger.info(err)
work_data = data
if not work_data or not edition_data:
@@ -254,9 +225,6 @@ def get_data(url, params=None, timeout=10):
# check if the url is blocked
raise_not_valid_url(url)
- if models.FederatedServer.is_blocked(url):
- raise ConnectorException(f"Attempting to load data from blocked url: {url}")
-
try:
resp = requests.get(
url,
@@ -270,7 +238,7 @@ def get_data(url, params=None, timeout=10):
timeout=timeout,
)
except RequestException as err:
- logger.exception(err)
+ logger.info(err)
raise ConnectorException(err)
if not resp.ok:
@@ -278,7 +246,7 @@ def get_data(url, params=None, timeout=10):
try:
data = resp.json()
except ValueError as err:
- logger.exception(err)
+ logger.info(err)
raise ConnectorException(err)
return data
@@ -296,7 +264,7 @@ def get_image(url, timeout=10):
timeout=timeout,
)
except RequestException as err:
- logger.exception(err)
+ logger.info(err)
return None, None
if not resp.ok:
@@ -305,26 +273,12 @@ def get_image(url, timeout=10):
image_content = ContentFile(resp.content)
extension = imghdr.what(None, image_content.read())
if not extension:
- logger.exception("File requested was not an image: %s", url)
+ logger.info("File requested was not an image: %s", url)
return None, None
return image_content, extension
-def raise_not_valid_url(url):
- """do some basic reality checks on the url"""
- parsed = urlparse(url)
- if not parsed.scheme in ["http", "https"]:
- raise ConnectorException("Invalid scheme: ", url)
-
- try:
- ipaddress.ip_address(parsed.netloc)
- raise ConnectorException("Provided url is an IP address: ", url)
- except ValueError:
- # it's not an IP address, which is good
- pass
-
-
class Mapping:
"""associate a local database field with a field in an external dataset"""
@@ -366,3 +320,9 @@ def unique_physical_format(format_text):
# try a direct match, so saving this would be redundant
return None
return format_text
+
+
+def maybe_isbn(query):
+ """check if a query looks like an isbn"""
+ isbn = re.sub(r"[\W_]", "", query) # removes filler characters
+ return len(isbn) in [10, 13] # ISBN10 or ISBN13
diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py
index 6dcba7c31..e07a0b281 100644
--- a/bookwyrm/connectors/bookwyrm_connector.py
+++ b/bookwyrm/connectors/bookwyrm_connector.py
@@ -10,15 +10,12 @@ class Connector(AbstractMinimalConnector):
def get_or_create_book(self, remote_id):
return activitypub.resolve_remote_id(remote_id, model=models.Edition)
- def parse_search_data(self, data):
- return data
-
- def format_search_result(self, search_result):
- search_result["connector"] = self
- return SearchResult(**search_result)
+ def parse_search_data(self, data, min_confidence):
+ for search_result in data:
+ search_result["connector"] = self
+ yield SearchResult(**search_result)
def parse_isbn_search_data(self, data):
- return data
-
- def format_isbn_search_result(self, search_result):
- return self.format_search_result(search_result)
+ for search_result in data:
+ search_result["connector"] = self
+ yield SearchResult(**search_result)
diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py
index 3bdd5cb41..385880e5a 100644
--- a/bookwyrm/connectors/connector_manager.py
+++ b/bookwyrm/connectors/connector_manager.py
@@ -1,17 +1,18 @@
""" interface with whatever connectors the app has """
-from datetime import datetime
+import asyncio
import importlib
+import ipaddress
import logging
-import re
from urllib.parse import urlparse
+import aiohttp
from django.dispatch import receiver
from django.db.models import signals
from requests import HTTPError
from bookwyrm import book_search, models
-from bookwyrm.settings import SEARCH_TIMEOUT
+from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
@@ -21,53 +22,85 @@ class ConnectorException(HTTPError):
"""when the connector can't do what was asked"""
+async def get_results(session, url, min_confidence, query, connector):
+ """try this specific connector"""
+ # pylint: disable=line-too-long
+ headers = {
+ "Accept": (
+ 'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'
+ ),
+ "User-Agent": USER_AGENT,
+ }
+ params = {"min_confidence": min_confidence}
+ try:
+ async with session.get(url, headers=headers, params=params) as response:
+ if not response.ok:
+ logger.info("Unable to connect to %s: %s", url, response.reason)
+ return
+
+ try:
+ raw_data = await response.json()
+ except aiohttp.client_exceptions.ContentTypeError as err:
+ logger.exception(err)
+ return
+
+ return {
+ "connector": connector,
+ "results": connector.process_search_response(
+ query, raw_data, min_confidence
+ ),
+ }
+ except asyncio.TimeoutError:
+ logger.info("Connection timed out for url: %s", url)
+ except aiohttp.ClientError as err:
+ logger.info(err)
+
+
+async def async_connector_search(query, items, min_confidence):
+ """Try a number of requests simultaneously"""
+ timeout = aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
+ tasks = []
+ for url, connector in items:
+ tasks.append(
+ asyncio.ensure_future(
+ get_results(session, url, min_confidence, query, connector)
+ )
+ )
+
+ results = await asyncio.gather(*tasks)
+ return results
+
+
def search(query, min_confidence=0.1, return_first=False):
"""find books based on arbitary keywords"""
if not query:
return []
results = []
- # Have we got a ISBN ?
- isbn = re.sub(r"[\W_]", "", query)
- maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
-
- start_time = datetime.now()
+ items = []
for connector in get_connectors():
- result_set = None
- if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url != "":
- # Search on ISBN
- try:
- result_set = connector.isbn_search(isbn)
- except Exception as err: # pylint: disable=broad-except
- logger.exception(err)
- # if this fails, we can still try regular search
+ # get the search url from the connector before sending
+ url = connector.get_search_url(query)
+ try:
+ raise_not_valid_url(url)
+ except ConnectorException:
+ # if this URL is invalid we should skip it and move on
+ logger.info("Request denied to blocked domain: %s", url)
+ continue
+ items.append((url, connector))
- # if no isbn search results, we fallback to generic search
- if not result_set:
- try:
- result_set = connector.search(query, min_confidence=min_confidence)
- except Exception as err: # pylint: disable=broad-except
- # we don't want *any* error to crash the whole search page
- logger.exception(err)
- continue
-
- if return_first and result_set:
- # if we found anything, return it
- return result_set[0]
-
- if result_set:
- results.append(
- {
- "connector": connector,
- "results": result_set,
- }
- )
- if (datetime.now() - start_time).seconds >= SEARCH_TIMEOUT:
- break
+ # load as many results as we can
+ results = asyncio.run(async_connector_search(query, items, min_confidence))
+ results = [r for r in results if r]
if return_first:
- return None
+ # find the best result from all the responses and return that
+ all_results = [r for con in results for r in con["results"]]
+ all_results = sorted(all_results, key=lambda r: r.confidence, reverse=True)
+ return all_results[0] if all_results else None
+ # failed requests will return None, so filter those out
return results
@@ -119,6 +152,15 @@ def load_more_data(connector_id, book_id):
connector.expand_book_data(book)
+@app.task(queue="low_priority")
+def create_edition_task(connector_id, work_id, data):
+ """separate task for each of the 10,000 editions of LoTR"""
+ connector_info = models.Connector.objects.get(id=connector_id)
+ connector = load_connector(connector_info)
+ work = models.Work.objects.select_subclasses().get(id=work_id)
+ connector.create_edition_from_data(work, data)
+
+
def load_connector(connector_info):
"""instantiate the connector class"""
connector = importlib.import_module(
@@ -133,3 +175,20 @@ def create_connector(sender, instance, created, *args, **kwargs):
"""create a connector to an external bookwyrm server"""
if instance.application_type == "bookwyrm":
get_or_create_connector(f"https://{instance.server_name}")
+
+
+def raise_not_valid_url(url):
+ """do some basic reality checks on the url"""
+ parsed = urlparse(url)
+ if not parsed.scheme in ["http", "https"]:
+ raise ConnectorException("Invalid scheme: ", url)
+
+ try:
+ ipaddress.ip_address(parsed.netloc)
+ raise ConnectorException("Provided url is an IP address: ", url)
+ except ValueError:
+ # it's not an IP address, which is good
+ pass
+
+ if models.FederatedServer.is_blocked(url):
+ raise ConnectorException(f"Attempting to load data from blocked url: {url}")
diff --git a/bookwyrm/connectors/inventaire.py b/bookwyrm/connectors/inventaire.py
index a9aeb94f9..df9b2e43a 100644
--- a/bookwyrm/connectors/inventaire.py
+++ b/bookwyrm/connectors/inventaire.py
@@ -5,7 +5,7 @@ from bookwyrm import models
from bookwyrm.book_search import SearchResult
from .abstract_connector import AbstractConnector, Mapping
from .abstract_connector import get_data
-from .connector_manager import ConnectorException
+from .connector_manager import ConnectorException, create_edition_task
class Connector(AbstractConnector):
@@ -77,53 +77,42 @@ class Connector(AbstractConnector):
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
}
- def search(self, query, min_confidence=None): # pylint: disable=arguments-differ
- """overrides default search function with confidence ranking"""
- results = super().search(query)
- if min_confidence:
- # filter the search results after the fact
- return [r for r in results if r.confidence >= min_confidence]
- return results
-
- def parse_search_data(self, data):
- return data.get("results")
-
- def format_search_result(self, search_result):
- images = search_result.get("image")
- cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
- # a deeply messy translation of inventaire's scores
- confidence = float(search_result.get("_score", 0.1))
- confidence = 0.1 if confidence < 150 else 0.999
- return SearchResult(
- title=search_result.get("label"),
- key=self.get_remote_id(search_result.get("uri")),
- author=search_result.get("description"),
- view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
- cover=cover,
- confidence=confidence,
- connector=self,
- )
+ def parse_search_data(self, data, min_confidence):
+ for search_result in data.get("results", []):
+ images = search_result.get("image")
+ cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
+ # a deeply messy translation of inventaire's scores
+ confidence = float(search_result.get("_score", 0.1))
+ confidence = 0.1 if confidence < 150 else 0.999
+ if confidence < min_confidence:
+ continue
+ yield SearchResult(
+ title=search_result.get("label"),
+ key=self.get_remote_id(search_result.get("uri")),
+ author=search_result.get("description"),
+ view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
+ cover=cover,
+ confidence=confidence,
+ connector=self,
+ )
def parse_isbn_search_data(self, data):
"""got some daaaata"""
results = data.get("entities")
if not results:
- return []
- return list(results.values())
-
- def format_isbn_search_result(self, search_result):
- """totally different format than a regular search result"""
- title = search_result.get("claims", {}).get("wdt:P1476", [])
- if not title:
- return None
- return SearchResult(
- title=title[0],
- key=self.get_remote_id(search_result.get("uri")),
- author=search_result.get("description"),
- view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
- cover=self.get_cover_url(search_result.get("image")),
- connector=self,
- )
+ return
+ for search_result in list(results.values()):
+ title = search_result.get("claims", {}).get("wdt:P1476", [])
+ if not title:
+ continue
+ yield SearchResult(
+ title=title[0],
+ key=self.get_remote_id(search_result.get("uri")),
+ author=search_result.get("description"),
+ view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
+ cover=self.get_cover_url(search_result.get("image")),
+ connector=self,
+ )
def is_work_data(self, data):
return data.get("type") == "work"
@@ -167,12 +156,17 @@ class Connector(AbstractConnector):
for edition_uri in edition_options.get("uris"):
remote_id = self.get_remote_id(edition_uri)
+ create_edition_task.delay(self.connector.id, work.id, remote_id)
+
+ def create_edition_from_data(self, work, edition_data, instance=None):
+ """pass in the url as data and then call the version in abstract connector"""
+ if isinstance(edition_data, str):
try:
- data = self.get_book_data(remote_id)
+ edition_data = self.get_book_data(edition_data)
except ConnectorException:
# who, indeed, knows
- continue
- self.create_edition_from_data(work, data)
+ return
+ super().create_edition_from_data(work, edition_data, instance=instance)
def get_cover_url(self, cover_blob, *_):
"""format the relative cover url into an absolute one:
diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py
index 118222a16..0fd786660 100644
--- a/bookwyrm/connectors/openlibrary.py
+++ b/bookwyrm/connectors/openlibrary.py
@@ -5,7 +5,7 @@ from bookwyrm import models
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 .connector_manager import ConnectorException, create_edition_task
from .openlibrary_languages import languages
@@ -152,39 +152,41 @@ class Connector(AbstractConnector):
image_name = f"{cover_id}-{size}.jpg"
return f"{self.covers_url}/b/id/{image_name}"
- def parse_search_data(self, data):
- return data.get("docs")
+ def parse_search_data(self, data, min_confidence):
+ for idx, search_result in enumerate(data.get("docs")):
+ # build the remote id from the openlibrary key
+ key = self.books_url + search_result["key"]
+ author = search_result.get("author_name") or ["Unknown"]
+ cover_blob = search_result.get("cover_i")
+ cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
- def format_search_result(self, search_result):
- # build the remote id from the openlibrary key
- key = self.books_url + search_result["key"]
- author = search_result.get("author_name") or ["Unknown"]
- cover_blob = search_result.get("cover_i")
- cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
- return SearchResult(
- title=search_result.get("title"),
- key=key,
- author=", ".join(author),
- connector=self,
- year=search_result.get("first_publish_year"),
- cover=cover,
- )
+ # OL doesn't provide confidence, but it does sort by an internal ranking, so
+ # this confidence value is relative to the list position
+ confidence = 1 / (idx + 1)
+
+ yield SearchResult(
+ title=search_result.get("title"),
+ key=key,
+ author=", ".join(author),
+ connector=self,
+ year=search_result.get("first_publish_year"),
+ cover=cover,
+ confidence=confidence,
+ )
def parse_isbn_search_data(self, data):
- return list(data.values())
-
- def format_isbn_search_result(self, search_result):
- # build the remote id from the openlibrary key
- key = self.books_url + search_result["key"]
- authors = search_result.get("authors") or [{"name": "Unknown"}]
- author_names = [author.get("name") for author in authors]
- return SearchResult(
- title=search_result.get("title"),
- key=key,
- author=", ".join(author_names),
- connector=self,
- year=search_result.get("publish_date"),
- )
+ for search_result in list(data.values()):
+ # build the remote id from the openlibrary key
+ key = self.books_url + search_result["key"]
+ authors = search_result.get("authors") or [{"name": "Unknown"}]
+ author_names = [author.get("name") for author in authors]
+ yield SearchResult(
+ title=search_result.get("title"),
+ key=key,
+ author=", ".join(author_names),
+ connector=self,
+ year=search_result.get("publish_date"),
+ )
def load_edition_data(self, olkey):
"""query openlibrary for editions of a work"""
@@ -208,7 +210,7 @@ class Connector(AbstractConnector):
# does this edition have ANY interesting data?
if ignore_edition(edition_data):
continue
- self.create_edition_from_data(work, edition_data)
+ create_edition_task.delay(self.connector.id, work.id, edition_data)
def ignore_edition(edition_data):
diff --git a/bookwyrm/context_processors.py b/bookwyrm/context_processors.py
index 309e84ed1..0047bfce1 100644
--- a/bookwyrm/context_processors.py
+++ b/bookwyrm/context_processors.py
@@ -8,8 +8,20 @@ def site_settings(request): # pylint: disable=unused-argument
if not request.is_secure():
request_protocol = "http://"
+ site = models.SiteSettings.objects.get()
+ theme = "css/themes/bookwyrm-light.scss"
+ if (
+ hasattr(request, "user")
+ and request.user.is_authenticated
+ and request.user.theme
+ ):
+ theme = request.user.theme.path
+ elif site.default_theme:
+ theme = site.default_theme.path
+
return {
- "site": models.SiteSettings.objects.get(),
+ "site": site,
+ "site_theme": theme,
"active_announcements": models.Announcement.active_announcements(),
"thumbnail_generation_enabled": settings.ENABLE_THUMBNAIL_GENERATION,
"media_full_url": settings.MEDIA_FULL_URL,
diff --git a/bookwyrm/emailing.py b/bookwyrm/emailing.py
index 80aca071b..9349b8ae2 100644
--- a/bookwyrm/emailing.py
+++ b/bookwyrm/emailing.py
@@ -45,7 +45,8 @@ def moderation_report_email(report):
"""a report was created"""
data = email_data()
data["reporter"] = report.reporter.localname or report.reporter.username
- data["reportee"] = report.user.localname or report.user.username
+ if report.user:
+ data["reportee"] = report.user.localname or report.user.username
data["report_link"] = report.remote_id
for admin in models.User.objects.filter(
diff --git a/bookwyrm/forms.py b/bookwyrm/forms.py
deleted file mode 100644
index 33d453e60..000000000
--- a/bookwyrm/forms.py
+++ /dev/null
@@ -1,559 +0,0 @@
-""" using django model forms """
-import datetime
-from collections import defaultdict
-from urllib.parse import urlparse
-
-from django import forms
-from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
-from django.forms.widgets import Textarea
-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):
- """add css classes to the forms"""
-
- def __init__(self, *args, **kwargs):
- css_classes = defaultdict(lambda: "")
- css_classes["text"] = "input"
- css_classes["password"] = "input"
- css_classes["email"] = "input"
- css_classes["number"] = "input"
- css_classes["checkbox"] = "checkbox"
- css_classes["textarea"] = "textarea"
- # pylint: disable=super-with-arguments
- super(CustomForm, self).__init__(*args, **kwargs)
- for visible in self.visible_fields():
- if hasattr(visible.field.widget, "input_type"):
- input_type = visible.field.widget.input_type
- if isinstance(visible.field.widget, Textarea):
- input_type = "textarea"
- visible.field.widget.attrs["rows"] = 5
- visible.field.widget.attrs["class"] = css_classes[input_type]
-
-
-# pylint: disable=missing-class-docstring
-class LoginForm(CustomForm):
- class Meta:
- model = models.User
- fields = ["localname", "password"]
- help_texts = {f: None for f in fields}
- widgets = {
- "password": PasswordInput(),
- }
-
-
-class RegisterForm(CustomForm):
- class Meta:
- model = models.User
- fields = ["localname", "email", "password"]
- help_texts = {f: None for f in fields}
- widgets = {"password": PasswordInput()}
-
- def clean(self):
- """Check if the username is taken"""
- cleaned_data = super().clean()
- localname = cleaned_data.get("localname").strip()
- if models.User.objects.filter(localname=localname).first():
- self.add_error("localname", _("User with this username already exists"))
-
-
-class RatingForm(CustomForm):
- class Meta:
- model = models.ReviewRating
- fields = ["user", "book", "rating", "privacy"]
-
-
-class ReviewForm(CustomForm):
- class Meta:
- model = models.Review
- fields = [
- "user",
- "book",
- "name",
- "content",
- "rating",
- "content_warning",
- "sensitive",
- "privacy",
- ]
-
-
-class CommentForm(CustomForm):
- class Meta:
- model = models.Comment
- fields = [
- "user",
- "book",
- "content",
- "content_warning",
- "sensitive",
- "privacy",
- "progress",
- "progress_mode",
- "reading_status",
- ]
-
-
-class QuotationForm(CustomForm):
- class Meta:
- model = models.Quotation
- fields = [
- "user",
- "book",
- "quote",
- "content",
- "content_warning",
- "sensitive",
- "privacy",
- "position",
- "position_mode",
- ]
-
-
-class ReplyForm(CustomForm):
- class Meta:
- model = models.Status
- fields = [
- "user",
- "content",
- "content_warning",
- "sensitive",
- "reply_parent",
- "privacy",
- ]
-
-
-class StatusForm(CustomForm):
- class Meta:
- model = models.Status
- fields = ["user", "content", "content_warning", "sensitive", "privacy"]
-
-
-class DirectForm(CustomForm):
- class Meta:
- model = models.Status
- fields = ["user", "content", "content_warning", "sensitive", "privacy"]
-
-
-class EditUserForm(CustomForm):
- class Meta:
- model = models.User
- fields = [
- "avatar",
- "name",
- "email",
- "summary",
- "show_goal",
- "show_suggested_users",
- "manually_approves_followers",
- "default_post_privacy",
- "discoverable",
- "hide_follows",
- "preferred_timezone",
- "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):
- class Meta:
- model = models.User
- fields = [
- "avatar",
- "name",
- "summary",
- "manually_approves_followers",
- "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):
- class Meta:
- model = models.User
- fields = ["password"]
-
-
-class UserGroupForm(CustomForm):
- class Meta:
- model = models.User
- 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
- fields = ["cover"]
- help_texts = {f: None for f in fields}
-
-
-class LinkDomainForm(CustomForm):
- class Meta:
- model = models.LinkDomain
- fields = ["name"]
-
-
-class FileLinkForm(CustomForm):
- class Meta:
- model = models.FileLink
- fields = ["url", "filetype", "availability", "book", "added_by"]
-
- def clean(self):
- """make sure the domain isn't blocked or pending"""
- cleaned_data = super().clean()
- url = cleaned_data.get("url")
- filetype = cleaned_data.get("filetype")
- book = cleaned_data.get("book")
- domain = urlparse(url).netloc
- if models.LinkDomain.objects.filter(domain=domain).exists():
- status = models.LinkDomain.objects.get(domain=domain).status
- if status == "blocked":
- # pylint: disable=line-too-long
- self.add_error(
- "url",
- _(
- "This domain is blocked. Please contact your administrator if you think this is an error."
- ),
- )
- elif models.FileLink.objects.filter(
- url=url, book=book, filetype=filetype
- ).exists():
- # pylint: disable=line-too-long
- self.add_error(
- "url",
- _(
- "This link with file type has already been added for this book. If it is not visible, the domain is still pending."
- ),
- )
-
-
-class EditionForm(CustomForm):
- class Meta:
- model = models.Edition
- exclude = [
- "remote_id",
- "origin_id",
- "created_date",
- "updated_date",
- "edition_rank",
- "authors",
- "parent_work",
- "shelves",
- "connector",
- "search_vector",
- "links",
- "file_links",
- ]
- 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):
- class Meta:
- model = models.Author
- fields = [
- "last_edited_by",
- "name",
- "aliases",
- "bio",
- "wikipedia_link",
- "born",
- "died",
- "openlibrary_key",
- "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):
- csv_file = forms.FileField()
-
-
-class ExpiryWidget(widgets.Select):
- def value_from_datadict(self, data, files, name):
- """human-readable exiration time buckets"""
- selected_string = super().value_from_datadict(data, files, name)
-
- if selected_string == "day":
- interval = datetime.timedelta(days=1)
- elif selected_string == "week":
- interval = datetime.timedelta(days=7)
- elif selected_string == "month":
- interval = datetime.timedelta(days=31) # Close enough?
- elif selected_string == "forever":
- return None
- else:
- return selected_string # This will raise
-
- return timezone.now() + interval
-
-
-class InviteRequestForm(CustomForm):
- def clean(self):
- """make sure the email isn't in use by a registered user"""
- cleaned_data = super().clean()
- email = cleaned_data.get("email")
- if email and models.User.objects.filter(email=email).exists():
- self.add_error("email", _("A user with this email already exists."))
-
- class Meta:
- model = models.InviteRequest
- fields = ["email"]
-
-
-class CreateInviteForm(CustomForm):
- class Meta:
- model = models.SiteInvite
- exclude = ["code", "user", "times_used", "invitees"]
- widgets = {
- "expiry": ExpiryWidget(
- choices=[
- ("day", _("One Day")),
- ("week", _("One Week")),
- ("month", _("One Month")),
- ("forever", _("Does Not Expire")),
- ]
- ),
- "use_limit": widgets.Select(
- choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
- + [(None, _("Unlimited"))]
- ),
- }
-
-
-class ShelfForm(CustomForm):
- class Meta:
- model = models.Shelf
- fields = ["user", "name", "privacy", "description"]
-
-
-class GoalForm(CustomForm):
- class Meta:
- model = models.AnnualGoal
- fields = ["user", "year", "goal", "privacy"]
-
-
-class SiteForm(CustomForm):
- class Meta:
- model = models.SiteSettings
- exclude = ["admin_code", "install_mode"]
- 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):
- class Meta:
- model = models.List
- fields = ["user", "name", "description", "curation", "privacy", "group"]
-
-
-class ListItemForm(CustomForm):
- class Meta:
- model = models.ListItem
- fields = ["user", "book", "book_list", "notes"]
-
-
-class GroupForm(CustomForm):
- class Meta:
- model = models.Group
- fields = ["user", "privacy", "name", "description"]
-
-
-class ReportForm(CustomForm):
- class Meta:
- model = models.Report
- fields = ["user", "reporter", "status", "links", "note"]
-
-
-class EmailBlocklistForm(CustomForm):
- class Meta:
- model = models.EmailBlocklist
- fields = ["domain"]
- widgets = {
- "avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
- }
-
-
-class IPBlocklistForm(CustomForm):
- class Meta:
- model = models.IPBlocklist
- fields = ["address"]
-
-
-class ServerForm(CustomForm):
- class Meta:
- model = models.FederatedServer
- exclude = ["remote_id"]
-
-
-class SortListForm(forms.Form):
- sort_by = ChoiceField(
- choices=(
- ("order", _("List Order")),
- ("title", _("Book Title")),
- ("rating", _("Rating")),
- ),
- label=_("Sort By"),
- )
- direction = ChoiceField(
- choices=(
- ("ascending", _("Ascending")),
- ("descending", _("Descending")),
- ),
- )
-
-
-class ReadThroughForm(CustomForm):
- def clean(self):
- """make sure the email isn't in use by a registered user"""
- cleaned_data = super().clean()
- start_date = cleaned_data.get("start_date")
- finish_date = cleaned_data.get("finish_date")
- if start_date and finish_date and start_date > finish_date:
- self.add_error(
- "finish_date", _("Reading finish date cannot be before start date.")
- )
-
- class Meta:
- model = models.ReadThrough
- fields = ["user", "book", "start_date", "finish_date"]
-
-
-class AutoModRuleForm(CustomForm):
- class Meta:
- model = models.AutoMod
- fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
diff --git a/bookwyrm/forms/__init__.py b/bookwyrm/forms/__init__.py
new file mode 100644
index 000000000..a37d126ac
--- /dev/null
+++ b/bookwyrm/forms/__init__.py
@@ -0,0 +1,13 @@
+""" make forms available to the app """
+# site admin
+from .admin import *
+from .author import *
+from .books import *
+from .edit_user import *
+from .forms import *
+from .groups import *
+from .landing import *
+from .links import *
+from .lists import *
+from .status import *
+from .user_admin import *
diff --git a/bookwyrm/forms/admin.py b/bookwyrm/forms/admin.py
new file mode 100644
index 000000000..4141327d3
--- /dev/null
+++ b/bookwyrm/forms/admin.py
@@ -0,0 +1,141 @@
+""" using django model forms """
+import datetime
+
+from django import forms
+from django.forms import widgets
+from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
+from django_celery_beat.models import IntervalSchedule
+
+from bookwyrm import models
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+class ExpiryWidget(widgets.Select):
+ def value_from_datadict(self, data, files, name):
+ """human-readable exiration time buckets"""
+ selected_string = super().value_from_datadict(data, files, name)
+
+ if selected_string == "day":
+ interval = datetime.timedelta(days=1)
+ elif selected_string == "week":
+ interval = datetime.timedelta(days=7)
+ elif selected_string == "month":
+ interval = datetime.timedelta(days=31) # Close enough?
+ elif selected_string == "forever":
+ return None
+ else:
+ return selected_string # This will raise
+
+ return timezone.now() + interval
+
+
+class CreateInviteForm(CustomForm):
+ class Meta:
+ model = models.SiteInvite
+ exclude = ["code", "user", "times_used", "invitees"]
+ widgets = {
+ "expiry": ExpiryWidget(
+ choices=[
+ ("day", _("One Day")),
+ ("week", _("One Week")),
+ ("month", _("One Month")),
+ ("forever", _("Does Not Expire")),
+ ]
+ ),
+ "use_limit": widgets.Select(
+ choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
+ + [(None, _("Unlimited"))]
+ ),
+ }
+
+
+class SiteForm(CustomForm):
+ class Meta:
+ model = models.SiteSettings
+ exclude = ["admin_code", "install_mode"]
+ 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 ThemeForm(CustomForm):
+ class Meta:
+ model = models.Theme
+ fields = ["name", "path"]
+ widgets = {
+ "name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
+ "path": forms.TextInput(
+ attrs={
+ "aria-describedby": "desc_path",
+ "placeholder": "css/themes/theme-name.scss",
+ }
+ ),
+ }
+
+
+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 EmailBlocklistForm(CustomForm):
+ class Meta:
+ model = models.EmailBlocklist
+ fields = ["domain"]
+ widgets = {
+ "avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
+ }
+
+
+class IPBlocklistForm(CustomForm):
+ class Meta:
+ model = models.IPBlocklist
+ fields = ["address"]
+
+
+class ServerForm(CustomForm):
+ class Meta:
+ model = models.FederatedServer
+ exclude = ["remote_id"]
+
+
+class AutoModRuleForm(CustomForm):
+ class Meta:
+ model = models.AutoMod
+ fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
+
+
+class IntervalScheduleForm(CustomForm):
+ class Meta:
+ model = IntervalSchedule
+ fields = ["every", "period"]
+
+ widgets = {
+ "every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
+ "period": forms.Select(attrs={"aria-describedby": "desc_period"}),
+ }
diff --git a/bookwyrm/forms/author.py b/bookwyrm/forms/author.py
new file mode 100644
index 000000000..ca59426de
--- /dev/null
+++ b/bookwyrm/forms/author.py
@@ -0,0 +1,47 @@
+""" using django model forms """
+from django import forms
+
+from bookwyrm import models
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+class AuthorForm(CustomForm):
+ class Meta:
+ model = models.Author
+ fields = [
+ "last_edited_by",
+ "name",
+ "aliases",
+ "bio",
+ "wikipedia_link",
+ "born",
+ "died",
+ "openlibrary_key",
+ "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"}
+ ),
+ }
diff --git a/bookwyrm/forms/books.py b/bookwyrm/forms/books.py
new file mode 100644
index 000000000..9b3c84010
--- /dev/null
+++ b/bookwyrm/forms/books.py
@@ -0,0 +1,104 @@
+""" using django model forms """
+from django import forms
+
+from bookwyrm import models
+from bookwyrm.models.fields import ClearableFileInputWithWarning
+from .custom_form import CustomForm
+from .widgets import ArrayWidget, SelectDateWidget, Select
+
+
+# pylint: disable=missing-class-docstring
+class CoverForm(CustomForm):
+ class Meta:
+ model = models.Book
+ fields = ["cover"]
+ help_texts = {f: None for f in fields}
+
+
+class EditionForm(CustomForm):
+ class Meta:
+ model = models.Edition
+ exclude = [
+ "remote_id",
+ "origin_id",
+ "created_date",
+ "updated_date",
+ "edition_rank",
+ "authors",
+ "parent_work",
+ "shelves",
+ "connector",
+ "search_vector",
+ "links",
+ "file_links",
+ ]
+ 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"}
+ ),
+ "subjects": ArrayWidget(),
+ "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": SelectDateWidget(
+ attrs={"aria-describedby": "desc_first_published_date"}
+ ),
+ "published_date": SelectDateWidget(
+ attrs={"aria-describedby": "desc_published_date"}
+ ),
+ "cover": ClearableFileInputWithWarning(
+ attrs={"aria-describedby": "desc_cover"}
+ ),
+ "physical_format": 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 EditionFromWorkForm(CustomForm):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ # make all fields hidden
+ for visible in self.visible_fields():
+ visible.field.widget = forms.HiddenInput()
+
+ class Meta:
+ model = models.Work
+ fields = [
+ "title",
+ "subtitle",
+ "authors",
+ "description",
+ "languages",
+ "series",
+ "series_number",
+ "subjects",
+ "subject_places",
+ "cover",
+ "first_published_date",
+ ]
diff --git a/bookwyrm/forms/custom_form.py b/bookwyrm/forms/custom_form.py
new file mode 100644
index 000000000..74a3417a2
--- /dev/null
+++ b/bookwyrm/forms/custom_form.py
@@ -0,0 +1,26 @@
+""" Overrides django's default form class """
+from collections import defaultdict
+from django.forms import ModelForm
+from django.forms.widgets import Textarea
+
+
+class CustomForm(ModelForm):
+ """add css classes to the forms"""
+
+ def __init__(self, *args, **kwargs):
+ css_classes = defaultdict(lambda: "")
+ css_classes["text"] = "input"
+ css_classes["password"] = "input"
+ css_classes["email"] = "input"
+ css_classes["number"] = "input"
+ css_classes["checkbox"] = "checkbox"
+ css_classes["textarea"] = "textarea"
+ # pylint: disable=super-with-arguments
+ super(CustomForm, self).__init__(*args, **kwargs)
+ for visible in self.visible_fields():
+ if hasattr(visible.field.widget, "input_type"):
+ input_type = visible.field.widget.input_type
+ if isinstance(visible.field.widget, Textarea):
+ input_type = "textarea"
+ visible.field.widget.attrs["rows"] = 5
+ visible.field.widget.attrs["class"] = css_classes[input_type]
diff --git a/bookwyrm/forms/edit_user.py b/bookwyrm/forms/edit_user.py
new file mode 100644
index 000000000..a291c6441
--- /dev/null
+++ b/bookwyrm/forms/edit_user.py
@@ -0,0 +1,101 @@
+""" using django model forms """
+from django import forms
+from django.contrib.auth.password_validation import validate_password
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+
+from bookwyrm import models
+from bookwyrm.models.fields import ClearableFileInputWithWarning
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+class EditUserForm(CustomForm):
+ class Meta:
+ model = models.User
+ fields = [
+ "avatar",
+ "name",
+ "email",
+ "summary",
+ "show_goal",
+ "show_suggested_users",
+ "manually_approves_followers",
+ "default_post_privacy",
+ "discoverable",
+ "hide_follows",
+ "preferred_timezone",
+ "preferred_language",
+ "theme",
+ ]
+ 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):
+ class Meta:
+ model = models.User
+ fields = [
+ "avatar",
+ "name",
+ "summary",
+ "manually_approves_followers",
+ "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):
+ class Meta:
+ model = models.User
+ fields = ["password"]
+
+
+class ChangePasswordForm(CustomForm):
+ current_password = forms.CharField(widget=forms.PasswordInput)
+ confirm_password = forms.CharField(widget=forms.PasswordInput)
+
+ class Meta:
+ model = models.User
+ fields = ["password"]
+ widgets = {
+ "password": forms.PasswordInput(),
+ }
+
+ def clean(self):
+ """Make sure passwords match and are valid"""
+ current_password = self.data.get("current_password")
+ if not self.instance.check_password(current_password):
+ self.add_error("current_password", _("Incorrect password"))
+
+ cleaned_data = super().clean()
+ new_password = cleaned_data.get("password")
+ confirm_password = self.data.get("confirm_password")
+
+ if new_password != confirm_password:
+ self.add_error("confirm_password", _("Password does not match"))
+
+ try:
+ validate_password(new_password)
+ except ValidationError as err:
+ self.add_error("password", err)
diff --git a/bookwyrm/forms/forms.py b/bookwyrm/forms/forms.py
new file mode 100644
index 000000000..4aa1e5758
--- /dev/null
+++ b/bookwyrm/forms/forms.py
@@ -0,0 +1,64 @@
+""" using django model forms """
+from django import forms
+from django.forms import widgets
+from django.utils.translation import gettext_lazy as _
+
+from bookwyrm import models
+from bookwyrm.models.user import FeedFilterChoices
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+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 ImportForm(forms.Form):
+ csv_file = forms.FileField()
+
+
+class ShelfForm(CustomForm):
+ class Meta:
+ model = models.Shelf
+ fields = ["user", "name", "privacy", "description"]
+
+
+class GoalForm(CustomForm):
+ class Meta:
+ model = models.AnnualGoal
+ fields = ["user", "year", "goal", "privacy"]
+
+
+class ReportForm(CustomForm):
+ class Meta:
+ model = models.Report
+ fields = ["user", "reporter", "status", "links", "note"]
+
+
+class ReadThroughForm(CustomForm):
+ def clean(self):
+ """don't let readthroughs end before they start"""
+ cleaned_data = super().clean()
+ start_date = cleaned_data.get("start_date")
+ finish_date = cleaned_data.get("finish_date")
+ if start_date and finish_date and start_date > finish_date:
+ self.add_error(
+ "finish_date", _("Reading finish date cannot be before start date.")
+ )
+ stopped_date = cleaned_data.get("stopped_date")
+ if start_date and stopped_date and start_date > stopped_date:
+ self.add_error(
+ "stopped_date", _("Reading stopped date cannot be before start date.")
+ )
+
+ class Meta:
+ model = models.ReadThrough
+ fields = ["user", "book", "start_date", "finish_date", "stopped_date"]
diff --git a/bookwyrm/forms/groups.py b/bookwyrm/forms/groups.py
new file mode 100644
index 000000000..90aace3ba
--- /dev/null
+++ b/bookwyrm/forms/groups.py
@@ -0,0 +1,10 @@
+""" using django model forms """
+from bookwyrm import models
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+class GroupForm(CustomForm):
+ class Meta:
+ model = models.Group
+ fields = ["user", "privacy", "name", "description"]
diff --git a/bookwyrm/forms/landing.py b/bookwyrm/forms/landing.py
new file mode 100644
index 000000000..a31e8a7c4
--- /dev/null
+++ b/bookwyrm/forms/landing.py
@@ -0,0 +1,76 @@
+""" Forms for the landing pages """
+from django import forms
+from django.contrib.auth.password_validation import validate_password
+from django.core.exceptions import ValidationError
+from django.utils.translation import gettext_lazy as _
+
+from bookwyrm import models
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+class LoginForm(CustomForm):
+ class Meta:
+ model = models.User
+ fields = ["localname", "password"]
+ help_texts = {f: None for f in fields}
+ widgets = {
+ "password": forms.PasswordInput(),
+ }
+
+
+class RegisterForm(CustomForm):
+ class Meta:
+ model = models.User
+ fields = ["localname", "email", "password"]
+ help_texts = {f: None for f in fields}
+ widgets = {"password": forms.PasswordInput()}
+
+ def clean(self):
+ """Check if the username is taken"""
+ cleaned_data = super().clean()
+ localname = cleaned_data.get("localname").strip()
+ try:
+ validate_password(cleaned_data.get("password"))
+ except ValidationError as err:
+ self.add_error("password", err)
+ if models.User.objects.filter(localname=localname).first():
+ self.add_error("localname", _("User with this username already exists"))
+
+
+class InviteRequestForm(CustomForm):
+ def clean(self):
+ """make sure the email isn't in use by a registered user"""
+ cleaned_data = super().clean()
+ email = cleaned_data.get("email")
+ if email and models.User.objects.filter(email=email).exists():
+ self.add_error("email", _("A user with this email already exists."))
+
+ class Meta:
+ model = models.InviteRequest
+ fields = ["email", "answer"]
+
+
+class PasswordResetForm(CustomForm):
+ confirm_password = forms.CharField(widget=forms.PasswordInput)
+
+ class Meta:
+ model = models.User
+ fields = ["password"]
+ widgets = {
+ "password": forms.PasswordInput(),
+ }
+
+ def clean(self):
+ """Make sure the passwords match and are valid"""
+ cleaned_data = super().clean()
+ new_password = cleaned_data.get("password")
+ confirm_password = self.data.get("confirm_password")
+
+ if new_password != confirm_password:
+ self.add_error("confirm_password", _("Password does not match"))
+
+ try:
+ validate_password(new_password)
+ except ValidationError as err:
+ self.add_error("password", err)
diff --git a/bookwyrm/forms/links.py b/bookwyrm/forms/links.py
new file mode 100644
index 000000000..de229bc2d
--- /dev/null
+++ b/bookwyrm/forms/links.py
@@ -0,0 +1,48 @@
+""" using django model forms """
+from urllib.parse import urlparse
+
+from django.utils.translation import gettext_lazy as _
+
+from bookwyrm import models
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+class LinkDomainForm(CustomForm):
+ class Meta:
+ model = models.LinkDomain
+ fields = ["name"]
+
+
+class FileLinkForm(CustomForm):
+ class Meta:
+ model = models.FileLink
+ fields = ["url", "filetype", "availability", "book", "added_by"]
+
+ def clean(self):
+ """make sure the domain isn't blocked or pending"""
+ cleaned_data = super().clean()
+ url = cleaned_data.get("url")
+ filetype = cleaned_data.get("filetype")
+ book = cleaned_data.get("book")
+ domain = urlparse(url).netloc
+ if models.LinkDomain.objects.filter(domain=domain).exists():
+ status = models.LinkDomain.objects.get(domain=domain).status
+ if status == "blocked":
+ # pylint: disable=line-too-long
+ self.add_error(
+ "url",
+ _(
+ "This domain is blocked. Please contact your administrator if you think this is an error."
+ ),
+ )
+ elif models.FileLink.objects.filter(
+ url=url, book=book, filetype=filetype
+ ).exists():
+ # pylint: disable=line-too-long
+ self.add_error(
+ "url",
+ _(
+ "This link with file type has already been added for this book. If it is not visible, the domain is still pending."
+ ),
+ )
diff --git a/bookwyrm/forms/lists.py b/bookwyrm/forms/lists.py
new file mode 100644
index 000000000..647db3bfe
--- /dev/null
+++ b/bookwyrm/forms/lists.py
@@ -0,0 +1,37 @@
+""" using django model forms """
+from django import forms
+from django.forms import ChoiceField
+from django.utils.translation import gettext_lazy as _
+
+from bookwyrm import models
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+class ListForm(CustomForm):
+ class Meta:
+ model = models.List
+ fields = ["user", "name", "description", "curation", "privacy", "group"]
+
+
+class ListItemForm(CustomForm):
+ class Meta:
+ model = models.ListItem
+ fields = ["user", "book", "book_list", "notes"]
+
+
+class SortListForm(forms.Form):
+ sort_by = ChoiceField(
+ choices=(
+ ("order", _("List Order")),
+ ("title", _("Book Title")),
+ ("rating", _("Rating")),
+ ),
+ label=_("Sort By"),
+ )
+ direction = ChoiceField(
+ choices=(
+ ("ascending", _("Ascending")),
+ ("descending", _("Descending")),
+ ),
+ )
diff --git a/bookwyrm/forms/status.py b/bookwyrm/forms/status.py
new file mode 100644
index 000000000..0800166bf
--- /dev/null
+++ b/bookwyrm/forms/status.py
@@ -0,0 +1,82 @@
+""" using django model forms """
+from bookwyrm import models
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+class RatingForm(CustomForm):
+ class Meta:
+ model = models.ReviewRating
+ fields = ["user", "book", "rating", "privacy"]
+
+
+class ReviewForm(CustomForm):
+ class Meta:
+ model = models.Review
+ fields = [
+ "user",
+ "book",
+ "name",
+ "content",
+ "rating",
+ "content_warning",
+ "sensitive",
+ "privacy",
+ ]
+
+
+class CommentForm(CustomForm):
+ class Meta:
+ model = models.Comment
+ fields = [
+ "user",
+ "book",
+ "content",
+ "content_warning",
+ "sensitive",
+ "privacy",
+ "progress",
+ "progress_mode",
+ "reading_status",
+ ]
+
+
+class QuotationForm(CustomForm):
+ class Meta:
+ model = models.Quotation
+ fields = [
+ "user",
+ "book",
+ "quote",
+ "content",
+ "content_warning",
+ "sensitive",
+ "privacy",
+ "position",
+ "position_mode",
+ ]
+
+
+class ReplyForm(CustomForm):
+ class Meta:
+ model = models.Status
+ fields = [
+ "user",
+ "content",
+ "content_warning",
+ "sensitive",
+ "reply_parent",
+ "privacy",
+ ]
+
+
+class StatusForm(CustomForm):
+ class Meta:
+ model = models.Status
+ fields = ["user", "content", "content_warning", "sensitive", "privacy"]
+
+
+class DirectForm(CustomForm):
+ class Meta:
+ model = models.Status
+ fields = ["user", "content", "content_warning", "sensitive", "privacy"]
diff --git a/bookwyrm/forms/user_admin.py b/bookwyrm/forms/user_admin.py
new file mode 100644
index 000000000..a3bf6fa8e
--- /dev/null
+++ b/bookwyrm/forms/user_admin.py
@@ -0,0 +1,10 @@
+""" using django model forms """
+from bookwyrm import models
+from .custom_form import CustomForm
+
+
+# pylint: disable=missing-class-docstring
+class UserGroupForm(CustomForm):
+ class Meta:
+ model = models.User
+ fields = ["groups"]
diff --git a/bookwyrm/forms/widgets.py b/bookwyrm/forms/widgets.py
new file mode 100644
index 000000000..ee9345aa0
--- /dev/null
+++ b/bookwyrm/forms/widgets.py
@@ -0,0 +1,70 @@
+""" using django model forms """
+from django import forms
+
+
+class ArrayWidget(forms.widgets.TextInput):
+ """Inputs for postgres array fields"""
+
+ # pylint: disable=unused-argument
+ # pylint: disable=no-self-use
+ def value_from_datadict(self, data, files, name):
+ """get all values for this name"""
+ return [i for i in data.getlist(name) if i]
+
+
+class Select(forms.Select):
+ """custom template for select widget"""
+
+ template_name = "widgets/select.html"
+
+
+class SelectDateWidget(forms.SelectDateWidget):
+ """
+ A widget that splits date input into two boxes and a numerical year.
+ """
+
+ template_name = "widgets/addon_multiwidget.html"
+ select_widget = Select
+
+ def get_context(self, name, value, attrs):
+ """sets individual widgets"""
+ context = super().get_context(name, value, attrs)
+ date_context = {}
+ year_name = self.year_field % name
+ date_context["year"] = forms.NumberInput().get_context(
+ name=year_name,
+ value=context["widget"]["value"]["year"],
+ attrs={
+ **context["widget"]["attrs"],
+ "id": f"id_{year_name}",
+ "class": "input",
+ },
+ )
+ month_choices = list(self.months.items())
+ if not self.is_required:
+ month_choices.insert(0, self.month_none_value)
+ month_name = self.month_field % name
+ date_context["month"] = self.select_widget(
+ attrs, choices=month_choices
+ ).get_context(
+ name=month_name,
+ value=context["widget"]["value"]["month"],
+ attrs={**context["widget"]["attrs"], "id": f"id_{month_name}"},
+ )
+ day_choices = [(i, i) for i in range(1, 32)]
+ if not self.is_required:
+ day_choices.insert(0, self.day_none_value)
+ day_name = self.day_field % name
+ date_context["day"] = self.select_widget(
+ attrs,
+ choices=day_choices,
+ ).get_context(
+ name=day_name,
+ value=context["widget"]["value"]["day"],
+ attrs={**context["widget"]["attrs"], "id": f"id_{day_name}"},
+ )
+ subwidgets = []
+ for field in self._parse_date_fmt():
+ subwidgets.append(date_context[field]["widget"])
+ context["widget"]["subwidgets"] = subwidgets
+ return context
diff --git a/bookwyrm/importers/__init__.py b/bookwyrm/importers/__init__.py
index dd3d62e8b..6ce50f160 100644
--- a/bookwyrm/importers/__init__.py
+++ b/bookwyrm/importers/__init__.py
@@ -1,6 +1,7 @@
""" import classes """
from .importer import Importer
+from .calibre_import import CalibreImporter
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
from .openlibrary_import import OpenLibraryImporter
diff --git a/bookwyrm/importers/calibre_import.py b/bookwyrm/importers/calibre_import.py
new file mode 100644
index 000000000..5426e9333
--- /dev/null
+++ b/bookwyrm/importers/calibre_import.py
@@ -0,0 +1,28 @@
+""" handle reading a csv from calibre """
+from bookwyrm.models import Shelf
+
+from . import Importer
+
+
+class CalibreImporter(Importer):
+ """csv downloads from Calibre"""
+
+ service = "Calibre"
+
+ def __init__(self, *args, **kwargs):
+ # Add timestamp to row_mappings_guesses for date_added to avoid
+ # integrity error
+ row_mappings_guesses = []
+
+ for field, mapping in self.row_mappings_guesses:
+ if field in ("date_added",):
+ row_mappings_guesses.append((field, mapping + ["timestamp"]))
+ else:
+ row_mappings_guesses.append((field, mapping))
+
+ self.row_mappings_guesses = row_mappings_guesses
+ super().__init__(*args, **kwargs)
+
+ def get_shelf(self, normalized_row):
+ # Calibre export does not indicate which shelf to use. Use a default one for now
+ return Shelf.TO_READ
diff --git a/bookwyrm/importers/librarything_import.py b/bookwyrm/importers/librarything_import.py
index 37730dee3..c6833547d 100644
--- a/bookwyrm/importers/librarything_import.py
+++ b/bookwyrm/importers/librarything_import.py
@@ -1,5 +1,8 @@
""" handle reading a tsv from librarything """
import re
+
+from bookwyrm.models import Shelf
+
from . import Importer
@@ -21,7 +24,7 @@ class LibrarythingImporter(Importer):
def get_shelf(self, normalized_row):
if normalized_row["date_finished"]:
- return "read"
+ return Shelf.READ_FINISHED
if normalized_row["date_started"]:
- return "reading"
- return "to-read"
+ return Shelf.READING
+ return Shelf.TO_READ
diff --git a/bookwyrm/lists_stream.py b/bookwyrm/lists_stream.py
index f6a35cc25..0977ad8c2 100644
--- a/bookwyrm/lists_stream.py
+++ b/bookwyrm/lists_stream.py
@@ -114,12 +114,20 @@ class ListsStream(RedisStore):
@receiver(signals.post_save, sender=models.List)
# pylint: disable=unused-argument
-def add_list_on_create(sender, instance, created, *args, **kwargs):
- """add newly created lists streamsstreams"""
- if not created:
+def add_list_on_create(sender, instance, created, *args, update_fields=None, **kwargs):
+ """add newly created lists streams"""
+ if created:
+ # when creating new things, gotta wait on the transaction
+ transaction.on_commit(lambda: add_list_on_create_command(instance.id))
return
- # when creating new things, gotta wait on the transaction
- transaction.on_commit(lambda: add_list_on_create_command(instance.id))
+
+ # if update_fields was specified, we can check if privacy was updated, but if
+ # it wasn't specified (ie, by an activitypub update), there's no way to know
+ if update_fields and "privacy" not in update_fields:
+ return
+
+ # the privacy may have changed, so we need to re-do the whole thing
+ remove_list_task.delay(instance.id, re_add=True)
@receiver(signals.post_delete, sender=models.List)
@@ -217,7 +225,7 @@ def populate_lists_task(user_id):
@app.task(queue=MEDIUM)
-def remove_list_task(list_id):
+def remove_list_task(list_id, re_add=False):
"""remove a list from any stream it might be in"""
stores = models.User.objects.filter(local=True, is_active=True).values_list(
"id", flat=True
@@ -227,6 +235,9 @@ def remove_list_task(list_id):
stores = [ListsStream().stream_id(idx) for idx in stores]
ListsStream().remove_object_from_related_stores(list_id, stores=stores)
+ if re_add:
+ add_list_task.delay(list_id)
+
@app.task(queue=HIGH)
def add_list_task(list_id):
diff --git a/bookwyrm/management/commands/generate_preview_images.py b/bookwyrm/management/commands/generate_preview_images.py
index 0454e5e51..9ff16c26a 100644
--- a/bookwyrm/management/commands/generate_preview_images.py
+++ b/bookwyrm/management/commands/generate_preview_images.py
@@ -56,12 +56,17 @@ class Command(BaseCommand):
self.stdout.write(" OK 🖼")
# Books
- books = models.Book.objects.select_subclasses().filter()
- self.stdout.write(
- " → Book preview images ({}): ".format(len(books)), ending=""
+ book_ids = (
+ models.Book.objects.select_subclasses()
+ .filter()
+ .values_list("id", flat=True)
)
- for book in books:
- preview_images.generate_edition_preview_image_task.delay(book.id)
+
+ self.stdout.write(
+ " → Book preview images ({}): ".format(len(book_ids)), ending=""
+ )
+ for book_id in book_ids:
+ preview_images.generate_edition_preview_image_task.delay(book_id)
self.stdout.write(".", ending="")
self.stdout.write(" OK 🖼")
diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py
index 4e23a5306..23020a0a6 100644
--- a/bookwyrm/management/commands/initdb.py
+++ b/bookwyrm/management/commands/initdb.py
@@ -89,7 +89,7 @@ def init_connectors():
covers_url="https://inventaire.io",
search_url="https://inventaire.io/api/search?types=works&types=works&search=",
isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
- priority=3,
+ priority=1,
)
models.Connector.objects.create(
@@ -101,20 +101,10 @@ def init_connectors():
covers_url="https://covers.openlibrary.org",
search_url="https://openlibrary.org/search?q=",
isbn_search_url="https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:",
- priority=3,
+ priority=1,
)
-def init_federated_servers():
- """big no to nazis"""
- built_in_blocks = ["gab.ai", "gab.com"]
- for server in built_in_blocks:
- models.FederatedServer.objects.create(
- server_name=server,
- status="blocked",
- )
-
-
def init_settings():
"""info about the instance"""
models.SiteSettings.objects.create(
@@ -163,7 +153,6 @@ class Command(BaseCommand):
"group",
"permission",
"connector",
- "federatedserver",
"settings",
"linkdomain",
]
@@ -176,8 +165,6 @@ class Command(BaseCommand):
init_permissions()
if not limit or limit == "connector":
init_connectors()
- if not limit or limit == "federatedserver":
- init_federated_servers()
if not limit or limit == "settings":
init_settings()
if not limit or limit == "linkdomain":
diff --git a/bookwyrm/management/commands/instance_version.py b/bookwyrm/management/commands/instance_version.py
new file mode 100644
index 000000000..ca150d640
--- /dev/null
+++ b/bookwyrm/management/commands/instance_version.py
@@ -0,0 +1,54 @@
+""" Get your admin code to allow install """
+from django.core.management.base import BaseCommand
+
+from bookwyrm import models
+from bookwyrm.settings import VERSION
+
+
+# pylint: disable=no-self-use
+class Command(BaseCommand):
+ """command-line options"""
+
+ help = "What version is this?"
+
+ def add_arguments(self, parser):
+ """specify which function to run"""
+ parser.add_argument(
+ "--current",
+ action="store_true",
+ help="Version stored in database",
+ )
+ parser.add_argument(
+ "--target",
+ action="store_true",
+ help="Version stored in settings",
+ )
+ parser.add_argument(
+ "--update",
+ action="store_true",
+ help="Update database version",
+ )
+
+ # pylint: disable=unused-argument
+ def handle(self, *args, **options):
+ """execute init"""
+ site = models.SiteSettings.objects.get()
+ current = site.version or "0.0.1"
+ target = VERSION
+ if options.get("current"):
+ print(current)
+ return
+
+ if options.get("target"):
+ print(target)
+ return
+
+ if options.get("update"):
+ site.version = target
+ site.save()
+ return
+
+ if current != target:
+ print(f"{current}/{target}")
+ else:
+ print(current)
diff --git a/bookwyrm/migrations/0142_auto_20220227_1752.py b/bookwyrm/migrations/0142_auto_20220227_1752.py
new file mode 100644
index 000000000..2282679da
--- /dev/null
+++ b/bookwyrm/migrations/0142_auto_20220227_1752.py
@@ -0,0 +1,68 @@
+# Generated by Django 3.2.12 on 2022-02-27 17:52
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def add_default_themes(apps, schema_editor):
+ """add light and dark themes"""
+ db_alias = schema_editor.connection.alias
+ theme_model = apps.get_model("bookwyrm", "Theme")
+ theme_model.objects.using(db_alias).create(
+ name="BookWyrm Light",
+ path="css/themes/bookwyrm-light.scss",
+ )
+ theme_model.objects.using(db_alias).create(
+ name="BookWyrm Dark",
+ path="css/themes/bookwyrm-dark.scss",
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0141_alter_report_status"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Theme",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("created_date", models.DateTimeField(auto_now_add=True)),
+ ("name", models.CharField(max_length=50, unique=True)),
+ ("path", models.CharField(max_length=50, unique=True)),
+ ],
+ ),
+ migrations.AddField(
+ model_name="sitesettings",
+ name="default_theme",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="bookwyrm.theme",
+ ),
+ ),
+ migrations.AddField(
+ model_name="user",
+ name="theme",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ to="bookwyrm.theme",
+ ),
+ ),
+ migrations.RunPython(
+ add_default_themes, reverse_code=migrations.RunPython.noop
+ ),
+ ]
diff --git a/bookwyrm/migrations/0143_merge_0142_auto_20220227_1752_0142_user_hide_follows.py b/bookwyrm/migrations/0143_merge_0142_auto_20220227_1752_0142_user_hide_follows.py
new file mode 100644
index 000000000..b36fa9f9c
--- /dev/null
+++ b/bookwyrm/migrations/0143_merge_0142_auto_20220227_1752_0142_user_hide_follows.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.12 on 2022-02-28 21:28
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0142_auto_20220227_1752"),
+ ("bookwyrm", "0142_user_hide_follows"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0144_alter_announcement_display_type.py b/bookwyrm/migrations/0144_alter_announcement_display_type.py
new file mode 100644
index 000000000..246dd5b4e
--- /dev/null
+++ b/bookwyrm/migrations/0144_alter_announcement_display_type.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.12 on 2022-03-01 18:46
+
+from django.db import migrations, models
+
+
+def remove_white(apps, schema_editor):
+ """don't hardcode white announcements"""
+ db_alias = schema_editor.connection.alias
+ announcement_model = apps.get_model("bookwyrm", "Announcement")
+ announcement_model.objects.using(db_alias).filter(display_type="white-ter").update(
+ display_type=None
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0143_merge_0142_auto_20220227_1752_0142_user_hide_follows"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="announcement",
+ name="display_type",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("primary-light", "Primary"),
+ ("success-light", "Success"),
+ ("link-light", "Link"),
+ ("warning-light", "Warning"),
+ ("danger-light", "Danger"),
+ ],
+ max_length=20,
+ null=True,
+ ),
+ ),
+ migrations.RunPython(remove_white, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/bookwyrm/migrations/0145_sitesettings_version.py b/bookwyrm/migrations/0145_sitesettings_version.py
new file mode 100644
index 000000000..649f90abe
--- /dev/null
+++ b/bookwyrm/migrations/0145_sitesettings_version.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.12 on 2022-03-16 18:10
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0144_alter_announcement_display_type"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="sitesettings",
+ name="version",
+ field=models.CharField(blank=True, max_length=10, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0146_auto_20220316_2320.py b/bookwyrm/migrations/0146_auto_20220316_2320.py
new file mode 100644
index 000000000..e50bf25ec
--- /dev/null
+++ b/bookwyrm/migrations/0146_auto_20220316_2320.py
@@ -0,0 +1,80 @@
+# Generated by Django 3.2.12 on 2022-03-16 23:20
+
+import bookwyrm.models.fields
+from django.db import migrations
+from bookwyrm.models import Shelf
+
+
+def add_shelves(apps, schema_editor):
+ """add any superusers to the "admin" group"""
+
+ db_alias = schema_editor.connection.alias
+ shelf_model = apps.get_model("bookwyrm", "Shelf")
+
+ users = apps.get_model("bookwyrm", "User")
+ local_users = users.objects.using(db_alias).filter(local=True)
+ for user in local_users:
+ remote_id = f"{user.remote_id}/books/stopped"
+ shelf_model.objects.using(db_alias).create(
+ name="Stopped reading",
+ identifier=Shelf.STOPPED_READING,
+ user=user,
+ editable=False,
+ remote_id=remote_id,
+ )
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0145_sitesettings_version"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="comment",
+ name="reading_status",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("to-read", "To-Read"),
+ ("reading", "Reading"),
+ ("read", "Read"),
+ ("stopped-reading", "Stopped-Reading"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="quotation",
+ name="reading_status",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("to-read", "To-Read"),
+ ("reading", "Reading"),
+ ("read", "Read"),
+ ("stopped-reading", "Stopped-Reading"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.AlterField(
+ model_name="review",
+ name="reading_status",
+ field=bookwyrm.models.fields.CharField(
+ blank=True,
+ choices=[
+ ("to-read", "To-Read"),
+ ("reading", "Reading"),
+ ("read", "Read"),
+ ("stopped-reading", "Stopped-Reading"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ migrations.RunPython(add_shelves, reverse_code=migrations.RunPython.noop),
+ ]
diff --git a/bookwyrm/migrations/0146_auto_20220316_2352.py b/bookwyrm/migrations/0146_auto_20220316_2352.py
new file mode 100644
index 000000000..2eab3b562
--- /dev/null
+++ b/bookwyrm/migrations/0146_auto_20220316_2352.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.12 on 2022-03-16 23:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0145_sitesettings_version"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="inviterequest",
+ name="answer",
+ field=models.TextField(blank=True, max_length=50, null=True),
+ ),
+ migrations.AddField(
+ model_name="sitesettings",
+ name="invite_question_text",
+ field=models.CharField(
+ blank=True, default="What is your favourite book?", max_length=255
+ ),
+ ),
+ migrations.AddField(
+ model_name="sitesettings",
+ name="invite_request_question",
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0147_alter_user_preferred_language.py b/bookwyrm/migrations/0147_alter_user_preferred_language.py
new file mode 100644
index 000000000..0c9609b0b
--- /dev/null
+++ b/bookwyrm/migrations/0147_alter_user_preferred_language.py
@@ -0,0 +1,38 @@
+# Generated by Django 3.2.12 on 2022-03-26 16:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0146_auto_20220316_2352"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_language",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("en-us", "English"),
+ ("de-de", "Deutsch (German)"),
+ ("es-es", "Español (Spanish)"),
+ ("gl-es", "Galego (Galician)"),
+ ("it-it", "Italiano (Italian)"),
+ ("fr-fr", "Français (French)"),
+ ("lt-lt", "Lietuvių (Lithuanian)"),
+ ("no-no", "Norsk (Norwegian)"),
+ ("pt-br", "Português do Brasil (Brazilian Portuguese)"),
+ ("pt-pt", "Português Europeu (European Portuguese)"),
+ ("ro-ro", "Română (Romanian)"),
+ ("sv-se", "Svenska (Swedish)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0148_alter_user_preferred_language.py b/bookwyrm/migrations/0148_alter_user_preferred_language.py
new file mode 100644
index 000000000..05784f280
--- /dev/null
+++ b/bookwyrm/migrations/0148_alter_user_preferred_language.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.2.12 on 2022-03-31 14:35
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0147_alter_user_preferred_language"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_language",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("en-us", "English"),
+ ("de-de", "Deutsch (German)"),
+ ("es-es", "Español (Spanish)"),
+ ("gl-es", "Galego (Galician)"),
+ ("it-it", "Italiano (Italian)"),
+ ("fi-fi", "Suomi (Finnish)"),
+ ("fr-fr", "Français (French)"),
+ ("lt-lt", "Lietuvių (Lithuanian)"),
+ ("no-no", "Norsk (Norwegian)"),
+ ("pt-br", "Português do Brasil (Brazilian Portuguese)"),
+ ("pt-pt", "Português Europeu (European Portuguese)"),
+ ("ro-ro", "Română (Romanian)"),
+ ("sv-se", "Svenska (Swedish)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0148_merge_20220326_2006.py b/bookwyrm/migrations/0148_merge_20220326_2006.py
new file mode 100644
index 000000000..978662765
--- /dev/null
+++ b/bookwyrm/migrations/0148_merge_20220326_2006.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.12 on 2022-03-26 20:06
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0146_auto_20220316_2320"),
+ ("bookwyrm", "0147_alter_user_preferred_language"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0149_merge_20220526_1716.py b/bookwyrm/migrations/0149_merge_20220526_1716.py
new file mode 100644
index 000000000..b42bccd3b
--- /dev/null
+++ b/bookwyrm/migrations/0149_merge_20220526_1716.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.13 on 2022-05-26 17:16
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0148_alter_user_preferred_language"),
+ ("bookwyrm", "0148_merge_20220326_2006"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0150_readthrough_stopped_date.py b/bookwyrm/migrations/0150_readthrough_stopped_date.py
new file mode 100644
index 000000000..6ce2f89a9
--- /dev/null
+++ b/bookwyrm/migrations/0150_readthrough_stopped_date.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.13 on 2022-05-26 18:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0149_merge_20220526_1716"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="readthrough",
+ name="stopped_date",
+ field=models.DateTimeField(blank=True, null=True),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0151_alter_report_user.py b/bookwyrm/migrations/0151_alter_report_user.py
new file mode 100644
index 000000000..4c3f9dbda
--- /dev/null
+++ b/bookwyrm/migrations/0151_alter_report_user.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.13 on 2022-07-05 23:54
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0150_readthrough_stopped_date"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="report",
+ name="user",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0151_auto_20220705_0049.py b/bookwyrm/migrations/0151_auto_20220705_0049.py
new file mode 100644
index 000000000..6010e38e5
--- /dev/null
+++ b/bookwyrm/migrations/0151_auto_20220705_0049.py
@@ -0,0 +1,90 @@
+# Generated by Django 3.2.13 on 2022-07-05 00:49
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0150_readthrough_stopped_date"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="notification",
+ name="related_book",
+ ),
+ migrations.AddField(
+ model_name="notification",
+ name="related_list_items",
+ field=models.ManyToManyField(
+ related_name="notifications", to="bookwyrm.ListItem"
+ ),
+ ),
+ migrations.AddField(
+ model_name="notification",
+ name="related_reports",
+ field=models.ManyToManyField(to="bookwyrm.Report"),
+ ),
+ migrations.AddField(
+ model_name="notification",
+ name="related_users",
+ field=models.ManyToManyField(
+ related_name="notifications", to=settings.AUTH_USER_MODEL
+ ),
+ ),
+ migrations.AlterField(
+ model_name="notification",
+ name="related_list_item",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notifications_tmp",
+ to="bookwyrm.listitem",
+ ),
+ ),
+ migrations.AlterField(
+ model_name="notification",
+ name="related_report",
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="notifications_tmp",
+ to="bookwyrm.report",
+ ),
+ ),
+ migrations.RunSQL(
+ sql="""
+ INSERT INTO bookwyrm_notification_related_users (notification_id, user_id)
+ SELECT id, related_user_id
+ FROM bookwyrm_notification
+ WHERE bookwyrm_notification.related_user_id IS NOT NULL;
+
+ INSERT INTO bookwyrm_notification_related_list_items (notification_id, listitem_id)
+ SELECT id, related_list_item_id
+ FROM bookwyrm_notification
+ WHERE bookwyrm_notification.related_list_item_id IS NOT NULL;
+
+ INSERT INTO bookwyrm_notification_related_reports (notification_id, report_id)
+ SELECT id, related_report_id
+ FROM bookwyrm_notification
+ WHERE bookwyrm_notification.related_report_id IS NOT NULL;
+
+ """,
+ reverse_sql=migrations.RunSQL.noop,
+ ),
+ migrations.RemoveField(
+ model_name="notification",
+ name="related_list_item",
+ ),
+ migrations.RemoveField(
+ model_name="notification",
+ name="related_report",
+ ),
+ migrations.RemoveField(
+ model_name="notification",
+ name="related_user",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0152_alter_report_user.py b/bookwyrm/migrations/0152_alter_report_user.py
new file mode 100644
index 000000000..1a67871c8
--- /dev/null
+++ b/bookwyrm/migrations/0152_alter_report_user.py
@@ -0,0 +1,25 @@
+# Generated by Django 3.2.13 on 2022-07-06 19:16
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0151_alter_report_user"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="report",
+ name="user",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/migrations/0152_remove_notification_notification_type_valid.py b/bookwyrm/migrations/0152_remove_notification_notification_type_valid.py
new file mode 100644
index 000000000..f7471c0d2
--- /dev/null
+++ b/bookwyrm/migrations/0152_remove_notification_notification_type_valid.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.13 on 2022-07-05 03:16
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0151_auto_20220705_0049"),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name="notification",
+ name="notification_type_valid",
+ ),
+ ]
diff --git a/bookwyrm/migrations/0153_merge_20220706_2141.py b/bookwyrm/migrations/0153_merge_20220706_2141.py
new file mode 100644
index 000000000..03959f9ef
--- /dev/null
+++ b/bookwyrm/migrations/0153_merge_20220706_2141.py
@@ -0,0 +1,13 @@
+# Generated by Django 3.2.13 on 2022-07-06 21:41
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0152_alter_report_user"),
+ ("bookwyrm", "0152_remove_notification_notification_type_valid"),
+ ]
+
+ operations = []
diff --git a/bookwyrm/migrations/0154_alter_user_preferred_language.py b/bookwyrm/migrations/0154_alter_user_preferred_language.py
new file mode 100644
index 000000000..2002cca66
--- /dev/null
+++ b/bookwyrm/migrations/0154_alter_user_preferred_language.py
@@ -0,0 +1,40 @@
+# Generated by Django 3.2.14 on 2022-07-15 19:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("bookwyrm", "0153_merge_20220706_2141"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="preferred_language",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("en-us", "English"),
+ ("ca-es", "Català (Catalan)"),
+ ("de-de", "Deutsch (German)"),
+ ("es-es", "Español (Spanish)"),
+ ("gl-es", "Galego (Galician)"),
+ ("it-it", "Italiano (Italian)"),
+ ("fi-fi", "Suomi (Finnish)"),
+ ("fr-fr", "Français (French)"),
+ ("lt-lt", "Lietuvių (Lithuanian)"),
+ ("no-no", "Norsk (Norwegian)"),
+ ("pt-br", "Português do Brasil (Brazilian Portuguese)"),
+ ("pt-pt", "Português Europeu (European Portuguese)"),
+ ("ro-ro", "Română (Romanian)"),
+ ("sv-se", "Svenska (Swedish)"),
+ ("zh-hans", "简体中文 (Simplified Chinese)"),
+ ("zh-hant", "繁體中文 (Traditional Chinese)"),
+ ],
+ max_length=255,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/bookwyrm/models/__init__.py b/bookwyrm/models/__init__.py
index 440d18d95..a8a84f095 100644
--- a/bookwyrm/models/__init__.py
+++ b/bookwyrm/models/__init__.py
@@ -26,7 +26,7 @@ from .group import Group, GroupMember, GroupMemberInvitation
from .import_job import ImportJob, ImportItem
-from .site import SiteSettings, SiteInvite
+from .site import SiteSettings, Theme, SiteInvite
from .site import PasswordReset, InviteRequest
from .announcement import Announcement
from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
diff --git a/bookwyrm/models/announcement.py b/bookwyrm/models/announcement.py
index cbed38aee..4581dbdae 100644
--- a/bookwyrm/models/announcement.py
+++ b/bookwyrm/models/announcement.py
@@ -8,7 +8,6 @@ from .base_model import BookWyrmModel
DisplayTypes = [
- ("white-ter", _("None")),
("primary-light", _("Primary")),
("success-light", _("Success")),
("link-light", _("Link")),
@@ -28,11 +27,7 @@ class Announcement(BookWyrmModel):
end_date = models.DateTimeField(blank=True, null=True)
active = models.BooleanField(default=True)
display_type = models.CharField(
- max_length=20,
- blank=False,
- null=False,
- choices=DisplayTypes,
- default="white-ter",
+ max_length=20, choices=DisplayTypes, null=True, blank=True
)
@classmethod
diff --git a/bookwyrm/models/antispam.py b/bookwyrm/models/antispam.py
index f506b6f19..dd2a6df26 100644
--- a/bookwyrm/models/antispam.py
+++ b/bookwyrm/models/antispam.py
@@ -3,7 +3,7 @@ from functools import reduce
import operator
from django.apps import apps
-from django.db import models
+from django.db import models, transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
@@ -58,25 +58,20 @@ def automod_task():
return
reporter = AutoMod.objects.first().created_by
reports = automod_users(reporter) + automod_statuses(reporter)
- if reports:
- admins = User.objects.filter(
- models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
- | models.Q(is_superuser=True)
- ).all()
- notification_model = apps.get_model(
- "bookwyrm", "Notification", require_ready=True
- )
+ if not reports:
+ return
+
+ admins = User.objects.filter(
+ models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
+ | models.Q(is_superuser=True)
+ ).all()
+ notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True)
+ with transaction.atomic():
for admin in admins:
- notification_model.objects.bulk_create(
- [
- notification_model(
- user=admin,
- related_report=r,
- notification_type="REPORT",
- )
- for r in reports
- ]
+ notification, _ = notification_model.objects.get_or_create(
+ user=admin, notification_type=notification_model.REPORT, read=False
)
+ notification.related_repors.add(reports)
def automod_users(reporter):
diff --git a/bookwyrm/models/base_model.py b/bookwyrm/models/base_model.py
index f8d3b7818..3ac220bc4 100644
--- a/bookwyrm/models/base_model.py
+++ b/bookwyrm/models/base_model.py
@@ -8,6 +8,7 @@ from django.db.models import Q
from django.dispatch import receiver
from django.http import Http404
from django.utils.translation import gettext_lazy as _
+from django.utils.text import slugify
from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
@@ -35,10 +36,11 @@ class BookWyrmModel(models.Model):
remote_id = RemoteIdField(null=True, activitypub_field="id")
def get_remote_id(self):
- """generate a url that resolves to the local object"""
+ """generate the url that resolves to the local object, without a slug"""
base_path = f"https://{DOMAIN}"
if hasattr(self, "user"):
base_path = f"{base_path}{self.user.local_path}"
+
model_name = type(self).__name__.lower()
return f"{base_path}/{model_name}/{self.id}"
@@ -49,8 +51,20 @@ class BookWyrmModel(models.Model):
@property
def local_path(self):
- """how to link to this object in the local app"""
- return self.get_remote_id().replace(f"https://{DOMAIN}", "")
+ """how to link to this object in the local app, with a slug"""
+ local = self.get_remote_id().replace(f"https://{DOMAIN}", "")
+
+ name = None
+ if hasattr(self, "name_field"):
+ name = getattr(self, self.name_field)
+ elif hasattr(self, "name"):
+ name = self.name
+
+ if name:
+ slug = slugify(name)
+ local = f"{local}/s/{slug}"
+
+ return local
def raise_visible_to_user(self, viewer):
"""is a user authorized to view an object?"""
@@ -118,7 +132,7 @@ class BookWyrmModel(models.Model):
return
# but generally moderators can delete other people's stuff
- if self.user == viewer or viewer.has_perm("moderate_post"):
+ if self.user == viewer or viewer.has_perm("bookwyrm.moderate_post"):
return
raise PermissionDenied()
diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py
index 3ea8e1a8e..190046019 100644
--- a/bookwyrm/models/book.py
+++ b/bookwyrm/models/book.py
@@ -176,8 +176,8 @@ class Book(BookDataModel):
"""properties of this edition, as a string"""
items = [
self.physical_format if hasattr(self, "physical_format") else None,
- self.languages[0] + " language"
- if self.languages and self.languages[0] != "English"
+ f"{self.languages[0]} language"
+ if self.languages and self.languages[0] and self.languages[0] != "English"
else None,
str(self.published_date.year) if self.published_date else None,
", ".join(self.publishers) if hasattr(self, "publishers") else None,
diff --git a/bookwyrm/models/fields.py b/bookwyrm/models/fields.py
index b506c11ca..785f3397c 100644
--- a/bookwyrm/models/fields.py
+++ b/bookwyrm/models/fields.py
@@ -16,7 +16,7 @@ from django.utils.encoding import filepath_to_uri
from bookwyrm import activitypub
from bookwyrm.connectors import get_image
-from bookwyrm.sanitize_html import InputHtmlParser
+from bookwyrm.utils.sanitizer import clean
from bookwyrm.settings import MEDIA_FULL_URL
@@ -125,7 +125,7 @@ class ActivitypubFieldMixin:
"""model_field_name to activitypubFieldName"""
if self.activitypub_field:
return self.activitypub_field
- name = self.name.split(".")[-1]
+ name = self.name.rsplit(".", maxsplit=1)[-1]
components = name.split("_")
return components[0] + "".join(x.title() for x in components[1:])
@@ -389,7 +389,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
self.alt_field = alt_field
super().__init__(*args, **kwargs)
- # pylint: disable=arguments-differ
+ # pylint: disable=arguments-differ,arguments-renamed
def set_field_from_activity(self, instance, data, save=True, overwrite=True):
"""helper function for assinging a value to the field"""
value = getattr(data, self.get_activitypub_field())
@@ -497,9 +497,7 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
def field_from_activity(self, value):
if not value or value == MISSING:
return None
- sanitizer = InputHtmlParser()
- sanitizer.feed(value)
- return sanitizer.get_output()
+ return clean(value)
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
diff --git a/bookwyrm/models/group.py b/bookwyrm/models/group.py
index 05ed39a27..003b23d02 100644
--- a/bookwyrm/models/group.py
+++ b/bookwyrm/models/group.py
@@ -140,16 +140,6 @@ class GroupMemberInvitation(models.Model):
# make an invitation
super().save(*args, **kwargs)
- # now send the invite
- model = apps.get_model("bookwyrm.Notification", require_ready=True)
- notification_type = "INVITE"
- model.objects.create(
- user=self.user,
- related_user=self.group.user,
- related_group=self.group,
- notification_type=notification_type,
- )
-
@transaction.atomic
def accept(self):
"""turn this request into the real deal"""
@@ -157,25 +147,24 @@ class GroupMemberInvitation(models.Model):
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# tell the group owner
- model.objects.create(
- user=self.group.user,
- related_user=self.user,
+ model.notify(
+ self.group.user,
+ self.user,
related_group=self.group,
- notification_type="ACCEPT",
+ notification_type=model.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,
+ model.notify(
+ member,
+ self.user,
related_group=self.group,
- notification_type="JOIN",
+ notification_type=model.JOIN,
)
def reject(self):
"""generate a Reject for this membership request"""
-
self.delete()
diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py
index bcba391b6..556f133f9 100644
--- a/bookwyrm/models/import_job.py
+++ b/bookwyrm/models/import_job.py
@@ -175,9 +175,15 @@ class ImportItem(models.Model):
def date_added(self):
"""when the book was added to this dataset"""
if self.normalized_data.get("date_added"):
- return timezone.make_aware(
- dateutil.parser.parse(self.normalized_data.get("date_added"))
+ parsed_date_added = dateutil.parser.parse(
+ self.normalized_data.get("date_added")
)
+
+ if timezone.is_aware(parsed_date_added):
+ # Keep timezone if import already had one
+ return parsed_date_added
+
+ return timezone.make_aware(parsed_date_added)
return None
@property
diff --git a/bookwyrm/models/link.py b/bookwyrm/models/link.py
index 0e4148ddd..56b096bc2 100644
--- a/bookwyrm/models/link.py
+++ b/bookwyrm/models/link.py
@@ -84,7 +84,7 @@ class LinkDomain(BookWyrmModel):
)
def raise_not_editable(self, viewer):
- if viewer.has_perm("moderate_post"):
+ if viewer.has_perm("bookwyrm.moderate_post"):
return
raise PermissionDenied()
diff --git a/bookwyrm/models/list.py b/bookwyrm/models/list.py
index ea524cc54..63dd5b23f 100644
--- a/bookwyrm/models/list.py
+++ b/bookwyrm/models/list.py
@@ -1,7 +1,6 @@
""" make a list of books!! """
import uuid
-from django.apps import apps
from django.core.exceptions import PermissionDenied
from django.db import models
from django.db.models import Q
@@ -129,7 +128,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
"""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)
+ super().save(*args, **kwargs)
class ListItem(CollectionItemMixin, BookWyrmModel):
@@ -151,33 +150,11 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
collection_field = "book_list"
def save(self, *args, **kwargs):
- """create a notification too"""
- created = not bool(self.id)
+ """Update the list's date"""
super().save(*args, **kwargs)
# tick the updated date on the parent list
self.book_list.updated_date = timezone.now()
- self.book_list.save(broadcast=False)
-
- list_owner = self.book_list.user
- model = apps.get_model("bookwyrm.Notification", require_ready=True)
- # create a notification if somoene ELSE added to a local user's list
- if created and list_owner.local and list_owner != self.user:
- model.objects.create(
- user=list_owner,
- related_user=self.user,
- related_list_item=self,
- notification_type="ADD",
- )
-
- if self.book_list.group:
- for membership in self.book_list.group.memberships.all():
- if membership.user != self.user:
- model.objects.create(
- user=membership.user,
- related_user=self.user,
- related_list_item=self,
- notification_type="ADD",
- )
+ self.book_list.save(broadcast=False, update_fields=["updated_date"])
def raise_not_deletable(self, viewer):
"""the associated user OR the list owner can delete"""
diff --git a/bookwyrm/models/notification.py b/bookwyrm/models/notification.py
index 417bf7591..b0b75a169 100644
--- a/bookwyrm/models/notification.py
+++ b/bookwyrm/models/notification.py
@@ -1,77 +1,125 @@
""" alert a user to activity """
-from django.db import models
+from django.db import models, transaction
from django.dispatch import receiver
from .base_model import BookWyrmModel
-from . import Boost, Favorite, ImportJob, Report, Status, User
-
-# pylint: disable=line-too-long
-NotificationType = models.TextChoices(
- "NotificationType",
- "FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE GROUP_PRIVACY GROUP_NAME GROUP_DESCRIPTION",
-)
+from . import Boost, Favorite, GroupMemberInvitation, ImportJob, ListItem, Report
+from . import Status, User, UserFollowRequest
class Notification(BookWyrmModel):
"""you've been tagged, liked, followed, etc"""
+ # Status interactions
+ FAVORITE = "FAVORITE"
+ BOOST = "BOOST"
+ REPLY = "REPLY"
+ MENTION = "MENTION"
+ TAG = "TAG"
+
+ # Relationships
+ FOLLOW = "FOLLOW"
+ FOLLOW_REQUEST = "FOLLOW_REQUEST"
+
+ # Imports
+ IMPORT = "IMPORT"
+
+ # List activity
+ ADD = "ADD"
+
+ # Admin
+ REPORT = "REPORT"
+
+ # Groups
+ INVITE = "INVITE"
+ ACCEPT = "ACCEPT"
+ JOIN = "JOIN"
+ LEAVE = "LEAVE"
+ REMOVE = "REMOVE"
+ GROUP_PRIVACY = "GROUP_PRIVACY"
+ GROUP_NAME = "GROUP_NAME"
+ GROUP_DESCRIPTION = "GROUP_DESCRIPTION"
+
+ # pylint: disable=line-too-long
+ NotificationType = models.TextChoices(
+ # there has got be a better way to do this
+ "NotificationType",
+ f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
+ )
+
user = models.ForeignKey("User", on_delete=models.CASCADE)
- related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
- related_user = models.ForeignKey(
- "User", on_delete=models.CASCADE, null=True, related_name="related_user"
+ read = models.BooleanField(default=False)
+ notification_type = models.CharField(
+ max_length=255, choices=NotificationType.choices
+ )
+
+ related_users = models.ManyToManyField(
+ "User", symmetrical=False, related_name="notifications"
)
related_group = models.ForeignKey(
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
)
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
- related_list_item = models.ForeignKey(
- "ListItem", on_delete=models.CASCADE, null=True
- )
- related_report = models.ForeignKey("Report", on_delete=models.CASCADE, null=True)
- read = models.BooleanField(default=False)
- notification_type = models.CharField(
- max_length=255, choices=NotificationType.choices
+ related_list_items = models.ManyToManyField(
+ "ListItem", symmetrical=False, related_name="notifications"
)
+ related_reports = models.ManyToManyField("Report", symmetrical=False)
- def save(self, *args, **kwargs):
- """save, but don't make dupes"""
- # there's probably a better way to do this
- if self.__class__.objects.filter(
- user=self.user,
- related_book=self.related_book,
- related_user=self.related_user,
- related_group=self.related_group,
- related_status=self.related_status,
- related_import=self.related_import,
- related_list_item=self.related_list_item,
- related_report=self.related_report,
- notification_type=self.notification_type,
- ).exists():
+ @classmethod
+ @transaction.atomic
+ def notify(cls, user, related_user, **kwargs):
+ """Create a notification"""
+ if related_user and (not user.local or user == related_user):
return
- super().save(*args, **kwargs)
+ notification = cls.objects.filter(user=user, **kwargs).first()
+ if not notification:
+ notification = cls.objects.create(user=user, **kwargs)
+ if related_user:
+ notification.related_users.add(related_user)
+ notification.read = False
+ notification.save()
- class Meta:
- """checks if notifcation is in enum list for valid types"""
-
- constraints = [
- models.CheckConstraint(
- check=models.Q(notification_type__in=NotificationType.values),
- name="notification_type_valid",
+ @classmethod
+ @transaction.atomic
+ def notify_list_item(cls, user, list_item):
+ """Group the notifications around the list items, not the user"""
+ related_user = list_item.user
+ notification = cls.objects.filter(
+ user=user,
+ related_users=related_user,
+ related_list_items__book_list=list_item.book_list,
+ notification_type=Notification.ADD,
+ ).first()
+ if not notification:
+ notification = cls.objects.create(
+ user=user, notification_type=Notification.ADD
)
- ]
+ notification.related_users.add(related_user)
+ notification.related_list_items.add(list_item)
+ notification.read = False
+ notification.save()
+
+ @classmethod
+ def unnotify(cls, user, related_user, **kwargs):
+ """Remove a user from a notification and delete it if that was the only user"""
+ try:
+ notification = cls.objects.filter(user=user, **kwargs).get()
+ except Notification.DoesNotExist:
+ return
+ notification.related_users.remove(related_user)
+ if not notification.related_users.count():
+ notification.delete()
@receiver(models.signals.post_save, sender=Favorite)
# pylint: disable=unused-argument
def notify_on_fav(sender, instance, *args, **kwargs):
"""someone liked your content, you ARE loved"""
- if not instance.status.user.local or instance.status.user == instance.user:
- return
- Notification.objects.create(
- user=instance.status.user,
- notification_type="FAVORITE",
- related_user=instance.user,
+ Notification.notify(
+ instance.status.user,
+ instance.user,
related_status=instance.status,
+ notification_type=Notification.FAVORITE,
)
@@ -81,15 +129,16 @@ def notify_on_unfav(sender, instance, *args, **kwargs):
"""oops, didn't like that after all"""
if not instance.status.user.local:
return
- Notification.objects.filter(
- user=instance.status.user,
- related_user=instance.user,
+ Notification.unnotify(
+ instance.status.user,
+ instance.user,
related_status=instance.status,
- notification_type="FAVORITE",
- ).delete()
+ notification_type=Notification.FAVORITE,
+ )
@receiver(models.signals.post_save)
+@transaction.atomic
# pylint: disable=unused-argument
def notify_user_on_mention(sender, instance, *args, **kwargs):
"""creating and deleting statuses with @ mentions and replies"""
@@ -105,22 +154,23 @@ def notify_user_on_mention(sender, instance, *args, **kwargs):
and instance.reply_parent.user != instance.user
and instance.reply_parent.user.local
):
- Notification.objects.create(
- user=instance.reply_parent.user,
- notification_type="REPLY",
- related_user=instance.user,
+ Notification.notify(
+ instance.reply_parent.user,
+ instance.user,
related_status=instance,
+ notification_type=Notification.REPLY,
)
+
for mention_user in instance.mention_users.all():
# avoid double-notifying about this status
if not mention_user.local or (
instance.reply_parent and mention_user == instance.reply_parent.user
):
continue
- Notification.objects.create(
- user=mention_user,
- notification_type="MENTION",
- related_user=instance.user,
+ Notification.notify(
+ mention_user,
+ instance.user,
+ notification_type=Notification.MENTION,
related_status=instance,
)
@@ -135,11 +185,11 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
):
return
- Notification.objects.create(
- user=instance.boosted_status.user,
+ Notification.notify(
+ instance.boosted_status.user,
+ instance.user,
related_status=instance.boosted_status,
- related_user=instance.user,
- notification_type="BOOST",
+ notification_type=Notification.BOOST,
)
@@ -147,12 +197,12 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
# pylint: disable=unused-argument
def notify_user_on_unboost(sender, instance, *args, **kwargs):
"""unboosting a status"""
- Notification.objects.filter(
- user=instance.boosted_status.user,
+ Notification.unnotify(
+ instance.boosted_status.user,
+ instance.user,
related_status=instance.boosted_status,
- related_user=instance.user,
- notification_type="BOOST",
- ).delete()
+ notification_type=Notification.BOOST,
+ )
@receiver(models.signals.post_save, sender=ImportJob)
@@ -166,23 +216,94 @@ def notify_user_on_import_complete(
return
Notification.objects.create(
user=instance.user,
- notification_type="IMPORT",
+ notification_type=Notification.IMPORT,
related_import=instance,
)
@receiver(models.signals.post_save, sender=Report)
+@transaction.atomic
# pylint: disable=unused-argument
-def notify_admins_on_report(sender, instance, *args, **kwargs):
+def notify_admins_on_report(sender, instance, created, *args, **kwargs):
"""something is up, make sure the admins know"""
+ if not created:
+ # otherwise you'll get a notification when you resolve a report
+ return
+
# moderators and superusers should be notified
admins = User.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| models.Q(is_superuser=True)
).all()
for admin in admins:
- Notification.objects.create(
+ notification, _ = Notification.objects.get_or_create(
user=admin,
- related_report=instance,
- notification_type="REPORT",
+ notification_type=Notification.REPORT,
+ read=False,
+ )
+ notification.related_reports.add(instance)
+
+
+@receiver(models.signals.post_save, sender=GroupMemberInvitation)
+# pylint: disable=unused-argument
+def notify_user_on_group_invite(sender, instance, *args, **kwargs):
+ """Cool kids club here we come"""
+ Notification.notify(
+ instance.user,
+ instance.group.user,
+ related_group=instance.group,
+ notification_type=Notification.INVITE,
+ )
+
+
+@receiver(models.signals.post_save, sender=ListItem)
+@transaction.atomic
+# pylint: disable=unused-argument
+def notify_user_on_list_item_add(sender, instance, created, *args, **kwargs):
+ """Someone added to your list"""
+ if not created:
+ return
+
+ list_owner = instance.book_list.user
+ # create a notification if somoene ELSE added to a local user's list
+ if list_owner.local and list_owner != instance.user:
+ # keep the related_user singular, group the items
+ Notification.notify_list_item(list_owner, instance)
+
+ if instance.book_list.group:
+ for membership in instance.book_list.group.memberships.all():
+ if membership.user != instance.user:
+ Notification.notify_list_item(membership.user, instance)
+
+
+@receiver(models.signals.post_save, sender=UserFollowRequest)
+@transaction.atomic
+# pylint: disable=unused-argument
+def notify_user_on_follow(sender, instance, created, *args, **kwargs):
+ """Someone added to your list"""
+ if not created or not instance.user_object.local:
+ return
+
+ manually_approves = instance.user_object.manually_approves_followers
+ if manually_approves:
+ # don't group notifications
+ notification = Notification.objects.filter(
+ user=instance.user_object,
+ related_users=instance.user_subject,
+ notification_type=Notification.FOLLOW_REQUEST,
+ ).first()
+ if not notification:
+ notification = Notification.objects.create(
+ user=instance.user_object, notification_type=Notification.FOLLOW_REQUEST
+ )
+ notification.related_users.set([instance.user_subject])
+ notification.read = False
+ notification.save()
+ else:
+ # Only group unread follows
+ Notification.notify(
+ instance.user_object,
+ instance.user_subject,
+ notification_type=Notification.FOLLOW,
+ read=False,
)
diff --git a/bookwyrm/models/readthrough.py b/bookwyrm/models/readthrough.py
index ceb8e0b6e..314b40a5c 100644
--- a/bookwyrm/models/readthrough.py
+++ b/bookwyrm/models/readthrough.py
@@ -27,6 +27,7 @@ class ReadThrough(BookWyrmModel):
)
start_date = models.DateTimeField(blank=True, null=True)
finish_date = models.DateTimeField(blank=True, null=True)
+ stopped_date = models.DateTimeField(blank=True, null=True)
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
@@ -34,7 +35,7 @@ class ReadThrough(BookWyrmModel):
cache.delete(f"latest_read_through-{self.user.id}-{self.book.id}")
self.user.update_active_date()
# an active readthrough must have an unset finish date
- if self.finish_date:
+ if self.finish_date or self.stopped_date:
self.is_active = False
super().save(*args, **kwargs)
diff --git a/bookwyrm/models/relationship.py b/bookwyrm/models/relationship.py
index e95c38fa5..082294c0e 100644
--- a/bookwyrm/models/relationship.py
+++ b/bookwyrm/models/relationship.py
@@ -1,5 +1,4 @@
""" defines relationships between users """
-from django.apps import apps
from django.core.cache import cache
from django.db import models, transaction, IntegrityError
from django.db.models import Q
@@ -39,15 +38,14 @@ class UserRelationship(BookWyrmModel):
def save(self, *args, **kwargs):
"""clear the template cache"""
- # invalidate the template cache
- cache.delete_many(
- [
- f"relationship-{self.user_subject.id}-{self.user_object.id}",
- f"relationship-{self.user_object.id}-{self.user_subject.id}",
- ]
- )
+ clear_cache(self.user_subject, self.user_object)
super().save(*args, **kwargs)
+ def delete(self, *args, **kwargs):
+ """clear the template cache"""
+ clear_cache(self.user_subject, self.user_object)
+ super().delete(*args, **kwargs)
+
class Meta:
"""relationships should be unique"""
@@ -90,7 +88,9 @@ class UserFollows(ActivityMixin, UserRelationship):
user_object=self.user_subject,
)
).exists():
- raise IntegrityError()
+ raise IntegrityError(
+ "Attempting to follow blocked user", self.user_subject, self.user_object
+ )
# don't broadcast this type of relationship -- accepts and requests
# are handled by the UserFollowRequest model
super().save(*args, broadcast=False, **kwargs)
@@ -98,11 +98,12 @@ class UserFollows(ActivityMixin, UserRelationship):
@classmethod
def from_request(cls, follow_request):
"""converts a follow request into a follow relationship"""
- return cls.objects.create(
+ obj, _ = cls.objects.get_or_create(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
remote_id=follow_request.remote_id,
)
+ return obj
class UserFollowRequest(ActivitypubMixin, UserRelationship):
@@ -133,7 +134,9 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
user_object=self.user_subject,
)
).exists():
- raise IntegrityError()
+ raise IntegrityError(
+ "Attempting to follow blocked user", self.user_subject, self.user_object
+ )
super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
@@ -144,14 +147,6 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
if not manually_approves:
self.accept()
- model = apps.get_model("bookwyrm.Notification", require_ready=True)
- notification_type = "FOLLOW_REQUEST" if manually_approves else "FOLLOW"
- model.objects.create(
- user=self.user_object,
- related_user=self.user_subject,
- notification_type=notification_type,
- )
-
def get_accept_reject_id(self, status):
"""get id for sending an accept or reject of a local user"""
@@ -174,7 +169,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
with transaction.atomic():
UserFollows.from_request(self)
- self.delete()
+ if self.id:
+ self.delete()
def reject(self):
"""generate a Reject for this follow request"""
@@ -207,3 +203,13 @@ class UserBlocks(ActivityMixin, UserRelationship):
Q(user_subject=self.user_subject, user_object=self.user_object)
| Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()
+
+
+def clear_cache(user_subject, user_object):
+ """clear relationship cache"""
+ cache.delete_many(
+ [
+ f"cached-relationship-{user_subject.id}-{user_object.id}",
+ f"cached-relationship-{user_object.id}-{user_subject.id}",
+ ]
+ )
diff --git a/bookwyrm/models/report.py b/bookwyrm/models/report.py
index bf3184f52..d161c0349 100644
--- a/bookwyrm/models/report.py
+++ b/bookwyrm/models/report.py
@@ -11,7 +11,7 @@ class Report(BookWyrmModel):
"User", related_name="reporter", on_delete=models.PROTECT
)
note = models.TextField(null=True, blank=True)
- user = models.ForeignKey("User", on_delete=models.PROTECT)
+ user = models.ForeignKey("User", on_delete=models.PROTECT, null=True, blank=True)
status = models.ForeignKey(
"Status",
null=True,
diff --git a/bookwyrm/models/shelf.py b/bookwyrm/models/shelf.py
index 320d495d2..d955e8d07 100644
--- a/bookwyrm/models/shelf.py
+++ b/bookwyrm/models/shelf.py
@@ -6,6 +6,7 @@ from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
+from bookwyrm.settings import DOMAIN
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@@ -17,8 +18,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
TO_READ = "to-read"
READING = "reading"
READ_FINISHED = "read"
+ STOPPED_READING = "stopped-reading"
- READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED)
+ READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED, STOPPED_READING)
name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100)
@@ -65,6 +67,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
identifier = self.identifier or self.get_identifier()
return f"{base_path}/books/{identifier}"
+ @property
+ def local_path(self):
+ """No slugs"""
+ return self.get_remote_id().replace(f"https://{DOMAIN}", "")
+
def raise_not_deletable(self, viewer):
"""don't let anyone delete a default shelf"""
super().raise_not_deletable(viewer)
@@ -96,12 +103,25 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
if not self.user:
self.user = self.shelf.user
if self.id and self.user.local:
- cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
+ # remove all caches related to all editions of this book
+ cache.delete_many(
+ [
+ f"book-on-shelf-{book.id}-{self.shelf.id}"
+ for book in self.book.parent_work.editions.all()
+ ]
+ )
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
if self.id and self.user.local:
- cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
+ cache.delete_many(
+ [
+ f"book-on-shelf-{book}-{self.shelf.id}"
+ for book in self.book.parent_work.editions.values_list(
+ "id", flat=True
+ )
+ ]
+ )
super().delete(*args, **kwargs)
class Meta:
diff --git a/bookwyrm/models/site.py b/bookwyrm/models/site.py
index a40d295bc..7730391f1 100644
--- a/bookwyrm/models/site.py
+++ b/bookwyrm/models/site.py
@@ -24,6 +24,10 @@ class SiteSettings(models.Model):
)
instance_description = models.TextField(default="This instance has no description.")
instance_short_description = models.CharField(max_length=255, blank=True, null=True)
+ default_theme = models.ForeignKey(
+ "Theme", null=True, blank=True, on_delete=models.SET_NULL
+ )
+ version = models.CharField(null=True, blank=True, max_length=10)
# admin setup options
install_mode = models.BooleanField(default=False)
@@ -45,8 +49,12 @@ class SiteSettings(models.Model):
# registration
allow_registration = models.BooleanField(default=False)
allow_invite_requests = models.BooleanField(default=True)
+ invite_request_question = models.BooleanField(default=False)
require_confirm_email = models.BooleanField(default=True)
+ invite_question_text = models.CharField(
+ max_length=255, blank=True, default="What is your favourite book?"
+ )
# images
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
@@ -96,14 +104,29 @@ class SiteSettings(models.Model):
return urljoin(STATIC_FULL_URL, default_path)
def save(self, *args, **kwargs):
- """if require_confirm_email is disabled, make sure no users are pending"""
+ """if require_confirm_email is disabled, make sure no users are pending,
+ if enabled, make sure invite_question_text is not empty"""
if not self.require_confirm_email:
User.objects.filter(is_active=False, deactivation_reason="pending").update(
is_active=True, deactivation_reason=None
)
+ if not self.invite_question_text:
+ self.invite_question_text = "What is your favourite book?"
super().save(*args, **kwargs)
+class Theme(models.Model):
+ """Theme files"""
+
+ created_date = models.DateTimeField(auto_now_add=True)
+ name = models.CharField(max_length=50, unique=True)
+ path = models.CharField(max_length=50, unique=True)
+
+ def __str__(self):
+ # pylint: disable=invalid-str-returned
+ return self.name
+
+
class SiteInvite(models.Model):
"""gives someone access to create an account on the instance"""
@@ -134,6 +157,7 @@ class InviteRequest(BookWyrmModel):
invite = models.ForeignKey(
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
)
+ answer = models.TextField(max_length=50, unique=False, null=True, blank=True)
invite_sent = models.BooleanField(default=False)
ignored = models.BooleanField(default=False)
diff --git a/bookwyrm/models/status.py b/bookwyrm/models/status.py
index 17fcd4587..fce69cae2 100644
--- a/bookwyrm/models/status.py
+++ b/bookwyrm/models/status.py
@@ -116,11 +116,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
"""keep notes if they are replies to existing statuses"""
if activity.type == "Announce":
- try:
- boosted = activitypub.resolve_remote_id(
- activity.object, get_activity=True
- )
- except activitypub.ActivitySerializerError:
+ boosted = activitypub.resolve_remote_id(activity.object, get_activity=True)
+ if not boosted:
# if we can't load the status, definitely ignore it
return True
# keep the boost if we would keep the status
@@ -221,7 +218,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""certain types of status aren't editable"""
# first, the standard raise
super().raise_not_editable(viewer)
- if isinstance(self, (GeneratedNote, ReviewRating)):
+ # if it's an edit (not a create) you can only edit content statuses
+ if self.id and isinstance(self, (GeneratedNote, ReviewRating)):
raise PermissionDenied()
@classmethod
@@ -265,7 +263,7 @@ class GeneratedNote(Status):
ReadingStatusChoices = models.TextChoices(
- "ReadingStatusChoices", ["to-read", "reading", "read"]
+ "ReadingStatusChoices", ["to-read", "reading", "read", "stopped-reading"]
)
@@ -306,10 +304,17 @@ class Comment(BookStatus):
@property
def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users"""
- return (
- f'{self.content}(comment on '
- f'"{self.book.title}" )
'
- )
+ if self.progress_mode == "PG" and self.progress and (self.progress > 0):
+ return_value = (
+ f'{self.content}(comment on '
+ f'"{self.book.title}" , page {self.progress})
'
+ )
+ else:
+ return_value = (
+ f'{self.content}(comment on '
+ f'"{self.book.title}" )
'
+ )
+ return return_value
activity_serializer = activitypub.Comment
@@ -335,10 +340,17 @@ class Quotation(BookStatus):
"""indicate the book in question for mastodon (or w/e) users"""
quote = re.sub(r"^", '
"', self.quote)
quote = re.sub(r"
$", '"
', quote)
- return (
- f'{quote} -- '
- f'"{self.book.title}"
{self.content}'
- )
+ if self.position_mode == "PG" and self.position and (self.position > 0):
+ return_value = (
+ f'{quote} -- '
+ f'"{self.book.title}" , page {self.position}
{self.content}'
+ )
+ else:
+ return_value = (
+ f'{quote} -- '
+ f'"{self.book.title}"
{self.content}'
+ )
+ return return_value
activity_serializer = activitypub.Quotation
@@ -377,7 +389,7 @@ class Review(BookStatus):
def save(self, *args, **kwargs):
"""clear rating caches"""
if self.book.parent_work:
- cache.delete(f"book-rating-{self.book.parent_work.id}-*")
+ cache.delete(f"book-rating-{self.book.parent_work.id}")
super().save(*args, **kwargs)
diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py
index 666ac612c..d6f764dd6 100644
--- a/bookwyrm/models/user.py
+++ b/bookwyrm/models/user.py
@@ -136,6 +136,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
updated_date = models.DateTimeField(auto_now=True)
last_active_date = models.DateTimeField(default=timezone.now)
manually_approves_followers = fields.BooleanField(default=False)
+ theme = models.ForeignKey("Theme", null=True, blank=True, on_delete=models.SET_NULL)
hide_follows = fields.BooleanField(default=False)
# options to turn features on and off
@@ -173,6 +174,11 @@ class User(OrderedCollectionPageMixin, AbstractUser):
property_fields = [("following_link", "following")]
field_tracker = FieldTracker(fields=["name", "avatar"])
+ @property
+ def active_follower_requests(self):
+ """Follow requests from active users"""
+ return self.follower_requests.filter(is_active=True)
+
@property
def confirmation_link(self):
"""helper for generating confirmation links"""
@@ -373,6 +379,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"name": "Read",
"identifier": "read",
},
+ {
+ "name": "Stopped Reading",
+ "identifier": "stopped-reading",
+ },
]
for shelf in shelves:
diff --git a/bookwyrm/sanitize_html.py b/bookwyrm/sanitize_html.py
deleted file mode 100644
index 4edd2818e..000000000
--- a/bookwyrm/sanitize_html.py
+++ /dev/null
@@ -1,71 +0,0 @@
-""" html parser to clean up incoming text from unknown sources """
-from html.parser import HTMLParser
-
-
-class InputHtmlParser(HTMLParser): # pylint: disable=abstract-method
- """Removes any html that isn't allowed_tagsed from a block"""
-
- def __init__(self):
- HTMLParser.__init__(self)
- self.allowed_tags = [
- "p",
- "blockquote",
- "br",
- "b",
- "i",
- "strong",
- "em",
- "pre",
- "a",
- "span",
- "ul",
- "ol",
- "li",
- ]
- self.allowed_attrs = ["href", "rel", "src", "alt"]
- self.tag_stack = []
- self.output = []
- # if the html appears invalid, we just won't allow any at all
- self.allow_html = True
-
- def handle_starttag(self, tag, attrs):
- """check if the tag is valid"""
- if self.allow_html and tag in self.allowed_tags:
- allowed_attrs = " ".join(
- f'{a}="{v}"' for a, v in attrs if a in self.allowed_attrs
- )
- reconstructed = f"<{tag}"
- if allowed_attrs:
- reconstructed += " " + allowed_attrs
- reconstructed += ">"
- self.output.append(("tag", reconstructed))
- self.tag_stack.append(tag)
- else:
- self.output.append(("data", ""))
-
- def handle_endtag(self, tag):
- """keep the close tag"""
- if not self.allow_html or tag not in self.allowed_tags:
- self.output.append(("data", ""))
- return
-
- if not self.tag_stack or self.tag_stack[-1] != tag:
- # the end tag doesn't match the most recent start tag
- self.allow_html = False
- self.output.append(("data", ""))
- return
-
- self.tag_stack = self.tag_stack[:-1]
- self.output.append(("tag", f"{tag}>"))
-
- def handle_data(self, data):
- """extract the answer, if we're in an answer tag"""
- self.output.append(("data", data))
-
- def get_output(self):
- """convert the output from a list of tuples to a string"""
- if self.tag_stack:
- self.allow_html = False
- if not self.allow_html:
- return "".join(v for (k, v) in self.output if k == "data")
- return "".join(v for (k, v) in self.output)
diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py
index 0fbe3b731..50bfc453f 100644
--- a/bookwyrm/settings.py
+++ b/bookwyrm/settings.py
@@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
-VERSION = "0.3.1"
+VERSION = "0.4.4"
RELEASE_API = env(
"RELEASE_API",
@@ -21,7 +21,7 @@ RELEASE_API = env(
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
-JS_CACHE = "c7144efb"
+JS_CACHE = "e678183b"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@@ -90,6 +90,7 @@ INSTALLED_APPS = [
"sass_processor",
"bookwyrm",
"celery",
+ "django_celery_beat",
"imagekit",
"storages",
]
@@ -188,10 +189,7 @@ STATICFILES_FINDERS = [
]
SASS_PROCESSOR_INCLUDE_FILE_PATTERN = r"^.+\.[s]{0,1}(?:a|c)ss$"
-
-SASS_PROCESSOR_INCLUDE_DIRS = [
- os.path.join(BASE_DIR, ".css-config-sample"),
-]
+SASS_PROCESSOR_ENABLED = True
# minify css is production but not dev
if not DEBUG:
@@ -214,7 +212,7 @@ STREAMS = [
# Search configuration
# total time in seconds that the instance will spend searching connectors
-SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 15))
+SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 8))
# timeout for a query to an individual connector
QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5))
@@ -282,15 +280,18 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = env("LANGUAGE_CODE", "en-us")
LANGUAGES = [
("en-us", _("English")),
+ ("ca-es", _("Català (Catalan)")),
("de-de", _("Deutsch (German)")),
("es-es", _("Español (Spanish)")),
("gl-es", _("Galego (Galician)")),
("it-it", _("Italiano (Italian)")),
+ ("fi-fi", _("Suomi (Finnish)")),
("fr-fr", _("Français (French)")),
("lt-lt", _("Lietuvių (Lithuanian)")),
("no-no", _("Norsk (Norwegian)")),
("pt-br", _("Português do Brasil (Brazilian Portuguese)")),
("pt-pt", _("Português Europeu (European Portuguese)")),
+ ("ro-ro", _("Română (Romanian)")),
("sv-se", _("Svenska (Swedish)")),
("zh-hans", _("简体中文 (Simplified Chinese)")),
("zh-hant", _("繁體中文 (Traditional Chinese)")),
diff --git a/bookwyrm/static/css/bookwyrm.scss b/bookwyrm/static/css/bookwyrm.scss
index 6b5e7e6b5..437795457 100644
--- a/bookwyrm/static/css/bookwyrm.scss
+++ b/bookwyrm/static/css/bookwyrm.scss
@@ -1,7 +1,4 @@
@charset "utf-8";
-@import "instance-settings";
-@import "themes/light.scss";
@import "vendor/bulma/bulma.sass";
-@import "vendor/icons.css";
@import "bookwyrm/all.scss";
diff --git a/bookwyrm/static/css/bookwyrm/_all.scss b/bookwyrm/static/css/bookwyrm/_all.scss
index 11d7e403d..31e732ebe 100644
--- a/bookwyrm/static/css/bookwyrm/_all.scss
+++ b/bookwyrm/static/css/bookwyrm/_all.scss
@@ -1,6 +1,7 @@
/** Imports
******************************************************************************/
@import "components/avatar";
+@import "components/barcode";
@import "components/book_cover";
@import "components/book_grid";
@import "components/book_list";
@@ -35,6 +36,18 @@ body {
flex-direction: column;
}
+::-webkit-scrollbar {
+ width: 12px;
+ height: 12px;
+}
+::-webkit-scrollbar-thumb {
+ background: $scrollbar-thumb;
+ border-radius: 0.5em;
+}
+::-webkit-scrollbar-track {
+ background: $scrollbar-track;
+}
+
button {
border: none;
margin: 0;
@@ -115,7 +128,7 @@ button .button-invisible-overlay {
align-items: center;
flex-direction: column;
justify-content: center;
- background: rgba($scheme-invert, 0.66);
+ background: $invisible-overlay-background-color;
color: white;
opacity: 0;
transition: opacity 0.2s ease;
@@ -128,14 +141,6 @@ button:focus-visible .button-invisible-overlay {
}
-
-/** Tooltips
- ******************************************************************************/
-
-.tooltip {
- width: 100%;
-}
-
/** States
******************************************************************************/
diff --git a/bookwyrm/static/css/bookwyrm/components/_barcode.scss b/bookwyrm/static/css/bookwyrm/components/_barcode.scss
new file mode 100644
index 000000000..c9c67e8e5
--- /dev/null
+++ b/bookwyrm/static/css/bookwyrm/components/_barcode.scss
@@ -0,0 +1,26 @@
+/* Barcode scanner CSS */
+#barcode-scanner {
+ position: relative;
+ max-width: 100%;
+ text-align: center;
+ height: calc(70vh - 200px);
+
+ video {
+ height: calc(70vh - 200px);
+ max-width: 100%;
+ }
+
+ canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ margin: auto;
+ height: calc(70vh - 200px);
+ max-width: 100%;
+ }
+}
+
+#barcode-camera-list {
+ float: right;
+}
diff --git a/bookwyrm/static/css/bookwyrm/components/_book_list.scss b/bookwyrm/static/css/bookwyrm/components/_book_list.scss
index 0b1093489..3377de6b3 100644
--- a/bookwyrm/static/css/bookwyrm/components/_book_list.scss
+++ b/bookwyrm/static/css/bookwyrm/components/_book_list.scss
@@ -6,11 +6,11 @@ ol.ordered-list {
counter-reset: list-counter;
}
-ol.ordered-list li {
+ol.ordered-list > li {
counter-increment: list-counter;
}
-ol.ordered-list li::before {
+ol.ordered-list > li::before {
content: counter(list-counter);
position: absolute;
left: -20px;
diff --git a/bookwyrm/static/css/bookwyrm/components/_details.scss b/bookwyrm/static/css/bookwyrm/components/_details.scss
index 645de4a1d..c9a0b33b8 100644
--- a/bookwyrm/static/css/bookwyrm/components/_details.scss
+++ b/bookwyrm/static/css/bookwyrm/components/_details.scss
@@ -53,7 +53,7 @@ details.dropdown .dropdown-menu a:focus-visible {
@media only screen and (max-width: 768px) {
details.dropdown[open] summary.dropdown-trigger::before {
- background-color: rgba($scheme-invert, 0.5);
+ background-color: $modal-background-background-color;
z-index: 30;
}
@@ -114,3 +114,17 @@ details[open] summary .details-close {
padding-bottom: 0.25rem;
}
}
+
+/** Navbar details
+ ******************************************************************************/
+
+#navbar-dropdown .navbar-item {
+ color: $text;
+ font-size: 0.875rem;
+ padding: 0.375rem 3rem 0.375rem 1rem;
+ white-space: nowrap;
+}
+
+#navbar-dropdown .navbar-item:hover {
+ background-color: $background-secondary;
+}
diff --git a/bookwyrm/static/css/bookwyrm/components/_toggle.scss b/bookwyrm/static/css/bookwyrm/components/_toggle.scss
index c2c07dfb8..74d7f0d92 100644
--- a/bookwyrm/static/css/bookwyrm/components/_toggle.scss
+++ b/bookwyrm/static/css/bookwyrm/components/_toggle.scss
@@ -3,7 +3,7 @@
.toggle-button[aria-pressed="true"],
.toggle-button[aria-pressed="true"]:hover {
- background-color: hsl(171deg, 100%, 41%);
+ background-color: $primary;
color: white;
}
diff --git a/bookwyrm/static/css/bookwyrm/utilities/_colors.scss b/bookwyrm/static/css/bookwyrm/utilities/_colors.scss
index e44efee95..f38d2a40b 100644
--- a/bookwyrm/static/css/bookwyrm/utilities/_colors.scss
+++ b/bookwyrm/static/css/bookwyrm/utilities/_colors.scss
@@ -23,3 +23,8 @@
.has-background-tertiary {
background-color: $background-tertiary !important;
}
+
+/* Workaround for dark theme as .has-text-black doesn't give desired effect. */
+.has-text-default {
+ color: $text !important;
+}
diff --git a/bookwyrm/static/css/fonts/icomoon.eot b/bookwyrm/static/css/fonts/icomoon.eot
index 7b1f2d9d9..69628662b 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 7dbbe0dc5..c67c8b225 100644
--- a/bookwyrm/static/css/fonts/icomoon.svg
+++ b/bookwyrm/static/css/fonts/icomoon.svg
@@ -39,6 +39,7 @@
+
diff --git a/bookwyrm/static/css/fonts/icomoon.ttf b/bookwyrm/static/css/fonts/icomoon.ttf
index 151f2b782..12c79d551 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.ttf and b/bookwyrm/static/css/fonts/icomoon.ttf differ
diff --git a/bookwyrm/static/css/fonts/icomoon.woff b/bookwyrm/static/css/fonts/icomoon.woff
index bc0818413..624b70f33 100644
Binary files a/bookwyrm/static/css/fonts/icomoon.woff and b/bookwyrm/static/css/fonts/icomoon.woff differ
diff --git a/bookwyrm/static/css/themes/bookwyrm-dark.scss b/bookwyrm/static/css/themes/bookwyrm-dark.scss
new file mode 100644
index 000000000..88ee865bb
--- /dev/null
+++ b/bookwyrm/static/css/themes/bookwyrm-dark.scss
@@ -0,0 +1,96 @@
+@import "../vendor/bulma/sass/utilities/initial-variables.sass";
+
+/* Colors
+ ******************************************************************************/
+
+/* states */
+$primary: #005e50;
+$primary-light: #1d2b28;
+$info: #1f4666;
+$success: #246447;
+$success-light: #0d2f1e;
+$warning: #8b6c15;
+$warning-light: #372e13;
+$danger: #872538;
+$danger-light: #481922;
+$light: #393939;
+$red: #ffa1b4;
+
+/* book cover standins */
+$no-cover-color: #002549;
+
+/* background colors */
+$scheme-main: rgb(24, 27, 28);
+$scheme-invert: #fff;
+$scheme-main-bis: rgb(28, 30, 32);
+$scheme-main-ter: rgb(32, 34, 36);
+$background-body: rgb(24, 27, 28);
+$background-secondary: rgb(28, 30, 32);
+$background-tertiary: rgb(32, 34, 36);
+$modal-background-background-color: rgba($black, 0.8);
+$scrollbar-track: $background-secondary;
+$scrollbar-thumb: $light;
+
+/* highlight colors */
+$primary-highlight: $primary;
+$info-highlight: $info;
+$success-highlight: $success;
+
+/* borders */
+$border: #2b3031;
+$border-light: #444;
+$border-hover: #51595d;
+
+/* text */
+$text: $grey-lightest;
+$text-light: $grey-lighter;
+$text-strong: $white-ter;
+
+/* links */
+$link: #2e7eb9;
+$link-background: $background-tertiary;
+$link-hover: $white-bis;
+$link-hover-border: #51595d;
+$link-focus: $white-bis;
+$link-active: $white-bis;
+$link-light: #0d1c26;
+
+/* bulma overrides */
+$background: $background-secondary;
+$menu-item-active-background-color: $link-background;
+$navbar-dropdown-item-hover-color: $white;
+
+/* These element's colors are hardcoded, probably a bug in bulma? */
+@media screen and (min-width: 769px) {
+ .navbar-dropdown {
+ box-shadow: 0 8px 8px rgba($black, 0.2) !important;
+ }
+}
+
+@media screen and (max-width: 768px) {
+ .navbar-menu {
+ box-shadow: 0 8px 8px rgba($black, 0.2) !important;
+ }
+}
+
+/* misc */
+$shadow: 0 0.5em 1em -0.125em rgba($black, 0.2), 0 0px 0 1px rgba($black, 0.02);
+$card-header-shadow: 0 0.125em 0.25em rgba($black, 0.1);
+$invisible-overlay-background-color: rgba($black, 0.66);
+$progress-value-background-color: $border-light;
+
+/* Fonts
+ ******************************************************************************/
+$family-primary: $family-sans-serif;
+$family-secondary: $family-sans-serif;
+
+.has-text-muted {
+ color: $grey-lighter !important;
+}
+
+.has-text-more-muted {
+ color: $grey-light !important;
+}
+
+@import "../bookwyrm.scss";
+@import "../vendor/icons.css";
diff --git a/bookwyrm/static/css/themes/light.scss b/bookwyrm/static/css/themes/bookwyrm-light.scss
similarity index 79%
rename from bookwyrm/static/css/themes/light.scss
rename to bookwyrm/static/css/themes/bookwyrm-light.scss
index 339fc2c36..75f05164b 100644
--- a/bookwyrm/static/css/themes/light.scss
+++ b/bookwyrm/static/css/themes/bookwyrm-light.scss
@@ -19,6 +19,8 @@ $scheme-main: $white-bis;
$background-body: $white;
$background-secondary: $white-ter;
$background-tertiary: $white-bis;
+$scrollbar-track: $background-secondary;
+$scrollbar-thumb: $grey-lighter;
/* highlight colors */
$primary-highlight: $primary-light;
@@ -47,7 +49,21 @@ $link-active: $grey-darker;
$background: $background-secondary;
$menu-item-active-background-color: $link-background;
+/* misc */
+$invisible-overlay-background-color: rgba($scheme-invert, 0.66);
+
/* Fonts
******************************************************************************/
$family-primary: $family-sans-serif;
$family-secondary: $family-sans-serif;
+
+.has-text-muted {
+ color: $grey-dark !important;
+}
+
+.has-text-more-muted {
+ color: $grey !important;
+}
+
+@import "../bookwyrm.scss";
+@import "../vendor/icons.css";
diff --git a/bookwyrm/static/css/themes/dark.scss b/bookwyrm/static/css/themes/dark.scss
deleted file mode 100644
index 8df4ce500..000000000
--- a/bookwyrm/static/css/themes/dark.scss
+++ /dev/null
@@ -1,55 +0,0 @@
-@import "../vendor/bulma/sass/utilities/derived-variables.sass";
-
-/* Colors
- ******************************************************************************/
-
-/* states */
-$primary: #016a5b;
-$info: #1f4666;
-$success: #246447;
-$warning: #8b6c15;
-$danger: #872538;
-
-/* book cover standins */
-$no-cover-color: #002549;
-
-/* background colors */
-$scheme-main: $grey-darker;
-$scheme-main-bis: $black-ter;
-$background-body: $grey-darker;
-$background-secondary: $grey-dark;
-$background-tertiary: #555;
-
-/* highlight colors */
-$primary-highlight: $primary;
-$info-highlight: $info;
-$success-highlight: $success;
-
-/* borders */
-$border: $grey;
-$border-hover: $grey-light;
-$border-light: $grey;
-$border-light-hover: $grey-light;
-
-/* text */
-$text: $grey-lightest;
-$text-light: $grey-lighter;
-$text-strong: $white-ter;
-
-/* links */
-$link: $white;
-$link-background: $background-tertiary;
-$link-hover: $white-bis;
-$link-focus: $white-bis;
-$link-active: $white-bis;
-
-/* misc */
-
-/* bulma overrides */
-$background: $background-secondary;
-$menu-item-active-background-color: $link-background;
-
-/* Fonts
- ******************************************************************************/
-$family-primary: $family-sans-serif;
-$family-secondary: $family-sans-serif;
diff --git a/bookwyrm/static/css/vendor/icons.css b/bookwyrm/static/css/vendor/icons.css
index 4bc7cca55..6477aee5c 100644
--- a/bookwyrm/static/css/vendor/icons.css
+++ b/bookwyrm/static/css/vendor/icons.css
@@ -149,3 +149,6 @@
.icon-download:before {
content: "\ea36";
}
+.icon-barcode:before {
+ content: "\e937";
+}
diff --git a/bookwyrm/static/js/bookwyrm.js b/bookwyrm/static/js/bookwyrm.js
index cf3ce3032..95271795d 100644
--- a/bookwyrm/static/js/bookwyrm.js
+++ b/bookwyrm/static/js/bookwyrm.js
@@ -1,5 +1,5 @@
/* exported BookWyrm */
-/* globals TabGroup */
+/* globals TabGroup, Quagga */
let BookWyrm = new (class {
constructor() {
@@ -38,15 +38,15 @@ let BookWyrm = new (class {
.querySelectorAll("[data-modal-open]")
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
- document
- .querySelectorAll("[data-duplicate]")
- .forEach((node) => node.addEventListener("click", this.duplicateInput.bind(this)));
-
document
.querySelectorAll("details.dropdown")
.forEach((node) =>
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this))
);
+
+ document
+ .querySelector("#barcode-scanner-modal")
+ .addEventListener("open", this.openBarcodeScanner.bind(this));
}
/**
@@ -427,9 +427,11 @@ let BookWyrm = new (class {
});
modalElement.addEventListener("keydown", handleFocusTrap);
+ modalElement.dispatchEvent(new Event("open"));
}
function handleModalClose(modalElement) {
+ modalElement.dispatchEvent(new Event("close"));
modalElement.removeEventListener("keydown", handleFocusTrap);
htmlElement.classList.remove("is-clipped");
modalElement.classList.remove("is-active");
@@ -489,26 +491,6 @@ let BookWyrm = new (class {
window.open(url, windowName, "left=100,top=100,width=430,height=600");
}
- duplicateInput(event) {
- const trigger = event.currentTarget;
- const input_id = trigger.dataset.duplicate;
- const orig = document.getElementById(input_id);
- const parent = orig.parentNode;
- const new_count = parent.querySelectorAll("input").length + 1;
-
- let input = orig.cloneNode();
-
- input.id += "-" + new_count;
- input.value = "";
-
- let label = parent.querySelector("label").cloneNode();
-
- label.setAttribute("for", input.id);
-
- parent.appendChild(label);
- parent.appendChild(input);
- }
-
/**
* Set up a "click-to-copy" component from a textarea element
* with `data-copytext`, `data-copytext-label`, `data-copytext-success`
@@ -632,4 +614,174 @@ let BookWyrm = new (class {
}
}
}
+
+ openBarcodeScanner(event) {
+ const scannerNode = document.getElementById("barcode-scanner");
+ const statusNode = document.getElementById("barcode-status");
+ const cameraListNode = document.querySelector("#barcode-camera-list > select");
+
+ cameraListNode.addEventListener("change", onChangeCamera);
+
+ function onChangeCamera(event) {
+ initBarcodes(event.target.value);
+ }
+
+ function toggleStatus(status) {
+ for (const child of statusNode.children) {
+ BookWyrm.toggleContainer(child, !child.classList.contains(status));
+ }
+ }
+
+ function initBarcodes(cameraId = null) {
+ toggleStatus("grant-access");
+
+ if (!cameraId) {
+ cameraId = sessionStorage.getItem("preferredCam");
+ } else {
+ sessionStorage.setItem("preferredCam", cameraId);
+ }
+
+ scannerNode.replaceChildren();
+ Quagga.stop();
+ Quagga.init(
+ {
+ inputStream: {
+ name: "Live",
+ type: "LiveStream",
+ target: scannerNode,
+ constraints: {
+ facingMode: "environment",
+ deviceId: cameraId,
+ },
+ },
+ decoder: {
+ readers: [
+ "ean_reader",
+ {
+ format: "ean_reader",
+ config: {
+ supplements: ["ean_2_reader", "ean_5_reader"],
+ },
+ },
+ ],
+ multiple: false,
+ },
+ },
+ (err) => {
+ if (err) {
+ scannerNode.replaceChildren();
+ console.log(err);
+ toggleStatus("access-denied");
+
+ return;
+ }
+
+ let activeId = null;
+ const track = Quagga.CameraAccess.getActiveTrack();
+
+ if (track) {
+ activeId = track.getSettings().deviceId;
+ }
+
+ Quagga.CameraAccess.enumerateVideoDevices().then((devices) => {
+ cameraListNode.replaceChildren();
+
+ for (const device of devices) {
+ const child = document.createElement("option");
+
+ child.value = device.deviceId;
+ child.innerText = device.label.slice(0, 30);
+
+ if (activeId === child.value) {
+ child.selected = true;
+ }
+
+ cameraListNode.appendChild(child);
+ }
+ });
+
+ toggleStatus("scanning");
+ Quagga.start();
+ }
+ );
+ }
+
+ function cleanup(clearDrawing = true) {
+ Quagga.stop();
+ cameraListNode.removeEventListener("change", onChangeCamera);
+
+ if (clearDrawing) {
+ scannerNode.replaceChildren();
+ }
+ }
+
+ Quagga.onProcessed((result) => {
+ const drawingCtx = Quagga.canvas.ctx.overlay;
+ const drawingCanvas = Quagga.canvas.dom.overlay;
+
+ if (result) {
+ if (result.boxes) {
+ drawingCtx.clearRect(
+ 0,
+ 0,
+ parseInt(drawingCanvas.getAttribute("width")),
+ parseInt(drawingCanvas.getAttribute("height"))
+ );
+ result.boxes
+ .filter((box) => box !== result.box)
+ .forEach((box) => {
+ Quagga.ImageDebug.drawPath(box, { x: 0, y: 1 }, drawingCtx, {
+ color: "green",
+ lineWidth: 2,
+ });
+ });
+ }
+
+ if (result.box) {
+ Quagga.ImageDebug.drawPath(result.box, { x: 0, y: 1 }, drawingCtx, {
+ color: "#00F",
+ lineWidth: 2,
+ });
+ }
+
+ if (result.codeResult && result.codeResult.code) {
+ Quagga.ImageDebug.drawPath(result.line, { x: "x", y: "y" }, drawingCtx, {
+ color: "red",
+ lineWidth: 3,
+ });
+ }
+ }
+ });
+
+ let lastDetection = null;
+ let numDetected = 0;
+
+ Quagga.onDetected((result) => {
+ // Detect the same code 3 times as an extra check to avoid bogus scans.
+ if (lastDetection === null || lastDetection !== result.codeResult.code) {
+ numDetected = 1;
+ lastDetection = result.codeResult.code;
+
+ return;
+ } else if (numDetected++ < 3) {
+ return;
+ }
+
+ const code = result.codeResult.code;
+
+ statusNode.querySelector(".isbn").innerText = code;
+ toggleStatus("found");
+
+ const search = new URL("/search", document.location);
+
+ search.searchParams.set("q", code);
+
+ cleanup(false);
+ location.assign(search);
+ });
+
+ event.target.addEventListener("close", cleanup, { once: true });
+
+ initBarcodes();
+ }
})();
diff --git a/bookwyrm/static/js/forms.js b/bookwyrm/static/js/forms.js
new file mode 100644
index 000000000..998873898
--- /dev/null
+++ b/bookwyrm/static/js/forms.js
@@ -0,0 +1,49 @@
+(function () {
+ "use strict";
+
+ /**
+ * Remoev input field
+ *
+ * @param {event} the button click event
+ */
+ function removeInput(event) {
+ const trigger = event.currentTarget;
+ const input_id = trigger.dataset.remove;
+ const input = document.getElementById(input_id);
+
+ input.remove();
+ }
+
+ /**
+ * Duplicate an input field
+ *
+ * @param {event} the click even on the associated button
+ */
+ function duplicateInput(event) {
+ const trigger = event.currentTarget;
+ const input_id = trigger.dataset.duplicate;
+ const orig = document.getElementById(input_id);
+ const parent = orig.parentNode;
+ const new_count = parent.querySelectorAll("input").length + 1;
+
+ let input = orig.cloneNode();
+
+ input.id += "-" + new_count;
+ input.value = "";
+
+ let label = parent.querySelector("label").cloneNode();
+
+ label.setAttribute("for", input.id);
+
+ parent.appendChild(label);
+ parent.appendChild(input);
+ }
+
+ document
+ .querySelectorAll("[data-duplicate]")
+ .forEach((node) => node.addEventListener("click", duplicateInput));
+
+ document
+ .querySelectorAll("[data-remove]")
+ .forEach((node) => node.addEventListener("click", removeInput));
+})();
diff --git a/bookwyrm/static/js/status_cache.js b/bookwyrm/static/js/status_cache.js
index b19489c1d..0a9f3abc5 100644
--- a/bookwyrm/static/js/status_cache.js
+++ b/bookwyrm/static/js/status_cache.js
@@ -203,6 +203,8 @@ let StatusCache = new (class {
.forEach((item) => (item.disabled = false));
next_identifier = next_identifier == "complete" ? "read" : next_identifier;
+ next_identifier =
+ next_identifier == "stopped-reading-complete" ? "stopped-reading" : next_identifier;
// Disable the current state
button.querySelector(
diff --git a/bookwyrm/static/js/vendor/quagga.min.js b/bookwyrm/static/js/vendor/quagga.min.js
new file mode 100644
index 000000000..84ccb74fc
--- /dev/null
+++ b/bookwyrm/static/js/vendor/quagga.min.js
@@ -0,0 +1,3 @@
+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Quagga=e():t.Quagga=e()}(window,(function(){return function(t){var e={};function n(r){if(e[r])return e[r].exports;var o=e[r]={i:r,l:!1,exports:{}};return t[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=t,n.c=e,n.d=function(t,e,r){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:r})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var o in t)n.d(r,o,function(e){return t[e]}.bind(null,o));return r},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="/",n(n.s=89)}([function(t,e){t.exports=function(t,e,n){return e in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t},t.exports.default=t.exports,t.exports.__esModule=!0},function(t,e){t.exports=function(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t},t.exports.default=t.exports,t.exports.__esModule=!0},function(t,e){function n(e){return t.exports=n=Object.setPrototypeOf?Object.getPrototypeOf:function(t){return t.__proto__||Object.getPrototypeOf(t)},t.exports.default=t.exports,t.exports.__esModule=!0,n(e)}t.exports=n,t.exports.default=t.exports,t.exports.__esModule=!0},function(t,e){t.exports=function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")},t.exports.default=t.exports,t.exports.__esModule=!0},function(t,e){function n(t,e){for(var n=0;n=0;e--){var n=Math.floor(Math.random()*e),r=t[e];t[e]=t[n],t[n]=r}return t},toPointList:function(t){var e=t.reduce((function(t,e){var n="[".concat(e.join(","),"]");return t.push(n),t}),[]);return"[".concat(e.join(",\r\n"),"]")},threshold:function(t,e,n){return t.reduce((function(r,o){return n.apply(t,[o])>=e&&r.push(o),r}),[])},maxIndex:function(t){for(var e=0,n=0;nt[e]&&(e=n);return e},max:function(t){for(var e=0,n=0;ne&&(e=t[n]);return e},sum:function(t){for(var e=t.length,n=0;e--;)n+=t[e];return n}}},function(t,e,n){"use strict";n.d(e,"h",(function(){return l})),n.d(e,"i",(function(){return d})),n.d(e,"b",(function(){return p})),n.d(e,"j",(function(){return v})),n.d(e,"e",(function(){return y})),n.d(e,"c",(function(){return g})),n.d(e,"f",(function(){return x})),n.d(e,"g",(function(){return _})),n.d(e,"a",(function(){return b})),n.d(e,"d",(function(){return O}));var r=n(7),o=n(84),i={clone:r.clone,dot:r.dot},a=function(t,e){var n=[],r={rad:0,vec:i.clone([0,0])},o={};function a(t){o[t.id]=t,n.push(t)}function u(){var t,e=0;for(t=0;te},getPoints:function(){return n},getCenter:function(){return r}}},u=function(t,e,n){return{rad:t[n],point:t,id:e}},c=n(8),s={clone:r.clone},f={clone:o.clone};function l(t,e){return{x:t,y:e,toVec2:function(){return s.clone([this.x,this.y])},toVec3:function(){return f.clone([this.x,this.y,1])},round:function(){return this.x=this.x>0?Math.floor(this.x+.5):Math.floor(this.x-.5),this.y=this.y>0?Math.floor(this.y+.5):Math.floor(this.y-.5),this}}}function h(t,e){e||(e=8);for(var n=t.data,r=n.length,o=8-e,i=new Int32Array(1<>o]++;return i}function d(t,e){var n=function(t){var e,n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:8,r=8-n;function o(t,n){for(var r=0,o=t;o<=n;o++)r+=e[o];return r}function i(t,n){for(var r=0,o=t;o<=n;o++)r+=o*e[o];return r}function a(){var r,a,u,s,f=[0],l=(1<c)for((i=s[u]).score=o,i.item=t[r],c=Number.MAX_VALUE,a=0;a1&&void 0!==arguments[1]?arguments[1]:[0,0,0],n=t[0],r=t[1],o=t[2],i=o*r,a=i*(1-Math.abs(n/60%2-1)),u=o-i,c=0,s=0,f=0;return n<60?(c=i,s=a):n<120?(c=a,s=i):n<180?(s=i,f=a):n<240?(s=a,f=i):n<300?(c=a,f=i):n<360&&(c=i,f=a),e[0]=255*(c+u)|0,e[1]=255*(s+u)|0,e[2]=255*(f+u)|0,e}function m(t){for(var e=[],n=[],r=1;re[r]?r++:n++;return o}(r,o),u=[8,10,15,20,32,60,80],c={"x-small":5,small:4,medium:3,large:2,"x-large":1},s=c[t]||c.medium,f=u[s],l=Math.floor(i/f);function h(t){for(var e=0,n=t[Math.floor(t.length/2)];e0&&(n=Math.abs(t[e]-l)>Math.abs(t[e-1]-l)?t[e-1]:t[e]),l/nu[s-1]/u[s]?{x:n,y:n}:null}return(n=h(a))||(n=h(m(i)))||(n=h(m(l*f))),n}var w={top:function(t,e){return"%"===t.unit?Math.floor(e.height*(t.value/100)):null},right:function(t,e){return"%"===t.unit?Math.floor(e.width-e.width*(t.value/100)):null},bottom:function(t,e){return"%"===t.unit?Math.floor(e.height-e.height*(t.value/100)):null},left:function(t,e){return"%"===t.unit?Math.floor(e.width*(t.value/100)):null}};function O(t,e,n){var r={width:t,height:e},o=Object.keys(n).reduce((function(t,e){var o=function(t){return{value:parseFloat(t),unit:(t.indexOf("%"),t.length,"%")}}(n[e]),i=w[e](o,r);return t[e]=i,t}),{});return{sx:o.left,sy:o.top,sw:o.right-o.left,sh:o.bottom-o.top}}},function(t,e,n){"use strict";var r=n(83),o=n.n(r),i=n(3),a=n.n(i),u=n(4),c=n.n(u),s=n(0),f=n.n(s),l=n(7),h=n(9),d=n(8),p={clone:l.clone};function v(t){if(t<0)throw new Error("expected positive number, received ".concat(t))}var y=function(){function t(e,n){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:Uint8Array,o=arguments.length>3?arguments[3]:void 0;a()(this,t),f()(this,"data",void 0),f()(this,"size",void 0),f()(this,"indexMapping",void 0),n?this.data=n:(this.data=new r(e.x*e.y),o&&d.a.init(this.data,0)),this.size=e}return c()(t,[{key:"inImageWithBorder",value:function(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0;return v(e),t.x>=0&&t.y>=0&&t.x0&&((a=v[r-1]).m00+=1,a.m01+=n,a.m10+=e,a.m11+=e*n,a.m02+=o,a.m20+=e*e);for(i=0;i=0?x:-x)+g,a.theta=(180*f/g+90)%180-90,a.theta<0&&(a.theta+=180),a.rad=f>g?f-g:f,a.vec=p.clone([Math.cos(f),Math.sin(f)]),y.push(a));return y}},{key:"getAsRGBA",value:function(){for(var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1,e=new Uint8ClampedArray(4*this.size.x*this.size.y),n=0;n1&&void 0!==arguments[1]?arguments[1]:1,n=t.getContext("2d");if(!n)throw new Error("Unable to get canvas context");var r=n.getImageData(0,0,t.width,t.height),o=this.getAsRGBA(e);t.width=this.size.x,t.height=this.size.y;var i=new ImageData(o,r.width,r.height);n.putImageData(i,0,0)}},{key:"overlay",value:function(t,e,n){var r=e<0||e>360?360:e,i=[0,1,1],a=[0,0,0],u=[255,255,255],c=[0,0,0],s=t.getContext("2d");if(!s)throw new Error("Unable to get canvas context");for(var f=s.getImageData(n.x,n.y,this.size.x,this.size.y),l=f.data,d=this.data.length;d--;){i[0]=this.data[d]*r;var p=4*d,v=i[0]<=0?u:i[0]>=360?c:Object(h.g)(i,a),y=o()(v,3);l[p]=y[0],l[p+1]=y[1],l[p+2]=y[2],l[p+3]=255}s.putImageData(f,n.x,n.y)}}]),t}();e.a=y},function(t,e,n){t.exports=n(228)},function(t,e,n){var r=n(227);function o(e,n,i){return"undefined"!=typeof Reflect&&Reflect.get?(t.exports=o=Reflect.get,t.exports.default=t.exports,t.exports.__esModule=!0):(t.exports=o=function(t,e,n){var o=r(t,e);if(o){var i=Object.getOwnPropertyDescriptor(o,e);return i.get?i.get.call(n):i.value}},t.exports.default=t.exports,t.exports.__esModule=!0),o(e,n,i||e)}t.exports=o,t.exports.default=t.exports,t.exports.__esModule=!0},function(t,e){t.exports=function(t){var e=typeof t;return null!=t&&("object"==e||"function"==e)}},function(t,e){var n=Array.isArray;t.exports=n},function(t,e,n){"use strict";e.a={drawRect:function(t,e,n,r){n.strokeStyle=r.color,n.fillStyle=r.color,n.lineWidth=r.lineWidth||1,n.beginPath(),n.strokeRect(t.x,t.y,e.x,e.y)},drawPath:function(t,e,n,r){n.strokeStyle=r.color,n.fillStyle=r.color,n.lineWidth=r.lineWidth,n.beginPath(),n.moveTo(t[0][e.x],t[0][e.y]);for(var o=1;oh&&(h=i.box[o][0]),i.box[o][1]d&&(d=i.box[o][1]);for(u=[[s,f],[h,f],[h,d],[s,d]],c=r.halfSample?2:1,a=y.invert(a,a),o=0;o<4;o++)v.transformMat2(u[o],u[o],a);for(o=0;o<4;o++)v.scale(u[o],u[o],c);return u}function E(t,e){l.subImageAsCopy(a,Object(x.h)(t,e)),p.skeletonize()}function M(t,e,n,r){var o,i,u,c,s=[],f=[],l=Math.ceil(h.x/3);if(t.length>=2){for(o=0;ol&&s.push(t[o]);if(s.length>=2){for(u=function(t){var e=Object(x.b)(t,.9),n=Object(x.j)(e,1,(function(t){return t.getPoints().length})),r=[],o=[];if(1===n.length){r=n[0].item.getPoints();for(var i=0;i1&&u.length>=s.length/4*3&&u.length>t.length/4&&(i/=u.length,c={index:e[1]*R.x+e[0],pos:{x:n,y:r},box:[v.clone([n,r]),v.clone([n+a.size.x,r]),v.clone([n+a.size.x,r+a.size.y]),v.clone([n,r+a.size.y])],moments:u,rad:i,vec:v.clone([Math.cos(i),Math.sin(i)])},f.push(c))}}return f}e.a={init:function(e,n){r=n,d=e,function(){o=r.halfSample?new g.a({x:d.size.x/2|0,y:d.size.y/2|0}):d,h=Object(x.a)(r.patchSize,o.size),R.x=o.size.x/h.x|0,R.y=o.size.y/h.y|0,l=new g.a(o.size,void 0,Uint8Array,!1),u=new g.a(h,void 0,Array,!0);var e=new ArrayBuffer(65536);a=new g.a(h,new Uint8Array(e,0,h.x*h.y)),i=new g.a(h,new Uint8Array(e,h.x*h.y*3,h.x*h.y),void 0,!0),p=Object(w.a)("undefined"!=typeof window?window:"undefined"!=typeof self?self:t,{size:h.x},e),f=new g.a({x:o.size.x/a.size.x|0,y:o.size.y/a.size.y|0},void 0,Array,!0),c=new g.a(f.size,void 0,void 0,!0),s=new g.a(f.size,void 0,Int32Array,!0)}(),r.useWorker||"undefined"==typeof document||(O.dom.binary=document.createElement("canvas"),O.dom.binary.className="binaryBuffer",O.ctx.binary=O.dom.binary.getContext("2d"),O.dom.binary.width=l.size.x,O.dom.binary.height=l.size.y)},locate:function(){r.halfSample&&Object(x.f)(d,o),Object(x.i)(o,l),l.zeroBorder();var t=function(){var t,e,n,r,o,c,s=[];for(t=0;t.95&&a(i):s.data[i]=Number.MAX_VALUE}for(_.a.init(c.data,0),_.a.init(s.data,0),_.a.init(f.data,null),e=0;e0&&r[s.data[n]-1]++;return(r=r.map((function(t,e){return{val:t,label:e+1}}))).sort((function(t,e){return e.val-t.val})),r.filter((function(t){return t.val>=5}))}(e);return 0===n.length?null:function(t,e){var n,r,o,i,a=[],u=[];for(n=0;n-1&&t%1==0&&t-1&&t%1==0&&t<=9007199254740991}},function(t,e){function n(e,r){return t.exports=n=Object.setPrototypeOf||function(t,e){return t.__proto__=e,t},t.exports.default=t.exports,t.exports.__esModule=!0,n(e,r)}t.exports=n,t.exports.default=t.exports,t.exports.__esModule=!0},function(t,e,n){var r=n(22),o=n(18);t.exports=function(t){return"symbol"==typeof t||o(t)&&"[object Symbol]"==r(t)}},function(t,e,n){var r=n(42);t.exports=function(t){if("string"==typeof t||r(t))return t;var e=t+"";return"0"==e&&1/t==-1/0?"-0":e}},function(t,e,n){var r=n(35)(n(17),"Map");t.exports=r},function(t,e,n){(function(e){var n="object"==typeof e&&e&&e.Object===Object&&e;t.exports=n}).call(this,n(46))},function(t,e){var n;n=function(){return this}();try{n=n||new Function("return this")()}catch(t){"object"==typeof window&&(n=window)}t.exports=n},function(t,e,n){var r=n(109),o=n(116),i=n(118),a=n(119),u=n(120);function c(t){var e=-1,n=null==t?0:t.length;for(this.clear();++et.length)&&(e=t.length);for(var n=0,r=new Array(e);n0&&(i=1/Math.sqrt(i),t[0]=e[0]*i,t[1]=e[1]*i,t[2]=e[2]*i);return t}},function(t,e){t.exports=function(t,e){return t[0]*e[0]+t[1]*e[1]+t[2]*e[2]}},function(t,e){t.exports=function(t,e,n){return t[0]=e[0]-n[0],t[1]=e[1]-n[1],t[2]=e[2]-n[2],t}},function(t,e){t.exports=function(t,e,n){return t[0]=e[0]*n[0],t[1]=e[1]*n[1],t[2]=e[2]*n[2],t}},function(t,e){t.exports=function(t,e,n){return t[0]=e[0]/n[0],t[1]=e[1]/n[1],t[2]=e[2]/n[2],t}},function(t,e){t.exports=function(t,e){var n=e[0]-t[0],r=e[1]-t[1],o=e[2]-t[2];return Math.sqrt(n*n+r*r+o*o)}},function(t,e){t.exports=function(t,e){var n=e[0]-t[0],r=e[1]-t[1],o=e[2]-t[2];return n*n+r*r+o*o}},function(t,e){t.exports=function(t){var e=t[0],n=t[1],r=t[2];return Math.sqrt(e*e+n*n+r*r)}},function(t,e){t.exports=function(t){var e=t[0],n=t[1],r=t[2];return e*e+n*n+r*r}},function(t,e,n){var r=n(153),o=n(154),i=n(60),a=n(155);t.exports=function(t,e){return r(t)||o(t,e)||i(t,e)||a()},t.exports.default=t.exports,t.exports.__esModule=!0},function(t,e,n){t.exports={EPSILON:n(71),create:n(72),clone:n(191),angle:n(192),fromValues:n(73),copy:n(193),set:n(194),equals:n(195),exactEquals:n(196),add:n(197),subtract:n(76),sub:n(198),multiply:n(77),mul:n(199),divide:n(78),div:n(200),min:n(201),max:n(202),floor:n(203),ceil:n(204),round:n(205),scale:n(206),scaleAndAdd:n(207),distance:n(79),dist:n(208),squaredDistance:n(80),sqrDist:n(209),length:n(81),len:n(210),squaredLength:n(82),sqrLen:n(211),negate:n(212),inverse:n(213),normalize:n(74),dot:n(75),cross:n(214),lerp:n(215),random:n(216),transformMat4:n(217),transformMat3:n(218),transformQuat:n(219),rotateX:n(220),rotateY:n(221),rotateZ:n(222),forEach:n(223)}},function(t,e,n){var r=n(229),o=n(243)((function(t,e){return null==t?{}:r(t,e)}));t.exports=o},function(t,e,n){var r=n(2),o=n(41),i=n(248),a=n(249);function u(e){var n="function"==typeof Map?new Map:void 0;return t.exports=u=function(t){if(null===t||!i(t))return t;if("function"!=typeof t)throw new TypeError("Super expression must either be null or a function");if(void 0!==n){if(n.has(t))return n.get(t);n.set(t,e)}function e(){return a(t,arguments,r(this).constructor)}return e.prototype=Object.create(t.prototype,{constructor:{value:e,enumerable:!1,writable:!0,configurable:!0}}),o(e,t)},t.exports.default=t.exports,t.exports.__esModule=!0,u(e)}t.exports=u,t.exports.default=t.exports,t.exports.__esModule=!0},function(t,e,n){"use strict";var r=n(21),o={createContour2D:function(){return{dir:null,index:null,firstVertex:null,insideContours:null,nextpeer:null,prevpeer:null}},CONTOUR_DIR:{CW_DIR:0,CCW_DIR:1,UNKNOWN_DIR:2},DIR:{OUTSIDE_EDGE:-32767,INSIDE_EDGE:-32766},create:function(t,e){var n=t.data,i=e.data,a=t.size.x,u=t.size.y,c=r.a.create(t,e);return{rasterize:function(t){var e,r,s,f,l,h,d,p,v,y,g,x,_=[],m=0;for(x=0;x<400;x++)_[x]=0;for(_[0]=n[0],v=null,h=1;h0){a=a-1|0;r[n+a|0]=(r[t+a|0]|0)-(r[e+a|0]|0)|0}}function c(t,e,n){t|=0;e|=0;n|=0;var a=0;a=i(o,o)|0;while((a|0)>0){a=a-1|0;r[n+a|0]=r[t+a|0]|0|(r[e+a|0]|0)|0}}function s(t){t|=0;var e=0;var n=0;n=i(o,o)|0;while((n|0)>0){n=n-1|0;e=(e|0)+(r[t+n|0]|0)|0}return e|0}function f(t,e){t|=0;e|=0;var n=0;n=i(o,o)|0;while((n|0)>0){n=n-1|0;r[t+n|0]=e}}function l(t,e){t|=0;e|=0;var n=0;var i=0;var a=0;var u=0;var c=0;var s=0;var f=0;var l=0;for(n=1;(n|0)<(o-1|0);n=n+1|0){l=l+o|0;for(i=1;(i|0)<(o-1|0);i=i+1|0){u=l-o|0;c=l+o|0;s=i-1|0;f=i+1|0;a=(r[t+u+s|0]|0)+(r[t+u+f|0]|0)+(r[t+l+i|0]|0)+(r[t+c+s|0]|0)+(r[t+c+f|0]|0)|0;if((a|0)>(0|0)){r[e+l+i|0]=1}else{r[e+l+i|0]=0}}}}function h(t,e){t|=0;e|=0;var n=0;n=i(o,o)|0;while((n|0)>0){n=n-1|0;r[e+n|0]=r[t+n|0]|0}}function d(t){t|=0;var e=0;var n=0;for(e=0;(e|0)<(o-1|0);e=e+1|0){r[t+e|0]=0;r[t+n|0]=0;n=n+o-1|0;r[t+n|0]=0;n=n+1|0}for(e=0;(e|0)<(o|0);e=e+1|0){r[t+n|0]=0;n=n+1|0}}function p(){var t=0;var e=0;var n=0;var r=0;var p=0;var v=0;e=i(o,o)|0;n=e+e|0;r=n+e|0;f(r,0);d(t);do{a(t,e);l(e,n);u(t,n,n);c(r,n,r);h(e,t);p=s(t)|0;v=(p|0)==0|0}while(!v)}return{skeletonize:p}}},function(t,e,n){t.exports=n(263)},function(t,e,n){var r=n(91),o=n(48),i=n(121),a=n(123),u=n(13),c=n(56),s=n(54);t.exports=function t(e,n,f,l,h){e!==n&&i(n,(function(i,c){if(h||(h=new r),u(i))a(e,n,c,f,t,l,h);else{var d=l?l(s(e,c),i,c+"",e,n,h):void 0;void 0===d&&(d=i),o(e,c,d)}}),c)}},function(t,e,n){var r=n(24),o=n(97),i=n(98),a=n(99),u=n(100),c=n(101);function s(t){var e=this.__data__=new r(t);this.size=e.size}s.prototype.clear=o,s.prototype.delete=i,s.prototype.get=a,s.prototype.has=u,s.prototype.set=c,t.exports=s},function(t,e){t.exports=function(){this.__data__=[],this.size=0}},function(t,e,n){var r=n(25),o=Array.prototype.splice;t.exports=function(t){var e=this.__data__,n=r(e,t);return!(n<0)&&(n==e.length-1?e.pop():o.call(e,n,1),--this.size,!0)}},function(t,e,n){var r=n(25);t.exports=function(t){var e=this.__data__,n=r(e,t);return n<0?void 0:e[n][1]}},function(t,e,n){var r=n(25);t.exports=function(t){return r(this.__data__,t)>-1}},function(t,e,n){var r=n(25);t.exports=function(t,e){var n=this.__data__,o=r(n,t);return o<0?(++this.size,n.push([t,e])):n[o][1]=e,this}},function(t,e,n){var r=n(24);t.exports=function(){this.__data__=new r,this.size=0}},function(t,e){t.exports=function(t){var e=this.__data__,n=e.delete(t);return this.size=e.size,n}},function(t,e){t.exports=function(t){return this.__data__.get(t)}},function(t,e){t.exports=function(t){return this.__data__.has(t)}},function(t,e,n){var r=n(24),o=n(44),i=n(47);t.exports=function(t,e){var n=this.__data__;if(n instanceof r){var a=n.__data__;if(!o||a.length<199)return a.push([t,e]),this.size=++n.size,this;n=this.__data__=new i(a)}return n.set(t,e),this.size=n.size,this}},function(t,e,n){var r=n(36),o=n(105),i=n(13),a=n(107),u=/^\[object .+?Constructor\]$/,c=Function.prototype,s=Object.prototype,f=c.toString,l=s.hasOwnProperty,h=RegExp("^"+f.call(l).replace(/[\\^$.*+?()[\]{}|]/g,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$");t.exports=function(t){return!(!i(t)||o(t))&&(r(t)?h:u).test(a(t))}},function(t,e,n){var r=n(27),o=Object.prototype,i=o.hasOwnProperty,a=o.toString,u=r?r.toStringTag:void 0;t.exports=function(t){var e=i.call(t,u),n=t[u];try{t[u]=void 0;var r=!0}catch(t){}var o=a.call(t);return r&&(e?t[u]=n:delete t[u]),o}},function(t,e){var n=Object.prototype.toString;t.exports=function(t){return n.call(t)}},function(t,e,n){var r,o=n(106),i=(r=/[^.]+$/.exec(o&&o.keys&&o.keys.IE_PROTO||""))?"Symbol(src)_1."+r:"";t.exports=function(t){return!!i&&i in t}},function(t,e,n){var r=n(17)["__core-js_shared__"];t.exports=r},function(t,e){var n=Function.prototype.toString;t.exports=function(t){if(null!=t){try{return n.call(t)}catch(t){}try{return t+""}catch(t){}}return""}},function(t,e){t.exports=function(t,e){return null==t?void 0:t[e]}},function(t,e,n){var r=n(110),o=n(24),i=n(44);t.exports=function(){this.size=0,this.__data__={hash:new r,map:new(i||o),string:new r}}},function(t,e,n){var r=n(111),o=n(112),i=n(113),a=n(114),u=n(115);function c(t){var e=-1,n=null==t?0:t.length;for(this.clear();++e1?n[i-1]:void 0,u=i>2?n[2]:void 0;for(a=t.length>3&&"function"==typeof a?(i--,a):void 0,u&&o(n[0],n[1],u)&&(a=i<3?void 0:a,i=1),e=Object(e);++r0){if(++e>=800)return arguments[0]}else e=0;return t.apply(void 0,arguments)}}},function(t,e,n){var r=n(26),o=n(39),i=n(31),a=n(13);t.exports=function(t,e,n){if(!a(n))return!1;var u=typeof e;return!!("number"==u?o(n)&&i(e,n.length):"string"==u&&e in n)&&r(n[e],t)}},function(t,e){"undefined"!=typeof window&&(window.requestAnimationFrame||(window.requestAnimationFrame=window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(t){window.setTimeout(t,1e3/60)})),"function"!=typeof Math.imul&&(Math.imul=function(t,e){var n=65535&t,r=65535&e;return n*r+((t>>>16&65535)*r+n*(e>>>16&65535)<<16>>>0)|0}),"function"!=typeof Object.assign&&(Object.assign=function(t){"use strict";if(null===t)throw new TypeError("Cannot convert undefined or null to object");for(var e=Object(t),n=1;n0&&(o=1/Math.sqrt(o),t[0]=e[0]*o,t[1]=e[1]*o);return t}},function(t,e){t.exports=function(t,e){return t[0]*e[0]+t[1]*e[1]}},function(t,e){t.exports=function(t,e,n){var r=e[0]*n[1]-e[1]*n[0];return t[0]=t[1]=0,t[2]=r,t}},function(t,e){t.exports=function(t,e,n,r){var o=e[0],i=e[1];return t[0]=o+r*(n[0]-o),t[1]=i+r*(n[1]-i),t}},function(t,e){t.exports=function(t,e){e=e||1;var n=2*Math.random()*Math.PI;return t[0]=Math.cos(n)*e,t[1]=Math.sin(n)*e,t}},function(t,e){t.exports=function(t,e,n){var r=e[0],o=e[1];return t[0]=n[0]*r+n[2]*o,t[1]=n[1]*r+n[3]*o,t}},function(t,e){t.exports=function(t,e,n){var r=e[0],o=e[1];return t[0]=n[0]*r+n[2]*o+n[4],t[1]=n[1]*r+n[3]*o+n[5],t}},function(t,e){t.exports=function(t,e,n){var r=e[0],o=e[1];return t[0]=n[0]*r+n[3]*o+n[6],t[1]=n[1]*r+n[4]*o+n[7],t}},function(t,e){t.exports=function(t,e,n){var r=e[0],o=e[1];return t[0]=n[0]*r+n[4]*o+n[12],t[1]=n[1]*r+n[5]*o+n[13],t}},function(t,e,n){t.exports=function(t,e,n,o,i,a){var u,c;e||(e=2);n||(n=0);c=o?Math.min(o*e+n,t.length):t.length;for(u=n;un*n){var o=Math.sqrt(r);t[0]=e[0]/o*n,t[1]=e[1]/o*n}else t[0]=e[0],t[1]=e[1];return t}},function(t,e){t.exports=function(t){var e=new Float32Array(3);return e[0]=t[0],e[1]=t[1],e[2]=t[2],e}},function(t,e,n){t.exports=function(t,e){var n=r(t[0],t[1],t[2]),a=r(e[0],e[1],e[2]);o(n,n),o(a,a);var u=i(n,a);return u>1?0:Math.acos(u)};var r=n(73),o=n(74),i=n(75)},function(t,e){t.exports=function(t,e){return t[0]=e[0],t[1]=e[1],t[2]=e[2],t}},function(t,e){t.exports=function(t,e,n,r){return t[0]=e,t[1]=n,t[2]=r,t}},function(t,e,n){t.exports=function(t,e){var n=t[0],o=t[1],i=t[2],a=e[0],u=e[1],c=e[2];return Math.abs(n-a)<=r*Math.max(1,Math.abs(n),Math.abs(a))&&Math.abs(o-u)<=r*Math.max(1,Math.abs(o),Math.abs(u))&&Math.abs(i-c)<=r*Math.max(1,Math.abs(i),Math.abs(c))};var r=n(71)},function(t,e){t.exports=function(t,e){return t[0]===e[0]&&t[1]===e[1]&&t[2]===e[2]}},function(t,e){t.exports=function(t,e,n){return t[0]=e[0]+n[0],t[1]=e[1]+n[1],t[2]=e[2]+n[2],t}},function(t,e,n){t.exports=n(76)},function(t,e,n){t.exports=n(77)},function(t,e,n){t.exports=n(78)},function(t,e){t.exports=function(t,e,n){return t[0]=Math.min(e[0],n[0]),t[1]=Math.min(e[1],n[1]),t[2]=Math.min(e[2],n[2]),t}},function(t,e){t.exports=function(t,e,n){return t[0]=Math.max(e[0],n[0]),t[1]=Math.max(e[1],n[1]),t[2]=Math.max(e[2],n[2]),t}},function(t,e){t.exports=function(t,e){return t[0]=Math.floor(e[0]),t[1]=Math.floor(e[1]),t[2]=Math.floor(e[2]),t}},function(t,e){t.exports=function(t,e){return t[0]=Math.ceil(e[0]),t[1]=Math.ceil(e[1]),t[2]=Math.ceil(e[2]),t}},function(t,e){t.exports=function(t,e){return t[0]=Math.round(e[0]),t[1]=Math.round(e[1]),t[2]=Math.round(e[2]),t}},function(t,e){t.exports=function(t,e,n){return t[0]=e[0]*n,t[1]=e[1]*n,t[2]=e[2]*n,t}},function(t,e){t.exports=function(t,e,n,r){return t[0]=e[0]+n[0]*r,t[1]=e[1]+n[1]*r,t[2]=e[2]+n[2]*r,t}},function(t,e,n){t.exports=n(79)},function(t,e,n){t.exports=n(80)},function(t,e,n){t.exports=n(81)},function(t,e,n){t.exports=n(82)},function(t,e){t.exports=function(t,e){return t[0]=-e[0],t[1]=-e[1],t[2]=-e[2],t}},function(t,e){t.exports=function(t,e){return t[0]=1/e[0],t[1]=1/e[1],t[2]=1/e[2],t}},function(t,e){t.exports=function(t,e,n){var r=e[0],o=e[1],i=e[2],a=n[0],u=n[1],c=n[2];return t[0]=o*c-i*u,t[1]=i*a-r*c,t[2]=r*u-o*a,t}},function(t,e){t.exports=function(t,e,n,r){var o=e[0],i=e[1],a=e[2];return t[0]=o+r*(n[0]-o),t[1]=i+r*(n[1]-i),t[2]=a+r*(n[2]-a),t}},function(t,e){t.exports=function(t,e){e=e||1;var n=2*Math.random()*Math.PI,r=2*Math.random()-1,o=Math.sqrt(1-r*r)*e;return t[0]=Math.cos(n)*o,t[1]=Math.sin(n)*o,t[2]=r*e,t}},function(t,e){t.exports=function(t,e,n){var r=e[0],o=e[1],i=e[2],a=n[3]*r+n[7]*o+n[11]*i+n[15];return a=a||1,t[0]=(n[0]*r+n[4]*o+n[8]*i+n[12])/a,t[1]=(n[1]*r+n[5]*o+n[9]*i+n[13])/a,t[2]=(n[2]*r+n[6]*o+n[10]*i+n[14])/a,t}},function(t,e){t.exports=function(t,e,n){var r=e[0],o=e[1],i=e[2];return t[0]=r*n[0]+o*n[3]+i*n[6],t[1]=r*n[1]+o*n[4]+i*n[7],t[2]=r*n[2]+o*n[5]+i*n[8],t}},function(t,e){t.exports=function(t,e,n){var r=e[0],o=e[1],i=e[2],a=n[0],u=n[1],c=n[2],s=n[3],f=s*r+u*i-c*o,l=s*o+c*r-a*i,h=s*i+a*o-u*r,d=-a*r-u*o-c*i;return t[0]=f*s+d*-a+l*-c-h*-u,t[1]=l*s+d*-u+h*-a-f*-c,t[2]=h*s+d*-c+f*-u-l*-a,t}},function(t,e){t.exports=function(t,e,n,r){var o=n[1],i=n[2],a=e[1]-o,u=e[2]-i,c=Math.sin(r),s=Math.cos(r);return t[0]=e[0],t[1]=o+a*s-u*c,t[2]=i+a*c+u*s,t}},function(t,e){t.exports=function(t,e,n,r){var o=n[0],i=n[2],a=e[0]-o,u=e[2]-i,c=Math.sin(r),s=Math.cos(r);return t[0]=o+u*c+a*s,t[1]=e[1],t[2]=i+u*s-a*c,t}},function(t,e){t.exports=function(t,e,n,r){var o=n[0],i=n[1],a=e[0]-o,u=e[1]-i,c=Math.sin(r),s=Math.cos(r);return t[0]=o+a*s-u*c,t[1]=i+a*c+u*s,t[2]=e[2],t}},function(t,e,n){t.exports=function(t,e,n,o,i,a){var u,c;e||(e=3);n||(n=0);c=o?Math.min(o*e+n,t.length):t.length;for(u=n;u=0;--o){var i=this.tryEntries[o],a=i.completion;if("root"===i.tryLoc)return r("end");if(i.tryLoc<=this.prev){var u=n.call(i,"catchLoc"),c=n.call(i,"finallyLoc");if(u&&c){if(this.prev=0;--r){var o=this.tryEntries[r];if(o.tryLoc<=this.prev&&n.call(o,"finallyLoc")&&this.prev=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),b(n),s}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var r=n.completion;if("throw"===r.type){var o=r.arg;b(n)}return o}}throw new Error("illegal catch attempt")},delegateYield:function(t,e,n){return this.delegate={iterator:O(t),resultName:e,nextLoc:n},"next"===this.method&&(this.arg=void 0),s}},t}(t.exports);try{regeneratorRuntime=r}catch(t){Function("r","regeneratorRuntime = r")(r)}},function(t,e,n){var r=n(230),o=n(240);t.exports=function(t,e){return r(t,e,(function(e,n){return o(t,n)}))}},function(t,e,n){var r=n(231),o=n(239),i=n(32);t.exports=function(t,e,n){for(var a=-1,u=e.length,c={};++a0&&i(f)?n>1?t(f,n-1,i,a,u):r(u,f):a||(u[u.length]=f)}return u}},function(t,e){t.exports=function(t,e){for(var n=-1,r=e.length,o=t.length;++nMath.abs(f-c),d=[],p=t.data,v=t.size.x,y=255,g=0;function x(t,e){u=p[e*v+t],y=ug?u:g,d.push(u)}h&&(i=c,c=s,s=i,i=f,f=l,l=i),c>f&&(i=c,c=f,f=i,i=s,s=l,l=i);var _=f-c,m=Math.abs(l-s);r=_/2|0,o=s;var b=sl?f.UP:f.DOWN,h.push({pos:0,val:s[0]}),i=0;id&&s[i+1]>.5*l?f.UP:r)&&(h.push({pos:i,val:s[i]}),r=o);for(h.push({pos:s.length,val:s[s.length-1]}),a=h[0].pos;al?0:1;for(i=1;ih[i].val?h[i].val+(h[i+1].val-h[i].val)/3*2|0:h[i+1].val+(h[i].val-h[i+1].val)/3|0,a=h[i].pos;ad?0:1;return{line:s,threshold:d}},s.debug={printFrequency:function(t,e){var n,r=e.getContext("2d");for(e.width=t.length,e.height=256,r.beginPath(),r.strokeStyle="blue",n=0;n1&&void 0!==arguments[1]?arguments[1]:0,n=e;nn)return Number.MAX_VALUE;o+=i}return o/u}},{key:"_nextSet",value:function(t){for(var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:0,n=e;n1&&(t[n[r]]=o)}},{key:"decodePattern",value:function(t){this._row=t;var e=this.decode();return null===e?(this._row.reverse(),(e=this.decode())&&(e.direction=l.Reverse,e.start=this._row.length-e.start,e.end=this._row.length-e.end)):e.direction=l.Forward,e&&(e.format=this.FORMAT),e}},{key:"_matchRange",value:function(t,e,n){var r;for(r=t=t<0?0:t;r0&&void 0!==arguments[0]?arguments[0]:this._nextUnset(this._row),e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this._row.length,n=!(arguments.length>2&&void 0!==arguments[2])||arguments[2],r=[],o=0;r[o]=0;for(var i=t;ithis.AVG_CODE_ERROR?null:(this.CODE_PATTERN[n.code]&&(n.correction.bar=this.calculateCorrection(this.CODE_PATTERN[n.code],r,this.MODULE_INDICES.bar),n.correction.space=this.calculateCorrection(this.CODE_PATTERN[n.code],r,this.MODULE_INDICES.space)),n)}r[++a]=1,i=!i}return null}},{key:"_correct",value:function(t,e){this._correctBars(t,e.bar,this.MODULE_INDICES.bar),this._correctBars(t,e.space,this.MODULE_INDICES.space)}},{key:"_findStart",value:function(){for(var t=[0,0,0,0,0,0],e=this._nextSet(this._row),n={error:Number.MAX_VALUE,code:-1,start:0,end:0,correction:{bar:1,space:1}},r=!1,o=0,i=e;i.48?null:o}n[++a]=1,i=!i}return null}},{key:"_findStart",value:function(){for(var t=this._nextSet(this._row),e=null;!e;){if(!(e=this._findPattern(I,t,!1,!0)))return null;var n=e.start-(e.end-e.start);if(n>=0&&this._matchRange(n,e.start,0))return e;t=e.end,e=null}return null}},{key:"_calculateFirstDigit",value:function(t){for(var e=0;e=10?(r.code-=10,o|=1<<5-i):o|=0<<5-i,e.push(r.code),n.push(r)}var a=this._calculateFirstDigit(o);if(null===a)return null;e.unshift(a);var u=this._findPattern(z,r.end,!0,!1);if(null===u||!u.end)return null;n.push(u);for(var c=0;c<6;c++){if(!(u=this._decodeCode(u.end,10)))return null;n.push(u),e.push(u.code)}return u}},{key:"_verifyTrailingWhitespace",value:function(t){var e=t.end+(t.end-t.start);return e=0;n-=2)e+=t[n];e*=3;for(var r=t.length-1;r>=0;r-=2)e+=t[r];return e%10==0}},{key:"_decodeExtensions",value:function(t){var e=this._nextSet(this._row,t),n=this._findPattern(U,e,!1,!1);if(null===n)return null;for(var r=0;r0){var u=this._decodeExtensions(a.end);if(!u)return null;if(!u.decodedCodes)return null;var c=u.decodedCodes[u.decodedCodes.length-1],s={start:c.start+((c.end-c.start)/2|0),end:c.end};if(!this._verifyTrailingWhitespace(s))return null;o={supplement:u,code:n.join("")+u.code}}return T(T({code:n.join(""),start:i.start,end:a.end,startInfo:i,decodedCodes:r},o),{},{format:this.FORMAT})}}]),n}(A),W=n(33),F=n.n(W);function V(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=C()(t);if(e){var o=C()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var q=new Uint16Array(F()("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-. *$/+%").map((function(t){return t.charCodeAt(0)}))),G=new Uint16Array([52,289,97,352,49,304,112,37,292,100,265,73,328,25,280,88,13,268,76,28,259,67,322,19,274,82,7,262,70,22,385,193,448,145,400,208,133,388,196,148,168,162,138,42]),H=function(t){b()(n,t);var e=V(n);function n(){var t;v()(this,n);for(var r=arguments.length,o=new Array(r),i=0;i3;){n=this._findNextWidth(t,n),r=0;for(var i=0,a=0;an&&(i|=1<0;u++)if(t[u]>n&&(r--,2*t[u]>=o))return-1;return i}}return-1}},{key:"_findNextWidth",value:function(t,e){for(var n=Number.MAX_VALUE,r=0;re&&(n=t[r]);return n}},{key:"_patternToChar",value:function(t){for(var e=0;e=r}},{key:"decode",value:function(t,e){var n=new Uint16Array([0,0,0,0,0,0,0,0,0]),r=[];if(!(e=this._findStart()))return null;var o,i,a=this._nextSet(this._row,e.end);do{n=this._toCounters(a,n);var u=this._toPattern(n);if(u<0)return null;if(null===(o=this._patternToChar(u)))return null;r.push(o),i=a,a+=S.a.sum(n),a=this._nextSet(this._row,a)}while("*"!==o);return r.pop(),r.length&&this._verifyTrailingWhitespace(i,a,n)?{code:r.join(""),start:e.start,end:a,startInfo:e,decodedCodes:r,format:this.FORMAT}:null}}]),n}(A),X=n(12),Q=n.n(X);function Y(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=C()(t);if(e){var o=C()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var $=/[IOQ]/g,Z=/[A-Z0-9]{17}/,K=function(t){b()(n,t);var e=Y(n);function n(){var t;v()(this,n);for(var r=arguments.length,o=new Array(r),i=0;ir&&(r=o),othis._counters.length)return-1;for(var n=this._computeAlternatingThreshold(t,e),r=this._computeAlternatingThreshold(t+1,e),o=64,i=0,a=0,u=0;u<7;u++)i=0==(1&u)?n:r,this._counters[t+u]>i&&(a|=o),o>>=1;return a}},{key:"_isStartEnd",value:function(t){for(var e=0;e=this._calculatePatternLength(t)/2)&&(e+8>=this._counters.length||this._counters[e+7]>=this._calculatePatternLength(e)/2)}},{key:"_charToPattern",value:function(t){for(var e=t.charCodeAt(0),n=0;n=0;a--){var u=2==(1&a)?r.bar:r.space,c=1==(1&n)?u.wide:u.narrow;c.size+=this._counters[o+a],c.counts++,n>>=1}o+=8}return["space","bar"].forEach((function(t){var e=r[t];e.wide.min=Math.floor((e.narrow.size/e.narrow.counts+e.wide.size/e.wide.counts)/2),e.narrow.max=Math.ceil(e.wide.min),e.wide.max=Math.ceil((2*e.wide.size+1.5)/e.wide.counts)})),r}},{key:"_validateResult",value:function(t,e){for(var n,r=this._thresholdResultPattern(t,e),o=e,i=0;i=0;a--){var u=0==(1&a)?r.bar:r.space,c=1==(1&n)?u.wide:u.narrow,s=this._counters[o+a];if(sc.max)return!1;n>>=1}o+=8}return!0}},{key:"decode",value:function(t,e){if(this._counters=this._fillCounters(),!(e=this._findStart()))return null;var n,r=e.startCounter,o=[];do{if((n=this._toPattern(r))<0)return null;var i=this._patternToChar(n);if(null===i)return null;if(o.push(i),r+=8,o.length>1&&this._isStartEnd(n))break}while(rthis._counters.length?this._counters.length:r;var a=e.start+this._sumCounters(e.startCounter,r-8);return{code:o.join(""),start:e.start,end:a,startInfo:e,decodedCodes:o,format:this.FORMAT}}}]),n}(A);function ot(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=C()(t);if(e){var o=C()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var it=function(t){b()(n,t);var e=ot(n);function n(){var t;v()(this,n);for(var r=arguments.length,o=new Array(r),i=0;i=10&&(n|=1<<1-c),1!==c&&(r=this._nextSet(this._row,u.end),r=this._nextUnset(this._row,r))}if(2!==i.length||parseInt(i.join(""))%4!==n)return null;var s=this._findStart();return{code:i.join(""),decodedCodes:a,end:u.end,format:this.FORMAT,startInfo:s,start:s.start}}}]),n}(B);function ft(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=C()(t);if(e){var o=C()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var lt=[24,20,18,17,12,6,3,10,9,5];var ht=function(t){b()(n,t);var e=ft(n);function n(){var t;v()(this,n);for(var r=arguments.length,o=new Array(r),i=0;i=10&&(n|=1<<4-c),4!==c&&(r=this._nextSet(this._row,i.end),r=this._nextUnset(this._row,r))}if(5!==a.length)return null;if(function(t){for(var e=t.length,n=0,r=e-2;r>=0;r-=2)n+=t[r];n*=3;for(var o=e-1;o>=0;o-=2)n+=t[o];return(n*=3)%10}(a)!==function(t){for(var e=0;e<10;e++)if(t===lt[e])return e;return null}(n))return null;var s=this._findStart();return{code:a.join(""),decodedCodes:u,end:i.end,format:this.FORMAT,startInfo:s,start:s.start}}}]),n}(B);function dt(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter((function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable}))),n.push.apply(n,r)}return n}function pt(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=C()(t);if(e){var o=C()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var vt=function(t){b()(n,t);var e=pt(n);function n(){var t;v()(this,n);for(var r=arguments.length,o=new Array(r),i=0;i=10&&(r.code=r.code-10,o|=1<<5-i),e.push(r.code),n.push(r)}return this._determineParity(o,e)?r:null}},{key:"_determineParity",value:function(t,e){for(var n=0;n2&&void 0!==arguments[2]&&arguments[2],r=arguments.length>3&&void 0!==arguments[3]&&arguments[3],o=new Array(t.length).fill(0),i=0,a={error:Number.MAX_VALUE,start:0,end:0},u=this.AVG_CODE_ERROR;n=n||!1,r=r||!1,e||(e=this._nextSet(this._row));for(var c=e;c=0&&this._matchRange(t,n.start,0))return n;e=n.end,n=null}return null}},{key:"_verifyTrailingWhitespace",value:function(t){var e=t.end+(t.end-t.start)/2;return e2&&void 0!==arguments[2]&&arguments[2],r=arguments.length>3&&void 0!==arguments[3]&&arguments[3],o=[],i=0,a={error:Number.MAX_VALUE,code:-1,start:0,end:0},u=0,c=0,s=this.AVG_CODE_ERROR;e||(e=this._nextSet(this._row));for(var f=0;f=0&&this._matchRange(r,t.start,0))return t;e=t.end,t=null}return t}},{key:"_verifyTrailingWhitespace",value:function(t){var e=t.end+(t.end-t.start)/2;return e4)return-1;if(0==(1&o))for(var a=0;a="a"&&o<="d"){if(r>e-2)return null;var i=t[++r],a=i.charCodeAt(0),u=void 0;switch(o){case"a":if(!(i>="A"&&i<="Z"))return null;u=String.fromCharCode(a-64);break;case"b":if(i>="A"&&i<="E")u=String.fromCharCode(a-38);else if(i>="F"&&i<="J")u=String.fromCharCode(a-11);else if(i>="K"&&i<="O")u=String.fromCharCode(a+16);else if(i>="P"&&i<="S")u=String.fromCharCode(a+43);else{if(!(i>="T"&&i<="Z"))return null;u=String.fromCharCode(127)}break;case"c":if(i>="A"&&i<="O")u=String.fromCharCode(a-32);else{if("Z"!==i)return null;u=":"}break;case"d":if(!(i>="A"&&i<="Z"))return null;u=String.fromCharCode(a+32);break;default:return console.warn("* code_93_reader _decodeExtended hit default case, this may be an error",u),null}n.push(u)}else n.push(o)}return n}},{key:"_matchCheckChar",value:function(t,e,n){var r=t.slice(0,e),o=r.length,i=r.reduce((function(t,e,r){return t+((-1*r+(o-1))%n+1)*Ct.indexOf(e.charCodeAt(0))}),0);return Ct[i%47]===t[e].charCodeAt(0)}},{key:"_verifyChecksums",value:function(t){return this._matchCheckChar(t,t.length-2,20)&&this._matchCheckChar(t,t.length-1,15)}},{key:"decode",value:function(t,e){if(!(e=this._findStart()))return null;var n,r,o=new Uint16Array([0,0,0,0,0,0]),i=[],a=this._nextSet(this._row,e.end);do{o=this._toCounters(a,o);var u=this._toPattern(o);if(u<0)return null;if(null===(r=this._patternToChar(u)))return null;i.push(r),n=a,a+=S.a.sum(o),a=this._nextSet(this._row,a)}while("*"!==r);return i.pop(),i.length&&this._verifyEnd(n,a)&&this._verifyChecksums(i)?(i=i.slice(0,i.length-2),null===(i=this._decodeExtended(i))?null:{code:i.join(""),start:e.start,end:a,startInfo:e,decodedCodes:i,format:this.FORMAT}):null}}]),n}(A);function St(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=C()(t);if(e){var o=C()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var At=/[AEIO]/g,kt=function(t){b()(n,t);var e=St(n);function n(){var t;v()(this,n);for(var r=arguments.length,o=new Array(r),i=0;i1&&(!e.inImageWithBorder(t[0])||!e.inImageWithBorder(t[1]));)o(-(r-=Math.ceil(r/2)));return t}(r,u,Math.floor(.1*i)))?null:(null===(o=a(r))&&(o=function(t,e,n){var r,o,i,u=Math.sqrt(Math.pow(t[1][0]-t[0][0],2)+Math.pow(t[1][1]-t[0][1],2)),c=null,s=Math.sin(n),f=Math.cos(n);for(r=1;r<16&&null===c;r++)i={y:(o=u/16*r*(r%2==0?-1:1))*s,x:o*f},e[0].y+=i.x,e[0].x-=i.y,e[1].y+=i.x,e[1].x-=i.y,c=a(e);return c}(t,r,u)),null===o?null:{codeResult:o.codeResult,line:r,angle:u,pattern:o.barcodeLine.line,threshold:o.barcodeLine.threshold})}return o(),{decodeFromBoundingBox:function(t){return u(t)},decodeFromBoundingBoxes:function(e){var n,r,o=[],i=t.multiple;for(n=0;n2&&void 0!==arguments[2]&&arguments[2];r(t,{callback:e,async:n,once:!0})},unsubscribe:function(n,r){if(n){var o=e(n);o.subscribers=o&&r?o.subscribers.filter((function(t){return t.callback!==r})):[]}else t={}}}}(),jt=n(20),It=n.n(jt),zt=n(11),Ut=n.n(zt),Lt=n(85),Nt=n.n(Lt),Bt=n(86);function Wt(t){var e=function(){if("undefined"==typeof Reflect||!Reflect.construct)return!1;if(Reflect.construct.sham)return!1;if("function"==typeof Proxy)return!0;try{return Boolean.prototype.valueOf.call(Reflect.construct(Boolean,[],(function(){}))),!0}catch(t){return!1}}();return function(){var n,r=C()(t);if(e){var o=C()(this).constructor;n=Reflect.construct(r,arguments,o)}else n=r.apply(this,arguments);return O()(this,n)}}var Ft,Vt=function(t){b()(n,t);var e=Wt(n);function n(t,r){var o;return v()(this,n),o=e.call(this,t),M()(_()(o),"code",void 0),o.code=r,Object.setPrototypeOf(_()(o),n.prototype),o}return n}(n.n(Bt)()(Error)),qt="This may mean that the user has declined camera access, or the browser does not support media APIs. If you are running in iOS, you must use Safari.";function Gt(){try{return navigator.mediaDevices.enumerateDevices()}catch(e){var t=new Vt("enumerateDevices is not defined. ".concat(qt),-1);return Promise.reject(t)}}function Ht(t){try{return navigator.mediaDevices.getUserMedia(t)}catch(t){var e=new Vt("getUserMedia is not defined. ".concat(qt),-1);return Promise.reject(e)}}function Xt(t){return new Promise((function(e,n){var r=10;!function o(){r>0?t.videoWidth>10&&t.videoHeight>10?e():window.setTimeout(o,500):n(new Vt("Unable to play video stream. Is webcam working?",-1)),r--}()}))}function Qt(t,e){return Yt.apply(this,arguments)}function Yt(){return(Yt=It()(Ut.a.mark((function t(e,n){var r;return Ut.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,Ht(n);case 2:if(r=t.sent,Ft=r,!e){t.next=11;break}return e.setAttribute("autoplay","true"),e.setAttribute("muted","true"),e.setAttribute("playsinline","true"),e.srcObject=r,e.addEventListener("loadedmetadata",(function(){e.play()})),t.abrupt("return",Xt(e));case 11:return t.abrupt("return",Promise.resolve());case 12:case"end":return t.stop()}}),t)})))).apply(this,arguments)}function $t(t){var e=Nt()(t,["width","height","facingMode","aspectRatio","deviceId"]);return void 0!==t.minAspectRatio&&t.minAspectRatio>0&&(e.aspectRatio=t.minAspectRatio,console.log("WARNING: Constraint 'minAspectRatio' is deprecated; Use 'aspectRatio' instead")),void 0!==t.facing&&(e.facingMode=t.facing,console.log("WARNING: Constraint 'facing' is deprecated. Use 'facingMode' instead'")),e}function Zt(){var t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},e=$t(t);return e&&e.deviceId&&e.facingMode&&delete e.facingMode,Promise.resolve({audio:!1,video:e})}function Kt(){return(Kt=It()(Ut.a.mark((function t(){var e;return Ut.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:return t.next=2,Gt();case 2:return e=t.sent,t.abrupt("return",e.filter((function(t){return"videoinput"===t.kind})));case 4:case"end":return t.stop()}}),t)})))).apply(this,arguments)}function Jt(){if(!Ft)return null;var t=Ft.getVideoTracks();return t&&null!=t&&t.length?t[0]:null}var te={requestedVideoElement:null,request:function(t,e){return It()(Ut.a.mark((function n(){var r;return Ut.a.wrap((function(n){for(;;)switch(n.prev=n.next){case 0:return te.requestedVideoElement=t,n.next=3,Zt(e);case 3:return r=n.sent,n.abrupt("return",Qt(t,r));case 5:case"end":return n.stop()}}),n)})))()},release:function(){var t=Ft&&Ft.getVideoTracks();return null!==te.requestedVideoElement&&te.requestedVideoElement.pause(),new Promise((function(e){setTimeout((function(){t&&t.length&&t[0].stop(),Ft=null,te.requestedVideoElement=null,e()}),0)}))},enumerateVideoDevices:function(){return Kt.apply(this,arguments)},getActiveStreamLabel:function(){var t=Jt();return t?t.label:""},getActiveTrack:Jt},ee=te;var ne={create:function(t){var e,n=document.createElement("canvas"),r=n.getContext("2d"),o=[],i=null!==(e=t.capacity)&&void 0!==e?e:20,a=!0===t.capture;function u(e){return!!i&&e&&!function(t,e){return e&&e.some((function(e){return Object.keys(e).every((function(n){return e[n]===t[n]}))}))}(e,t.blacklist)&&function(t,e){return"function"!=typeof e||e(t)}(e,t.filter)}return{addResult:function(t,e,c){var s={};u(c)&&(i--,s.codeResult=c,a&&(n.width=e.x,n.height=e.y,d.a.drawImage(t,e,r),s.frame=n.toDataURL()),o.push(s))},getResults:function(){return o}}}},re={inputStream:{name:"Live",type:"LiveStream",constraints:{width:640,height:480,facingMode:"environment"},area:{top:"0%",right:"0%",left:"0%",bottom:"0%"},singleChannel:!1},locate:!0,numOfWorkers:4,decoder:{readers:["code_128_reader"]},locator:{halfSample:!0,patchSize:"medium"}},oe=n(7),ie=function t(){v()(this,t),M()(this,"config",void 0),M()(this,"inputStream",void 0),M()(this,"framegrabber",void 0),M()(this,"inputImageWrapper",void 0),M()(this,"stopped",!1),M()(this,"boxSize",void 0),M()(this,"resultCollector",void 0),M()(this,"decoder",void 0),M()(this,"workerPool",[]),M()(this,"onUIThread",!0),M()(this,"canvasContainer",new ue)},ae=function t(){v()(this,t),M()(this,"image",void 0),M()(this,"overlay",void 0)},ue=function t(){v()(this,t),M()(this,"ctx",void 0),M()(this,"dom",void 0),this.ctx=new ae,this.dom=new ae},ce=n(23);function se(t){if("undefined"==typeof document)return null;if(t instanceof HTMLElement&&t.nodeName&&1===t.nodeType)return t;var e="string"==typeof t?t:"#interactive.viewport";return document.querySelector(e)}function fe(t,e){var n=function(t,e){var n=document.querySelector(t);return n||((n=document.createElement("canvas")).className=e),n}(t,e),r=n.getContext("2d");return{canvas:n,context:r}}function le(t){var e,n,r,o,i=se(null==t||null===(e=t.config)||void 0===e||null===(n=e.inputStream)||void 0===n?void 0:n.target),a=null==t||null===(r=t.config)||void 0===r||null===(o=r.inputStream)||void 0===o?void 0:o.type;if(!a)return null;var u=function(t){if("undefined"!=typeof document){var e=fe("canvas.imgBuffer","imgBuffer"),n=fe("canvas.drawingBuffer","drawingBuffer");return e.canvas.width=n.canvas.width=t.x,e.canvas.height=n.canvas.height=t.y,{dom:{image:e.canvas,overlay:n.canvas},ctx:{image:e.context,overlay:n.context}}}return null}(t.inputStream.getCanvasSize());if(!u)return{dom:{image:null,overlay:null},ctx:{image:null,overlay:null}};var c=u.dom;return"undefined"!=typeof document&&i&&("ImageStream"!==a||i.contains(c.image)||i.appendChild(c.image),i.contains(c.overlay)||i.appendChild(c.overlay)),u}var he={274:"orientation"},de=Object.keys(he).map((function(t){return he[t]}));function pe(t){return new Promise((function(e){var n=new FileReader;n.onload=function(t){return e(t.target.result)},n.readAsArrayBuffer(t)}))}function ve(t){return new Promise((function(e,n){var r=new XMLHttpRequest;r.open("GET",t,!0),r.responseType="blob",r.onreadystatechange=function(){r.readyState!==XMLHttpRequest.DONE||200!==r.status&&0!==r.status||e(this.response)},r.onerror=n,r.send()}))}function ye(t){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:de,n=new DataView(t),r=t.byteLength,o=e.reduce((function(t,e){var n=Object.keys(he).filter((function(t){return he[t]===e}))[0];return n&&(t[n]=e),t}),{}),i=2;if(255!==n.getUint8(0)||216!==n.getUint8(1))return!1;for(;i1&&void 0!==arguments[1]?arguments[1]:de;return/^blob:/i.test(t)?ve(t).then(pe).then((function(t){return ye(t,e)})):Promise.resolve(null)}(t,["orientation"]).then((function(t){s[0].tags=t,e(s)})).catch((function(t){console.log(t),e(s)})):e(s))},i=0;i0&&n.forEach((function(n){t.removeEventListener(e,n)}))}))},trigger:function(o,a){var s,f,l,h,d,p=i[o];if("canrecord"===o&&(h=t.videoWidth,d=t.videoHeight,e=null!==(f=r)&&void 0!==f&&f.size?h/d>1?r.size:Math.floor(h/d*r.size):h,n=null!==(l=r)&&void 0!==l&&l.size?h/d>1?Math.floor(d/h*r.size):r.size:d,u.x=e,u.y=n),p&&p.length>0)for(s=0;s0)for(n=0;n1?n.size:Math.floor(r/o*n.size):r,e=null!==(f=n)&&void 0!==f&&f.size?r/o>1?Math.floor(o/r*n.size):n.size:o,v.x=t,v.y=e,u=!0,i=0,setTimeout((function(){y("canrecord",[])}),0)}),1,s,null===(l=n)||void 0===l?void 0:l.sequence)},ended:function(){return l},setAttribute:function(){},getConfig:function(){return n},pause:function(){a=!0},play:function(){a=!1},setCurrentTime:function(t){i=t},addEventListener:function(t,e){-1!==h.indexOf(t)&&(d[t]||(d[t]=[]),d[t].push(e))},clearEventHandlers:function(){Object.keys(d).forEach((function(t){return delete d[t]}))},setTopRight:function(t){p.x=t.x,p.y=t.y},getTopRight:function(){return p},setCanvasSize:function(t){v.x=t.x,v.y=t.y},getCanvasSize:function(){return v},getFrame:function(){var t,e;if(!u)return null;a||(t=null===(e=c)||void 0===e?void 0:e[i],i=t&&r&&r()};if(e)for(var a=0;a0&&void 0!==arguments[0]?arguments[0]:"LiveStream",e=arguments.length>1?arguments[1]:void 0,n=arguments.length>2?arguments[2]:void 0;switch(t){case"VideoStream":var r=document.createElement("video");return{video:r,inputStream:n.createVideoStream(r)};case"ImageStream":return{inputStream:n.createImageStream()};case"LiveStream":var o=null;return e&&((o=e.querySelector("video"))||(o=document.createElement("video"),e.appendChild(o))),{video:o,inputStream:n.createLiveStream(o)};default:return console.error("* setupInputStream invalid type ".concat(t)),{video:null,inputStream:null}}}(n,this.getViewPort(),Oe),i=o.video,a=o.inputStream;"LiveStream"===n&&i&&ee.request(i,r).then((function(){return a.trigger("canrecord")})).catch((function(e){return t(e)})),a.setAttribute("preload","auto"),a.setInputStream(this.context.config.inputStream),a.addEventListener("canrecord",this.canRecord.bind(void 0,t)),this.context.inputStream=a}}},{key:"getBoundingBoxes",value:function(){var t;return null!==(t=this.context.config)&&void 0!==t&&t.locate?ce.a.locate():[[Object(oe.clone)(this.context.boxSize[0]),Object(oe.clone)(this.context.boxSize[1]),Object(oe.clone)(this.context.boxSize[2]),Object(oe.clone)(this.context.boxSize[3])]]}},{key:"transformResult",value:function(t){var e=this,n=this.context.inputStream.getTopRight(),r=n.x,o=n.y;if((0!==r||0!==o)&&(t.barcodes&&t.barcodes.forEach((function(t){return e.transformResult(t)})),t.line&&2===t.line.length&&function(t,e,n){t[0].x+=e,t[0].y+=n,t[1].x+=e,t[1].y+=n}(t.line,r,o),t.box&&Ie(t.box,r,o),t.boxes&&t.boxes.length>0))for(var i=0;i0&&void 0!==arguments[0]?arguments[0]:null,e=arguments.length>1?arguments[1]:void 0,n=t;t&&this.context.onUIThread&&(this.transformResult(t),this.addResult(t,e),n=t.barcodes||t),Tt.publish("processed",n),this.hasCodeResult(t)&&Tt.publish("detected",n)}},{key:"locateAndDecode",value:function(){var t=this.getBoundingBoxes();if(t){var e,n=this.context.decoder.decodeFromBoundingBoxes(t)||{};n.boxes=t,this.publishResult(n,null===(e=this.context.inputImageWrapper)||void 0===e?void 0:e.data)}else{var r,o=this.context.decoder.decodeFromImage(this.context.inputImageWrapper);if(o)this.publishResult(o,null===(r=this.context.inputImageWrapper)||void 0===r?void 0:r.data);else this.publishResult()}}},{key:"startContinuousUpdate",value:function(){var t,e=this,n=null,r=1e3/((null===(t=this.context.config)||void 0===t?void 0:t.frequency)||60);this.context.stopped=!1;var o=this.context;!function t(i){n=n||i,o.stopped||(i>=n&&(n+=r,e.update()),window.requestAnimationFrame(t))}(performance.now())}},{key:"start",value:function(){var t,e;this.context.onUIThread&&"LiveStream"===(null===(t=this.context.config)||void 0===t||null===(e=t.inputStream)||void 0===e?void 0:e.type)?this.startContinuousUpdate():this.update()}},{key:"stop",value:(e=It()(Ut.a.mark((function t(){var e;return Ut.a.wrap((function(t){for(;;)switch(t.prev=t.next){case 0:if(this.context.stopped=!0,je(0),null===(e=this.context.config)||void 0===e||!e.inputStream||"LiveStream"!==this.context.config.inputStream.type){t.next=6;break}return t.next=5,ee.release();case 5:this.context.inputStream.clearEventHandlers();case 6:case"end":return t.stop()}}),t,this)}))),function(){return e.apply(this,arguments)})},{key:"setReaders",value:function(t){this.context.decoder&&this.context.decoder.setReaders(t),function(t){ke.forEach((function(e){return e.worker.postMessage({cmd:"setReaders",readers:t})}))}(t)}},{key:"registerReader",value:function(t,e){Dt.registerReader(t,e),this.context.decoder&&this.context.decoder.registerReader(t,e),function(t,e){ke.forEach((function(n){return n.worker.postMessage({cmd:"registerReader",name:t,reader:e})}))}(t,e)}}]),t}(),Ue=new ze,Le=Ue.context,Ne={init:function(t,e,n){var r,o=arguments.length>3&&void 0!==arguments[3]?arguments[3]:Ue;return e||(r=new Promise((function(t,n){e=function(e){e?n(e):t()}}))),o.context.config=u()({},re,t),o.context.config.numOfWorkers>0&&(o.context.config.numOfWorkers=0),n?(o.context.onUIThread=!1,o.initializeData(n),e&&e()):o.initInputStream(e),r},start:function(){return Ue.start()},stop:function(){return Ue.stop()},pause:function(){Le.stopped=!0},onDetected:function(t){t&&("function"==typeof t||"object"===i()(t)&&t.callback)?Tt.subscribe("detected",t):console.trace("* warning: Quagga.onDetected called with invalid callback, ignoring")},offDetected:function(t){Tt.unsubscribe("detected",t)},onProcessed:function(t){t&&("function"==typeof t||"object"===i()(t)&&t.callback)?Tt.subscribe("processed",t):console.trace("* warning: Quagga.onProcessed called with invalid callback, ignoring")},offProcessed:function(t){Tt.unsubscribe("processed",t)},setReaders:function(t){t?Ue.setReaders(t):console.trace("* warning: Quagga.setReaders called with no readers, ignoring")},registerReader:function(t,e){t?e?Ue.registerReader(t,e):console.trace("* warning: Quagga.registerReader called with no reader, ignoring"):console.trace("* warning: Quagga.registerReader called with no name, ignoring")},registerResultCollector:function(t){t&&"function"==typeof t.addResult&&(Le.resultCollector=t)},get canvas(){return Le.canvasContainer},decodeSingle:function(t,e){var n=this,r=new ze;return(t=u()({inputStream:{type:"ImageStream",sequence:!1,size:800,src:t.src},numOfWorkers:1,locator:{halfSample:!1}},t)).numOfWorkers>0&&(t.numOfWorkers=0),t.numOfWorkers>0&&("undefined"==typeof Blob||"undefined"==typeof Worker)&&(console.warn("* no Worker and/or Blob support - forcing numOfWorkers to 0"),t.numOfWorkers=0),new Promise((function(o,i){try{n.init(t,(function(){Tt.once("processed",(function(t){r.stop(),e&&e.call(null,t),o(t)}),!0),r.start()}),null,r)}catch(t){i(t)}}))},get default(){return Ne},Readers:r,CameraAccess:ee,ImageDebug:d.a,ImageWrapper:c.a,ResultCollector:ne};e.default=Ne}]).default}));
\ No newline at end of file
diff --git a/bookwyrm/status.py b/bookwyrm/status.py
index 09fbdc06e..de7682ee7 100644
--- a/bookwyrm/status.py
+++ b/bookwyrm/status.py
@@ -2,15 +2,13 @@
from django.db import transaction
from bookwyrm import models
-from bookwyrm.sanitize_html import InputHtmlParser
+from bookwyrm.utils import sanitizer
def create_generated_note(user, content, mention_books=None, privacy="public"):
"""a note created by the app about user activity"""
# sanitize input html
- parser = InputHtmlParser()
- parser.feed(content)
- content = parser.get_output()
+ content = sanitizer.clean(content)
with transaction.atomic():
# create but don't save
diff --git a/bookwyrm/templates/about/about.html b/bookwyrm/templates/about/about.html
index 553bfee11..b04e21b17 100644
--- a/bookwyrm/templates/about/about.html
+++ b/bookwyrm/templates/about/about.html
@@ -14,23 +14,25 @@
{% cache 604800 about_page %}
{% get_book_superlatives as superlatives %}
-
-
- {% blocktrans with site_name=site.name %}Welcome to {{ site_name }}!{% endblocktrans %}
-
+
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/bookwyrm/templates/book/editions/editions.html b/bookwyrm/templates/book/editions/editions.html
index a3ff08022..16f65e459 100644
--- a/bookwyrm/templates/book/editions/editions.html
+++ b/bookwyrm/templates/book/editions/editions.html
@@ -21,7 +21,7 @@
@@ -46,7 +46,36 @@
{% endfor %}
-
+
{% include 'snippets/pagination.html' with page=editions path=request.path %}
+
+
+
+ {% trans "Can't find the edition you're looking for?" %}
+
+
+
+
+
{% endblock %}
diff --git a/bookwyrm/templates/book/file_links/add_link_modal.html b/bookwyrm/templates/book/file_links/add_link_modal.html
index d5b3fcd0d..67b437bd7 100644
--- a/bookwyrm/templates/book/file_links/add_link_modal.html
+++ b/bookwyrm/templates/book/file_links/add_link_modal.html
@@ -55,8 +55,10 @@
{% endblock %}
{% block modal-footer %}
-
{% trans "Save" %}
-
{% trans "Cancel" %}
-
+
+ {% trans "Cancel" %}
+ {% trans "Save" %}
+
{% endblock %}
+
{% block modal-form-close %}{% endblock %}
diff --git a/bookwyrm/templates/book/file_links/edit_links.html b/bookwyrm/templates/book/file_links/edit_links.html
index 9048ce7a2..fb722753f 100644
--- a/bookwyrm/templates/book/file_links/edit_links.html
+++ b/bookwyrm/templates/book/file_links/edit_links.html
@@ -8,14 +8,14 @@
- {% blocktrans with title=book|book_title %}
+ {% blocktrans trimmed with title=book|book_title %}
Links for "{{ title }} "
{% endblocktrans %}
- {{ book|book_title }}
+ {{ book|book_title }}
{% trans "Edit links" %}
@@ -42,7 +42,11 @@
{{ link.url }}
+ {% if link.added_by %}
{{ link.added_by.display_name }}
+ {% else %}
+ {% trans "Unknown user" %}
+ {% endif %}
{{ link.filelink.filetype }}
@@ -50,7 +54,7 @@
{{ link.domain.name }}
- {% trans "Report spam" %}
+ {% trans "Report spam" %}
diff --git a/bookwyrm/templates/book/file_links/verification_modal.html b/bookwyrm/templates/book/file_links/verification_modal.html
index 81685da0f..75678763f 100644
--- a/bookwyrm/templates/book/file_links/verification_modal.html
+++ b/bookwyrm/templates/book/file_links/verification_modal.html
@@ -17,13 +17,13 @@ Is that where you'd like to go?
{% block modal-footer %}
-{% trans "Continue" %}
-{% trans "Cancel" %}
-
{% if request.user.is_authenticated %}
-
-
{% trans "Report spam" %}
+
+
+
{% trans "Cancel" %}
+
{% trans "Continue" %}
{% endif %}
{% endblock %}
diff --git a/bookwyrm/templates/book/sync_modal.html b/bookwyrm/templates/book/sync_modal.html
index 6e5df0c0f..81ad8db92 100644
--- a/bookwyrm/templates/book/sync_modal.html
+++ b/bookwyrm/templates/book/sync_modal.html
@@ -19,8 +19,10 @@
{% endblock %}
{% block modal-footer %}
-
{% trans "Confirm" %}
-
{% trans "Cancel" %}
+
+ {% trans "Cancel" %}
+ {% trans "Confirm" %}
+
{% endblock %}
{% block modal-form-close %}{% endblock %}
diff --git a/bookwyrm/templates/components/tooltip.html b/bookwyrm/templates/components/tooltip.html
deleted file mode 100644
index 3176a6399..000000000
--- a/bookwyrm/templates/components/tooltip.html
+++ /dev/null
@@ -1,11 +0,0 @@
-{% load i18n %}
-
-{% trans "Help" as button_text %}
-{% include 'snippets/toggle/open_button.html' with text=button_text class="ml-3 is-rounded is-small has-background-body p-0 pb-1" icon="question-circle is-size-6" controls_text=controls_text controls_uid=controls_uid %}
-
-
- {% trans "Close" as button_text %}
- {% include 'snippets/toggle/close_button.html' with label=button_text class="delete" nonbutton=True controls_text=controls_text controls_uid=controls_uid %}
-
- {% block tooltip_content %}{% endblock %}
-
diff --git a/bookwyrm/templates/confirm_email/confirm_email.html b/bookwyrm/templates/confirm_email/confirm_email.html
index 8c8adcdd9..abdd3a734 100644
--- a/bookwyrm/templates/confirm_email/confirm_email.html
+++ b/bookwyrm/templates/confirm_email/confirm_email.html
@@ -29,9 +29,16 @@
- {% trans "Can't find your code?" as button_text %}
- {% include "snippets/toggle/open_button.html" with text=button_text controls_text="resend_form" focus="resend_form_header" %}
- {% include "confirm_email/resend_form.html" with controls_text="resend_form" %}
+
+ {% include "confirm_email/resend_modal.html" with id="resend_form" %}
diff --git a/bookwyrm/templates/confirm_email/resend.html b/bookwyrm/templates/confirm_email/resend.html
new file mode 100644
index 000000000..221f07565
--- /dev/null
+++ b/bookwyrm/templates/confirm_email/resend.html
@@ -0,0 +1,10 @@
+{% extends 'landing/layout.html' %}
+{% load i18n %}
+
+{% block title %}
+{% trans "Resend confirmation link" %}
+{% endblock %}
+
+{% block content %}
+{% include "confirm_email/resend_modal.html" with active=True static=True id="resend-modal" %}
+{% endblock %}
diff --git a/bookwyrm/templates/confirm_email/resend_form.html b/bookwyrm/templates/confirm_email/resend_form.html
deleted file mode 100644
index 7c0c10980..000000000
--- a/bookwyrm/templates/confirm_email/resend_form.html
+++ /dev/null
@@ -1,20 +0,0 @@
-{% extends "components/inline_form.html" %}
-{% load i18n %}
-{% block header %}
-{% trans "Resend confirmation link" %}
-{% endblock %}
-
-{% block form %}
-
-{% endblock %}
diff --git a/bookwyrm/templates/confirm_email/resend_modal.html b/bookwyrm/templates/confirm_email/resend_modal.html
new file mode 100644
index 000000000..4d155cbb6
--- /dev/null
+++ b/bookwyrm/templates/confirm_email/resend_modal.html
@@ -0,0 +1,36 @@
+{% extends "components/modal.html" %}
+{% load i18n %}
+
+{% block modal-title %}
+{% trans "Resend confirmation link" %}
+{% endblock %}
+
+{% block modal-form-open %}
+
+{% endblock %}
diff --git a/bookwyrm/templates/email/moderation_report/html_content.html b/bookwyrm/templates/email/moderation_report/html_content.html
index 10df380f2..3828ff70c 100644
--- a/bookwyrm/templates/email/moderation_report/html_content.html
+++ b/bookwyrm/templates/email/moderation_report/html_content.html
@@ -3,7 +3,19 @@
{% block content %}
-{% blocktrans %}@{{ reporter }} has flagged behavior by @{{ reportee }} for moderation. {% endblocktrans %}
+{% if report_link %}
+
+ {% blocktrans trimmed %}
+ @{{ reporter }} has flagged a link domain for moderation.
+ {% endblocktrans %}
+
+{% else %}
+
+ {% blocktrans trimmed %}
+ @{{ reporter }} has flagged behavior by @{{ reportee }} for moderation.
+ {% endblocktrans %}
+
+{% endif %}
{% trans "View report" as text %}
diff --git a/bookwyrm/templates/email/moderation_report/text_content.html b/bookwyrm/templates/email/moderation_report/text_content.html
index 57d37d446..764a3c72a 100644
--- a/bookwyrm/templates/email/moderation_report/text_content.html
+++ b/bookwyrm/templates/email/moderation_report/text_content.html
@@ -2,7 +2,15 @@
{% load i18n %}
{% block content %}
-{% blocktrans %}@{{ reporter}} has flagged behavior by @{{ reportee }} for moderation. {% endblocktrans %}
+{% if report_link %}
+{% blocktrans trimmed %}
+@{{ reporter }} has flagged a link domain for moderation.
+{% endblocktrans %}
+{% else %}
+{% blocktrans trimmed %}
+@{{ reporter }} has flagged behavior by @{{ reportee }} for moderation.
+{% endblocktrans %}
+{% endif %}
{% trans "View report" %}
{{ report_link }}
diff --git a/bookwyrm/templates/embed-layout.html b/bookwyrm/templates/embed-layout.html
index e0234494c..233ba387f 100644
--- a/bookwyrm/templates/embed-layout.html
+++ b/bookwyrm/templates/embed-layout.html
@@ -43,7 +43,7 @@
{% endif %}
- {% trans "Join Bookwyrm" %}
+ {% trans "Join BookWyrm" %}
diff --git a/bookwyrm/templates/feed/direct_messages.html b/bookwyrm/templates/feed/direct_messages.html
index 77f9aac19..115e1e6f4 100644
--- a/bookwyrm/templates/feed/direct_messages.html
+++ b/bookwyrm/templates/feed/direct_messages.html
@@ -14,7 +14,7 @@
- {% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner no_script=True %}
+ {% include 'snippets/create_status/status.html' with type="direct" uuid=1 mention=partner %}