mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-22 09:31:08 +00:00
Merge branch 'main' into bw-dev-npm-fix
This commit is contained in:
commit
e1f6110dc8
350 changed files with 44101 additions and 8321 deletions
|
@ -56,7 +56,7 @@ EMAIL_SENDER_NAME=admin
|
||||||
EMAIL_SENDER_DOMAIN=
|
EMAIL_SENDER_DOMAIN=
|
||||||
|
|
||||||
# Query timeouts
|
# Query timeouts
|
||||||
SEARCH_TIMEOUT=15
|
SEARCH_TIMEOUT=5
|
||||||
QUERY_TIMEOUT=5
|
QUERY_TIMEOUT=5
|
||||||
|
|
||||||
# Thumbnails Generation
|
# Thumbnails Generation
|
||||||
|
|
1
.github/workflows/django-tests.yml
vendored
1
.github/workflows/django-tests.yml
vendored
|
@ -55,5 +55,6 @@ jobs:
|
||||||
EMAIL_HOST_PASSWORD: ""
|
EMAIL_HOST_PASSWORD: ""
|
||||||
EMAIL_USE_TLS: true
|
EMAIL_USE_TLS: true
|
||||||
ENABLE_PREVIEW_IMAGES: false
|
ENABLE_PREVIEW_IMAGES: false
|
||||||
|
ENABLE_THUMBNAIL_GENERATION: true
|
||||||
run: |
|
run: |
|
||||||
pytest -n 3
|
pytest -n 3
|
||||||
|
|
8
.github/workflows/lint-frontend.yaml
vendored
8
.github/workflows/lint-frontend.yaml
vendored
|
@ -25,10 +25,10 @@ jobs:
|
||||||
run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
|
run: npm install stylelint stylelint-config-recommended stylelint-config-standard stylelint-order eslint
|
||||||
|
|
||||||
# See .stylelintignore for files that are not linted.
|
# See .stylelintignore for files that are not linted.
|
||||||
- name: Run stylelint
|
# - name: Run stylelint
|
||||||
run: >
|
# run: >
|
||||||
npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
|
# npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
|
||||||
--config dev-tools/.stylelintrc.js
|
# --config dev-tools/.stylelintrc.js
|
||||||
|
|
||||||
# See .eslintignore for files that are not linted.
|
# See .eslintignore for files that are not linted.
|
||||||
- name: Run ESLint
|
- name: Run ESLint
|
||||||
|
|
|
@ -3,4 +3,7 @@ ignore=migrations
|
||||||
load-plugins=pylint.extensions.no_self_use
|
load-plugins=pylint.extensions.no_self_use
|
||||||
|
|
||||||
[MESSAGES CONTROL]
|
[MESSAGES CONTROL]
|
||||||
disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C3001
|
disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C3001,import-error
|
||||||
|
|
||||||
|
[FORMAT]
|
||||||
|
max-line-length=88
|
||||||
|
|
71
README.md
71
README.md
|
@ -1,60 +1,45 @@
|
||||||
# BookWyrm
|
# BookWyrm
|
||||||
|
|
||||||
Social reading and reviewing, decentralized with ActivityPub
|
[![](https://img.shields.io/github/release/bookwyrm-social/bookwyrm.svg?colorB=58839b)](https://github.com/bookwyrm-social/bookwyrm/releases)
|
||||||
|
[![Run Python Tests](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/django-tests.yml/badge.svg)](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/django-tests.yml)
|
||||||
|
[![Pylint](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/pylint.yml/badge.svg)](https://github.com/bookwyrm-social/bookwyrm/actions/workflows/pylint.yml)
|
||||||
|
|
||||||
## Contents
|
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/).
|
||||||
- [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)
|
|
||||||
- [Set up BookWyrm](#set-up-bookwyrm)
|
|
||||||
|
|
||||||
## Joining BookWyrm
|
|
||||||
If you'd like to join an instance, you can check out the [instances](https://joinbookwyrm.com/instances/) list.
|
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Links
|
||||||
See [contributing](https://docs.joinbookwyrm.com/contributing.html) for code, translation or monetary contributions.
|
|
||||||
|
[![Mastodon Follow](https://img.shields.io/mastodon/follow/000146121?domain=https%3A%2F%2Ftech.lgbt&style=social)](https://tech.lgbt/@bookwyrm)
|
||||||
|
[![Twitter Follow](https://img.shields.io/twitter/follow/BookWyrmSocial?style=social)](https://twitter.com/BookWyrmSocial)
|
||||||
|
|
||||||
|
- [Project homepage](https://joinbookwyrm.com/)
|
||||||
|
- [Support](https://patreon.com/bookwyrm)
|
||||||
|
- [Documentation](https://docs.joinbookwyrm.com/)
|
||||||
|
|
||||||
|
|
||||||
## About BookWyrm
|
## 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.
|
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.
|
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
|
## 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
|
|
||||||
|
|
||||||
### 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
|
Web backend
|
||||||
- [Django](https://www.djangoproject.com/) web server
|
- [Django](https://www.djangoproject.com/) web server
|
||||||
- [PostgreSQL](https://www.postgresql.org/) database
|
- [PostgreSQL](https://www.postgresql.org/) database
|
||||||
|
|
5
SECURITY.md
Normal file
5
SECURITY.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
Please report security issues to `mousereeve@riseup.net`
|
|
@ -7,7 +7,7 @@ from django.apps import apps
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
|
|
||||||
from bookwyrm.connectors import ConnectorException, get_data
|
from bookwyrm.connectors import ConnectorException, get_data
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, MEDIUM
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -202,7 +202,7 @@ class ActivityObject:
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def set_related_field(
|
def set_related_field(
|
||||||
model_name, origin_model_name, related_field_name, related_remote_id, data
|
model_name, origin_model_name, related_field_name, related_remote_id, data
|
||||||
|
|
|
@ -117,6 +117,17 @@ class ActivityStream(RedisStore):
|
||||||
Q(id=status.user.id) # if the user is the post's author
|
Q(id=status.user.id) # if the user is the post's author
|
||||||
| Q(id__in=status.mention_users.all()) # if the user is mentioned
|
| Q(id__in=status.mention_users.all()) # if the user is mentioned
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# don't show replies to statuses the user can't see
|
||||||
|
elif status.reply_parent and status.reply_parent.privacy == "followers":
|
||||||
|
audience = audience.filter(
|
||||||
|
Q(id=status.user.id) # if the user is the post's author
|
||||||
|
| Q(id=status.reply_parent.user.id) # if the user is the OG author
|
||||||
|
| (
|
||||||
|
Q(following=status.user) & Q(following=status.reply_parent.user)
|
||||||
|
) # if the user is following both authors
|
||||||
|
).distinct()
|
||||||
|
|
||||||
# only visible to the poster's followers and tagged users
|
# only visible to the poster's followers and tagged users
|
||||||
elif status.privacy == "followers":
|
elif status.privacy == "followers":
|
||||||
audience = audience.filter(
|
audience = audience.filter(
|
||||||
|
|
|
@ -7,6 +7,7 @@ from django.contrib.postgres.search import SearchRank, SearchQuery
|
||||||
from django.db.models import OuterRef, Subquery, F, Q
|
from django.db.models import OuterRef, Subquery, F, Q
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
|
from bookwyrm import connectors
|
||||||
from bookwyrm.settings import MEDIA_FULL_URL
|
from bookwyrm.settings import MEDIA_FULL_URL
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,7 +31,9 @@ def isbn_search(query):
|
||||||
"""search your local database"""
|
"""search your local database"""
|
||||||
if not query:
|
if not query:
|
||||||
return []
|
return []
|
||||||
|
# Up-case the ISBN string to ensure any 'X' check-digit is correct
|
||||||
|
# If the ISBN has only 9 characters, prepend missing zero
|
||||||
|
query = query.strip().upper().rjust(10, "0")
|
||||||
filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
|
filters = [{f: query} for f in ["isbn_10", "isbn_13"]]
|
||||||
results = models.Edition.objects.filter(
|
results = models.Edition.objects.filter(
|
||||||
reduce(operator.or_, (Q(**f) for f in filters))
|
reduce(operator.or_, (Q(**f) for f in filters))
|
||||||
|
@ -72,6 +75,10 @@ def format_search_result(search_result):
|
||||||
|
|
||||||
def search_identifiers(query, *filters, return_first=False):
|
def search_identifiers(query, *filters, return_first=False):
|
||||||
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
||||||
|
if connectors.maybe_isbn(query):
|
||||||
|
# Oh did you think the 'S' in ISBN stood for 'standard'?
|
||||||
|
normalized_isbn = query.strip().upper().rjust(10, "0")
|
||||||
|
query = normalized_isbn
|
||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
or_filters = [
|
or_filters = [
|
||||||
{f.name: query}
|
{f.name: query}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
""" bring connectors into the namespace """
|
""" bring connectors into the namespace """
|
||||||
from .settings import CONNECTORS
|
from .settings import CONNECTORS
|
||||||
from .abstract_connector import ConnectorException
|
from .abstract_connector import ConnectorException
|
||||||
from .abstract_connector import get_data, get_image
|
from .abstract_connector import get_data, get_image, maybe_isbn
|
||||||
|
|
||||||
from .connector_manager import search, first_search_result
|
from .connector_manager import search, first_search_result
|
||||||
|
|
|
@ -42,8 +42,10 @@ class AbstractMinimalConnector(ABC):
|
||||||
"""format the query url"""
|
"""format the query url"""
|
||||||
# Check if the query resembles an ISBN
|
# Check if the query resembles an ISBN
|
||||||
if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
|
if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
|
||||||
return f"{self.isbn_search_url}{query}"
|
# Up-case the ISBN string to ensure any 'X' check-digit is correct
|
||||||
|
# If the ISBN has only 9 characters, prepend missing zero
|
||||||
|
normalized_query = query.strip().upper().rjust(10, "0")
|
||||||
|
return f"{self.isbn_search_url}{normalized_query}"
|
||||||
# NOTE: previously, we tried searching isbn and if that produces no 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
|
# searched as free text. This, instead, only searches isbn if it's isbn-y
|
||||||
return f"{self.search_url}{query}"
|
return f"{self.search_url}{query}"
|
||||||
|
@ -325,4 +327,11 @@ def unique_physical_format(format_text):
|
||||||
def maybe_isbn(query):
|
def maybe_isbn(query):
|
||||||
"""check if a query looks like an isbn"""
|
"""check if a query looks like an isbn"""
|
||||||
isbn = re.sub(r"[\W_]", "", query) # removes filler characters
|
isbn = re.sub(r"[\W_]", "", query) # removes filler characters
|
||||||
return len(isbn) in [10, 13] # ISBN10 or ISBN13
|
# ISBNs must be numeric except an ISBN10 checkdigit can be 'X'
|
||||||
|
if not isbn.upper().rstrip("X").isnumeric():
|
||||||
|
return False
|
||||||
|
return len(isbn) in [
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
] # ISBN10 or ISBN13, or maybe ISBN10 missing a leading zero
|
||||||
|
|
|
@ -13,7 +13,7 @@ from requests import HTTPError
|
||||||
|
|
||||||
from bookwyrm import book_search, models
|
from bookwyrm import book_search, models
|
||||||
from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
|
from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -53,7 +53,7 @@ async def get_results(session, url, min_confidence, query, connector):
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.info("Connection timed out for url: %s", url)
|
logger.info("Connection timed out for url: %s", url)
|
||||||
except aiohttp.ClientError as err:
|
except aiohttp.ClientError as err:
|
||||||
logger.exception(err)
|
logger.info(err)
|
||||||
|
|
||||||
|
|
||||||
async def async_connector_search(query, items, min_confidence):
|
async def async_connector_search(query, items, min_confidence):
|
||||||
|
@ -143,7 +143,7 @@ def get_or_create_connector(remote_id):
|
||||||
return load_connector(connector_info)
|
return load_connector(connector_info)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def load_more_data(connector_id, book_id):
|
def load_more_data(connector_id, book_id):
|
||||||
"""background the work of getting all 10,000 editions of LoTR"""
|
"""background the work of getting all 10,000 editions of LoTR"""
|
||||||
connector_info = models.Connector.objects.get(id=connector_id)
|
connector_info = models.Connector.objects.get(id=connector_id)
|
||||||
|
@ -152,6 +152,15 @@ def load_more_data(connector_id, book_id):
|
||||||
connector.expand_book_data(book)
|
connector.expand_book_data(book)
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(queue=LOW)
|
||||||
|
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):
|
def load_connector(connector_info):
|
||||||
"""instantiate the connector class"""
|
"""instantiate the connector class"""
|
||||||
connector = importlib.import_module(
|
connector = importlib.import_module(
|
||||||
|
|
|
@ -5,7 +5,7 @@ from bookwyrm import models
|
||||||
from bookwyrm.book_search import SearchResult
|
from bookwyrm.book_search import SearchResult
|
||||||
from .abstract_connector import AbstractConnector, Mapping
|
from .abstract_connector import AbstractConnector, Mapping
|
||||||
from .abstract_connector import get_data
|
from .abstract_connector import get_data
|
||||||
from .connector_manager import ConnectorException
|
from .connector_manager import ConnectorException, create_edition_task
|
||||||
|
|
||||||
|
|
||||||
class Connector(AbstractConnector):
|
class Connector(AbstractConnector):
|
||||||
|
@ -156,12 +156,17 @@ class Connector(AbstractConnector):
|
||||||
|
|
||||||
for edition_uri in edition_options.get("uris"):
|
for edition_uri in edition_options.get("uris"):
|
||||||
remote_id = self.get_remote_id(edition_uri)
|
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:
|
try:
|
||||||
data = self.get_book_data(remote_id)
|
edition_data = self.get_book_data(edition_data)
|
||||||
except ConnectorException:
|
except ConnectorException:
|
||||||
# who, indeed, knows
|
# who, indeed, knows
|
||||||
continue
|
return
|
||||||
self.create_edition_from_data(work, data)
|
super().create_edition_from_data(work, edition_data, instance=instance)
|
||||||
|
|
||||||
def get_cover_url(self, cover_blob, *_):
|
def get_cover_url(self, cover_blob, *_):
|
||||||
"""format the relative cover url into an absolute one:
|
"""format the relative cover url into an absolute one:
|
||||||
|
|
|
@ -5,7 +5,7 @@ from bookwyrm import models
|
||||||
from bookwyrm.book_search import SearchResult
|
from bookwyrm.book_search import SearchResult
|
||||||
from .abstract_connector import AbstractConnector, Mapping
|
from .abstract_connector import AbstractConnector, Mapping
|
||||||
from .abstract_connector import get_data, infer_physical_format, unique_physical_format
|
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
|
from .openlibrary_languages import languages
|
||||||
|
|
||||||
|
|
||||||
|
@ -210,7 +210,7 @@ class Connector(AbstractConnector):
|
||||||
# does this edition have ANY interesting data?
|
# does this edition have ANY interesting data?
|
||||||
if ignore_edition(edition_data):
|
if ignore_edition(edition_data):
|
||||||
continue
|
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):
|
def ignore_edition(edition_data):
|
||||||
|
|
|
@ -3,7 +3,7 @@ from django.core.mail import EmailMultiAlternatives
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
|
|
||||||
from bookwyrm import models, settings
|
from bookwyrm import models, settings
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, HIGH
|
||||||
from bookwyrm.settings import DOMAIN
|
from bookwyrm.settings import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ def email_confirmation_email(user):
|
||||||
data = email_data()
|
data = email_data()
|
||||||
data["confirmation_code"] = user.confirmation_code
|
data["confirmation_code"] = user.confirmation_code
|
||||||
data["confirmation_link"] = user.confirmation_link
|
data["confirmation_link"] = user.confirmation_link
|
||||||
send_email.delay(user.email, *format_email("confirm", data))
|
send_email(user.email, *format_email("confirm", data))
|
||||||
|
|
||||||
|
|
||||||
def invite_email(invite_request):
|
def invite_email(invite_request):
|
||||||
|
@ -45,6 +45,7 @@ def moderation_report_email(report):
|
||||||
"""a report was created"""
|
"""a report was created"""
|
||||||
data = email_data()
|
data = email_data()
|
||||||
data["reporter"] = report.reporter.localname or report.reporter.username
|
data["reporter"] = report.reporter.localname or report.reporter.username
|
||||||
|
if report.user:
|
||||||
data["reportee"] = report.user.localname or report.user.username
|
data["reportee"] = report.user.localname or report.user.username
|
||||||
data["report_link"] = report.remote_id
|
data["report_link"] = report.remote_id
|
||||||
|
|
||||||
|
@ -67,7 +68,7 @@ def format_email(email_name, data):
|
||||||
return (subject, html_content, text_content)
|
return (subject, html_content, text_content)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="high_priority")
|
@app.task(queue=HIGH)
|
||||||
def send_email(recipient, subject, html_content, text_content):
|
def send_email(recipient, subject, html_content, text_content):
|
||||||
"""use a task to send the email"""
|
"""use a task to send the email"""
|
||||||
email = EmailMultiAlternatives(
|
email = EmailMultiAlternatives(
|
||||||
|
|
|
@ -10,3 +10,4 @@ from .landing import *
|
||||||
from .links import *
|
from .links import *
|
||||||
from .lists import *
|
from .lists import *
|
||||||
from .status import *
|
from .status import *
|
||||||
|
from .user_admin import *
|
||||||
|
|
|
@ -2,13 +2,14 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.forms import widgets
|
from django.forms import widgets
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_celery_beat.models import IntervalSchedule
|
from django_celery_beat.models import IntervalSchedule
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from .custom_form import CustomForm
|
from .custom_form import CustomForm, StyledForm
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=missing-class-docstring
|
# pylint: disable=missing-class-docstring
|
||||||
|
@ -130,7 +131,7 @@ class AutoModRuleForm(CustomForm):
|
||||||
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
|
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
|
||||||
|
|
||||||
|
|
||||||
class IntervalScheduleForm(CustomForm):
|
class IntervalScheduleForm(StyledForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = IntervalSchedule
|
model = IntervalSchedule
|
||||||
fields = ["every", "period"]
|
fields = ["every", "period"]
|
||||||
|
@ -139,3 +140,10 @@ class IntervalScheduleForm(CustomForm):
|
||||||
"every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
|
"every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
|
||||||
"period": forms.Select(attrs={"aria-describedby": "desc_period"}),
|
"period": forms.Select(attrs={"aria-describedby": "desc_period"}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# pylint: disable=arguments-differ
|
||||||
|
def save(self, request, *args, **kwargs):
|
||||||
|
"""This is an outside model so the perms check works differently"""
|
||||||
|
if not request.user.has_perm("bookwyrm.moderate_user"):
|
||||||
|
raise PermissionDenied()
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
|
@ -4,7 +4,7 @@ from django.forms import ModelForm
|
||||||
from django.forms.widgets import Textarea
|
from django.forms.widgets import Textarea
|
||||||
|
|
||||||
|
|
||||||
class CustomForm(ModelForm):
|
class StyledForm(ModelForm):
|
||||||
"""add css classes to the forms"""
|
"""add css classes to the forms"""
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -16,7 +16,7 @@ class CustomForm(ModelForm):
|
||||||
css_classes["checkbox"] = "checkbox"
|
css_classes["checkbox"] = "checkbox"
|
||||||
css_classes["textarea"] = "textarea"
|
css_classes["textarea"] = "textarea"
|
||||||
# pylint: disable=super-with-arguments
|
# pylint: disable=super-with-arguments
|
||||||
super(CustomForm, self).__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
for visible in self.visible_fields():
|
for visible in self.visible_fields():
|
||||||
if hasattr(visible.field.widget, "input_type"):
|
if hasattr(visible.field.widget, "input_type"):
|
||||||
input_type = visible.field.widget.input_type
|
input_type = visible.field.widget.input_type
|
||||||
|
@ -24,3 +24,13 @@ class CustomForm(ModelForm):
|
||||||
input_type = "textarea"
|
input_type = "textarea"
|
||||||
visible.field.widget.attrs["rows"] = 5
|
visible.field.widget.attrs["rows"] = 5
|
||||||
visible.field.widget.attrs["class"] = css_classes[input_type]
|
visible.field.widget.attrs["class"] = css_classes[input_type]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomForm(StyledForm):
|
||||||
|
"""Check permissions on save"""
|
||||||
|
|
||||||
|
# pylint: disable=arguments-differ
|
||||||
|
def save(self, request, *args, **kwargs):
|
||||||
|
"""Save and check perms"""
|
||||||
|
self.instance.raise_not_editable(request.user)
|
||||||
|
return super().save(*args, **kwargs)
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
""" using django model forms """
|
""" using django model forms """
|
||||||
from django import 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 import models
|
||||||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||||
from .custom_form import CustomForm
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=missing-class-docstring
|
# pylint: disable=missing-class-docstring
|
||||||
class EditUserForm(CustomForm):
|
class EditUserForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -66,3 +68,51 @@ class DeleteUserForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["password"]
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmPasswordForm(CustomForm):
|
||||||
|
password = forms.CharField(widget=forms.PasswordInput)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["password"]
|
||||||
|
widgets = {
|
||||||
|
"password": forms.PasswordInput(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
"""Make sure password is correct"""
|
||||||
|
password = self.data.get("password")
|
||||||
|
|
||||||
|
if not self.instance.check_password(password):
|
||||||
|
self.add_error("password", _("Incorrect Password"))
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
""" using django model forms """
|
""" using django model forms """
|
||||||
|
import datetime
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.forms import widgets
|
from django.forms import widgets
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
@ -7,7 +8,6 @@ from bookwyrm import models
|
||||||
from bookwyrm.models.user import FeedFilterChoices
|
from bookwyrm.models.user import FeedFilterChoices
|
||||||
from .custom_form import CustomForm
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=missing-class-docstring
|
# pylint: disable=missing-class-docstring
|
||||||
class FeedStatusTypesForm(CustomForm):
|
class FeedStatusTypesForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -58,6 +58,21 @@ class ReadThroughForm(CustomForm):
|
||||||
self.add_error(
|
self.add_error(
|
||||||
"stopped_date", _("Reading stopped date cannot be before start date.")
|
"stopped_date", _("Reading stopped date cannot be before start date.")
|
||||||
)
|
)
|
||||||
|
current_time = datetime.datetime.now()
|
||||||
|
if (
|
||||||
|
stopped_date is not None
|
||||||
|
and current_time.timestamp() < stopped_date.timestamp()
|
||||||
|
):
|
||||||
|
self.add_error(
|
||||||
|
"stopped_date", _("Reading stopped date cannot be in the future.")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
finish_date is not None
|
||||||
|
and current_time.timestamp() < finish_date.timestamp()
|
||||||
|
):
|
||||||
|
self.add_error(
|
||||||
|
"finish_date", _("Reading finished date cannot be in the future.")
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.ReadThrough
|
model = models.ReadThrough
|
||||||
|
|
|
@ -4,12 +4,6 @@ from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=missing-class-docstring
|
# pylint: disable=missing-class-docstring
|
||||||
class UserGroupForm(CustomForm):
|
|
||||||
class Meta:
|
|
||||||
model = models.User
|
|
||||||
fields = ["groups"]
|
|
||||||
|
|
||||||
|
|
||||||
class GroupForm(CustomForm):
|
class GroupForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Group
|
model = models.Group
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
""" Forms for the landing pages """
|
""" Forms for the landing pages """
|
||||||
from django.forms import PasswordInput
|
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 django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
import pyotp
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
|
from bookwyrm.settings import DOMAIN
|
||||||
from .custom_form import CustomForm
|
from .custom_form import CustomForm
|
||||||
|
|
||||||
|
|
||||||
|
@ -13,21 +18,40 @@ class LoginForm(CustomForm):
|
||||||
fields = ["localname", "password"]
|
fields = ["localname", "password"]
|
||||||
help_texts = {f: None for f in fields}
|
help_texts = {f: None for f in fields}
|
||||||
widgets = {
|
widgets = {
|
||||||
"password": PasswordInput(),
|
"password": forms.PasswordInput(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def infer_username(self):
|
||||||
|
"""Users may enter their localname, username, or email"""
|
||||||
|
localname = self.data.get("localname")
|
||||||
|
if "@" in localname: # looks like an email address to me
|
||||||
|
try:
|
||||||
|
return models.User.objects.get(email=localname).username
|
||||||
|
except models.User.DoesNotExist: # maybe it's a full username?
|
||||||
|
return localname
|
||||||
|
return f"{localname}@{DOMAIN}"
|
||||||
|
|
||||||
|
def add_invalid_password_error(self):
|
||||||
|
"""We don't want to be too specific about this"""
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
self.non_field_errors = _("Username or password are incorrect")
|
||||||
|
|
||||||
|
|
||||||
class RegisterForm(CustomForm):
|
class RegisterForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = ["localname", "email", "password"]
|
fields = ["localname", "email", "password"]
|
||||||
help_texts = {f: None for f in fields}
|
help_texts = {f: None for f in fields}
|
||||||
widgets = {"password": PasswordInput()}
|
widgets = {"password": forms.PasswordInput()}
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
"""Check if the username is taken"""
|
"""Check if the username is taken"""
|
||||||
cleaned_data = super().clean()
|
cleaned_data = super().clean()
|
||||||
localname = cleaned_data.get("localname").strip()
|
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():
|
if models.User.objects.filter(localname=localname).first():
|
||||||
self.add_error("localname", _("User with this username already exists"))
|
self.add_error("localname", _("User with this username already exists"))
|
||||||
|
|
||||||
|
@ -43,3 +67,65 @@ class InviteRequestForm(CustomForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.InviteRequest
|
model = models.InviteRequest
|
||||||
fields = ["email", "answer"]
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class Confirm2FAForm(CustomForm):
|
||||||
|
otp = forms.CharField(
|
||||||
|
max_length=6, min_length=6, widget=forms.TextInput(attrs={"autofocus": True})
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = models.User
|
||||||
|
fields = ["otp_secret", "hotp_count"]
|
||||||
|
|
||||||
|
def clean_otp(self):
|
||||||
|
"""Check otp matches"""
|
||||||
|
otp = self.data.get("otp")
|
||||||
|
totp = pyotp.TOTP(self.instance.otp_secret)
|
||||||
|
|
||||||
|
if not totp.verify(otp):
|
||||||
|
|
||||||
|
if self.instance.hotp_secret:
|
||||||
|
# maybe it's a backup code?
|
||||||
|
hotp = pyotp.HOTP(self.instance.hotp_secret)
|
||||||
|
hotp_count = (
|
||||||
|
self.instance.hotp_count
|
||||||
|
if self.instance.hotp_count is not None
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hotp.verify(otp, hotp_count):
|
||||||
|
self.add_error("otp", _("Incorrect code"))
|
||||||
|
|
||||||
|
# increment the user hotp_count
|
||||||
|
else:
|
||||||
|
self.instance.hotp_count = hotp_count + 1
|
||||||
|
self.instance.save(broadcast=False, update_fields=["hotp_count"])
|
||||||
|
|
||||||
|
else:
|
||||||
|
self.add_error("otp", _("Incorrect code"))
|
||||||
|
|
10
bookwyrm/forms/user_admin.py
Normal file
10
bookwyrm/forms/user_admin.py
Normal file
|
@ -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"]
|
|
@ -24,5 +24,5 @@ class CalibreImporter(Importer):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def get_shelf(self, normalized_row):
|
def get_shelf(self, normalized_row):
|
||||||
# Calibre export does not indicate which shelf to use. Go with a default one for now
|
# Calibre export does not indicate which shelf to use. Use a default one for now
|
||||||
return Shelf.TO_READ
|
return Shelf.TO_READ
|
||||||
|
|
|
@ -1,15 +1,7 @@
|
||||||
""" handle reading a csv from an external service, defaults are from Goodreads """
|
""" handle reading a csv from an external service, defaults are from Goodreads """
|
||||||
import csv
|
import csv
|
||||||
import logging
|
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from bookwyrm import models
|
|
||||||
from bookwyrm.models import ImportJob, ImportItem
|
from bookwyrm.models import ImportJob, ImportItem
|
||||||
from bookwyrm.tasks import app, LOW
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class Importer:
|
class Importer:
|
||||||
|
@ -118,127 +110,3 @@ class Importer:
|
||||||
# this will re-normalize the raw data
|
# this will re-normalize the raw data
|
||||||
self.create_item(job, item.index, item.data)
|
self.create_item(job, item.index, item.data)
|
||||||
return job
|
return job
|
||||||
|
|
||||||
def start_import(self, job): # pylint: disable=no-self-use
|
|
||||||
"""initalizes a csv import job"""
|
|
||||||
result = start_import_task.delay(job.id)
|
|
||||||
job.task_id = result.id
|
|
||||||
job.save()
|
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
|
||||||
def start_import_task(job_id):
|
|
||||||
"""trigger the child tasks for each row"""
|
|
||||||
job = ImportJob.objects.get(id=job_id)
|
|
||||||
# these are sub-tasks so that one big task doesn't use up all the memory in celery
|
|
||||||
for item in job.items.values_list("id", flat=True).all():
|
|
||||||
import_item_task.delay(item)
|
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
|
||||||
def import_item_task(item_id):
|
|
||||||
"""resolve a row into a book"""
|
|
||||||
item = models.ImportItem.objects.get(id=item_id)
|
|
||||||
try:
|
|
||||||
item.resolve()
|
|
||||||
except Exception as err: # pylint: disable=broad-except
|
|
||||||
item.fail_reason = _("Error loading book")
|
|
||||||
item.save()
|
|
||||||
item.update_job()
|
|
||||||
raise err
|
|
||||||
|
|
||||||
if item.book:
|
|
||||||
# shelves book and handles reviews
|
|
||||||
handle_imported_book(item)
|
|
||||||
else:
|
|
||||||
item.fail_reason = _("Could not find a match for book")
|
|
||||||
|
|
||||||
item.save()
|
|
||||||
item.update_job()
|
|
||||||
|
|
||||||
|
|
||||||
def handle_imported_book(item):
|
|
||||||
"""process a csv and then post about it"""
|
|
||||||
job = item.job
|
|
||||||
user = job.user
|
|
||||||
if isinstance(item.book, models.Work):
|
|
||||||
item.book = item.book.default_edition
|
|
||||||
if not item.book:
|
|
||||||
item.fail_reason = _("Error loading book")
|
|
||||||
item.save()
|
|
||||||
return
|
|
||||||
if not isinstance(item.book, models.Edition):
|
|
||||||
item.book = item.book.edition
|
|
||||||
|
|
||||||
existing_shelf = models.ShelfBook.objects.filter(book=item.book, user=user).exists()
|
|
||||||
|
|
||||||
# shelve the book if it hasn't been shelved already
|
|
||||||
if item.shelf and not existing_shelf:
|
|
||||||
desired_shelf = models.Shelf.objects.get(identifier=item.shelf, user=user)
|
|
||||||
shelved_date = item.date_added or timezone.now()
|
|
||||||
models.ShelfBook(
|
|
||||||
book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
|
|
||||||
).save(priority=LOW)
|
|
||||||
|
|
||||||
for read in item.reads:
|
|
||||||
# check for an existing readthrough with the same dates
|
|
||||||
if models.ReadThrough.objects.filter(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
start_date=read.start_date,
|
|
||||||
finish_date=read.finish_date,
|
|
||||||
).exists():
|
|
||||||
continue
|
|
||||||
read.book = item.book
|
|
||||||
read.user = user
|
|
||||||
read.save()
|
|
||||||
|
|
||||||
if job.include_reviews and (item.rating or item.review) and not item.linked_review:
|
|
||||||
# we don't know the publication date of the review,
|
|
||||||
# but "now" is a bad guess
|
|
||||||
published_date_guess = item.date_read or item.date_added
|
|
||||||
if item.review:
|
|
||||||
# pylint: disable=consider-using-f-string
|
|
||||||
review_title = "Review of {!r} on {!r}".format(
|
|
||||||
item.book.title,
|
|
||||||
job.source,
|
|
||||||
)
|
|
||||||
review = models.Review.objects.filter(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
name=review_title,
|
|
||||||
rating=item.rating,
|
|
||||||
published_date=published_date_guess,
|
|
||||||
).first()
|
|
||||||
if not review:
|
|
||||||
review = models.Review(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
name=review_title,
|
|
||||||
content=item.review,
|
|
||||||
rating=item.rating,
|
|
||||||
published_date=published_date_guess,
|
|
||||||
privacy=job.privacy,
|
|
||||||
)
|
|
||||||
review.save(software="bookwyrm", priority=LOW)
|
|
||||||
else:
|
|
||||||
# just a rating
|
|
||||||
review = models.ReviewRating.objects.filter(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
published_date=published_date_guess,
|
|
||||||
rating=item.rating,
|
|
||||||
).first()
|
|
||||||
if not review:
|
|
||||||
review = models.ReviewRating(
|
|
||||||
user=user,
|
|
||||||
book=item.book,
|
|
||||||
rating=item.rating,
|
|
||||||
published_date=published_date_guess,
|
|
||||||
privacy=job.privacy,
|
|
||||||
)
|
|
||||||
review.save(software="bookwyrm", priority=LOW)
|
|
||||||
|
|
||||||
# only broadcast this review to other bookwyrm instances
|
|
||||||
item.linked_review = review
|
|
||||||
item.save()
|
|
||||||
|
|
|
@ -114,12 +114,20 @@ class ListsStream(RedisStore):
|
||||||
|
|
||||||
@receiver(signals.post_save, sender=models.List)
|
@receiver(signals.post_save, sender=models.List)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def add_list_on_create(sender, instance, created, *args, **kwargs):
|
def add_list_on_create(sender, instance, created, *args, update_fields=None, **kwargs):
|
||||||
"""add newly created lists streamsstreams"""
|
"""add newly created lists streams"""
|
||||||
if not created:
|
if created:
|
||||||
return
|
|
||||||
# when creating new things, gotta wait on the transaction
|
# when creating new things, gotta wait on the transaction
|
||||||
transaction.on_commit(lambda: add_list_on_create_command(instance.id))
|
transaction.on_commit(lambda: add_list_on_create_command(instance.id))
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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)
|
@receiver(signals.post_delete, sender=models.List)
|
||||||
|
@ -217,7 +225,7 @@ def populate_lists_task(user_id):
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue=MEDIUM)
|
@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"""
|
"""remove a list from any stream it might be in"""
|
||||||
stores = models.User.objects.filter(local=True, is_active=True).values_list(
|
stores = models.User.objects.filter(local=True, is_active=True).values_list(
|
||||||
"id", flat=True
|
"id", flat=True
|
||||||
|
@ -227,6 +235,9 @@ def remove_list_task(list_id):
|
||||||
stores = [ListsStream().stream_id(idx) for idx in stores]
|
stores = [ListsStream().stream_id(idx) for idx in stores]
|
||||||
ListsStream().remove_object_from_related_stores(list_id, stores=stores)
|
ListsStream().remove_object_from_related_stores(list_id, stores=stores)
|
||||||
|
|
||||||
|
if re_add:
|
||||||
|
add_list_task.delay(list_id)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue=HIGH)
|
@app.task(queue=HIGH)
|
||||||
def add_list_task(list_id):
|
def add_list_task(list_id):
|
||||||
|
|
|
@ -14,6 +14,8 @@ class Migration(migrations.Migration):
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="annualgoal",
|
model_name="annualgoal",
|
||||||
name="year",
|
name="year",
|
||||||
field=models.IntegerField(default=bookwyrm.models.user.get_current_year),
|
field=models.IntegerField(
|
||||||
|
default=bookwyrm.models.annual_goal.get_current_year
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
24
bookwyrm/migrations/0151_alter_report_user.py
Normal file
24
bookwyrm/migrations/0151_alter_report_user.py
Normal file
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
90
bookwyrm/migrations/0151_auto_20220705_0049.py
Normal file
90
bookwyrm/migrations/0151_auto_20220705_0049.py
Normal file
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
25
bookwyrm/migrations/0152_alter_report_user.py
Normal file
25
bookwyrm/migrations/0152_alter_report_user.py
Normal file
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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",
|
||||||
|
),
|
||||||
|
]
|
13
bookwyrm/migrations/0153_merge_20220706_2141.py
Normal file
13
bookwyrm/migrations/0153_merge_20220706_2141.py
Normal file
|
@ -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 = []
|
40
bookwyrm/migrations/0154_alter_user_preferred_language.py
Normal file
40
bookwyrm/migrations/0154_alter_user_preferred_language.py
Normal file
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
25
bookwyrm/migrations/0155_user_show_guided_tour.py
Normal file
25
bookwyrm/migrations/0155_user_show_guided_tour.py
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# Generated by Django 3.2.14 on 2022-07-09 23:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def existing_users_default(apps, schema_editor):
|
||||||
|
db_alias = schema_editor.connection.alias
|
||||||
|
user_model = apps.get_model("bookwyrm", "User")
|
||||||
|
user_model.objects.using(db_alias).filter(local=True).update(show_guided_tour=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0154_alter_user_preferred_language"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="show_guided_tour",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(existing_users_default, migrations.RunPython.noop),
|
||||||
|
]
|
41
bookwyrm/migrations/0156_alter_user_preferred_language.py
Normal file
41
bookwyrm/migrations/0156_alter_user_preferred_language.py
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
# Generated by Django 3.2.14 on 2022-08-02 18:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0155_user_show_guided_tour"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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)"),
|
||||||
|
("pl-pl", "Polski (Polish)"),
|
||||||
|
("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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
647
bookwyrm/migrations/0157_auto_20220909_2338.py
Normal file
647
bookwyrm/migrations/0157_auto_20220909_2338.py
Normal file
|
@ -0,0 +1,647 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-09-09 23:38
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0156_alter_user_preferred_language"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="review",
|
||||||
|
name="rating",
|
||||||
|
field=bookwyrm.models.fields.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
default=None,
|
||||||
|
max_digits=3,
|
||||||
|
null=True,
|
||||||
|
validators=[
|
||||||
|
django.core.validators.MinValueValidator(0.5),
|
||||||
|
django.core.validators.MaxValueValidator(5),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="preferred_timezone",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("Africa/Abidjan", "Africa/Abidjan"),
|
||||||
|
("Africa/Accra", "Africa/Accra"),
|
||||||
|
("Africa/Addis_Ababa", "Africa/Addis_Ababa"),
|
||||||
|
("Africa/Algiers", "Africa/Algiers"),
|
||||||
|
("Africa/Asmara", "Africa/Asmara"),
|
||||||
|
("Africa/Asmera", "Africa/Asmera"),
|
||||||
|
("Africa/Bamako", "Africa/Bamako"),
|
||||||
|
("Africa/Bangui", "Africa/Bangui"),
|
||||||
|
("Africa/Banjul", "Africa/Banjul"),
|
||||||
|
("Africa/Bissau", "Africa/Bissau"),
|
||||||
|
("Africa/Blantyre", "Africa/Blantyre"),
|
||||||
|
("Africa/Brazzaville", "Africa/Brazzaville"),
|
||||||
|
("Africa/Bujumbura", "Africa/Bujumbura"),
|
||||||
|
("Africa/Cairo", "Africa/Cairo"),
|
||||||
|
("Africa/Casablanca", "Africa/Casablanca"),
|
||||||
|
("Africa/Ceuta", "Africa/Ceuta"),
|
||||||
|
("Africa/Conakry", "Africa/Conakry"),
|
||||||
|
("Africa/Dakar", "Africa/Dakar"),
|
||||||
|
("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"),
|
||||||
|
("Africa/Djibouti", "Africa/Djibouti"),
|
||||||
|
("Africa/Douala", "Africa/Douala"),
|
||||||
|
("Africa/El_Aaiun", "Africa/El_Aaiun"),
|
||||||
|
("Africa/Freetown", "Africa/Freetown"),
|
||||||
|
("Africa/Gaborone", "Africa/Gaborone"),
|
||||||
|
("Africa/Harare", "Africa/Harare"),
|
||||||
|
("Africa/Johannesburg", "Africa/Johannesburg"),
|
||||||
|
("Africa/Juba", "Africa/Juba"),
|
||||||
|
("Africa/Kampala", "Africa/Kampala"),
|
||||||
|
("Africa/Khartoum", "Africa/Khartoum"),
|
||||||
|
("Africa/Kigali", "Africa/Kigali"),
|
||||||
|
("Africa/Kinshasa", "Africa/Kinshasa"),
|
||||||
|
("Africa/Lagos", "Africa/Lagos"),
|
||||||
|
("Africa/Libreville", "Africa/Libreville"),
|
||||||
|
("Africa/Lome", "Africa/Lome"),
|
||||||
|
("Africa/Luanda", "Africa/Luanda"),
|
||||||
|
("Africa/Lubumbashi", "Africa/Lubumbashi"),
|
||||||
|
("Africa/Lusaka", "Africa/Lusaka"),
|
||||||
|
("Africa/Malabo", "Africa/Malabo"),
|
||||||
|
("Africa/Maputo", "Africa/Maputo"),
|
||||||
|
("Africa/Maseru", "Africa/Maseru"),
|
||||||
|
("Africa/Mbabane", "Africa/Mbabane"),
|
||||||
|
("Africa/Mogadishu", "Africa/Mogadishu"),
|
||||||
|
("Africa/Monrovia", "Africa/Monrovia"),
|
||||||
|
("Africa/Nairobi", "Africa/Nairobi"),
|
||||||
|
("Africa/Ndjamena", "Africa/Ndjamena"),
|
||||||
|
("Africa/Niamey", "Africa/Niamey"),
|
||||||
|
("Africa/Nouakchott", "Africa/Nouakchott"),
|
||||||
|
("Africa/Ouagadougou", "Africa/Ouagadougou"),
|
||||||
|
("Africa/Porto-Novo", "Africa/Porto-Novo"),
|
||||||
|
("Africa/Sao_Tome", "Africa/Sao_Tome"),
|
||||||
|
("Africa/Timbuktu", "Africa/Timbuktu"),
|
||||||
|
("Africa/Tripoli", "Africa/Tripoli"),
|
||||||
|
("Africa/Tunis", "Africa/Tunis"),
|
||||||
|
("Africa/Windhoek", "Africa/Windhoek"),
|
||||||
|
("America/Adak", "America/Adak"),
|
||||||
|
("America/Anchorage", "America/Anchorage"),
|
||||||
|
("America/Anguilla", "America/Anguilla"),
|
||||||
|
("America/Antigua", "America/Antigua"),
|
||||||
|
("America/Araguaina", "America/Araguaina"),
|
||||||
|
(
|
||||||
|
"America/Argentina/Buenos_Aires",
|
||||||
|
"America/Argentina/Buenos_Aires",
|
||||||
|
),
|
||||||
|
("America/Argentina/Catamarca", "America/Argentina/Catamarca"),
|
||||||
|
(
|
||||||
|
"America/Argentina/ComodRivadavia",
|
||||||
|
"America/Argentina/ComodRivadavia",
|
||||||
|
),
|
||||||
|
("America/Argentina/Cordoba", "America/Argentina/Cordoba"),
|
||||||
|
("America/Argentina/Jujuy", "America/Argentina/Jujuy"),
|
||||||
|
("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"),
|
||||||
|
("America/Argentina/Mendoza", "America/Argentina/Mendoza"),
|
||||||
|
(
|
||||||
|
"America/Argentina/Rio_Gallegos",
|
||||||
|
"America/Argentina/Rio_Gallegos",
|
||||||
|
),
|
||||||
|
("America/Argentina/Salta", "America/Argentina/Salta"),
|
||||||
|
("America/Argentina/San_Juan", "America/Argentina/San_Juan"),
|
||||||
|
("America/Argentina/San_Luis", "America/Argentina/San_Luis"),
|
||||||
|
("America/Argentina/Tucuman", "America/Argentina/Tucuman"),
|
||||||
|
("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"),
|
||||||
|
("America/Aruba", "America/Aruba"),
|
||||||
|
("America/Asuncion", "America/Asuncion"),
|
||||||
|
("America/Atikokan", "America/Atikokan"),
|
||||||
|
("America/Atka", "America/Atka"),
|
||||||
|
("America/Bahia", "America/Bahia"),
|
||||||
|
("America/Bahia_Banderas", "America/Bahia_Banderas"),
|
||||||
|
("America/Barbados", "America/Barbados"),
|
||||||
|
("America/Belem", "America/Belem"),
|
||||||
|
("America/Belize", "America/Belize"),
|
||||||
|
("America/Blanc-Sablon", "America/Blanc-Sablon"),
|
||||||
|
("America/Boa_Vista", "America/Boa_Vista"),
|
||||||
|
("America/Bogota", "America/Bogota"),
|
||||||
|
("America/Boise", "America/Boise"),
|
||||||
|
("America/Buenos_Aires", "America/Buenos_Aires"),
|
||||||
|
("America/Cambridge_Bay", "America/Cambridge_Bay"),
|
||||||
|
("America/Campo_Grande", "America/Campo_Grande"),
|
||||||
|
("America/Cancun", "America/Cancun"),
|
||||||
|
("America/Caracas", "America/Caracas"),
|
||||||
|
("America/Catamarca", "America/Catamarca"),
|
||||||
|
("America/Cayenne", "America/Cayenne"),
|
||||||
|
("America/Cayman", "America/Cayman"),
|
||||||
|
("America/Chicago", "America/Chicago"),
|
||||||
|
("America/Chihuahua", "America/Chihuahua"),
|
||||||
|
("America/Coral_Harbour", "America/Coral_Harbour"),
|
||||||
|
("America/Cordoba", "America/Cordoba"),
|
||||||
|
("America/Costa_Rica", "America/Costa_Rica"),
|
||||||
|
("America/Creston", "America/Creston"),
|
||||||
|
("America/Cuiaba", "America/Cuiaba"),
|
||||||
|
("America/Curacao", "America/Curacao"),
|
||||||
|
("America/Danmarkshavn", "America/Danmarkshavn"),
|
||||||
|
("America/Dawson", "America/Dawson"),
|
||||||
|
("America/Dawson_Creek", "America/Dawson_Creek"),
|
||||||
|
("America/Denver", "America/Denver"),
|
||||||
|
("America/Detroit", "America/Detroit"),
|
||||||
|
("America/Dominica", "America/Dominica"),
|
||||||
|
("America/Edmonton", "America/Edmonton"),
|
||||||
|
("America/Eirunepe", "America/Eirunepe"),
|
||||||
|
("America/El_Salvador", "America/El_Salvador"),
|
||||||
|
("America/Ensenada", "America/Ensenada"),
|
||||||
|
("America/Fort_Nelson", "America/Fort_Nelson"),
|
||||||
|
("America/Fort_Wayne", "America/Fort_Wayne"),
|
||||||
|
("America/Fortaleza", "America/Fortaleza"),
|
||||||
|
("America/Glace_Bay", "America/Glace_Bay"),
|
||||||
|
("America/Godthab", "America/Godthab"),
|
||||||
|
("America/Goose_Bay", "America/Goose_Bay"),
|
||||||
|
("America/Grand_Turk", "America/Grand_Turk"),
|
||||||
|
("America/Grenada", "America/Grenada"),
|
||||||
|
("America/Guadeloupe", "America/Guadeloupe"),
|
||||||
|
("America/Guatemala", "America/Guatemala"),
|
||||||
|
("America/Guayaquil", "America/Guayaquil"),
|
||||||
|
("America/Guyana", "America/Guyana"),
|
||||||
|
("America/Halifax", "America/Halifax"),
|
||||||
|
("America/Havana", "America/Havana"),
|
||||||
|
("America/Hermosillo", "America/Hermosillo"),
|
||||||
|
("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"),
|
||||||
|
("America/Indiana/Knox", "America/Indiana/Knox"),
|
||||||
|
("America/Indiana/Marengo", "America/Indiana/Marengo"),
|
||||||
|
("America/Indiana/Petersburg", "America/Indiana/Petersburg"),
|
||||||
|
("America/Indiana/Tell_City", "America/Indiana/Tell_City"),
|
||||||
|
("America/Indiana/Vevay", "America/Indiana/Vevay"),
|
||||||
|
("America/Indiana/Vincennes", "America/Indiana/Vincennes"),
|
||||||
|
("America/Indiana/Winamac", "America/Indiana/Winamac"),
|
||||||
|
("America/Indianapolis", "America/Indianapolis"),
|
||||||
|
("America/Inuvik", "America/Inuvik"),
|
||||||
|
("America/Iqaluit", "America/Iqaluit"),
|
||||||
|
("America/Jamaica", "America/Jamaica"),
|
||||||
|
("America/Jujuy", "America/Jujuy"),
|
||||||
|
("America/Juneau", "America/Juneau"),
|
||||||
|
("America/Kentucky/Louisville", "America/Kentucky/Louisville"),
|
||||||
|
("America/Kentucky/Monticello", "America/Kentucky/Monticello"),
|
||||||
|
("America/Knox_IN", "America/Knox_IN"),
|
||||||
|
("America/Kralendijk", "America/Kralendijk"),
|
||||||
|
("America/La_Paz", "America/La_Paz"),
|
||||||
|
("America/Lima", "America/Lima"),
|
||||||
|
("America/Los_Angeles", "America/Los_Angeles"),
|
||||||
|
("America/Louisville", "America/Louisville"),
|
||||||
|
("America/Lower_Princes", "America/Lower_Princes"),
|
||||||
|
("America/Maceio", "America/Maceio"),
|
||||||
|
("America/Managua", "America/Managua"),
|
||||||
|
("America/Manaus", "America/Manaus"),
|
||||||
|
("America/Marigot", "America/Marigot"),
|
||||||
|
("America/Martinique", "America/Martinique"),
|
||||||
|
("America/Matamoros", "America/Matamoros"),
|
||||||
|
("America/Mazatlan", "America/Mazatlan"),
|
||||||
|
("America/Mendoza", "America/Mendoza"),
|
||||||
|
("America/Menominee", "America/Menominee"),
|
||||||
|
("America/Merida", "America/Merida"),
|
||||||
|
("America/Metlakatla", "America/Metlakatla"),
|
||||||
|
("America/Mexico_City", "America/Mexico_City"),
|
||||||
|
("America/Miquelon", "America/Miquelon"),
|
||||||
|
("America/Moncton", "America/Moncton"),
|
||||||
|
("America/Monterrey", "America/Monterrey"),
|
||||||
|
("America/Montevideo", "America/Montevideo"),
|
||||||
|
("America/Montreal", "America/Montreal"),
|
||||||
|
("America/Montserrat", "America/Montserrat"),
|
||||||
|
("America/Nassau", "America/Nassau"),
|
||||||
|
("America/New_York", "America/New_York"),
|
||||||
|
("America/Nipigon", "America/Nipigon"),
|
||||||
|
("America/Nome", "America/Nome"),
|
||||||
|
("America/Noronha", "America/Noronha"),
|
||||||
|
("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"),
|
||||||
|
("America/North_Dakota/Center", "America/North_Dakota/Center"),
|
||||||
|
(
|
||||||
|
"America/North_Dakota/New_Salem",
|
||||||
|
"America/North_Dakota/New_Salem",
|
||||||
|
),
|
||||||
|
("America/Nuuk", "America/Nuuk"),
|
||||||
|
("America/Ojinaga", "America/Ojinaga"),
|
||||||
|
("America/Panama", "America/Panama"),
|
||||||
|
("America/Pangnirtung", "America/Pangnirtung"),
|
||||||
|
("America/Paramaribo", "America/Paramaribo"),
|
||||||
|
("America/Phoenix", "America/Phoenix"),
|
||||||
|
("America/Port-au-Prince", "America/Port-au-Prince"),
|
||||||
|
("America/Port_of_Spain", "America/Port_of_Spain"),
|
||||||
|
("America/Porto_Acre", "America/Porto_Acre"),
|
||||||
|
("America/Porto_Velho", "America/Porto_Velho"),
|
||||||
|
("America/Puerto_Rico", "America/Puerto_Rico"),
|
||||||
|
("America/Punta_Arenas", "America/Punta_Arenas"),
|
||||||
|
("America/Rainy_River", "America/Rainy_River"),
|
||||||
|
("America/Rankin_Inlet", "America/Rankin_Inlet"),
|
||||||
|
("America/Recife", "America/Recife"),
|
||||||
|
("America/Regina", "America/Regina"),
|
||||||
|
("America/Resolute", "America/Resolute"),
|
||||||
|
("America/Rio_Branco", "America/Rio_Branco"),
|
||||||
|
("America/Rosario", "America/Rosario"),
|
||||||
|
("America/Santa_Isabel", "America/Santa_Isabel"),
|
||||||
|
("America/Santarem", "America/Santarem"),
|
||||||
|
("America/Santiago", "America/Santiago"),
|
||||||
|
("America/Santo_Domingo", "America/Santo_Domingo"),
|
||||||
|
("America/Sao_Paulo", "America/Sao_Paulo"),
|
||||||
|
("America/Scoresbysund", "America/Scoresbysund"),
|
||||||
|
("America/Shiprock", "America/Shiprock"),
|
||||||
|
("America/Sitka", "America/Sitka"),
|
||||||
|
("America/St_Barthelemy", "America/St_Barthelemy"),
|
||||||
|
("America/St_Johns", "America/St_Johns"),
|
||||||
|
("America/St_Kitts", "America/St_Kitts"),
|
||||||
|
("America/St_Lucia", "America/St_Lucia"),
|
||||||
|
("America/St_Thomas", "America/St_Thomas"),
|
||||||
|
("America/St_Vincent", "America/St_Vincent"),
|
||||||
|
("America/Swift_Current", "America/Swift_Current"),
|
||||||
|
("America/Tegucigalpa", "America/Tegucigalpa"),
|
||||||
|
("America/Thule", "America/Thule"),
|
||||||
|
("America/Thunder_Bay", "America/Thunder_Bay"),
|
||||||
|
("America/Tijuana", "America/Tijuana"),
|
||||||
|
("America/Toronto", "America/Toronto"),
|
||||||
|
("America/Tortola", "America/Tortola"),
|
||||||
|
("America/Vancouver", "America/Vancouver"),
|
||||||
|
("America/Virgin", "America/Virgin"),
|
||||||
|
("America/Whitehorse", "America/Whitehorse"),
|
||||||
|
("America/Winnipeg", "America/Winnipeg"),
|
||||||
|
("America/Yakutat", "America/Yakutat"),
|
||||||
|
("America/Yellowknife", "America/Yellowknife"),
|
||||||
|
("Antarctica/Casey", "Antarctica/Casey"),
|
||||||
|
("Antarctica/Davis", "Antarctica/Davis"),
|
||||||
|
("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"),
|
||||||
|
("Antarctica/Macquarie", "Antarctica/Macquarie"),
|
||||||
|
("Antarctica/Mawson", "Antarctica/Mawson"),
|
||||||
|
("Antarctica/McMurdo", "Antarctica/McMurdo"),
|
||||||
|
("Antarctica/Palmer", "Antarctica/Palmer"),
|
||||||
|
("Antarctica/Rothera", "Antarctica/Rothera"),
|
||||||
|
("Antarctica/South_Pole", "Antarctica/South_Pole"),
|
||||||
|
("Antarctica/Syowa", "Antarctica/Syowa"),
|
||||||
|
("Antarctica/Troll", "Antarctica/Troll"),
|
||||||
|
("Antarctica/Vostok", "Antarctica/Vostok"),
|
||||||
|
("Arctic/Longyearbyen", "Arctic/Longyearbyen"),
|
||||||
|
("Asia/Aden", "Asia/Aden"),
|
||||||
|
("Asia/Almaty", "Asia/Almaty"),
|
||||||
|
("Asia/Amman", "Asia/Amman"),
|
||||||
|
("Asia/Anadyr", "Asia/Anadyr"),
|
||||||
|
("Asia/Aqtau", "Asia/Aqtau"),
|
||||||
|
("Asia/Aqtobe", "Asia/Aqtobe"),
|
||||||
|
("Asia/Ashgabat", "Asia/Ashgabat"),
|
||||||
|
("Asia/Ashkhabad", "Asia/Ashkhabad"),
|
||||||
|
("Asia/Atyrau", "Asia/Atyrau"),
|
||||||
|
("Asia/Baghdad", "Asia/Baghdad"),
|
||||||
|
("Asia/Bahrain", "Asia/Bahrain"),
|
||||||
|
("Asia/Baku", "Asia/Baku"),
|
||||||
|
("Asia/Bangkok", "Asia/Bangkok"),
|
||||||
|
("Asia/Barnaul", "Asia/Barnaul"),
|
||||||
|
("Asia/Beirut", "Asia/Beirut"),
|
||||||
|
("Asia/Bishkek", "Asia/Bishkek"),
|
||||||
|
("Asia/Brunei", "Asia/Brunei"),
|
||||||
|
("Asia/Calcutta", "Asia/Calcutta"),
|
||||||
|
("Asia/Chita", "Asia/Chita"),
|
||||||
|
("Asia/Choibalsan", "Asia/Choibalsan"),
|
||||||
|
("Asia/Chongqing", "Asia/Chongqing"),
|
||||||
|
("Asia/Chungking", "Asia/Chungking"),
|
||||||
|
("Asia/Colombo", "Asia/Colombo"),
|
||||||
|
("Asia/Dacca", "Asia/Dacca"),
|
||||||
|
("Asia/Damascus", "Asia/Damascus"),
|
||||||
|
("Asia/Dhaka", "Asia/Dhaka"),
|
||||||
|
("Asia/Dili", "Asia/Dili"),
|
||||||
|
("Asia/Dubai", "Asia/Dubai"),
|
||||||
|
("Asia/Dushanbe", "Asia/Dushanbe"),
|
||||||
|
("Asia/Famagusta", "Asia/Famagusta"),
|
||||||
|
("Asia/Gaza", "Asia/Gaza"),
|
||||||
|
("Asia/Harbin", "Asia/Harbin"),
|
||||||
|
("Asia/Hebron", "Asia/Hebron"),
|
||||||
|
("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"),
|
||||||
|
("Asia/Hong_Kong", "Asia/Hong_Kong"),
|
||||||
|
("Asia/Hovd", "Asia/Hovd"),
|
||||||
|
("Asia/Irkutsk", "Asia/Irkutsk"),
|
||||||
|
("Asia/Istanbul", "Asia/Istanbul"),
|
||||||
|
("Asia/Jakarta", "Asia/Jakarta"),
|
||||||
|
("Asia/Jayapura", "Asia/Jayapura"),
|
||||||
|
("Asia/Jerusalem", "Asia/Jerusalem"),
|
||||||
|
("Asia/Kabul", "Asia/Kabul"),
|
||||||
|
("Asia/Kamchatka", "Asia/Kamchatka"),
|
||||||
|
("Asia/Karachi", "Asia/Karachi"),
|
||||||
|
("Asia/Kashgar", "Asia/Kashgar"),
|
||||||
|
("Asia/Kathmandu", "Asia/Kathmandu"),
|
||||||
|
("Asia/Katmandu", "Asia/Katmandu"),
|
||||||
|
("Asia/Khandyga", "Asia/Khandyga"),
|
||||||
|
("Asia/Kolkata", "Asia/Kolkata"),
|
||||||
|
("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"),
|
||||||
|
("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"),
|
||||||
|
("Asia/Kuching", "Asia/Kuching"),
|
||||||
|
("Asia/Kuwait", "Asia/Kuwait"),
|
||||||
|
("Asia/Macao", "Asia/Macao"),
|
||||||
|
("Asia/Macau", "Asia/Macau"),
|
||||||
|
("Asia/Magadan", "Asia/Magadan"),
|
||||||
|
("Asia/Makassar", "Asia/Makassar"),
|
||||||
|
("Asia/Manila", "Asia/Manila"),
|
||||||
|
("Asia/Muscat", "Asia/Muscat"),
|
||||||
|
("Asia/Nicosia", "Asia/Nicosia"),
|
||||||
|
("Asia/Novokuznetsk", "Asia/Novokuznetsk"),
|
||||||
|
("Asia/Novosibirsk", "Asia/Novosibirsk"),
|
||||||
|
("Asia/Omsk", "Asia/Omsk"),
|
||||||
|
("Asia/Oral", "Asia/Oral"),
|
||||||
|
("Asia/Phnom_Penh", "Asia/Phnom_Penh"),
|
||||||
|
("Asia/Pontianak", "Asia/Pontianak"),
|
||||||
|
("Asia/Pyongyang", "Asia/Pyongyang"),
|
||||||
|
("Asia/Qatar", "Asia/Qatar"),
|
||||||
|
("Asia/Qostanay", "Asia/Qostanay"),
|
||||||
|
("Asia/Qyzylorda", "Asia/Qyzylorda"),
|
||||||
|
("Asia/Rangoon", "Asia/Rangoon"),
|
||||||
|
("Asia/Riyadh", "Asia/Riyadh"),
|
||||||
|
("Asia/Saigon", "Asia/Saigon"),
|
||||||
|
("Asia/Sakhalin", "Asia/Sakhalin"),
|
||||||
|
("Asia/Samarkand", "Asia/Samarkand"),
|
||||||
|
("Asia/Seoul", "Asia/Seoul"),
|
||||||
|
("Asia/Shanghai", "Asia/Shanghai"),
|
||||||
|
("Asia/Singapore", "Asia/Singapore"),
|
||||||
|
("Asia/Srednekolymsk", "Asia/Srednekolymsk"),
|
||||||
|
("Asia/Taipei", "Asia/Taipei"),
|
||||||
|
("Asia/Tashkent", "Asia/Tashkent"),
|
||||||
|
("Asia/Tbilisi", "Asia/Tbilisi"),
|
||||||
|
("Asia/Tehran", "Asia/Tehran"),
|
||||||
|
("Asia/Tel_Aviv", "Asia/Tel_Aviv"),
|
||||||
|
("Asia/Thimbu", "Asia/Thimbu"),
|
||||||
|
("Asia/Thimphu", "Asia/Thimphu"),
|
||||||
|
("Asia/Tokyo", "Asia/Tokyo"),
|
||||||
|
("Asia/Tomsk", "Asia/Tomsk"),
|
||||||
|
("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"),
|
||||||
|
("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"),
|
||||||
|
("Asia/Ulan_Bator", "Asia/Ulan_Bator"),
|
||||||
|
("Asia/Urumqi", "Asia/Urumqi"),
|
||||||
|
("Asia/Ust-Nera", "Asia/Ust-Nera"),
|
||||||
|
("Asia/Vientiane", "Asia/Vientiane"),
|
||||||
|
("Asia/Vladivostok", "Asia/Vladivostok"),
|
||||||
|
("Asia/Yakutsk", "Asia/Yakutsk"),
|
||||||
|
("Asia/Yangon", "Asia/Yangon"),
|
||||||
|
("Asia/Yekaterinburg", "Asia/Yekaterinburg"),
|
||||||
|
("Asia/Yerevan", "Asia/Yerevan"),
|
||||||
|
("Atlantic/Azores", "Atlantic/Azores"),
|
||||||
|
("Atlantic/Bermuda", "Atlantic/Bermuda"),
|
||||||
|
("Atlantic/Canary", "Atlantic/Canary"),
|
||||||
|
("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"),
|
||||||
|
("Atlantic/Faeroe", "Atlantic/Faeroe"),
|
||||||
|
("Atlantic/Faroe", "Atlantic/Faroe"),
|
||||||
|
("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"),
|
||||||
|
("Atlantic/Madeira", "Atlantic/Madeira"),
|
||||||
|
("Atlantic/Reykjavik", "Atlantic/Reykjavik"),
|
||||||
|
("Atlantic/South_Georgia", "Atlantic/South_Georgia"),
|
||||||
|
("Atlantic/St_Helena", "Atlantic/St_Helena"),
|
||||||
|
("Atlantic/Stanley", "Atlantic/Stanley"),
|
||||||
|
("Australia/ACT", "Australia/ACT"),
|
||||||
|
("Australia/Adelaide", "Australia/Adelaide"),
|
||||||
|
("Australia/Brisbane", "Australia/Brisbane"),
|
||||||
|
("Australia/Broken_Hill", "Australia/Broken_Hill"),
|
||||||
|
("Australia/Canberra", "Australia/Canberra"),
|
||||||
|
("Australia/Currie", "Australia/Currie"),
|
||||||
|
("Australia/Darwin", "Australia/Darwin"),
|
||||||
|
("Australia/Eucla", "Australia/Eucla"),
|
||||||
|
("Australia/Hobart", "Australia/Hobart"),
|
||||||
|
("Australia/LHI", "Australia/LHI"),
|
||||||
|
("Australia/Lindeman", "Australia/Lindeman"),
|
||||||
|
("Australia/Lord_Howe", "Australia/Lord_Howe"),
|
||||||
|
("Australia/Melbourne", "Australia/Melbourne"),
|
||||||
|
("Australia/NSW", "Australia/NSW"),
|
||||||
|
("Australia/North", "Australia/North"),
|
||||||
|
("Australia/Perth", "Australia/Perth"),
|
||||||
|
("Australia/Queensland", "Australia/Queensland"),
|
||||||
|
("Australia/South", "Australia/South"),
|
||||||
|
("Australia/Sydney", "Australia/Sydney"),
|
||||||
|
("Australia/Tasmania", "Australia/Tasmania"),
|
||||||
|
("Australia/Victoria", "Australia/Victoria"),
|
||||||
|
("Australia/West", "Australia/West"),
|
||||||
|
("Australia/Yancowinna", "Australia/Yancowinna"),
|
||||||
|
("Brazil/Acre", "Brazil/Acre"),
|
||||||
|
("Brazil/DeNoronha", "Brazil/DeNoronha"),
|
||||||
|
("Brazil/East", "Brazil/East"),
|
||||||
|
("Brazil/West", "Brazil/West"),
|
||||||
|
("CET", "CET"),
|
||||||
|
("CST6CDT", "CST6CDT"),
|
||||||
|
("Canada/Atlantic", "Canada/Atlantic"),
|
||||||
|
("Canada/Central", "Canada/Central"),
|
||||||
|
("Canada/Eastern", "Canada/Eastern"),
|
||||||
|
("Canada/Mountain", "Canada/Mountain"),
|
||||||
|
("Canada/Newfoundland", "Canada/Newfoundland"),
|
||||||
|
("Canada/Pacific", "Canada/Pacific"),
|
||||||
|
("Canada/Saskatchewan", "Canada/Saskatchewan"),
|
||||||
|
("Canada/Yukon", "Canada/Yukon"),
|
||||||
|
("Chile/Continental", "Chile/Continental"),
|
||||||
|
("Chile/EasterIsland", "Chile/EasterIsland"),
|
||||||
|
("Cuba", "Cuba"),
|
||||||
|
("EET", "EET"),
|
||||||
|
("EST", "EST"),
|
||||||
|
("EST5EDT", "EST5EDT"),
|
||||||
|
("Egypt", "Egypt"),
|
||||||
|
("Eire", "Eire"),
|
||||||
|
("Etc/GMT", "Etc/GMT"),
|
||||||
|
("Etc/GMT+0", "Etc/GMT+0"),
|
||||||
|
("Etc/GMT+1", "Etc/GMT+1"),
|
||||||
|
("Etc/GMT+10", "Etc/GMT+10"),
|
||||||
|
("Etc/GMT+11", "Etc/GMT+11"),
|
||||||
|
("Etc/GMT+12", "Etc/GMT+12"),
|
||||||
|
("Etc/GMT+2", "Etc/GMT+2"),
|
||||||
|
("Etc/GMT+3", "Etc/GMT+3"),
|
||||||
|
("Etc/GMT+4", "Etc/GMT+4"),
|
||||||
|
("Etc/GMT+5", "Etc/GMT+5"),
|
||||||
|
("Etc/GMT+6", "Etc/GMT+6"),
|
||||||
|
("Etc/GMT+7", "Etc/GMT+7"),
|
||||||
|
("Etc/GMT+8", "Etc/GMT+8"),
|
||||||
|
("Etc/GMT+9", "Etc/GMT+9"),
|
||||||
|
("Etc/GMT-0", "Etc/GMT-0"),
|
||||||
|
("Etc/GMT-1", "Etc/GMT-1"),
|
||||||
|
("Etc/GMT-10", "Etc/GMT-10"),
|
||||||
|
("Etc/GMT-11", "Etc/GMT-11"),
|
||||||
|
("Etc/GMT-12", "Etc/GMT-12"),
|
||||||
|
("Etc/GMT-13", "Etc/GMT-13"),
|
||||||
|
("Etc/GMT-14", "Etc/GMT-14"),
|
||||||
|
("Etc/GMT-2", "Etc/GMT-2"),
|
||||||
|
("Etc/GMT-3", "Etc/GMT-3"),
|
||||||
|
("Etc/GMT-4", "Etc/GMT-4"),
|
||||||
|
("Etc/GMT-5", "Etc/GMT-5"),
|
||||||
|
("Etc/GMT-6", "Etc/GMT-6"),
|
||||||
|
("Etc/GMT-7", "Etc/GMT-7"),
|
||||||
|
("Etc/GMT-8", "Etc/GMT-8"),
|
||||||
|
("Etc/GMT-9", "Etc/GMT-9"),
|
||||||
|
("Etc/GMT0", "Etc/GMT0"),
|
||||||
|
("Etc/Greenwich", "Etc/Greenwich"),
|
||||||
|
("Etc/UCT", "Etc/UCT"),
|
||||||
|
("Etc/UTC", "Etc/UTC"),
|
||||||
|
("Etc/Universal", "Etc/Universal"),
|
||||||
|
("Etc/Zulu", "Etc/Zulu"),
|
||||||
|
("Europe/Amsterdam", "Europe/Amsterdam"),
|
||||||
|
("Europe/Andorra", "Europe/Andorra"),
|
||||||
|
("Europe/Astrakhan", "Europe/Astrakhan"),
|
||||||
|
("Europe/Athens", "Europe/Athens"),
|
||||||
|
("Europe/Belfast", "Europe/Belfast"),
|
||||||
|
("Europe/Belgrade", "Europe/Belgrade"),
|
||||||
|
("Europe/Berlin", "Europe/Berlin"),
|
||||||
|
("Europe/Bratislava", "Europe/Bratislava"),
|
||||||
|
("Europe/Brussels", "Europe/Brussels"),
|
||||||
|
("Europe/Bucharest", "Europe/Bucharest"),
|
||||||
|
("Europe/Budapest", "Europe/Budapest"),
|
||||||
|
("Europe/Busingen", "Europe/Busingen"),
|
||||||
|
("Europe/Chisinau", "Europe/Chisinau"),
|
||||||
|
("Europe/Copenhagen", "Europe/Copenhagen"),
|
||||||
|
("Europe/Dublin", "Europe/Dublin"),
|
||||||
|
("Europe/Gibraltar", "Europe/Gibraltar"),
|
||||||
|
("Europe/Guernsey", "Europe/Guernsey"),
|
||||||
|
("Europe/Helsinki", "Europe/Helsinki"),
|
||||||
|
("Europe/Isle_of_Man", "Europe/Isle_of_Man"),
|
||||||
|
("Europe/Istanbul", "Europe/Istanbul"),
|
||||||
|
("Europe/Jersey", "Europe/Jersey"),
|
||||||
|
("Europe/Kaliningrad", "Europe/Kaliningrad"),
|
||||||
|
("Europe/Kiev", "Europe/Kiev"),
|
||||||
|
("Europe/Kirov", "Europe/Kirov"),
|
||||||
|
("Europe/Kyiv", "Europe/Kyiv"),
|
||||||
|
("Europe/Lisbon", "Europe/Lisbon"),
|
||||||
|
("Europe/Ljubljana", "Europe/Ljubljana"),
|
||||||
|
("Europe/London", "Europe/London"),
|
||||||
|
("Europe/Luxembourg", "Europe/Luxembourg"),
|
||||||
|
("Europe/Madrid", "Europe/Madrid"),
|
||||||
|
("Europe/Malta", "Europe/Malta"),
|
||||||
|
("Europe/Mariehamn", "Europe/Mariehamn"),
|
||||||
|
("Europe/Minsk", "Europe/Minsk"),
|
||||||
|
("Europe/Monaco", "Europe/Monaco"),
|
||||||
|
("Europe/Moscow", "Europe/Moscow"),
|
||||||
|
("Europe/Nicosia", "Europe/Nicosia"),
|
||||||
|
("Europe/Oslo", "Europe/Oslo"),
|
||||||
|
("Europe/Paris", "Europe/Paris"),
|
||||||
|
("Europe/Podgorica", "Europe/Podgorica"),
|
||||||
|
("Europe/Prague", "Europe/Prague"),
|
||||||
|
("Europe/Riga", "Europe/Riga"),
|
||||||
|
("Europe/Rome", "Europe/Rome"),
|
||||||
|
("Europe/Samara", "Europe/Samara"),
|
||||||
|
("Europe/San_Marino", "Europe/San_Marino"),
|
||||||
|
("Europe/Sarajevo", "Europe/Sarajevo"),
|
||||||
|
("Europe/Saratov", "Europe/Saratov"),
|
||||||
|
("Europe/Simferopol", "Europe/Simferopol"),
|
||||||
|
("Europe/Skopje", "Europe/Skopje"),
|
||||||
|
("Europe/Sofia", "Europe/Sofia"),
|
||||||
|
("Europe/Stockholm", "Europe/Stockholm"),
|
||||||
|
("Europe/Tallinn", "Europe/Tallinn"),
|
||||||
|
("Europe/Tirane", "Europe/Tirane"),
|
||||||
|
("Europe/Tiraspol", "Europe/Tiraspol"),
|
||||||
|
("Europe/Ulyanovsk", "Europe/Ulyanovsk"),
|
||||||
|
("Europe/Uzhgorod", "Europe/Uzhgorod"),
|
||||||
|
("Europe/Vaduz", "Europe/Vaduz"),
|
||||||
|
("Europe/Vatican", "Europe/Vatican"),
|
||||||
|
("Europe/Vienna", "Europe/Vienna"),
|
||||||
|
("Europe/Vilnius", "Europe/Vilnius"),
|
||||||
|
("Europe/Volgograd", "Europe/Volgograd"),
|
||||||
|
("Europe/Warsaw", "Europe/Warsaw"),
|
||||||
|
("Europe/Zagreb", "Europe/Zagreb"),
|
||||||
|
("Europe/Zaporozhye", "Europe/Zaporozhye"),
|
||||||
|
("Europe/Zurich", "Europe/Zurich"),
|
||||||
|
("GB", "GB"),
|
||||||
|
("GB-Eire", "GB-Eire"),
|
||||||
|
("GMT", "GMT"),
|
||||||
|
("GMT+0", "GMT+0"),
|
||||||
|
("GMT-0", "GMT-0"),
|
||||||
|
("GMT0", "GMT0"),
|
||||||
|
("Greenwich", "Greenwich"),
|
||||||
|
("HST", "HST"),
|
||||||
|
("Hongkong", "Hongkong"),
|
||||||
|
("Iceland", "Iceland"),
|
||||||
|
("Indian/Antananarivo", "Indian/Antananarivo"),
|
||||||
|
("Indian/Chagos", "Indian/Chagos"),
|
||||||
|
("Indian/Christmas", "Indian/Christmas"),
|
||||||
|
("Indian/Cocos", "Indian/Cocos"),
|
||||||
|
("Indian/Comoro", "Indian/Comoro"),
|
||||||
|
("Indian/Kerguelen", "Indian/Kerguelen"),
|
||||||
|
("Indian/Mahe", "Indian/Mahe"),
|
||||||
|
("Indian/Maldives", "Indian/Maldives"),
|
||||||
|
("Indian/Mauritius", "Indian/Mauritius"),
|
||||||
|
("Indian/Mayotte", "Indian/Mayotte"),
|
||||||
|
("Indian/Reunion", "Indian/Reunion"),
|
||||||
|
("Iran", "Iran"),
|
||||||
|
("Israel", "Israel"),
|
||||||
|
("Jamaica", "Jamaica"),
|
||||||
|
("Japan", "Japan"),
|
||||||
|
("Kwajalein", "Kwajalein"),
|
||||||
|
("Libya", "Libya"),
|
||||||
|
("MET", "MET"),
|
||||||
|
("MST", "MST"),
|
||||||
|
("MST7MDT", "MST7MDT"),
|
||||||
|
("Mexico/BajaNorte", "Mexico/BajaNorte"),
|
||||||
|
("Mexico/BajaSur", "Mexico/BajaSur"),
|
||||||
|
("Mexico/General", "Mexico/General"),
|
||||||
|
("NZ", "NZ"),
|
||||||
|
("NZ-CHAT", "NZ-CHAT"),
|
||||||
|
("Navajo", "Navajo"),
|
||||||
|
("PRC", "PRC"),
|
||||||
|
("PST8PDT", "PST8PDT"),
|
||||||
|
("Pacific/Apia", "Pacific/Apia"),
|
||||||
|
("Pacific/Auckland", "Pacific/Auckland"),
|
||||||
|
("Pacific/Bougainville", "Pacific/Bougainville"),
|
||||||
|
("Pacific/Chatham", "Pacific/Chatham"),
|
||||||
|
("Pacific/Chuuk", "Pacific/Chuuk"),
|
||||||
|
("Pacific/Easter", "Pacific/Easter"),
|
||||||
|
("Pacific/Efate", "Pacific/Efate"),
|
||||||
|
("Pacific/Enderbury", "Pacific/Enderbury"),
|
||||||
|
("Pacific/Fakaofo", "Pacific/Fakaofo"),
|
||||||
|
("Pacific/Fiji", "Pacific/Fiji"),
|
||||||
|
("Pacific/Funafuti", "Pacific/Funafuti"),
|
||||||
|
("Pacific/Galapagos", "Pacific/Galapagos"),
|
||||||
|
("Pacific/Gambier", "Pacific/Gambier"),
|
||||||
|
("Pacific/Guadalcanal", "Pacific/Guadalcanal"),
|
||||||
|
("Pacific/Guam", "Pacific/Guam"),
|
||||||
|
("Pacific/Honolulu", "Pacific/Honolulu"),
|
||||||
|
("Pacific/Johnston", "Pacific/Johnston"),
|
||||||
|
("Pacific/Kanton", "Pacific/Kanton"),
|
||||||
|
("Pacific/Kiritimati", "Pacific/Kiritimati"),
|
||||||
|
("Pacific/Kosrae", "Pacific/Kosrae"),
|
||||||
|
("Pacific/Kwajalein", "Pacific/Kwajalein"),
|
||||||
|
("Pacific/Majuro", "Pacific/Majuro"),
|
||||||
|
("Pacific/Marquesas", "Pacific/Marquesas"),
|
||||||
|
("Pacific/Midway", "Pacific/Midway"),
|
||||||
|
("Pacific/Nauru", "Pacific/Nauru"),
|
||||||
|
("Pacific/Niue", "Pacific/Niue"),
|
||||||
|
("Pacific/Norfolk", "Pacific/Norfolk"),
|
||||||
|
("Pacific/Noumea", "Pacific/Noumea"),
|
||||||
|
("Pacific/Pago_Pago", "Pacific/Pago_Pago"),
|
||||||
|
("Pacific/Palau", "Pacific/Palau"),
|
||||||
|
("Pacific/Pitcairn", "Pacific/Pitcairn"),
|
||||||
|
("Pacific/Pohnpei", "Pacific/Pohnpei"),
|
||||||
|
("Pacific/Ponape", "Pacific/Ponape"),
|
||||||
|
("Pacific/Port_Moresby", "Pacific/Port_Moresby"),
|
||||||
|
("Pacific/Rarotonga", "Pacific/Rarotonga"),
|
||||||
|
("Pacific/Saipan", "Pacific/Saipan"),
|
||||||
|
("Pacific/Samoa", "Pacific/Samoa"),
|
||||||
|
("Pacific/Tahiti", "Pacific/Tahiti"),
|
||||||
|
("Pacific/Tarawa", "Pacific/Tarawa"),
|
||||||
|
("Pacific/Tongatapu", "Pacific/Tongatapu"),
|
||||||
|
("Pacific/Truk", "Pacific/Truk"),
|
||||||
|
("Pacific/Wake", "Pacific/Wake"),
|
||||||
|
("Pacific/Wallis", "Pacific/Wallis"),
|
||||||
|
("Pacific/Yap", "Pacific/Yap"),
|
||||||
|
("Poland", "Poland"),
|
||||||
|
("Portugal", "Portugal"),
|
||||||
|
("ROC", "ROC"),
|
||||||
|
("ROK", "ROK"),
|
||||||
|
("Singapore", "Singapore"),
|
||||||
|
("Turkey", "Turkey"),
|
||||||
|
("UCT", "UCT"),
|
||||||
|
("US/Alaska", "US/Alaska"),
|
||||||
|
("US/Aleutian", "US/Aleutian"),
|
||||||
|
("US/Arizona", "US/Arizona"),
|
||||||
|
("US/Central", "US/Central"),
|
||||||
|
("US/East-Indiana", "US/East-Indiana"),
|
||||||
|
("US/Eastern", "US/Eastern"),
|
||||||
|
("US/Hawaii", "US/Hawaii"),
|
||||||
|
("US/Indiana-Starke", "US/Indiana-Starke"),
|
||||||
|
("US/Michigan", "US/Michigan"),
|
||||||
|
("US/Mountain", "US/Mountain"),
|
||||||
|
("US/Pacific", "US/Pacific"),
|
||||||
|
("US/Samoa", "US/Samoa"),
|
||||||
|
("UTC", "UTC"),
|
||||||
|
("Universal", "Universal"),
|
||||||
|
("W-SU", "W-SU"),
|
||||||
|
("WET", "WET"),
|
||||||
|
("Zulu", "Zulu"),
|
||||||
|
],
|
||||||
|
default="UTC",
|
||||||
|
max_length=255,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
65
bookwyrm/migrations/0158_auto_20220919_1634.py
Normal file
65
bookwyrm/migrations/0158_auto_20220919_1634.py
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-09-19 16:34
|
||||||
|
|
||||||
|
import bookwyrm.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0157_auto_20220909_2338"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="automod",
|
||||||
|
name="created_date",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
auto_now_add=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="automod",
|
||||||
|
name="remote_id",
|
||||||
|
field=bookwyrm.models.fields.RemoteIdField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="automod",
|
||||||
|
name="updated_date",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="emailblocklist",
|
||||||
|
name="remote_id",
|
||||||
|
field=bookwyrm.models.fields.RemoteIdField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="emailblocklist",
|
||||||
|
name="updated_date",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ipblocklist",
|
||||||
|
name="remote_id",
|
||||||
|
field=bookwyrm.models.fields.RemoteIdField(
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
validators=[bookwyrm.models.fields.validate_remote_id],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="ipblocklist",
|
||||||
|
name="updated_date",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
]
|
33
bookwyrm/migrations/0159_auto_20220924_0634.py
Normal file
33
bookwyrm/migrations/0159_auto_20220924_0634.py
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-09-24 06:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0158_auto_20220919_1634"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="hotp_count",
|
||||||
|
field=models.IntegerField(blank=True, default=0, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="hotp_secret",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=32, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="otp_secret",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=32, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="two_factor_auth",
|
||||||
|
field=models.BooleanField(blank=True, default=None, null=True),
|
||||||
|
),
|
||||||
|
]
|
52
bookwyrm/migrations/0160_auto_20221101_2251.py
Normal file
52
bookwyrm/migrations/0160_auto_20221101_2251.py
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-01 22:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0159_auto_20220924_0634"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="user",
|
||||||
|
name="allow_reactivation",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="connector",
|
||||||
|
name="deactivation_reason",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("self_deletion", "Self deletion"),
|
||||||
|
("self_deactivation", "Self deactivation"),
|
||||||
|
("moderator_suspension", "Moderator suspension"),
|
||||||
|
("moderator_deletion", "Moderator deletion"),
|
||||||
|
("domain_block", "Domain block"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="deactivation_reason",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("self_deletion", "Self deletion"),
|
||||||
|
("self_deactivation", "Self deactivation"),
|
||||||
|
("moderator_suspension", "Moderator suspension"),
|
||||||
|
("moderator_deletion", "Moderator deletion"),
|
||||||
|
("domain_block", "Domain block"),
|
||||||
|
],
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
32
bookwyrm/migrations/0160_auto_20221105_2030.py
Normal file
32
bookwyrm/migrations/0160_auto_20221105_2030.py
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-05 20:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0159_auto_20220924_0634"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importitem",
|
||||||
|
name="task_id",
|
||||||
|
field=models.CharField(blank=True, max_length=200, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("active", "Active"),
|
||||||
|
("complete", "Complete"),
|
||||||
|
("stopped", "Stopped"),
|
||||||
|
],
|
||||||
|
max_length=50,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
28
bookwyrm/migrations/0161_alter_importjob_status.py
Normal file
28
bookwyrm/migrations/0161_alter_importjob_status.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-05 20:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0160_auto_20221105_2030"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="status",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("pending", "Pending"),
|
||||||
|
("active", "Active"),
|
||||||
|
("complete", "Complete"),
|
||||||
|
("stopped", "Stopped"),
|
||||||
|
],
|
||||||
|
default="pending",
|
||||||
|
max_length=50,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
18
bookwyrm/migrations/0162_importjob_task_id.py
Normal file
18
bookwyrm/migrations/0162_importjob_task_id.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-05 22:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0161_alter_importjob_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="importjob",
|
||||||
|
name="task_id",
|
||||||
|
field=models.CharField(blank=True, max_length=200, null=True),
|
||||||
|
),
|
||||||
|
]
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Generated by Django 3.2.15 on 2022-11-10 20:34
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookwyrm", "0160_auto_20221101_2251"),
|
||||||
|
("bookwyrm", "0162_importjob_task_id"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = []
|
|
@ -17,7 +17,8 @@ from .attachment import Image
|
||||||
from .favorite import Favorite
|
from .favorite import Favorite
|
||||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||||
|
|
||||||
from .user import User, KeyPair, AnnualGoal
|
from .user import User, KeyPair
|
||||||
|
from .annual_goal import AnnualGoal
|
||||||
from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
||||||
from .report import Report, ReportComment
|
from .report import Report, ReportComment
|
||||||
from .federated_server import FederatedServer
|
from .federated_server import FederatedServer
|
||||||
|
|
|
@ -1,14 +1,15 @@
|
||||||
""" activitypub model functionality """
|
""" activitypub model functionality """
|
||||||
|
import asyncio
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
import json
|
import json
|
||||||
import operator
|
import operator
|
||||||
import logging
|
import logging
|
||||||
|
from typing import List
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
import requests
|
|
||||||
from requests.exceptions import RequestException
|
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
from Crypto.PublicKey import RSA
|
from Crypto.PublicKey import RSA
|
||||||
from Crypto.Signature import pkcs1_15
|
from Crypto.Signature import pkcs1_15
|
||||||
from Crypto.Hash import SHA256
|
from Crypto.Hash import SHA256
|
||||||
|
@ -136,7 +137,7 @@ class ActivitypubMixin:
|
||||||
queue=queue,
|
queue=queue,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_recipients(self, software=None):
|
def get_recipients(self, software=None) -> List[str]:
|
||||||
"""figure out which inbox urls to post to"""
|
"""figure out which inbox urls to post to"""
|
||||||
# first we have to figure out who should receive this activity
|
# first we have to figure out who should receive this activity
|
||||||
privacy = self.privacy if hasattr(self, "privacy") else "public"
|
privacy = self.privacy if hasattr(self, "privacy") else "public"
|
||||||
|
@ -506,19 +507,31 @@ def unfurl_related_field(related_field, sort_field=None):
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue=MEDIUM)
|
@app.task(queue=MEDIUM)
|
||||||
def broadcast_task(sender_id, activity, recipients):
|
def broadcast_task(sender_id: int, activity: str, recipients: List[str]):
|
||||||
"""the celery task for broadcast"""
|
"""the celery task for broadcast"""
|
||||||
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
user_model = apps.get_model("bookwyrm.User", require_ready=True)
|
||||||
sender = user_model.objects.get(id=sender_id)
|
sender = user_model.objects.select_related("key_pair").get(id=sender_id)
|
||||||
|
asyncio.run(async_broadcast(recipients, sender, activity))
|
||||||
|
|
||||||
|
|
||||||
|
async def async_broadcast(recipients: List[str], sender, data: str):
|
||||||
|
"""Send all the broadcasts simultaneously"""
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
tasks = []
|
||||||
for recipient in recipients:
|
for recipient in recipients:
|
||||||
try:
|
tasks.append(
|
||||||
sign_and_send(sender, activity, recipient)
|
asyncio.ensure_future(sign_and_send(session, sender, data, recipient))
|
||||||
except RequestException:
|
)
|
||||||
pass
|
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def sign_and_send(sender, data, destination):
|
async def sign_and_send(
|
||||||
"""crpyto whatever and http junk"""
|
session: aiohttp.ClientSession, sender, data: str, destination: str
|
||||||
|
):
|
||||||
|
"""Sign the messages and send them in an asynchronous bundle"""
|
||||||
now = http_date()
|
now = http_date()
|
||||||
|
|
||||||
if not sender.key_pair.private_key:
|
if not sender.key_pair.private_key:
|
||||||
|
@ -527,20 +540,25 @@ def sign_and_send(sender, data, destination):
|
||||||
|
|
||||||
digest = make_digest(data)
|
digest = make_digest(data)
|
||||||
|
|
||||||
response = requests.post(
|
headers = {
|
||||||
destination,
|
|
||||||
data=data,
|
|
||||||
headers={
|
|
||||||
"Date": now,
|
"Date": now,
|
||||||
"Digest": digest,
|
"Digest": digest,
|
||||||
"Signature": make_signature(sender, destination, now, digest),
|
"Signature": make_signature(sender, destination, now, digest),
|
||||||
"Content-Type": "application/activity+json; charset=utf-8",
|
"Content-Type": "application/activity+json; charset=utf-8",
|
||||||
"User-Agent": USER_AGENT,
|
"User-Agent": USER_AGENT,
|
||||||
},
|
}
|
||||||
)
|
|
||||||
|
try:
|
||||||
|
async with session.post(destination, data=data, headers=headers) as response:
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
response.raise_for_status()
|
logger.exception(
|
||||||
|
"Failed to send broadcast to %s: %s", destination, response.reason
|
||||||
|
)
|
||||||
return response
|
return response
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.info("Connection timed out for url: %s", destination)
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
logger.exception(err)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
|
|
67
bookwyrm/models/annual_goal.py
Normal file
67
bookwyrm/models/annual_goal.py
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
""" How many books do you want to read this year """
|
||||||
|
from django.core.validators import MinValueValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from bookwyrm.models.status import Review
|
||||||
|
from .base_model import BookWyrmModel
|
||||||
|
from . import fields, Review
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_year():
|
||||||
|
"""sets default year for annual goal to this year"""
|
||||||
|
return timezone.now().year
|
||||||
|
|
||||||
|
|
||||||
|
class AnnualGoal(BookWyrmModel):
|
||||||
|
"""set a goal for how many books you read in a year"""
|
||||||
|
|
||||||
|
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||||
|
goal = models.IntegerField(validators=[MinValueValidator(1)])
|
||||||
|
year = models.IntegerField(default=get_current_year)
|
||||||
|
privacy = models.CharField(
|
||||||
|
max_length=255, default="public", choices=fields.PrivacyLevels
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""unqiueness constraint"""
|
||||||
|
|
||||||
|
unique_together = ("user", "year")
|
||||||
|
|
||||||
|
def get_remote_id(self):
|
||||||
|
"""put the year in the path"""
|
||||||
|
return f"{self.user.remote_id}/goal/{self.year}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def books(self):
|
||||||
|
"""the books you've read this year"""
|
||||||
|
return (
|
||||||
|
self.user.readthrough_set.filter(
|
||||||
|
finish_date__year__gte=self.year,
|
||||||
|
finish_date__year__lt=self.year + 1,
|
||||||
|
)
|
||||||
|
.order_by("-finish_date")
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ratings(self):
|
||||||
|
"""ratings for books read this year"""
|
||||||
|
book_ids = [r.book.id for r in self.books]
|
||||||
|
reviews = Review.objects.filter(
|
||||||
|
user=self.user,
|
||||||
|
book__in=book_ids,
|
||||||
|
)
|
||||||
|
return {r.book.id: r.rating for r in reviews}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def progress(self):
|
||||||
|
"""how many books you've read this year"""
|
||||||
|
count = self.user.readthrough_set.filter(
|
||||||
|
finish_date__year__gte=self.year,
|
||||||
|
finish_date__year__lt=self.year + 1,
|
||||||
|
).count()
|
||||||
|
return {
|
||||||
|
"count": count,
|
||||||
|
"percent": int(float(count / self.goal) * 100),
|
||||||
|
}
|
|
@ -3,18 +3,33 @@ from functools import reduce
|
||||||
import operator
|
import operator
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db import models
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.db import models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW
|
||||||
|
from .base_model import BookWyrmModel
|
||||||
from .user import User
|
from .user import User
|
||||||
|
|
||||||
|
|
||||||
class EmailBlocklist(models.Model):
|
class AdminModel(BookWyrmModel):
|
||||||
|
"""Overrides the permissions methods"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""this is just here to provide default fields for other models"""
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
if viewer.has_perm("bookwyrm.moderate_user"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
|
||||||
|
class EmailBlocklist(AdminModel):
|
||||||
"""blocked email addresses"""
|
"""blocked email addresses"""
|
||||||
|
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
|
||||||
domain = models.CharField(max_length=255, unique=True)
|
domain = models.CharField(max_length=255, unique=True)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
@ -29,10 +44,9 @@ class EmailBlocklist(models.Model):
|
||||||
return User.objects.filter(email__endswith=f"@{self.domain}")
|
return User.objects.filter(email__endswith=f"@{self.domain}")
|
||||||
|
|
||||||
|
|
||||||
class IPBlocklist(models.Model):
|
class IPBlocklist(AdminModel):
|
||||||
"""blocked ip addresses"""
|
"""blocked ip addresses"""
|
||||||
|
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
|
||||||
address = models.CharField(max_length=255, unique=True)
|
address = models.CharField(max_length=255, unique=True)
|
||||||
is_active = models.BooleanField(default=True)
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
@ -42,7 +56,7 @@ class IPBlocklist(models.Model):
|
||||||
ordering = ("-created_date",)
|
ordering = ("-created_date",)
|
||||||
|
|
||||||
|
|
||||||
class AutoMod(models.Model):
|
class AutoMod(AdminModel):
|
||||||
"""rules to automatically flag suspicious activity"""
|
"""rules to automatically flag suspicious activity"""
|
||||||
|
|
||||||
string_match = models.CharField(max_length=200, unique=True)
|
string_match = models.CharField(max_length=200, unique=True)
|
||||||
|
@ -51,32 +65,24 @@ class AutoMod(models.Model):
|
||||||
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
|
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def automod_task():
|
def automod_task():
|
||||||
"""Create reports"""
|
"""Create reports"""
|
||||||
if not AutoMod.objects.exists():
|
if not AutoMod.objects.exists():
|
||||||
return
|
return
|
||||||
reporter = AutoMod.objects.first().created_by
|
reporter = AutoMod.objects.first().created_by
|
||||||
reports = automod_users(reporter) + automod_statuses(reporter)
|
reports = automod_users(reporter) + automod_statuses(reporter)
|
||||||
if reports:
|
if not reports:
|
||||||
admins = User.objects.filter(
|
return
|
||||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
|
||||||
| models.Q(is_superuser=True)
|
admins = User.admins()
|
||||||
).all()
|
notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True)
|
||||||
notification_model = apps.get_model(
|
with transaction.atomic():
|
||||||
"bookwyrm", "Notification", require_ready=True
|
|
||||||
)
|
|
||||||
for admin in admins:
|
for admin in admins:
|
||||||
notification_model.objects.bulk_create(
|
notification, _ = notification_model.objects.get_or_create(
|
||||||
[
|
user=admin, notification_type=notification_model.REPORT, read=False
|
||||||
notification_model(
|
|
||||||
user=admin,
|
|
||||||
related_report=r,
|
|
||||||
notification_type="REPORT",
|
|
||||||
)
|
|
||||||
for r in reports
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
notification.related_reports.set(reports)
|
||||||
|
|
||||||
|
|
||||||
def automod_users(reporter):
|
def automod_users(reporter):
|
||||||
|
|
|
@ -42,6 +42,11 @@ class Author(BookDataModel):
|
||||||
for book in self.book_set.values_list("id", flat=True)
|
for book in self.book_set.values_list("id", flat=True)
|
||||||
]
|
]
|
||||||
cache.delete_many(cache_keys)
|
cache.delete_many(cache_keys)
|
||||||
|
|
||||||
|
# normalize isni format
|
||||||
|
if self.isni:
|
||||||
|
self.isni = re.sub(r"\s", "", self.isni)
|
||||||
|
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|
|
@ -17,6 +17,7 @@ from .fields import RemoteIdField
|
||||||
DeactivationReason = [
|
DeactivationReason = [
|
||||||
("pending", _("Pending")),
|
("pending", _("Pending")),
|
||||||
("self_deletion", _("Self deletion")),
|
("self_deletion", _("Self deletion")),
|
||||||
|
("self_deactivation", _("Self deactivation")),
|
||||||
("moderator_suspension", _("Moderator suspension")),
|
("moderator_suspension", _("Moderator suspension")),
|
||||||
("moderator_deletion", _("Moderator deletion")),
|
("moderator_deletion", _("Moderator deletion")),
|
||||||
("domain_block", _("Domain block")),
|
("domain_block", _("Domain block")),
|
||||||
|
@ -132,7 +133,7 @@ class BookWyrmModel(models.Model):
|
||||||
return
|
return
|
||||||
|
|
||||||
# but generally moderators can delete other people's stuff
|
# 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
|
return
|
||||||
|
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
|
|
|
@ -241,6 +241,10 @@ class Work(OrderedCollectionPageMixin, Book):
|
||||||
"""in case the default edition is not set"""
|
"""in case the default edition is not set"""
|
||||||
return self.editions.order_by("-edition_rank").first()
|
return self.editions.order_by("-edition_rank").first()
|
||||||
|
|
||||||
|
def author_edition(self, author):
|
||||||
|
"""in case the default edition doesn't have the required author"""
|
||||||
|
return self.editions.filter(authors=author).order_by("-edition_rank").first()
|
||||||
|
|
||||||
def to_edition_list(self, **kwargs):
|
def to_edition_list(self, **kwargs):
|
||||||
"""an ordered collection of editions"""
|
"""an ordered collection of editions"""
|
||||||
return self.to_ordered_collection(
|
return self.to_ordered_collection(
|
||||||
|
|
|
@ -16,7 +16,7 @@ from django.utils.encoding import filepath_to_uri
|
||||||
|
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from bookwyrm.connectors import get_image
|
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
|
from bookwyrm.settings import MEDIA_FULL_URL
|
||||||
|
|
||||||
|
|
||||||
|
@ -497,9 +497,7 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
|
||||||
def field_from_activity(self, value):
|
def field_from_activity(self, value):
|
||||||
if not value or value == MISSING:
|
if not value or value == MISSING:
|
||||||
return None
|
return None
|
||||||
sanitizer = InputHtmlParser()
|
return clean(value)
|
||||||
sanitizer.feed(value)
|
|
||||||
return sanitizer.get_output()
|
|
||||||
|
|
||||||
|
|
||||||
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):
|
||||||
|
|
|
@ -140,16 +140,6 @@ class GroupMemberInvitation(models.Model):
|
||||||
# make an invitation
|
# make an invitation
|
||||||
super().save(*args, **kwargs)
|
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
|
@transaction.atomic
|
||||||
def accept(self):
|
def accept(self):
|
||||||
"""turn this request into the real deal"""
|
"""turn this request into the real deal"""
|
||||||
|
@ -157,25 +147,24 @@ class GroupMemberInvitation(models.Model):
|
||||||
|
|
||||||
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
model = apps.get_model("bookwyrm.Notification", require_ready=True)
|
||||||
# tell the group owner
|
# tell the group owner
|
||||||
model.objects.create(
|
model.notify(
|
||||||
user=self.group.user,
|
self.group.user,
|
||||||
related_user=self.user,
|
self.user,
|
||||||
related_group=self.group,
|
related_group=self.group,
|
||||||
notification_type="ACCEPT",
|
notification_type=model.ACCEPT,
|
||||||
)
|
)
|
||||||
|
|
||||||
# let the other members know about it
|
# let the other members know about it
|
||||||
for membership in self.group.memberships.all():
|
for membership in self.group.memberships.all():
|
||||||
member = membership.user
|
member = membership.user
|
||||||
if member not in (self.user, self.group.user):
|
if member not in (self.user, self.group.user):
|
||||||
model.objects.create(
|
model.notify(
|
||||||
user=member,
|
member,
|
||||||
related_user=self.user,
|
self.user,
|
||||||
related_group=self.group,
|
related_group=self.group,
|
||||||
notification_type="JOIN",
|
notification_type=model.JOIN,
|
||||||
)
|
)
|
||||||
|
|
||||||
def reject(self):
|
def reject(self):
|
||||||
"""generate a Reject for this membership request"""
|
"""generate a Reject for this membership request"""
|
||||||
|
|
||||||
self.delete()
|
self.delete()
|
||||||
|
|
|
@ -1,12 +1,25 @@
|
||||||
""" track progress of goodreads imports """
|
""" track progress of goodreads imports """
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import dateutil.parser
|
import dateutil.parser
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from bookwyrm.connectors import connector_manager
|
from bookwyrm.connectors import connector_manager
|
||||||
from bookwyrm.models import ReadThrough, User, Book, Edition
|
from bookwyrm.models import (
|
||||||
|
User,
|
||||||
|
Book,
|
||||||
|
Edition,
|
||||||
|
Work,
|
||||||
|
ShelfBook,
|
||||||
|
Shelf,
|
||||||
|
ReadThrough,
|
||||||
|
Review,
|
||||||
|
ReviewRating,
|
||||||
|
)
|
||||||
|
from bookwyrm.tasks import app, LOW
|
||||||
from .fields import PrivacyLevels
|
from .fields import PrivacyLevels
|
||||||
|
|
||||||
|
|
||||||
|
@ -30,6 +43,14 @@ def construct_search_term(title, author):
|
||||||
return " ".join([title, author])
|
return " ".join([title, author])
|
||||||
|
|
||||||
|
|
||||||
|
ImportStatuses = [
|
||||||
|
("pending", _("Pending")),
|
||||||
|
("active", _("Active")),
|
||||||
|
("complete", _("Complete")),
|
||||||
|
("stopped", _("Stopped")),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class ImportJob(models.Model):
|
class ImportJob(models.Model):
|
||||||
"""entry for a specific request for book data import"""
|
"""entry for a specific request for book data import"""
|
||||||
|
|
||||||
|
@ -38,16 +59,78 @@ class ImportJob(models.Model):
|
||||||
updated_date = models.DateTimeField(default=timezone.now)
|
updated_date = models.DateTimeField(default=timezone.now)
|
||||||
include_reviews = models.BooleanField(default=True)
|
include_reviews = models.BooleanField(default=True)
|
||||||
mappings = models.JSONField()
|
mappings = models.JSONField()
|
||||||
complete = models.BooleanField(default=False)
|
|
||||||
source = models.CharField(max_length=100)
|
source = models.CharField(max_length=100)
|
||||||
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
|
privacy = models.CharField(max_length=255, default="public", choices=PrivacyLevels)
|
||||||
retry = models.BooleanField(default=False)
|
retry = models.BooleanField(default=False)
|
||||||
|
task_id = models.CharField(max_length=200, null=True, blank=True)
|
||||||
|
|
||||||
|
complete = models.BooleanField(default=False)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=50, choices=ImportStatuses, default="pending", null=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def start_job(self):
|
||||||
|
"""Report that the job has started"""
|
||||||
|
task = start_import_task.delay(self.id)
|
||||||
|
self.task_id = task.id
|
||||||
|
|
||||||
|
self.status = "active"
|
||||||
|
self.save(update_fields=["status", "task_id"])
|
||||||
|
|
||||||
|
def complete_job(self):
|
||||||
|
"""Report that the job has completed"""
|
||||||
|
self.status = "complete"
|
||||||
|
self.complete = True
|
||||||
|
self.pending_items.update(fail_reason=_("Import stopped"))
|
||||||
|
self.save(update_fields=["status", "complete"])
|
||||||
|
|
||||||
|
def stop_job(self):
|
||||||
|
"""Stop the job"""
|
||||||
|
self.status = "stopped"
|
||||||
|
self.complete = True
|
||||||
|
self.save(update_fields=["status", "complete"])
|
||||||
|
self.pending_items.update(fail_reason=_("Import stopped"))
|
||||||
|
|
||||||
|
# stop starting
|
||||||
|
app.control.revoke(self.task_id, terminate=True)
|
||||||
|
tasks = self.pending_items.filter(task_id__isnull=False).values_list(
|
||||||
|
"task_id", flat=True
|
||||||
|
)
|
||||||
|
app.control.revoke(list(tasks))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending_items(self):
|
def pending_items(self):
|
||||||
"""items that haven't been processed yet"""
|
"""items that haven't been processed yet"""
|
||||||
return self.items.filter(fail_reason__isnull=True, book__isnull=True)
|
return self.items.filter(fail_reason__isnull=True, book__isnull=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_count(self):
|
||||||
|
"""How many books do you want to import???"""
|
||||||
|
return self.items.count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def percent_complete(self):
|
||||||
|
"""How far along?"""
|
||||||
|
item_count = self.item_count
|
||||||
|
if not item_count:
|
||||||
|
return 0
|
||||||
|
return math.floor((item_count - self.pending_item_count) / item_count * 100)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_item_count(self):
|
||||||
|
"""And how many pending items??"""
|
||||||
|
return self.pending_items.count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def successful_item_count(self):
|
||||||
|
"""How many found a book?"""
|
||||||
|
return self.items.filter(book__isnull=False).count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def failed_item_count(self):
|
||||||
|
"""How many found a book?"""
|
||||||
|
return self.items.filter(fail_reason__isnull=False).count()
|
||||||
|
|
||||||
|
|
||||||
class ImportItem(models.Model):
|
class ImportItem(models.Model):
|
||||||
"""a single line of a csv being imported"""
|
"""a single line of a csv being imported"""
|
||||||
|
@ -68,15 +151,18 @@ class ImportItem(models.Model):
|
||||||
linked_review = models.ForeignKey(
|
linked_review = models.ForeignKey(
|
||||||
"Review", on_delete=models.SET_NULL, null=True, blank=True
|
"Review", on_delete=models.SET_NULL, null=True, blank=True
|
||||||
)
|
)
|
||||||
|
task_id = models.CharField(max_length=200, null=True, blank=True)
|
||||||
|
|
||||||
def update_job(self):
|
def update_job(self):
|
||||||
"""let the job know when the items get work done"""
|
"""let the job know when the items get work done"""
|
||||||
job = self.job
|
job = self.job
|
||||||
|
if job.complete:
|
||||||
|
return
|
||||||
|
|
||||||
job.updated_date = timezone.now()
|
job.updated_date = timezone.now()
|
||||||
job.save()
|
job.save()
|
||||||
if not job.pending_items.exists() and not job.complete:
|
if not job.pending_items.exists() and not job.complete:
|
||||||
job.complete = True
|
job.complete_job()
|
||||||
job.save(update_fields=["complete"])
|
|
||||||
|
|
||||||
def resolve(self):
|
def resolve(self):
|
||||||
"""try various ways to lookup a book"""
|
"""try various ways to lookup a book"""
|
||||||
|
@ -240,3 +326,136 @@ class ImportItem(models.Model):
|
||||||
return "{} by {}".format(
|
return "{} by {}".format(
|
||||||
self.normalized_data.get("title"), self.normalized_data.get("authors")
|
self.normalized_data.get("title"), self.normalized_data.get("authors")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(queue=LOW)
|
||||||
|
def start_import_task(job_id):
|
||||||
|
"""trigger the child tasks for each row"""
|
||||||
|
job = ImportJob.objects.get(id=job_id)
|
||||||
|
# don't start the job if it was stopped from the UI
|
||||||
|
if job.complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
# these are sub-tasks so that one big task doesn't use up all the memory in celery
|
||||||
|
for item in job.items.all():
|
||||||
|
task = import_item_task.delay(item.id)
|
||||||
|
item.task_id = task.id
|
||||||
|
item.save()
|
||||||
|
job.status = "active"
|
||||||
|
job.save()
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(queue=LOW)
|
||||||
|
def import_item_task(item_id):
|
||||||
|
"""resolve a row into a book"""
|
||||||
|
item = ImportItem.objects.get(id=item_id)
|
||||||
|
# make sure the job has not been stopped
|
||||||
|
if item.job.complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
item.resolve()
|
||||||
|
except Exception as err: # pylint: disable=broad-except
|
||||||
|
item.fail_reason = _("Error loading book")
|
||||||
|
item.save()
|
||||||
|
item.update_job()
|
||||||
|
raise err
|
||||||
|
|
||||||
|
if item.book:
|
||||||
|
# shelves book and handles reviews
|
||||||
|
handle_imported_book(item)
|
||||||
|
else:
|
||||||
|
item.fail_reason = _("Could not find a match for book")
|
||||||
|
|
||||||
|
item.save()
|
||||||
|
item.update_job()
|
||||||
|
|
||||||
|
|
||||||
|
def handle_imported_book(item):
|
||||||
|
"""process a csv and then post about it"""
|
||||||
|
job = item.job
|
||||||
|
if job.complete:
|
||||||
|
return
|
||||||
|
|
||||||
|
user = job.user
|
||||||
|
if isinstance(item.book, Work):
|
||||||
|
item.book = item.book.default_edition
|
||||||
|
if not item.book:
|
||||||
|
item.fail_reason = _("Error loading book")
|
||||||
|
item.save()
|
||||||
|
return
|
||||||
|
if not isinstance(item.book, Edition):
|
||||||
|
item.book = item.book.edition
|
||||||
|
|
||||||
|
existing_shelf = ShelfBook.objects.filter(book=item.book, user=user).exists()
|
||||||
|
|
||||||
|
# shelve the book if it hasn't been shelved already
|
||||||
|
if item.shelf and not existing_shelf:
|
||||||
|
desired_shelf = Shelf.objects.get(identifier=item.shelf, user=user)
|
||||||
|
shelved_date = item.date_added or timezone.now()
|
||||||
|
ShelfBook(
|
||||||
|
book=item.book, shelf=desired_shelf, user=user, shelved_date=shelved_date
|
||||||
|
).save(priority=LOW)
|
||||||
|
|
||||||
|
for read in item.reads:
|
||||||
|
# check for an existing readthrough with the same dates
|
||||||
|
if ReadThrough.objects.filter(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
start_date=read.start_date,
|
||||||
|
finish_date=read.finish_date,
|
||||||
|
).exists():
|
||||||
|
continue
|
||||||
|
read.book = item.book
|
||||||
|
read.user = user
|
||||||
|
read.save()
|
||||||
|
|
||||||
|
if job.include_reviews and (item.rating or item.review) and not item.linked_review:
|
||||||
|
# we don't know the publication date of the review,
|
||||||
|
# but "now" is a bad guess
|
||||||
|
published_date_guess = item.date_read or item.date_added
|
||||||
|
if item.review:
|
||||||
|
# pylint: disable=consider-using-f-string
|
||||||
|
review_title = "Review of {!r} on {!r}".format(
|
||||||
|
item.book.title,
|
||||||
|
job.source,
|
||||||
|
)
|
||||||
|
review = Review.objects.filter(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
name=review_title,
|
||||||
|
rating=item.rating,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
).first()
|
||||||
|
if not review:
|
||||||
|
review = Review(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
name=review_title,
|
||||||
|
content=item.review,
|
||||||
|
rating=item.rating,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
privacy=job.privacy,
|
||||||
|
)
|
||||||
|
review.save(software="bookwyrm", priority=LOW)
|
||||||
|
else:
|
||||||
|
# just a rating
|
||||||
|
review = ReviewRating.objects.filter(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
rating=item.rating,
|
||||||
|
).first()
|
||||||
|
if not review:
|
||||||
|
review = ReviewRating(
|
||||||
|
user=user,
|
||||||
|
book=item.book,
|
||||||
|
rating=item.rating,
|
||||||
|
published_date=published_date_guess,
|
||||||
|
privacy=job.privacy,
|
||||||
|
)
|
||||||
|
review.save(software="bookwyrm", priority=LOW)
|
||||||
|
|
||||||
|
# only broadcast this review to other bookwyrm instances
|
||||||
|
item.linked_review = review
|
||||||
|
item.save()
|
||||||
|
|
|
@ -84,7 +84,7 @@ class LinkDomain(BookWyrmModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
def raise_not_editable(self, viewer):
|
def raise_not_editable(self, viewer):
|
||||||
if viewer.has_perm("moderate_post"):
|
if viewer.has_perm("bookwyrm.moderate_post"):
|
||||||
return
|
return
|
||||||
raise PermissionDenied()
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
""" make a list of books!! """
|
""" make a list of books!! """
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.apps import apps
|
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
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"""
|
"""on save, update embed_key and avoid clash with existing code"""
|
||||||
if not self.embed_key:
|
if not self.embed_key:
|
||||||
self.embed_key = uuid.uuid4()
|
self.embed_key = uuid.uuid4()
|
||||||
return super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ListItem(CollectionItemMixin, BookWyrmModel):
|
class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||||
|
@ -151,33 +150,11 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
||||||
collection_field = "book_list"
|
collection_field = "book_list"
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""create a notification too"""
|
"""Update the list's date"""
|
||||||
created = not bool(self.id)
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
# tick the updated date on the parent list
|
# tick the updated date on the parent list
|
||||||
self.book_list.updated_date = timezone.now()
|
self.book_list.updated_date = timezone.now()
|
||||||
self.book_list.save(broadcast=False)
|
self.book_list.save(broadcast=False, update_fields=["updated_date"])
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
|
|
||||||
def raise_not_deletable(self, viewer):
|
def raise_not_deletable(self, viewer):
|
||||||
"""the associated user OR the list owner can delete"""
|
"""the associated user OR the list owner can delete"""
|
||||||
|
|
|
@ -1,77 +1,125 @@
|
||||||
""" alert a user to activity """
|
""" alert a user to activity """
|
||||||
from django.db import models
|
from django.db import models, transaction
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
from . import Boost, Favorite, ImportJob, Report, Status, User
|
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, ListItem, Report
|
||||||
|
from . import Status, User, UserFollowRequest
|
||||||
# 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",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Notification(BookWyrmModel):
|
class Notification(BookWyrmModel):
|
||||||
"""you've been tagged, liked, followed, etc"""
|
"""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)
|
user = models.ForeignKey("User", on_delete=models.CASCADE)
|
||||||
related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
|
read = models.BooleanField(default=False)
|
||||||
related_user = models.ForeignKey(
|
notification_type = models.CharField(
|
||||||
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
|
max_length=255, choices=NotificationType.choices
|
||||||
|
)
|
||||||
|
|
||||||
|
related_users = models.ManyToManyField(
|
||||||
|
"User", symmetrical=False, related_name="notifications"
|
||||||
)
|
)
|
||||||
related_group = models.ForeignKey(
|
related_group = models.ForeignKey(
|
||||||
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
|
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
|
||||||
)
|
)
|
||||||
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
|
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
|
||||||
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
|
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
|
||||||
related_list_item = models.ForeignKey(
|
related_list_items = models.ManyToManyField(
|
||||||
"ListItem", on_delete=models.CASCADE, null=True
|
"ListItem", symmetrical=False, related_name="notifications"
|
||||||
)
|
|
||||||
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_reports = models.ManyToManyField("Report", symmetrical=False)
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
@classmethod
|
||||||
"""save, but don't make dupes"""
|
@transaction.atomic
|
||||||
# there's probably a better way to do this
|
def notify(cls, user, related_user, **kwargs):
|
||||||
if self.__class__.objects.filter(
|
"""Create a notification"""
|
||||||
user=self.user,
|
if related_user and (not user.local or user == related_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():
|
|
||||||
return
|
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:
|
@classmethod
|
||||||
"""checks if notifcation is in enum list for valid types"""
|
@transaction.atomic
|
||||||
|
def notify_list_item(cls, user, list_item):
|
||||||
constraints = [
|
"""Group the notifications around the list items, not the user"""
|
||||||
models.CheckConstraint(
|
related_user = list_item.user
|
||||||
check=models.Q(notification_type__in=NotificationType.values),
|
notification = cls.objects.filter(
|
||||||
name="notification_type_valid",
|
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)
|
@receiver(models.signals.post_save, sender=Favorite)
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def notify_on_fav(sender, instance, *args, **kwargs):
|
def notify_on_fav(sender, instance, *args, **kwargs):
|
||||||
"""someone liked your content, you ARE loved"""
|
"""someone liked your content, you ARE loved"""
|
||||||
if not instance.status.user.local or instance.status.user == instance.user:
|
Notification.notify(
|
||||||
return
|
instance.status.user,
|
||||||
Notification.objects.create(
|
instance.user,
|
||||||
user=instance.status.user,
|
|
||||||
notification_type="FAVORITE",
|
|
||||||
related_user=instance.user,
|
|
||||||
related_status=instance.status,
|
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"""
|
"""oops, didn't like that after all"""
|
||||||
if not instance.status.user.local:
|
if not instance.status.user.local:
|
||||||
return
|
return
|
||||||
Notification.objects.filter(
|
Notification.unnotify(
|
||||||
user=instance.status.user,
|
instance.status.user,
|
||||||
related_user=instance.user,
|
instance.user,
|
||||||
related_status=instance.status,
|
related_status=instance.status,
|
||||||
notification_type="FAVORITE",
|
notification_type=Notification.FAVORITE,
|
||||||
).delete()
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.post_save)
|
@receiver(models.signals.post_save)
|
||||||
|
@transaction.atomic
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def notify_user_on_mention(sender, instance, *args, **kwargs):
|
def notify_user_on_mention(sender, instance, *args, **kwargs):
|
||||||
"""creating and deleting statuses with @ mentions and replies"""
|
"""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 != instance.user
|
||||||
and instance.reply_parent.user.local
|
and instance.reply_parent.user.local
|
||||||
):
|
):
|
||||||
Notification.objects.create(
|
Notification.notify(
|
||||||
user=instance.reply_parent.user,
|
instance.reply_parent.user,
|
||||||
notification_type="REPLY",
|
instance.user,
|
||||||
related_user=instance.user,
|
|
||||||
related_status=instance,
|
related_status=instance,
|
||||||
|
notification_type=Notification.REPLY,
|
||||||
)
|
)
|
||||||
|
|
||||||
for mention_user in instance.mention_users.all():
|
for mention_user in instance.mention_users.all():
|
||||||
# avoid double-notifying about this status
|
# avoid double-notifying about this status
|
||||||
if not mention_user.local or (
|
if not mention_user.local or (
|
||||||
instance.reply_parent and mention_user == instance.reply_parent.user
|
instance.reply_parent and mention_user == instance.reply_parent.user
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
Notification.objects.create(
|
Notification.notify(
|
||||||
user=mention_user,
|
mention_user,
|
||||||
notification_type="MENTION",
|
instance.user,
|
||||||
related_user=instance.user,
|
notification_type=Notification.MENTION,
|
||||||
related_status=instance,
|
related_status=instance,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -135,11 +185,11 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
|
||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
Notification.objects.create(
|
Notification.notify(
|
||||||
user=instance.boosted_status.user,
|
instance.boosted_status.user,
|
||||||
|
instance.user,
|
||||||
related_status=instance.boosted_status,
|
related_status=instance.boosted_status,
|
||||||
related_user=instance.user,
|
notification_type=Notification.BOOST,
|
||||||
notification_type="BOOST",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -147,12 +197,12 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
|
||||||
# pylint: disable=unused-argument
|
# pylint: disable=unused-argument
|
||||||
def notify_user_on_unboost(sender, instance, *args, **kwargs):
|
def notify_user_on_unboost(sender, instance, *args, **kwargs):
|
||||||
"""unboosting a status"""
|
"""unboosting a status"""
|
||||||
Notification.objects.filter(
|
Notification.unnotify(
|
||||||
user=instance.boosted_status.user,
|
instance.boosted_status.user,
|
||||||
|
instance.user,
|
||||||
related_status=instance.boosted_status,
|
related_status=instance.boosted_status,
|
||||||
related_user=instance.user,
|
notification_type=Notification.BOOST,
|
||||||
notification_type="BOOST",
|
)
|
||||||
).delete()
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.post_save, sender=ImportJob)
|
@receiver(models.signals.post_save, sender=ImportJob)
|
||||||
|
@ -164,25 +214,93 @@ def notify_user_on_import_complete(
|
||||||
update_fields = update_fields or []
|
update_fields = update_fields or []
|
||||||
if not instance.complete or "complete" not in update_fields:
|
if not instance.complete or "complete" not in update_fields:
|
||||||
return
|
return
|
||||||
Notification.objects.create(
|
Notification.objects.get_or_create(
|
||||||
user=instance.user,
|
user=instance.user,
|
||||||
notification_type="IMPORT",
|
notification_type=Notification.IMPORT,
|
||||||
related_import=instance,
|
related_import=instance,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.post_save, sender=Report)
|
@receiver(models.signals.post_save, sender=Report)
|
||||||
|
@transaction.atomic
|
||||||
# pylint: disable=unused-argument
|
# 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"""
|
"""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
|
# moderators and superusers should be notified
|
||||||
admins = User.objects.filter(
|
admins = User.admins()
|
||||||
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
|
||||||
| models.Q(is_superuser=True)
|
|
||||||
).all()
|
|
||||||
for admin in admins:
|
for admin in admins:
|
||||||
Notification.objects.create(
|
notification, _ = Notification.objects.get_or_create(
|
||||||
user=admin,
|
user=admin,
|
||||||
related_report=instance,
|
notification_type=Notification.REPORT,
|
||||||
notification_type="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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
""" defines relationships between users """
|
""" defines relationships between users """
|
||||||
from django.apps import apps
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.db import models, transaction, IntegrityError
|
from django.db import models, transaction, IntegrityError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
@ -148,14 +147,6 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||||
if not manually_approves:
|
if not manually_approves:
|
||||||
self.accept()
|
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):
|
def get_accept_reject_id(self, status):
|
||||||
"""get id for sending an accept or reject of a local user"""
|
"""get id for sending an accept or reject of a local user"""
|
||||||
|
|
||||||
|
@ -218,7 +209,7 @@ def clear_cache(user_subject, user_object):
|
||||||
"""clear relationship cache"""
|
"""clear relationship cache"""
|
||||||
cache.delete_many(
|
cache.delete_many(
|
||||||
[
|
[
|
||||||
f"relationship-{user_subject.id}-{user_object.id}",
|
f"cached-relationship-{user_subject.id}-{user_object.id}",
|
||||||
f"relationship-{user_object.id}-{user_subject.id}",
|
f"cached-relationship-{user_object.id}-{user_subject.id}",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
""" flagged for moderation """
|
""" flagged for moderation """
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
from bookwyrm.settings import DOMAIN
|
from bookwyrm.settings import DOMAIN
|
||||||
from .base_model import BookWyrmModel
|
from .base_model import BookWyrmModel
|
||||||
|
|
||||||
|
@ -11,7 +13,7 @@ class Report(BookWyrmModel):
|
||||||
"User", related_name="reporter", on_delete=models.PROTECT
|
"User", related_name="reporter", on_delete=models.PROTECT
|
||||||
)
|
)
|
||||||
note = models.TextField(null=True, blank=True)
|
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 = models.ForeignKey(
|
||||||
"Status",
|
"Status",
|
||||||
null=True,
|
null=True,
|
||||||
|
@ -21,6 +23,12 @@ class Report(BookWyrmModel):
|
||||||
links = models.ManyToManyField("Link", blank=True)
|
links = models.ManyToManyField("Link", blank=True)
|
||||||
resolved = models.BooleanField(default=False)
|
resolved = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""instead of user being the owner field, it's reporter"""
|
||||||
|
if self.reporter == viewer or viewer.has_perm("bookwyrm.moderate_user"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
def get_remote_id(self):
|
def get_remote_id(self):
|
||||||
return f"https://{DOMAIN}/settings/reports/{self.id}"
|
return f"https://{DOMAIN}/settings/reports/{self.id}"
|
||||||
|
|
||||||
|
|
|
@ -103,12 +103,25 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
|
||||||
if not self.user:
|
if not self.user:
|
||||||
self.user = self.shelf.user
|
self.user = self.shelf.user
|
||||||
if self.id and self.user.local:
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
if self.id and self.user.local:
|
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)
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -3,6 +3,7 @@ import datetime
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.db import models, IntegrityError
|
from django.db import models, IntegrityError
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -15,7 +16,23 @@ from .user import User
|
||||||
from .fields import get_absolute_url
|
from .fields import get_absolute_url
|
||||||
|
|
||||||
|
|
||||||
class SiteSettings(models.Model):
|
class SiteModel(models.Model):
|
||||||
|
"""we just need edit perms"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
"""this is just here to provide default fields for other models"""
|
||||||
|
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""Check if the user has the right permissions"""
|
||||||
|
if viewer.has_perm("bookwyrm.edit_instance_settings"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
|
||||||
|
class SiteSettings(SiteModel):
|
||||||
"""customized settings for this instance"""
|
"""customized settings for this instance"""
|
||||||
|
|
||||||
name = models.CharField(default="BookWyrm", max_length=100)
|
name = models.CharField(default="BookWyrm", max_length=100)
|
||||||
|
@ -115,7 +132,7 @@ class SiteSettings(models.Model):
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Theme(models.Model):
|
class Theme(SiteModel):
|
||||||
"""Theme files"""
|
"""Theme files"""
|
||||||
|
|
||||||
created_date = models.DateTimeField(auto_now_add=True)
|
created_date = models.DateTimeField(auto_now_add=True)
|
||||||
|
@ -138,6 +155,13 @@ class SiteInvite(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
invitees = models.ManyToManyField(User, related_name="invitees")
|
invitees = models.ManyToManyField(User, related_name="invitees")
|
||||||
|
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""Admins only"""
|
||||||
|
if viewer.has_perm("bookwyrm.create_invites"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
def valid(self):
|
def valid(self):
|
||||||
"""make sure it hasn't expired or been used"""
|
"""make sure it hasn't expired or been used"""
|
||||||
return (self.expiry is None or self.expiry > timezone.now()) and (
|
return (self.expiry is None or self.expiry > timezone.now()) and (
|
||||||
|
@ -161,6 +185,12 @@ class InviteRequest(BookWyrmModel):
|
||||||
invite_sent = models.BooleanField(default=False)
|
invite_sent = models.BooleanField(default=False)
|
||||||
ignored = models.BooleanField(default=False)
|
ignored = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""Only check perms on edit, not create"""
|
||||||
|
if not self.id or viewer.has_perm("bookwyrm.create_invites"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""don't create a request for a registered email"""
|
"""don't create a request for a registered email"""
|
||||||
if not self.id and User.objects.filter(email=self.email).exists():
|
if not self.id and User.objects.filter(email=self.email).exists():
|
||||||
|
|
|
@ -218,7 +218,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
|
||||||
"""certain types of status aren't editable"""
|
"""certain types of status aren't editable"""
|
||||||
# first, the standard raise
|
# first, the standard raise
|
||||||
super().raise_not_editable(viewer)
|
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()
|
raise PermissionDenied()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -362,7 +363,7 @@ class Review(BookStatus):
|
||||||
default=None,
|
default=None,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
validators=[MinValueValidator(1), MaxValueValidator(5)],
|
validators=[MinValueValidator(0.5), MaxValueValidator(5)],
|
||||||
decimal_places=2,
|
decimal_places=2,
|
||||||
max_digits=3,
|
max_digits=3,
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,7 +5,7 @@ from urllib.parse import urlparse
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.contrib.auth.models import AbstractUser, Group
|
from django.contrib.auth.models import AbstractUser, Group
|
||||||
from django.contrib.postgres.fields import ArrayField, CICharField
|
from django.contrib.postgres.fields import ArrayField, CICharField
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -16,16 +16,16 @@ import pytz
|
||||||
from bookwyrm import activitypub
|
from bookwyrm import activitypub
|
||||||
from bookwyrm.connectors import get_data, ConnectorException
|
from bookwyrm.connectors import get_data, ConnectorException
|
||||||
from bookwyrm.models.shelf import Shelf
|
from bookwyrm.models.shelf import Shelf
|
||||||
from bookwyrm.models.status import Status, Review
|
from bookwyrm.models.status import Status
|
||||||
from bookwyrm.preview_images import generate_user_preview_image_task
|
from bookwyrm.preview_images import generate_user_preview_image_task
|
||||||
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
|
from bookwyrm.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
|
||||||
from bookwyrm.signatures import create_key_pair
|
from bookwyrm.signatures import create_key_pair
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW
|
||||||
from bookwyrm.utils import regex
|
from bookwyrm.utils import regex
|
||||||
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
|
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
|
||||||
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
|
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
|
||||||
from .federated_server import FederatedServer
|
from .federated_server import FederatedServer
|
||||||
from . import fields, Review
|
from . import fields
|
||||||
|
|
||||||
|
|
||||||
FeedFilterChoices = [
|
FeedFilterChoices = [
|
||||||
|
@ -47,6 +47,7 @@ def site_link():
|
||||||
return f"{protocol}://{DOMAIN}"
|
return f"{protocol}://{DOMAIN}"
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=too-many-public-methods
|
||||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
"""a user who wants to read books"""
|
"""a user who wants to read books"""
|
||||||
|
|
||||||
|
@ -143,6 +144,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
show_goal = models.BooleanField(default=True)
|
show_goal = models.BooleanField(default=True)
|
||||||
show_suggested_users = models.BooleanField(default=True)
|
show_suggested_users = models.BooleanField(default=True)
|
||||||
discoverable = fields.BooleanField(default=False)
|
discoverable = fields.BooleanField(default=False)
|
||||||
|
show_guided_tour = models.BooleanField(default=True)
|
||||||
|
|
||||||
# feed options
|
# feed options
|
||||||
feed_status_types = ArrayField(
|
feed_status_types = ArrayField(
|
||||||
|
@ -168,12 +170,24 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
max_length=255, choices=DeactivationReason, null=True, blank=True
|
max_length=255, choices=DeactivationReason, null=True, blank=True
|
||||||
)
|
)
|
||||||
deactivation_date = models.DateTimeField(null=True, blank=True)
|
deactivation_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
allow_reactivation = models.BooleanField(default=False)
|
||||||
confirmation_code = models.CharField(max_length=32, default=new_access_code)
|
confirmation_code = models.CharField(max_length=32, default=new_access_code)
|
||||||
|
|
||||||
name_field = "username"
|
name_field = "username"
|
||||||
property_fields = [("following_link", "following")]
|
property_fields = [("following_link", "following")]
|
||||||
field_tracker = FieldTracker(fields=["name", "avatar"])
|
field_tracker = FieldTracker(fields=["name", "avatar"])
|
||||||
|
|
||||||
|
# two factor authentication
|
||||||
|
two_factor_auth = models.BooleanField(default=None, blank=True, null=True)
|
||||||
|
otp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
|
||||||
|
hotp_secret = models.CharField(max_length=32, default=None, blank=True, null=True)
|
||||||
|
hotp_count = models.IntegerField(default=0, blank=True, null=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_follower_requests(self):
|
||||||
|
"""Follow requests from active users"""
|
||||||
|
return self.follower_requests.filter(is_active=True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def confirmation_link(self):
|
def confirmation_link(self):
|
||||||
"""helper for generating confirmation links"""
|
"""helper for generating confirmation links"""
|
||||||
|
@ -226,6 +240,14 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
queryset = queryset.exclude(blocks=viewer)
|
queryset = queryset.exclude(blocks=viewer)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def admins(cls):
|
||||||
|
"""Get a queryset of the admins for this instance"""
|
||||||
|
return cls.objects.filter(
|
||||||
|
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
|
||||||
|
| models.Q(is_superuser=True)
|
||||||
|
)
|
||||||
|
|
||||||
def update_active_date(self):
|
def update_active_date(self):
|
||||||
"""this user is here! they are doing things!"""
|
"""this user is here! they are doing things!"""
|
||||||
self.last_active_date = timezone.now()
|
self.last_active_date = timezone.now()
|
||||||
|
@ -347,12 +369,28 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
self.create_shelves()
|
self.create_shelves()
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
"""deactivate rather than delete a user"""
|
"""We don't actually delete the database entry"""
|
||||||
# pylint: disable=attribute-defined-outside-init
|
# pylint: disable=attribute-defined-outside-init
|
||||||
self.is_active = False
|
self.is_active = False
|
||||||
# skip the logic in this class's save()
|
# skip the logic in this class's save()
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def deactivate(self):
|
||||||
|
"""Disable the user but allow them to reactivate"""
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
self.is_active = False
|
||||||
|
self.deactivation_reason = "self_deactivation"
|
||||||
|
self.allow_reactivation = True
|
||||||
|
super().save(broadcast=False)
|
||||||
|
|
||||||
|
def reactivate(self):
|
||||||
|
"""Now you want to come back, huh?"""
|
||||||
|
# pylint: disable=attribute-defined-outside-init
|
||||||
|
self.is_active = True
|
||||||
|
self.deactivation_reason = None
|
||||||
|
self.allow_reactivation = False
|
||||||
|
super().save(broadcast=False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def local_path(self):
|
def local_path(self):
|
||||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||||
|
@ -388,6 +426,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
||||||
editable=False,
|
editable=False,
|
||||||
).save(broadcast=False)
|
).save(broadcast=False)
|
||||||
|
|
||||||
|
def raise_not_editable(self, viewer):
|
||||||
|
"""Who can edit the user object?"""
|
||||||
|
if self == viewer or viewer.has_perm("bookwyrm.moderate_user"):
|
||||||
|
return
|
||||||
|
raise PermissionDenied()
|
||||||
|
|
||||||
|
|
||||||
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||||
"""public and private keys for a user"""
|
"""public and private keys for a user"""
|
||||||
|
@ -414,66 +458,7 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def get_current_year():
|
@app.task(queue=LOW)
|
||||||
"""sets default year for annual goal to this year"""
|
|
||||||
return timezone.now().year
|
|
||||||
|
|
||||||
|
|
||||||
class AnnualGoal(BookWyrmModel):
|
|
||||||
"""set a goal for how many books you read in a year"""
|
|
||||||
|
|
||||||
user = models.ForeignKey("User", on_delete=models.PROTECT)
|
|
||||||
goal = models.IntegerField(validators=[MinValueValidator(1)])
|
|
||||||
year = models.IntegerField(default=get_current_year)
|
|
||||||
privacy = models.CharField(
|
|
||||||
max_length=255, default="public", choices=fields.PrivacyLevels
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
"""unqiueness constraint"""
|
|
||||||
|
|
||||||
unique_together = ("user", "year")
|
|
||||||
|
|
||||||
def get_remote_id(self):
|
|
||||||
"""put the year in the path"""
|
|
||||||
return f"{self.user.remote_id}/goal/{self.year}"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def books(self):
|
|
||||||
"""the books you've read this year"""
|
|
||||||
return (
|
|
||||||
self.user.readthrough_set.filter(
|
|
||||||
finish_date__year__gte=self.year,
|
|
||||||
finish_date__year__lt=self.year + 1,
|
|
||||||
)
|
|
||||||
.order_by("-finish_date")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def ratings(self):
|
|
||||||
"""ratings for books read this year"""
|
|
||||||
book_ids = [r.book.id for r in self.books]
|
|
||||||
reviews = Review.objects.filter(
|
|
||||||
user=self.user,
|
|
||||||
book__in=book_ids,
|
|
||||||
)
|
|
||||||
return {r.book.id: r.rating for r in reviews}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def progress(self):
|
|
||||||
"""how many books you've read this year"""
|
|
||||||
count = self.user.readthrough_set.filter(
|
|
||||||
finish_date__year__gte=self.year,
|
|
||||||
finish_date__year__lt=self.year + 1,
|
|
||||||
).count()
|
|
||||||
return {
|
|
||||||
"count": count,
|
|
||||||
"percent": int(float(count / self.goal) * 100),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
|
||||||
def set_remote_server(user_id):
|
def set_remote_server(user_id):
|
||||||
"""figure out the user's remote server in the background"""
|
"""figure out the user's remote server in the background"""
|
||||||
user = User.objects.get(id=user_id)
|
user = User.objects.get(id=user_id)
|
||||||
|
@ -517,7 +502,7 @@ def get_or_create_remote_server(domain, refresh=False):
|
||||||
return server
|
return server
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def get_remote_reviews(outbox):
|
def get_remote_reviews(outbox):
|
||||||
"""ingest reviews by a new remote bookwyrm user"""
|
"""ingest reviews by a new remote bookwyrm user"""
|
||||||
outbox_page = outbox + "?page=true&type=Review"
|
outbox_page = outbox + "?page=true&type=Review"
|
||||||
|
|
|
@ -16,7 +16,7 @@ from django.core.files.storage import default_storage
|
||||||
from django.db.models import Avg
|
from django.db.models import Avg
|
||||||
|
|
||||||
from bookwyrm import models, settings
|
from bookwyrm import models, settings
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@ -401,7 +401,7 @@ def save_and_cleanup(image, instance=None):
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def generate_site_preview_image_task():
|
def generate_site_preview_image_task():
|
||||||
"""generate preview_image for the website"""
|
"""generate preview_image for the website"""
|
||||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||||
|
@ -426,7 +426,7 @@ def generate_site_preview_image_task():
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def generate_edition_preview_image_task(book_id):
|
def generate_edition_preview_image_task(book_id):
|
||||||
"""generate preview_image for a book"""
|
"""generate preview_image for a book"""
|
||||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||||
|
@ -451,7 +451,7 @@ def generate_edition_preview_image_task(book_id):
|
||||||
save_and_cleanup(image, instance=book)
|
save_and_cleanup(image, instance=book)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def generate_user_preview_image_task(user_id):
|
def generate_user_preview_image_task(user_id):
|
||||||
"""generate preview_image for a book"""
|
"""generate preview_image for a book"""
|
||||||
if not settings.ENABLE_PREVIEW_IMAGES:
|
if not settings.ENABLE_PREVIEW_IMAGES:
|
||||||
|
|
|
@ -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)
|
|
|
@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
|
||||||
env = Env()
|
env = Env()
|
||||||
env.read_env()
|
env.read_env()
|
||||||
DOMAIN = env("DOMAIN")
|
DOMAIN = env("DOMAIN")
|
||||||
VERSION = "0.4.0"
|
VERSION = "0.4.6"
|
||||||
|
|
||||||
RELEASE_API = env(
|
RELEASE_API = env(
|
||||||
"RELEASE_API",
|
"RELEASE_API",
|
||||||
|
@ -21,7 +21,7 @@ RELEASE_API = env(
|
||||||
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
PAGE_LENGTH = env("PAGE_LENGTH", 15)
|
||||||
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
|
||||||
|
|
||||||
JS_CACHE = "e678183b"
|
JS_CACHE = "e678183c"
|
||||||
|
|
||||||
# email
|
# email
|
||||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||||
|
@ -280,6 +280,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
LANGUAGE_CODE = env("LANGUAGE_CODE", "en-us")
|
LANGUAGE_CODE = env("LANGUAGE_CODE", "en-us")
|
||||||
LANGUAGES = [
|
LANGUAGES = [
|
||||||
("en-us", _("English")),
|
("en-us", _("English")),
|
||||||
|
("ca-es", _("Català (Catalan)")),
|
||||||
("de-de", _("Deutsch (German)")),
|
("de-de", _("Deutsch (German)")),
|
||||||
("es-es", _("Español (Spanish)")),
|
("es-es", _("Español (Spanish)")),
|
||||||
("gl-es", _("Galego (Galician)")),
|
("gl-es", _("Galego (Galician)")),
|
||||||
|
@ -288,6 +289,7 @@ LANGUAGES = [
|
||||||
("fr-fr", _("Français (French)")),
|
("fr-fr", _("Français (French)")),
|
||||||
("lt-lt", _("Lietuvių (Lithuanian)")),
|
("lt-lt", _("Lietuvių (Lithuanian)")),
|
||||||
("no-no", _("Norsk (Norwegian)")),
|
("no-no", _("Norsk (Norwegian)")),
|
||||||
|
("pl-pl", _("Polski (Polish)")),
|
||||||
("pt-br", _("Português do Brasil (Brazilian Portuguese)")),
|
("pt-br", _("Português do Brasil (Brazilian Portuguese)")),
|
||||||
("pt-pt", _("Português Europeu (European Portuguese)")),
|
("pt-pt", _("Português Europeu (European Portuguese)")),
|
||||||
("ro-ro", _("Română (Romanian)")),
|
("ro-ro", _("Română (Romanian)")),
|
||||||
|
@ -356,3 +358,5 @@ else:
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
|
OTEL_EXPORTER_OTLP_ENDPOINT = env("OTEL_EXPORTER_OTLP_ENDPOINT", None)
|
||||||
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
|
OTEL_EXPORTER_OTLP_HEADERS = env("OTEL_EXPORTER_OTLP_HEADERS", None)
|
||||||
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)
|
OTEL_SERVICE_NAME = env("OTEL_SERVICE_NAME", None)
|
||||||
|
|
||||||
|
TWO_FACTOR_LOGIN_MAX_SECONDS = 60
|
||||||
|
|
|
@ -6,11 +6,11 @@ ol.ordered-list {
|
||||||
counter-reset: list-counter;
|
counter-reset: list-counter;
|
||||||
}
|
}
|
||||||
|
|
||||||
ol.ordered-list li {
|
ol.ordered-list > li {
|
||||||
counter-increment: list-counter;
|
counter-increment: list-counter;
|
||||||
}
|
}
|
||||||
|
|
||||||
ol.ordered-list li::before {
|
ol.ordered-list > li::before {
|
||||||
content: counter(list-counter);
|
content: counter(list-counter);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -20px;
|
left: -20px;
|
||||||
|
|
|
@ -67,7 +67,7 @@ details.dropdown .dropdown-menu a:focus-visible {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 100;
|
z-index: 35;
|
||||||
}
|
}
|
||||||
|
|
||||||
details .dropdown-menu > * {
|
details .dropdown-menu > * {
|
||||||
|
|
|
@ -94,3 +94,4 @@ $family-secondary: $family-sans-serif;
|
||||||
|
|
||||||
@import "../bookwyrm";
|
@import "../bookwyrm";
|
||||||
@import "../vendor/icons.css";
|
@import "../vendor/icons.css";
|
||||||
|
@import "../vendor/shepherd.scss";
|
||||||
|
|
|
@ -68,3 +68,4 @@ $family-secondary: $family-sans-serif;
|
||||||
|
|
||||||
@import "../bookwyrm";
|
@import "../bookwyrm";
|
||||||
@import "../vendor/icons.css";
|
@import "../vendor/icons.css";
|
||||||
|
@import "../vendor/shepherd.scss";
|
||||||
|
|
48
bookwyrm/static/css/vendor/shepherd.scss
vendored
Normal file
48
bookwyrm/static/css/vendor/shepherd.scss
vendored
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
Shepherd styles for guided tour.
|
||||||
|
Based on Shepherd v 10.0.0 styles.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@use 'bulma/bulma.sass';
|
||||||
|
|
||||||
|
.shepherd-button {
|
||||||
|
@extend .button.mr-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shepherd-button.shepherd-button-secondary {
|
||||||
|
@extend .button.is-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shepherd-footer {
|
||||||
|
@extend .message-body;
|
||||||
|
@extend .is-info.is-light;
|
||||||
|
border-color: $info-light;
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shepherd-cancel-icon{background:transparent;border:none;color:hsla(0,0%,50%,.75);cursor:pointer;font-size:2em;font-weight:400;margin:0;padding:0;transition:color .5s ease}.shepherd-cancel-icon:hover{color:rgba(0,0,0,.75)}.shepherd-has-title .shepherd-content .shepherd-cancel-icon{color:hsla(0,0%,50%,.75)}.shepherd-has-title .shepherd-content .shepherd-cancel-icon:hover{color:rgba(0,0,0,.75)}
|
||||||
|
|
||||||
|
.shepherd-header {
|
||||||
|
@extend .message-header;
|
||||||
|
@extend .is-info;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shepherd-text {
|
||||||
|
@extend .message-body;
|
||||||
|
@extend .is-info.is-light;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shepherd-content {
|
||||||
|
@extend .message;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shepherd-element{background:$info-light;border-radius:5px;box-shadow:4px 4px 6px rgba(0,0,0,.2);max-width:400px;opacity:0;outline:none;transition:opacity .3s,visibility .3s;visibility:hidden;width:100%;z-index:9999}.shepherd-enabled.shepherd-element{opacity:1;visibility:visible}.shepherd-element[data-popper-reference-hidden]:not(.shepherd-centered){opacity:0;pointer-events:none;visibility:hidden}.shepherd-element,.shepherd-element *,.shepherd-element :after,.shepherd-element :before{box-sizing:border-box}.shepherd-arrow,.shepherd-arrow:before{height:16px;position:absolute;width:16px;z-index:-1}.shepherd-arrow:before{background:$info-light;box-shadow:0 2px 4px rgba(0,0,0,.2);content:"";transform:rotate(45deg)}.shepherd-element[data-popper-placement^=top]>.shepherd-arrow{bottom:-8px}.shepherd-element[data-popper-placement^=bottom]>.shepherd-arrow{top:-8px}.shepherd-element[data-popper-placement^=left]>.shepherd-arrow{right:-8px}.shepherd-element[data-popper-placement^=right]>.shepherd-arrow{left:-8px}.shepherd-element.shepherd-centered>.shepherd-arrow{opacity:0}.shepherd-element.shepherd-has-title[data-popper-placement^=bottom]>.shepherd-arrow:before{background-color:$info}.shepherd-target-click-disabled.shepherd-enabled.shepherd-target,.shepherd-target-click-disabled.shepherd-enabled.shepherd-target *{pointer-events:none}
|
||||||
|
|
||||||
|
.shepherd-modal-overlay-container{height:0;left:0;opacity:0;overflow:hidden;pointer-events:none;position:fixed;top:0;transition:all .3s ease-out,height 0ms .3s,opacity .3s 0ms;width:100vw;z-index:9997}.shepherd-modal-overlay-container.shepherd-modal-is-visible{height:100vh;opacity:.5;transform:translateZ(0);transition:all .3s ease-out,height 0s 0s,opacity .3s 0s}.shepherd-modal-overlay-container.shepherd-modal-is-visible path{pointer-events:all}
|
||||||
|
|
||||||
|
.tour-element-highlight {
|
||||||
|
border: 5px solid $info;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow:4px 4px 6px rgba(0,0,0,.2);
|
||||||
|
}
|
|
@ -38,11 +38,12 @@ let BookWyrm = new (class {
|
||||||
.querySelectorAll("[data-modal-open]")
|
.querySelectorAll("[data-modal-open]")
|
||||||
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
|
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
|
||||||
|
|
||||||
document
|
document.querySelectorAll("details.dropdown").forEach((node) => {
|
||||||
.querySelectorAll("details.dropdown")
|
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this));
|
||||||
.forEach((node) =>
|
node.querySelectorAll("[data-modal-open]").forEach((modal_node) =>
|
||||||
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this))
|
modal_node.addEventListener("click", () => (node.open = false))
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
document
|
document
|
||||||
.querySelector("#barcode-scanner-modal")
|
.querySelector("#barcode-scanner-modal")
|
||||||
|
|
18
bookwyrm/static/js/guided_tour.js
Normal file
18
bookwyrm/static/js/guided_tour.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* Set guided tour user value to False
|
||||||
|
* @param {csrf_token} string
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-unused-vars */
|
||||||
|
function disableGuidedTour(csrf_token) {
|
||||||
|
"use strict";
|
||||||
|
fetch("/guided-tour/False", {
|
||||||
|
headers: {
|
||||||
|
"X-CSRFToken": csrf_token,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
redirect: "follow",
|
||||||
|
mode: "same-origin",
|
||||||
|
});
|
||||||
|
}
|
120
bookwyrm/static/js/vendor/shepherd.min.js
vendored
Normal file
120
bookwyrm/static/js/vendor/shepherd.min.js
vendored
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
/*! shepherd.js 10.0.0 */
|
||||||
|
|
||||||
|
'use strict';(function(O,pa){"object"===typeof exports&&"undefined"!==typeof module?module.exports=pa():"function"===typeof define&&define.amd?define(pa):(O="undefined"!==typeof globalThis?globalThis:O||self,O.Shepherd=pa())})(this,function(){function O(a,b){return!1!==b.clone&&b.isMergeableObject(a)?ea(Array.isArray(a)?[]:{},a,b):a}function pa(a,b,c){return a.concat(b).map(function(d){return O(d,c)})}function Cb(a){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(a).filter(function(b){return a.propertyIsEnumerable(b)}):
|
||||||
|
[]}function Sa(a){return Object.keys(a).concat(Cb(a))}function Ta(a,b){try{return b in a}catch(c){return!1}}function Db(a,b,c){var d={};c.isMergeableObject(a)&&Sa(a).forEach(function(e){d[e]=O(a[e],c)});Sa(b).forEach(function(e){if(!Ta(a,e)||Object.hasOwnProperty.call(a,e)&&Object.propertyIsEnumerable.call(a,e))if(Ta(a,e)&&c.isMergeableObject(b[e])){if(c.customMerge){var f=c.customMerge(e);f="function"===typeof f?f:ea}else f=ea;d[e]=f(a[e],b[e],c)}else d[e]=O(b[e],c)});return d}function ea(a,b,c){c=
|
||||||
|
c||{};c.arrayMerge=c.arrayMerge||pa;c.isMergeableObject=c.isMergeableObject||Eb;c.cloneUnlessOtherwiseSpecified=O;var d=Array.isArray(b),e=Array.isArray(a);return d!==e?O(b,c):d?c.arrayMerge(a,b,c):Db(a,b,c)}function Z(a){return"function"===typeof a}function qa(a){return"string"===typeof a}function Ua(a){let b=Object.getOwnPropertyNames(a.constructor.prototype);for(let c=0;c<b.length;c++){let d=b[c],e=a[d];"constructor"!==d&&"function"===typeof e&&(a[d]=e.bind(a))}return a}function Fb(a,b){return c=>
|
||||||
|
{if(b.isOpen()){let d=b.el&&c.currentTarget===b.el;(void 0!==a&&c.currentTarget.matches(a)||d)&&b.tour.next()}}}function Gb(a){let {event:b,selector:c}=a.options.advanceOn||{};if(b){let d=Fb(c,a),e;try{e=document.querySelector(c)}catch(f){}if(void 0===c||e)e?(e.addEventListener(b,d),a.on("destroy",()=>e.removeEventListener(b,d))):(document.body.addEventListener(b,d,!0),a.on("destroy",()=>document.body.removeEventListener(b,d,!0)));else return console.error(`No element was found for the selector supplied to advanceOn: ${c}`)}else return console.error("advanceOn was defined, but no event name was passed.")}
|
||||||
|
function M(a){return a?(a.nodeName||"").toLowerCase():null}function K(a){return null==a?window:"[object Window]"!==a.toString()?(a=a.ownerDocument)?a.defaultView||window:window:a}function fa(a){var b=K(a).Element;return a instanceof b||a instanceof Element}function F(a){var b=K(a).HTMLElement;return a instanceof b||a instanceof HTMLElement}function Ea(a){if("undefined"===typeof ShadowRoot)return!1;var b=K(a).ShadowRoot;return a instanceof b||a instanceof ShadowRoot}function N(a){return a.split("-")[0]}
|
||||||
|
function ha(a,b){void 0===b&&(b=!1);var c=a.getBoundingClientRect(),d=1,e=1;F(a)&&b&&(b=a.offsetHeight,a=a.offsetWidth,0<a&&(d=ia(c.width)/a||1),0<b&&(e=ia(c.height)/b||1));return{width:c.width/d,height:c.height/e,top:c.top/e,right:c.right/d,bottom:c.bottom/e,left:c.left/d,x:c.left/d,y:c.top/e}}function Fa(a){var b=ha(a),c=a.offsetWidth,d=a.offsetHeight;1>=Math.abs(b.width-c)&&(c=b.width);1>=Math.abs(b.height-d)&&(d=b.height);return{x:a.offsetLeft,y:a.offsetTop,width:c,height:d}}function Va(a,b){var c=
|
||||||
|
b.getRootNode&&b.getRootNode();if(a.contains(b))return!0;if(c&&Ea(c)){do{if(b&&a.isSameNode(b))return!0;b=b.parentNode||b.host}while(b)}return!1}function P(a){return K(a).getComputedStyle(a)}function U(a){return((fa(a)?a.ownerDocument:a.document)||window.document).documentElement}function wa(a){return"html"===M(a)?a:a.assignedSlot||a.parentNode||(Ea(a)?a.host:null)||U(a)}function Wa(a){return F(a)&&"fixed"!==P(a).position?a.offsetParent:null}function ra(a){for(var b=K(a),c=Wa(a);c&&0<=["table","td",
|
||||||
|
"th"].indexOf(M(c))&&"static"===P(c).position;)c=Wa(c);if(c&&("html"===M(c)||"body"===M(c)&&"static"===P(c).position))return b;if(!c)a:{c=-1!==navigator.userAgent.toLowerCase().indexOf("firefox");if(-1===navigator.userAgent.indexOf("Trident")||!F(a)||"fixed"!==P(a).position)for(a=wa(a),Ea(a)&&(a=a.host);F(a)&&0>["html","body"].indexOf(M(a));){var d=P(a);if("none"!==d.transform||"none"!==d.perspective||"paint"===d.contain||-1!==["transform","perspective"].indexOf(d.willChange)||c&&"filter"===d.willChange||
|
||||||
|
c&&d.filter&&"none"!==d.filter){c=a;break a}else a=a.parentNode}c=null}return c||b}function Ga(a){return 0<=["top","bottom"].indexOf(a)?"x":"y"}function Xa(a){return Object.assign({},{top:0,right:0,bottom:0,left:0},a)}function Ya(a,b){return b.reduce(function(c,d){c[d]=a;return c},{})}function ja(a){return a.split("-")[1]}function Za(a){var b,c=a.popper,d=a.popperRect,e=a.placement,f=a.variation,g=a.offsets,l=a.position,m=a.gpuAcceleration,k=a.adaptive,p=a.roundOffsets,q=a.isFixed;a=g.x;a=void 0===
|
||||||
|
a?0:a;var n=g.y,r=void 0===n?0:n;n="function"===typeof p?p({x:a,y:r}):{x:a,y:r};a=n.x;r=n.y;n=g.hasOwnProperty("x");g=g.hasOwnProperty("y");var x="left",h="top",t=window;if(k){var v=ra(c),A="clientHeight",u="clientWidth";v===K(c)&&(v=U(c),"static"!==P(v).position&&"absolute"===l&&(A="scrollHeight",u="scrollWidth"));if("top"===e||("left"===e||"right"===e)&&"end"===f)h="bottom",r-=(q&&v===t&&t.visualViewport?t.visualViewport.height:v[A])-d.height,r*=m?1:-1;if("left"===e||("top"===e||"bottom"===e)&&
|
||||||
|
"end"===f)x="right",a-=(q&&v===t&&t.visualViewport?t.visualViewport.width:v[u])-d.width,a*=m?1:-1}c=Object.assign({position:l},k&&Hb);!0===p?(p=r,d=window.devicePixelRatio||1,a={x:ia(a*d)/d||0,y:ia(p*d)/d||0}):a={x:a,y:r};p=a;a=p.x;r=p.y;if(m){var w;return Object.assign({},c,(w={},w[h]=g?"0":"",w[x]=n?"0":"",w.transform=1>=(t.devicePixelRatio||1)?"translate("+a+"px, "+r+"px)":"translate3d("+a+"px, "+r+"px, 0)",w))}return Object.assign({},c,(b={},b[h]=g?r+"px":"",b[x]=n?a+"px":"",b.transform="",b))}
|
||||||
|
function xa(a){return a.replace(/left|right|bottom|top/g,function(b){return Ib[b]})}function $a(a){return a.replace(/start|end/g,function(b){return Jb[b]})}function Ha(a){a=K(a);return{scrollLeft:a.pageXOffset,scrollTop:a.pageYOffset}}function Ia(a){return ha(U(a)).left+Ha(a).scrollLeft}function Ja(a){a=P(a);return/auto|scroll|overlay|hidden/.test(a.overflow+a.overflowY+a.overflowX)}function ab(a){return 0<=["html","body","#document"].indexOf(M(a))?a.ownerDocument.body:F(a)&&Ja(a)?a:ab(wa(a))}function sa(a,
|
||||||
|
b){var c;void 0===b&&(b=[]);var d=ab(a);a=d===(null==(c=a.ownerDocument)?void 0:c.body);c=K(d);d=a?[c].concat(c.visualViewport||[],Ja(d)?d:[]):d;b=b.concat(d);return a?b:b.concat(sa(wa(d)))}function Ka(a){return Object.assign({},a,{left:a.x,top:a.y,right:a.x+a.width,bottom:a.y+a.height})}function bb(a,b){if("viewport"===b){b=K(a);var c=U(a);b=b.visualViewport;var d=c.clientWidth;c=c.clientHeight;var e=0,f=0;b&&(d=b.width,c=b.height,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)||(e=b.offsetLeft,
|
||||||
|
f=b.offsetTop));a={width:d,height:c,x:e+Ia(a),y:f};a=Ka(a)}else fa(b)?(a=ha(b),a.top+=b.clientTop,a.left+=b.clientLeft,a.bottom=a.top+b.clientHeight,a.right=a.left+b.clientWidth,a.width=b.clientWidth,a.height=b.clientHeight,a.x=a.left,a.y=a.top):(f=U(a),a=U(f),d=Ha(f),b=null==(c=f.ownerDocument)?void 0:c.body,c=L(a.scrollWidth,a.clientWidth,b?b.scrollWidth:0,b?b.clientWidth:0),e=L(a.scrollHeight,a.clientHeight,b?b.scrollHeight:0,b?b.clientHeight:0),f=-d.scrollLeft+Ia(f),d=-d.scrollTop,"rtl"===P(b||
|
||||||
|
a).direction&&(f+=L(a.clientWidth,b?b.clientWidth:0)-c),a=Ka({width:c,height:e,x:f,y:d}));return a}function Kb(a){var b=sa(wa(a)),c=0<=["absolute","fixed"].indexOf(P(a).position)&&F(a)?ra(a):a;return fa(c)?b.filter(function(d){return fa(d)&&Va(d,c)&&"body"!==M(d)}):[]}function Lb(a,b,c){b="clippingParents"===b?Kb(a):[].concat(b);c=[].concat(b,[c]);c=c.reduce(function(d,e){e=bb(a,e);d.top=L(e.top,d.top);d.right=V(e.right,d.right);d.bottom=V(e.bottom,d.bottom);d.left=L(e.left,d.left);return d},bb(a,
|
||||||
|
c[0]));c.width=c.right-c.left;c.height=c.bottom-c.top;c.x=c.left;c.y=c.top;return c}function cb(a){var b=a.reference,c=a.element,d=(a=a.placement)?N(a):null;a=a?ja(a):null;var e=b.x+b.width/2-c.width/2,f=b.y+b.height/2-c.height/2;switch(d){case "top":e={x:e,y:b.y-c.height};break;case "bottom":e={x:e,y:b.y+b.height};break;case "right":e={x:b.x+b.width,y:f};break;case "left":e={x:b.x-c.width,y:f};break;default:e={x:b.x,y:b.y}}d=d?Ga(d):null;if(null!=d)switch(f="y"===d?"height":"width",a){case "start":e[d]-=
|
||||||
|
b[f]/2-c[f]/2;break;case "end":e[d]+=b[f]/2-c[f]/2}return e}function ta(a,b){void 0===b&&(b={});var c=b;b=c.placement;b=void 0===b?a.placement:b;var d=c.boundary,e=void 0===d?"clippingParents":d;d=c.rootBoundary;var f=void 0===d?"viewport":d;d=c.elementContext;d=void 0===d?"popper":d;var g=c.altBoundary,l=void 0===g?!1:g;c=c.padding;c=void 0===c?0:c;c=Xa("number"!==typeof c?c:Ya(c,ua));g=a.rects.popper;l=a.elements[l?"popper"===d?"reference":"popper":d];e=Lb(fa(l)?l:l.contextElement||U(a.elements.popper),
|
||||||
|
e,f);f=ha(a.elements.reference);l=cb({reference:f,element:g,strategy:"absolute",placement:b});g=Ka(Object.assign({},g,l));f="popper"===d?g:f;var m={top:e.top-f.top+c.top,bottom:f.bottom-e.bottom+c.bottom,left:e.left-f.left+c.left,right:f.right-e.right+c.right};a=a.modifiersData.offset;if("popper"===d&&a){var k=a[b];Object.keys(m).forEach(function(p){var q=0<=["right","bottom"].indexOf(p)?1:-1,n=0<=["top","bottom"].indexOf(p)?"y":"x";m[p]+=k[n]*q})}return m}function Mb(a,b){void 0===b&&(b={});var c=
|
||||||
|
b.boundary,d=b.rootBoundary,e=b.padding,f=b.flipVariations,g=b.allowedAutoPlacements,l=void 0===g?db:g,m=ja(b.placement);b=m?f?eb:eb.filter(function(p){return ja(p)===m}):ua;f=b.filter(function(p){return 0<=l.indexOf(p)});0===f.length&&(f=b);var k=f.reduce(function(p,q){p[q]=ta(a,{placement:q,boundary:c,rootBoundary:d,padding:e})[N(q)];return p},{});return Object.keys(k).sort(function(p,q){return k[p]-k[q]})}function Nb(a){if("auto"===N(a))return[];var b=xa(a);return[$a(a),b,$a(b)]}function fb(a,
|
||||||
|
b,c){void 0===c&&(c={x:0,y:0});return{top:a.top-b.height-c.y,right:a.right-b.width+c.x,bottom:a.bottom-b.height+c.y,left:a.left-b.width-c.x}}function gb(a){return["top","right","bottom","left"].some(function(b){return 0<=a[b]})}function Ob(a,b,c){void 0===c&&(c=!1);var d=F(b),e;if(e=F(b)){var f=b.getBoundingClientRect();e=ia(f.width)/b.offsetWidth||1;f=ia(f.height)/b.offsetHeight||1;e=1!==e||1!==f}f=e;e=U(b);a=ha(a,f);f={scrollLeft:0,scrollTop:0};var g={x:0,y:0};if(d||!d&&!c){if("body"!==M(b)||Ja(e))f=
|
||||||
|
b!==K(b)&&F(b)?{scrollLeft:b.scrollLeft,scrollTop:b.scrollTop}:Ha(b);F(b)?(g=ha(b,!0),g.x+=b.clientLeft,g.y+=b.clientTop):e&&(g.x=Ia(e))}return{x:a.left+f.scrollLeft-g.x,y:a.top+f.scrollTop-g.y,width:a.width,height:a.height}}function Pb(a){function b(f){d.add(f.name);[].concat(f.requires||[],f.requiresIfExists||[]).forEach(function(g){d.has(g)||(g=c.get(g))&&b(g)});e.push(f)}var c=new Map,d=new Set,e=[];a.forEach(function(f){c.set(f.name,f)});a.forEach(function(f){d.has(f.name)||b(f)});return e}function Qb(a){var b=
|
||||||
|
Pb(a);return Rb.reduce(function(c,d){return c.concat(b.filter(function(e){return e.phase===d}))},[])}function Sb(a){var b;return function(){b||(b=new Promise(function(c){Promise.resolve().then(function(){b=void 0;c(a())})}));return b}}function Tb(a){var b=a.reduce(function(c,d){var e=c[d.name];c[d.name]=e?Object.assign({},e,d,{options:Object.assign({},e.options,d.options),data:Object.assign({},e.data,d.data)}):d;return c},{});return Object.keys(b).map(function(c){return b[c]})}function hb(){for(var a=
|
||||||
|
arguments.length,b=Array(a),c=0;c<a;c++)b[c]=arguments[c];return!b.some(function(d){return!(d&&"function"===typeof d.getBoundingClientRect)})}function La(){La=Object.assign?Object.assign.bind():function(a){for(var b=1;b<arguments.length;b++){var c=arguments[b],d;for(d in c)Object.prototype.hasOwnProperty.call(c,d)&&(a[d]=c[d])}return a};return La.apply(this,arguments)}function Ub(){return[{name:"applyStyles",fn(a){let {state:b}=a;Object.keys(b.elements).forEach(c=>{if("popper"===c){var d=b.attributes[c]||
|
||||||
|
{},e=b.elements[c];Object.assign(e.style,{position:"fixed",left:"50%",top:"50%",transform:"translate(-50%, -50%)"});Object.keys(d).forEach(f=>{let g=d[f];!1===g?e.removeAttribute(f):e.setAttribute(f,!0===g?"":g)})}})}},{name:"computeStyles",options:{adaptive:!1}}]}function Vb(a){let b=Ub(),c={placement:"top",strategy:"fixed",modifiers:[{name:"focusAfterRender",enabled:!0,phase:"afterWrite",fn(){setTimeout(()=>{a.el&&a.el.focus()},300)}}]};return c=La({},c,{modifiers:Array.from(new Set([...c.modifiers,
|
||||||
|
...b]))})}function ib(a){return qa(a)&&""!==a?"-"!==a.charAt(a.length-1)?`${a}-`:a:""}function Ma(){let a=Date.now();return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,b=>{let c=(a+16*Math.random())%16|0;a=Math.floor(a/16);return("x"==b?c:c&3|8).toString(16)})}function Wb(a,b){let c={modifiers:[{name:"preventOverflow",options:{altAxis:!0,tether:!1}},{name:"focusAfterRender",enabled:!0,phase:"afterWrite",fn(){setTimeout(()=>{b.el&&b.el.focus()},300)}}],strategy:"absolute"};void 0!==a&&null!==
|
||||||
|
a&&a.element&&a.on?c.placement=a.on:c=Vb(b);(a=b.tour&&b.tour.options&&b.tour.options.defaultStepOptions)&&(c=jb(a,c));return c=jb(b.options,c)}function jb(a,b){if(a.popperOptions){let c=Object.assign({},b,a.popperOptions);if(a.popperOptions.modifiers&&0<a.popperOptions.modifiers.length){let d=a.popperOptions.modifiers.map(e=>e.name);b=b.modifiers.filter(e=>!d.includes(e.name));c.modifiers=Array.from(new Set([...b,...a.popperOptions.modifiers]))}return c}return b}function G(){}function Xb(a,b){for(let c in b)a[c]=
|
||||||
|
b[c];return a}function ka(a){return a()}function kb(a){return"function"===typeof a}function Q(a,b){return a!=a?b==b:a!==b||a&&"object"===typeof a||"function"===typeof a}function H(a){a.parentNode.removeChild(a)}function lb(a){return document.createElementNS("http://www.w3.org/2000/svg",a)}function ya(a,b,c,d){a.addEventListener(b,c,d);return()=>a.removeEventListener(b,c,d)}function B(a,b,c){null==c?a.removeAttribute(b):a.getAttribute(b)!==c&&a.setAttribute(b,c)}function mb(a,b){let c=Object.getOwnPropertyDescriptors(a.__proto__);
|
||||||
|
for(let d in b)null==b[d]?a.removeAttribute(d):"style"===d?a.style.cssText=b[d]:"__value"===d?a.value=a[d]=b[d]:c[d]&&c[d].set?a[d]=b[d]:B(a,d,b[d])}function la(a,b,c){a.classList[c?"add":"remove"](b)}function za(){if(!R)throw Error("Function called outside component initialization");return R}function Na(a){Aa.push(a)}function nb(){let a=R;do{for(;Ba<va.length;){var b=va[Ba];Ba++;R=b;b=b.$$;if(null!==b.fragment){b.update();b.before_update.forEach(ka);var c=b.dirty;b.dirty=[-1];b.fragment&&b.fragment.p(b.ctx,
|
||||||
|
c);b.after_update.forEach(Na)}}R=null;for(Ba=va.length=0;ma.length;)ma.pop()();for(b=0;b<Aa.length;b+=1)c=Aa[b],Oa.has(c)||(Oa.add(c),c());Aa.length=0}while(va.length);for(;ob.length;)ob.pop()();Pa=!1;Oa.clear();R=a}function aa(){ba={r:0,c:[],p:ba}}function ca(){ba.r||ba.c.forEach(ka);ba=ba.p}function z(a,b){a&&a.i&&(Ca.delete(a),a.i(b))}function C(a,b,c,d){a&&a.o&&!Ca.has(a)&&(Ca.add(a),ba.c.push(()=>{Ca.delete(a);d&&(c&&a.d(1),d())}),a.o(b))}function da(a){a&&a.c()}function W(a,b,c,d){let {fragment:e,
|
||||||
|
on_mount:f,on_destroy:g,after_update:l}=a.$$;e&&e.m(b,c);d||Na(()=>{let m=f.map(ka).filter(kb);g?g.push(...m):m.forEach(ka);a.$$.on_mount=[]});l.forEach(Na)}function X(a,b){a=a.$$;null!==a.fragment&&(a.on_destroy.forEach(ka),a.fragment&&a.fragment.d(b),a.on_destroy=a.fragment=null,a.ctx=[])}function S(a,b,c,d,e,f,g,l){void 0===l&&(l=[-1]);let m=R;R=a;let k=a.$$={fragment:null,ctx:null,props:f,update:G,not_equal:e,bound:Object.create(null),on_mount:[],on_destroy:[],on_disconnect:[],before_update:[],
|
||||||
|
after_update:[],context:new Map(b.context||(m?m.$$.context:[])),callbacks:Object.create(null),dirty:l,skip_bound:!1,root:b.target||m.$$.root};g&&g(k.root);let p=!1;k.ctx=c?c(a,b.props||{},function(q,n){let r=(2>=arguments.length?0:arguments.length-2)?2>=arguments.length?void 0:arguments[2]:n;if(k.ctx&&e(k.ctx[q],k.ctx[q]=r)){if(!k.skip_bound&&k.bound[q])k.bound[q](r);p&&(-1===a.$$.dirty[0]&&(va.push(a),Pa||(Pa=!0,Yb.then(nb)),a.$$.dirty.fill(0)),a.$$.dirty[q/31|0]|=1<<q%31)}return n}):[];k.update();
|
||||||
|
p=!0;k.before_update.forEach(ka);k.fragment=d?d(k.ctx):!1;b.target&&(b.hydrate?(c=Array.from(b.target.childNodes),k.fragment&&k.fragment.l(c),c.forEach(H)):k.fragment&&k.fragment.c(),b.intro&&z(a.$$.fragment),W(a,b.target,b.anchor,b.customElement),nb());R=m}function Zb(a){let b,c,d,e,f;return{c(){b=document.createElement("button");B(b,"aria-label",c=a[3]?a[3]:null);B(b,"class",d=`${a[1]||""} shepherd-button ${a[4]?"shepherd-button-secondary":""}`);b.disabled=a[2];B(b,"tabindex","0")},m(g,l){g.insertBefore(b,
|
||||||
|
l||null);b.innerHTML=a[5];e||(f=ya(b,"click",function(){kb(a[0])&&a[0].apply(this,arguments)}),e=!0)},p(g,l){[l]=l;a=g;l&32&&(b.innerHTML=a[5]);l&8&&c!==(c=a[3]?a[3]:null)&&B(b,"aria-label",c);l&18&&d!==(d=`${a[1]||""} shepherd-button ${a[4]?"shepherd-button-secondary":""}`)&&B(b,"class",d);l&4&&(b.disabled=a[2])},i:G,o:G,d(g){g&&H(b);e=!1;f()}}}function $b(a,b,c){function d(n){return Z(n)?n.call(f):n}let {config:e,step:f}=b,g,l,m,k,p,q;a.$$set=n=>{"config"in n&&c(6,e=n.config);"step"in n&&c(7,f=
|
||||||
|
n.step)};a.$$.update=()=>{a.$$.dirty&192&&(c(0,g=e.action?e.action.bind(f.tour):null),c(1,l=e.classes),c(2,m=e.disabled?d(e.disabled):!1),c(3,k=e.label?d(e.label):null),c(4,p=e.secondary),c(5,q=e.text?d(e.text):null))};return[g,l,m,k,p,q,e,f]}function pb(a,b,c){a=a.slice();a[2]=b[c];return a}function qb(a){let b,c,d=a[1],e=[];for(let g=0;g<d.length;g+=1)e[g]=rb(pb(a,d,g));let f=g=>C(e[g],1,1,()=>{e[g]=null});return{c(){for(let g=0;g<e.length;g+=1)e[g].c();b=document.createTextNode("")},m(g,l){for(let m=
|
||||||
|
0;m<e.length;m+=1)e[m].m(g,l);g.insertBefore(b,l||null);c=!0},p(g,l){if(l&3){d=g[1];let m;for(m=0;m<d.length;m+=1){let k=pb(g,d,m);e[m]?(e[m].p(k,l),z(e[m],1)):(e[m]=rb(k),e[m].c(),z(e[m],1),e[m].m(b.parentNode,b))}aa();for(m=d.length;m<e.length;m+=1)f(m);ca()}},i(g){if(!c){for(g=0;g<d.length;g+=1)z(e[g]);c=!0}},o(g){e=e.filter(Boolean);for(g=0;g<e.length;g+=1)C(e[g]);c=!1},d(g){var l=e;for(let m=0;m<l.length;m+=1)l[m]&&l[m].d(g);g&&H(b)}}}function rb(a){let b,c;b=new ac({props:{config:a[2],step:a[0]}});
|
||||||
|
return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&2&&(f.config=d[2]);e&1&&(f.step=d[0]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b,d)}}}function bc(a){let b,c,d=a[1]&&qb(a);return{c(){b=document.createElement("footer");d&&d.c();B(b,"class","shepherd-footer")},m(e,f){e.insertBefore(b,f||null);d&&d.m(b,null);c=!0},p(e,f){[f]=f;e[1]?d?(d.p(e,f),f&2&&z(d,1)):(d=qb(e),d.c(),z(d,1),d.m(b,null)):d&&(aa(),C(d,1,1,()=>{d=null}),ca())},i(e){c||(z(d),
|
||||||
|
c=!0)},o(e){C(d);c=!1},d(e){e&&H(b);d&&d.d()}}}function cc(a,b,c){let d,{step:e}=b;a.$$set=f=>{"step"in f&&c(0,e=f.step)};a.$$.update=()=>{a.$$.dirty&1&&c(1,d=e.options.buttons)};return[e,d]}function dc(a){let b,c,d,e,f;return{c(){b=document.createElement("button");c=document.createElement("span");c.textContent="\u00d7";B(c,"aria-hidden","true");B(b,"aria-label",d=a[0].label?a[0].label:"Close Tour");B(b,"class","shepherd-cancel-icon");B(b,"type","button")},m(g,l){g.insertBefore(b,l||null);b.appendChild(c);
|
||||||
|
e||(f=ya(b,"click",a[1]),e=!0)},p(g,l){[l]=l;l&1&&d!==(d=g[0].label?g[0].label:"Close Tour")&&B(b,"aria-label",d)},i:G,o:G,d(g){g&&H(b);e=!1;f()}}}function ec(a,b,c){let {cancelIcon:d,step:e}=b;a.$$set=f=>{"cancelIcon"in f&&c(0,d=f.cancelIcon);"step"in f&&c(2,e=f.step)};return[d,f=>{f.preventDefault();e.cancel()},e]}function fc(a){let b;return{c(){b=document.createElement("h3");B(b,"id",a[1]);B(b,"class","shepherd-title")},m(c,d){c.insertBefore(b,d||null);a[3](b)},p(c,d){[d]=d;d&2&&B(b,"id",c[1])},
|
||||||
|
i:G,o:G,d(c){c&&H(b);a[3](null)}}}function gc(a,b,c){let {labelId:d,element:e,title:f}=b;za().$$.after_update.push(()=>{Z(f)&&c(2,f=f());c(0,e.innerHTML=f,e)});a.$$set=g=>{"labelId"in g&&c(1,d=g.labelId);"element"in g&&c(0,e=g.element);"title"in g&&c(2,f=g.title)};return[e,d,f,function(g){ma[g?"unshift":"push"](()=>{e=g;c(0,e)})}]}function sb(a){let b,c;b=new hc({props:{labelId:a[0],title:a[2]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&1&&(f.labelId=d[0]);e&4&&(f.title=
|
||||||
|
d[2]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b,d)}}}function tb(a){let b,c;b=new ic({props:{cancelIcon:a[3],step:a[1]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&8&&(f.cancelIcon=d[3]);e&2&&(f.step=d[1]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b,d)}}}function jc(a){let b,c,d,e=a[2]&&sb(a),f=a[3]&&a[3].enabled&&tb(a);return{c(){b=document.createElement("header");e&&e.c();c=document.createTextNode(" ");
|
||||||
|
f&&f.c();B(b,"class","shepherd-header")},m(g,l){g.insertBefore(b,l||null);e&&e.m(b,null);b.appendChild(c);f&&f.m(b,null);d=!0},p(g,l){[l]=l;g[2]?e?(e.p(g,l),l&4&&z(e,1)):(e=sb(g),e.c(),z(e,1),e.m(b,c)):e&&(aa(),C(e,1,1,()=>{e=null}),ca());g[3]&&g[3].enabled?f?(f.p(g,l),l&8&&z(f,1)):(f=tb(g),f.c(),z(f,1),f.m(b,null)):f&&(aa(),C(f,1,1,()=>{f=null}),ca())},i(g){d||(z(e),z(f),d=!0)},o(g){C(e);C(f);d=!1},d(g){g&&H(b);e&&e.d();f&&f.d()}}}function kc(a,b,c){let {labelId:d,step:e}=b,f,g;a.$$set=l=>{"labelId"in
|
||||||
|
l&&c(0,d=l.labelId);"step"in l&&c(1,e=l.step)};a.$$.update=()=>{a.$$.dirty&2&&(c(2,f=e.options.title),c(3,g=e.options.cancelIcon))};return[d,e,f,g]}function lc(a){let b;return{c(){b=document.createElement("div");B(b,"class","shepherd-text");B(b,"id",a[1])},m(c,d){c.insertBefore(b,d||null);a[3](b)},p(c,d){[d]=d;d&2&&B(b,"id",c[1])},i:G,o:G,d(c){c&&H(b);a[3](null)}}}function mc(a,b,c){let {descriptionId:d,element:e,step:f}=b;za().$$.after_update.push(()=>{let {text:g}=f.options;Z(g)&&(g=g.call(f));
|
||||||
|
g instanceof HTMLElement?e.appendChild(g):c(0,e.innerHTML=g,e)});a.$$set=g=>{"descriptionId"in g&&c(1,d=g.descriptionId);"element"in g&&c(0,e=g.element);"step"in g&&c(2,f=g.step)};return[e,d,f,function(g){ma[g?"unshift":"push"](()=>{e=g;c(0,e)})}]}function ub(a){let b,c;b=new nc({props:{labelId:a[1],step:a[2]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&2&&(f.labelId=d[1]);e&4&&(f.step=d[2]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b,
|
||||||
|
d)}}}function vb(a){let b,c;b=new oc({props:{descriptionId:a[0],step:a[2]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&1&&(f.descriptionId=d[0]);e&4&&(f.step=d[2]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b,d)}}}function wb(a){let b,c;b=new pc({props:{step:a[2]}});return{c(){da(b.$$.fragment)},m(d,e){W(b,d,e);c=!0},p(d,e){let f={};e&4&&(f.step=d[2]);b.$set(f)},i(d){c||(z(b.$$.fragment,d),c=!0)},o(d){C(b.$$.fragment,d);c=!1},d(d){X(b,
|
||||||
|
d)}}}function qc(a){let b,c=void 0!==a[2].options.title||a[2].options.cancelIcon&&a[2].options.cancelIcon.enabled,d,e=void 0!==a[2].options.text,f,g=Array.isArray(a[2].options.buttons)&&a[2].options.buttons.length,l,m=c&&ub(a),k=e&&vb(a),p=g&&wb(a);return{c(){b=document.createElement("div");m&&m.c();d=document.createTextNode(" ");k&&k.c();f=document.createTextNode(" ");p&&p.c();B(b,"class","shepherd-content")},m(q,n){q.insertBefore(b,n||null);m&&m.m(b,null);b.appendChild(d);k&&k.m(b,null);b.appendChild(f);
|
||||||
|
p&&p.m(b,null);l=!0},p(q,n){[n]=n;n&4&&(c=void 0!==q[2].options.title||q[2].options.cancelIcon&&q[2].options.cancelIcon.enabled);c?m?(m.p(q,n),n&4&&z(m,1)):(m=ub(q),m.c(),z(m,1),m.m(b,d)):m&&(aa(),C(m,1,1,()=>{m=null}),ca());n&4&&(e=void 0!==q[2].options.text);e?k?(k.p(q,n),n&4&&z(k,1)):(k=vb(q),k.c(),z(k,1),k.m(b,f)):k&&(aa(),C(k,1,1,()=>{k=null}),ca());n&4&&(g=Array.isArray(q[2].options.buttons)&&q[2].options.buttons.length);g?p?(p.p(q,n),n&4&&z(p,1)):(p=wb(q),p.c(),z(p,1),p.m(b,null)):p&&(aa(),
|
||||||
|
C(p,1,1,()=>{p=null}),ca())},i(q){l||(z(m),z(k),z(p),l=!0)},o(q){C(m);C(k);C(p);l=!1},d(q){q&&H(b);m&&m.d();k&&k.d();p&&p.d()}}}function rc(a,b,c){let {descriptionId:d,labelId:e,step:f}=b;a.$$set=g=>{"descriptionId"in g&&c(0,d=g.descriptionId);"labelId"in g&&c(1,e=g.labelId);"step"in g&&c(2,f=g.step)};return[d,e,f]}function xb(a){let b;return{c(){b=document.createElement("div");B(b,"class","shepherd-arrow");B(b,"data-popper-arrow","")},m(c,d){c.insertBefore(b,d||null)},d(c){c&&H(b)}}}function sc(a){let b,
|
||||||
|
c,d,e,f,g,l,m,k=a[4].options.arrow&&a[4].options.attachTo&&a[4].options.attachTo.element&&a[4].options.attachTo.on&&xb();d=new tc({props:{descriptionId:a[2],labelId:a[3],step:a[4]}});let p=[{"aria-describedby":e=void 0!==a[4].options.text?a[2]:null},{"aria-labelledby":f=a[4].options.title?a[3]:null},a[1],{role:"dialog"},{tabindex:"0"}],q={};for(let n=0;n<p.length;n+=1)q=Xb(q,p[n]);return{c(){b=document.createElement("div");k&&k.c();c=document.createTextNode(" ");da(d.$$.fragment);mb(b,q);la(b,"shepherd-has-cancel-icon",
|
||||||
|
a[5]);la(b,"shepherd-has-title",a[6]);la(b,"shepherd-element",!0)},m(n,r){n.insertBefore(b,r||null);k&&k.m(b,null);b.appendChild(c);W(d,b,null);a[13](b);g=!0;l||(m=ya(b,"keydown",a[7]),l=!0)},p(n,r){var [x]=r;n[4].options.arrow&&n[4].options.attachTo&&n[4].options.attachTo.element&&n[4].options.attachTo.on?k||(k=xb(),k.c(),k.m(b,c)):k&&(k.d(1),k=null);r={};x&4&&(r.descriptionId=n[2]);x&8&&(r.labelId=n[3]);x&16&&(r.step=n[4]);d.$set(r);r=b;x=[(!g||x&20&&e!==(e=void 0!==n[4].options.text?n[2]:null))&&
|
||||||
|
{"aria-describedby":e},(!g||x&24&&f!==(f=n[4].options.title?n[3]:null))&&{"aria-labelledby":f},x&2&&n[1],{role:"dialog"},{tabindex:"0"}];let h={},t={},v={$$scope:1},A=p.length;for(;A--;){let u=p[A],w=x[A];if(w){for(let y in u)y in w||(t[y]=1);for(let y in w)v[y]||(h[y]=w[y],v[y]=1);p[A]=w}else for(let y in u)v[y]=1}for(let u in t)u in h||(h[u]=void 0);mb(r,q=h);la(b,"shepherd-has-cancel-icon",n[5]);la(b,"shepherd-has-title",n[6]);la(b,"shepherd-element",!0)},i(n){g||(z(d.$$.fragment,n),g=!0)},o(n){C(d.$$.fragment,
|
||||||
|
n);g=!1},d(n){n&&H(b);k&&k.d();X(d);a[13](null);l=!1;m()}}}function yb(a){return a.split(" ").filter(b=>!!b.length)}function uc(a,b,c){let {classPrefix:d,element:e,descriptionId:f,firstFocusableElement:g,focusableElements:l,labelId:m,lastFocusableElement:k,step:p,dataStepId:q}=b,n,r,x;za().$$.on_mount.push(()=>{c(1,q={[`data-${d}shepherd-step-id`]:p.id});c(9,l=e.querySelectorAll('a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex="0"]'));
|
||||||
|
c(8,g=l[0]);c(10,k=l[l.length-1])});za().$$.after_update.push(()=>{if(x!==p.options.classes){var h=x;qa(h)&&(h=yb(h),h.length&&e.classList.remove(...h));h=x=p.options.classes;qa(h)&&(h=yb(h),h.length&&e.classList.add(...h))}});a.$$set=h=>{"classPrefix"in h&&c(11,d=h.classPrefix);"element"in h&&c(0,e=h.element);"descriptionId"in h&&c(2,f=h.descriptionId);"firstFocusableElement"in h&&c(8,g=h.firstFocusableElement);"focusableElements"in h&&c(9,l=h.focusableElements);"labelId"in h&&c(3,m=h.labelId);"lastFocusableElement"in
|
||||||
|
h&&c(10,k=h.lastFocusableElement);"step"in h&&c(4,p=h.step);"dataStepId"in h&&c(1,q=h.dataStepId)};a.$$.update=()=>{a.$$.dirty&16&&(c(5,n=p.options&&p.options.cancelIcon&&p.options.cancelIcon.enabled),c(6,r=p.options&&p.options.title))};return[e,q,f,m,p,n,r,h=>{const {tour:t}=p;switch(h.keyCode){case 9:if(0===l.length){h.preventDefault();break}if(h.shiftKey){if(document.activeElement===g||document.activeElement.classList.contains("shepherd-element"))h.preventDefault(),k.focus()}else document.activeElement===
|
||||||
|
k&&(h.preventDefault(),g.focus());break;case 27:t.options.exitOnEsc&&p.cancel();break;case 37:t.options.keyboardNavigation&&t.back();break;case 39:t.options.keyboardNavigation&&t.next()}},g,l,k,d,()=>e,function(h){ma[h?"unshift":"push"](()=>{e=h;c(0,e)})}]}function vc(a){a&&({steps:a}=a,a.forEach(b=>{b.options&&!1===b.options.canClickTarget&&b.options.attachTo&&b.target instanceof HTMLElement&&b.target.classList.remove("shepherd-target-click-disabled")}))}function wc(a){let b,c,d,e,f;return{c(){b=
|
||||||
|
lb("svg");c=lb("path");B(c,"d",a[2]);B(b,"class",d=`${a[1]?"shepherd-modal-is-visible":""} shepherd-modal-overlay-container`)},m(g,l){g.insertBefore(b,l||null);b.appendChild(c);a[11](b);e||(f=ya(b,"touchmove",a[3]),e=!0)},p(g,l){[l]=l;l&4&&B(c,"d",g[2]);l&2&&d!==(d=`${g[1]?"shepherd-modal-is-visible":""} shepherd-modal-overlay-container`)&&B(b,"class",d)},i:G,o:G,d(g){g&&H(b);a[11](null);e=!1;f()}}}function zb(a){if(!a)return null;let b=a instanceof HTMLElement&&window.getComputedStyle(a).overflowY;
|
||||||
|
return"hidden"!==b&&"visible"!==b&&a.scrollHeight>=a.clientHeight?a:zb(a.parentElement)}function xc(a,b,c){function d(){c(4,p={width:0,height:0,x:0,y:0,r:0})}function e(){c(1,q=!1);l()}function f(h,t,v,A){void 0===h&&(h=0);void 0===t&&(t=0);if(A){var u=A.getBoundingClientRect();let y=u.y||u.top;u=u.bottom||y+u.height;if(v){var w=v.getBoundingClientRect();v=w.y||w.top;w=w.bottom||v+w.height;y=Math.max(y,v);u=Math.min(u,w)}let {y:Y,height:E}={y,height:Math.max(u-y,0)},{x:I,width:D,left:na}=A.getBoundingClientRect();
|
||||||
|
c(4,p={width:D+2*h,height:E+2*h,x:(I||na)-h,y:Y-h,r:t})}else d()}function g(){c(1,q=!0)}function l(){n&&(cancelAnimationFrame(n),n=void 0);window.removeEventListener("touchmove",x,{passive:!1})}function m(h){let {modalOverlayOpeningPadding:t,modalOverlayOpeningRadius:v}=h.options,A=zb(h.target),u=()=>{n=void 0;f(t,v,A,h.target);n=requestAnimationFrame(u)};u();window.addEventListener("touchmove",x,{passive:!1})}let {element:k,openingProperties:p}=b;Ma();let q=!1,n=void 0,r;d();let x=h=>{h.preventDefault()};
|
||||||
|
a.$$set=h=>{"element"in h&&c(0,k=h.element);"openingProperties"in h&&c(4,p=h.openingProperties)};a.$$.update=()=>{if(a.$$.dirty&16){let {width:h,height:t,x:v=0,y:A=0,r:u=0}=p,{innerWidth:w,innerHeight:y}=window;c(2,r=`M${w},${y}\
|
||||||
|
H0\
|
||||||
|
V0\
|
||||||
|
H${w}\
|
||||||
|
V${y}\
|
||||||
|
Z\
|
||||||
|
M${v+u},${A}\
|
||||||
|
a${u},${u},0,0,0-${u},${u}\
|
||||||
|
V${t+A-u}\
|
||||||
|
a${u},${u},0,0,0,${u},${u}\
|
||||||
|
H${h+v-u}\
|
||||||
|
a${u},${u},0,0,0,${u}-${u}\
|
||||||
|
V${A+u}\
|
||||||
|
a${u},${u},0,0,0-${u}-${u}\
|
||||||
|
Z`)}};return[k,q,r,h=>{h.stopPropagation()},p,()=>k,d,e,f,function(h){l();h.tour.options.useModalOverlay?(m(h),g()):e()},g,function(h){ma[h?"unshift":"push"](()=>{k=h;c(0,k)})}]}var Eb=function(a){var b;if(b=!!a&&"object"===typeof a)b=Object.prototype.toString.call(a),b=!("[object RegExp]"===b||"[object Date]"===b||a.$$typeof===yc);return b},yc="function"===typeof Symbol&&Symbol.for?Symbol.for("react.element"):60103;ea.all=function(a,b){if(!Array.isArray(a))throw Error("first argument should be an array");
|
||||||
|
return a.reduce(function(c,d){return ea(c,d,b)},{})};var zc=ea;class Qa{on(a,b,c,d){void 0===d&&(d=!1);void 0===this.bindings&&(this.bindings={});void 0===this.bindings[a]&&(this.bindings[a]=[]);this.bindings[a].push({handler:b,ctx:c,once:d});return this}once(a,b,c){return this.on(a,b,c,!0)}off(a,b){if(void 0===this.bindings||void 0===this.bindings[a])return this;void 0===b?delete this.bindings[a]:this.bindings[a].forEach((c,d)=>{c.handler===b&&this.bindings[a].splice(d,1)});return this}trigger(a){for(var b=
|
||||||
|
arguments.length,c=Array(1<b?b-1:0),d=1;d<b;d++)c[d-1]=arguments[d];void 0!==this.bindings&&this.bindings[a]&&this.bindings[a].forEach((e,f)=>{let {ctx:g,handler:l,once:m}=e;l.apply(g||this,c);m&&this.bindings[a].splice(f,1)});return this}}var ua=["top","bottom","right","left"],eb=ua.reduce(function(a,b){return a.concat([b+"-start",b+"-end"])},[]),db=[].concat(ua,["auto"]).reduce(function(a,b){return a.concat([b,b+"-start",b+"-end"])},[]),Rb="beforeRead read afterRead beforeMain main afterMain beforeWrite write afterWrite".split(" "),
|
||||||
|
L=Math.max,V=Math.min,ia=Math.round,Hb={top:"auto",right:"auto",bottom:"auto",left:"auto"},Da={passive:!0},Ib={left:"right",right:"left",bottom:"top",top:"bottom"},Jb={start:"end",end:"start"},Ab={placement:"bottom",modifiers:[],strategy:"absolute"},Ac=function(a){void 0===a&&(a={});var b=a.defaultModifiers,c=void 0===b?[]:b;a=a.defaultOptions;var d=void 0===a?Ab:a;return function(e,f,g){function l(){k.orderedModifiers.forEach(function(r){var x=r.name,h=r.options;h=void 0===h?{}:h;r=r.effect;"function"===
|
||||||
|
typeof r&&(x=r({state:k,name:x,instance:n,options:h}),p.push(x||function(){}))})}function m(){p.forEach(function(r){return r()});p=[]}void 0===g&&(g=d);var k={placement:"bottom",orderedModifiers:[],options:Object.assign({},Ab,d),modifiersData:{},elements:{reference:e,popper:f},attributes:{},styles:{}},p=[],q=!1,n={state:k,setOptions:function(r){r="function"===typeof r?r(k.options):r;m();k.options=Object.assign({},d,k.options,r);k.scrollParents={reference:fa(e)?sa(e):e.contextElement?sa(e.contextElement):
|
||||||
|
[],popper:sa(f)};r=Qb(Tb([].concat(c,k.options.modifiers)));k.orderedModifiers=r.filter(function(x){return x.enabled});l();return n.update()},forceUpdate:function(){if(!q){var r=k.elements,x=r.reference;r=r.popper;if(hb(x,r))for(k.rects={reference:Ob(x,ra(r),"fixed"===k.options.strategy),popper:Fa(r)},k.reset=!1,k.placement=k.options.placement,k.orderedModifiers.forEach(function(v){return k.modifiersData[v.name]=Object.assign({},v.data)}),x=0;x<k.orderedModifiers.length;x++)if(!0===k.reset)k.reset=
|
||||||
|
!1,x=-1;else{var h=k.orderedModifiers[x];r=h.fn;var t=h.options;t=void 0===t?{}:t;h=h.name;"function"===typeof r&&(k=r({state:k,options:t,name:h,instance:n})||k)}}},update:Sb(function(){return new Promise(function(r){n.forceUpdate();r(k)})}),destroy:function(){m();q=!0}};if(!hb(e,f))return n;n.setOptions(g).then(function(r){if(!q&&g.onFirstUpdate)g.onFirstUpdate(r)});return n}}({defaultModifiers:[{name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(a){var b=a.state,c=a.instance;
|
||||||
|
a=a.options;var d=a.scroll,e=void 0===d?!0:d;a=a.resize;var f=void 0===a?!0:a,g=K(b.elements.popper),l=[].concat(b.scrollParents.reference,b.scrollParents.popper);e&&l.forEach(function(m){m.addEventListener("scroll",c.update,Da)});f&&g.addEventListener("resize",c.update,Da);return function(){e&&l.forEach(function(m){m.removeEventListener("scroll",c.update,Da)});f&&g.removeEventListener("resize",c.update,Da)}},data:{}},{name:"popperOffsets",enabled:!0,phase:"read",fn:function(a){var b=a.state;b.modifiersData[a.name]=
|
||||||
|
cb({reference:b.rects.reference,element:b.rects.popper,strategy:"absolute",placement:b.placement})},data:{}},{name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(a){var b=a.state,c=a.options;a=c.gpuAcceleration;a=void 0===a?!0:a;var d=c.adaptive;d=void 0===d?!0:d;c=c.roundOffsets;c=void 0===c?!0:c;a={placement:N(b.placement),variation:ja(b.placement),popper:b.elements.popper,popperRect:b.rects.popper,gpuAcceleration:a,isFixed:"fixed"===b.options.strategy};null!=b.modifiersData.popperOffsets&&
|
||||||
|
(b.styles.popper=Object.assign({},b.styles.popper,Za(Object.assign({},a,{offsets:b.modifiersData.popperOffsets,position:b.options.strategy,adaptive:d,roundOffsets:c}))));null!=b.modifiersData.arrow&&(b.styles.arrow=Object.assign({},b.styles.arrow,Za(Object.assign({},a,{offsets:b.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:c}))));b.attributes.popper=Object.assign({},b.attributes.popper,{"data-popper-placement":b.placement})},data:{}},{name:"applyStyles",enabled:!0,phase:"write",
|
||||||
|
fn:function(a){var b=a.state;Object.keys(b.elements).forEach(function(c){var d=b.styles[c]||{},e=b.attributes[c]||{},f=b.elements[c];F(f)&&M(f)&&(Object.assign(f.style,d),Object.keys(e).forEach(function(g){var l=e[g];!1===l?f.removeAttribute(g):f.setAttribute(g,!0===l?"":l)}))})},effect:function(a){var b=a.state,c={popper:{position:b.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};Object.assign(b.elements.popper.style,c.popper);b.styles=c;b.elements.arrow&&
|
||||||
|
Object.assign(b.elements.arrow.style,c.arrow);return function(){Object.keys(b.elements).forEach(function(d){var e=b.elements[d],f=b.attributes[d]||{};d=Object.keys(b.styles.hasOwnProperty(d)?b.styles[d]:c[d]).reduce(function(g,l){g[l]="";return g},{});F(e)&&M(e)&&(Object.assign(e.style,d),Object.keys(f).forEach(function(g){e.removeAttribute(g)}))})}},requires:["computeStyles"]},{name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(a){var b=a.state,c=a.name;a=a.options.offset;
|
||||||
|
var d=void 0===a?[0,0]:a;a=db.reduce(function(g,l){var m=b.rects;var k=N(l);var p=0<=["left","top"].indexOf(k)?-1:1,q="function"===typeof d?d(Object.assign({},m,{placement:l})):d;m=q[0];q=q[1];m=m||0;q=(q||0)*p;k=0<=["left","right"].indexOf(k)?{x:q,y:m}:{x:m,y:q};g[l]=k;return g},{});var e=a[b.placement],f=e.x;e=e.y;null!=b.modifiersData.popperOffsets&&(b.modifiersData.popperOffsets.x+=f,b.modifiersData.popperOffsets.y+=e);b.modifiersData[c]=a}},{name:"flip",enabled:!0,phase:"main",fn:function(a){var b=
|
||||||
|
a.state,c=a.options;a=a.name;if(!b.modifiersData[a]._skip){var d=c.mainAxis;d=void 0===d?!0:d;var e=c.altAxis;e=void 0===e?!0:e;var f=c.fallbackPlacements,g=c.padding,l=c.boundary,m=c.rootBoundary,k=c.altBoundary,p=c.flipVariations,q=void 0===p?!0:p,n=c.allowedAutoPlacements;c=b.options.placement;p=N(c);f=f||(p!==c&&q?Nb(c):[xa(c)]);var r=[c].concat(f).reduce(function(E,I){return E.concat("auto"===N(I)?Mb(b,{placement:I,boundary:l,rootBoundary:m,padding:g,flipVariations:q,allowedAutoPlacements:n}):
|
||||||
|
I)},[]);c=b.rects.reference;f=b.rects.popper;var x=new Map;p=!0;for(var h=r[0],t=0;t<r.length;t++){var v=r[t],A=N(v),u="start"===ja(v),w=0<=["top","bottom"].indexOf(A),y=w?"width":"height",Y=ta(b,{placement:v,boundary:l,rootBoundary:m,altBoundary:k,padding:g});u=w?u?"right":"left":u?"bottom":"top";c[y]>f[y]&&(u=xa(u));y=xa(u);w=[];d&&w.push(0>=Y[A]);e&&w.push(0>=Y[u],0>=Y[y]);if(w.every(function(E){return E})){h=v;p=!1;break}x.set(v,w)}if(p)for(d=function(E){var I=r.find(function(D){if(D=x.get(D))return D.slice(0,
|
||||||
|
E).every(function(na){return na})});if(I)return h=I,"break"},e=q?3:1;0<e&&"break"!==d(e);e--);b.placement!==h&&(b.modifiersData[a]._skip=!0,b.placement=h,b.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}},{name:"preventOverflow",enabled:!0,phase:"main",fn:function(a){var b=a.state,c=a.options;a=a.name;var d=c.mainAxis,e=void 0===d?!0:d;d=c.altAxis;var f=void 0===d?!1:d;d=c.tether;var g=void 0===d?!0:d;d=c.tetherOffset;var l=void 0===d?0:d,m=ta(b,{boundary:c.boundary,rootBoundary:c.rootBoundary,
|
||||||
|
padding:c.padding,altBoundary:c.altBoundary}),k=N(b.placement),p=ja(b.placement),q=!p,n=Ga(k);c="x"===n?"y":"x";d=b.modifiersData.popperOffsets;var r=b.rects.reference,x=b.rects.popper;l="function"===typeof l?l(Object.assign({},b.rects,{placement:b.placement})):l;var h="number"===typeof l?{mainAxis:l,altAxis:l}:Object.assign({mainAxis:0,altAxis:0},l),t=b.modifiersData.offset?b.modifiersData.offset[b.placement]:null;l={x:0,y:0};if(d){if(e){var v,A="y"===n?"top":"left",u="y"===n?"bottom":"right",w=
|
||||||
|
"y"===n?"height":"width";e=d[n];var y=e+m[A],Y=e-m[u],E=g?-x[w]/2:0,I="start"===p?r[w]:x[w];p="start"===p?-x[w]:-r[w];var D=b.elements.arrow;D=g&&D?Fa(D):{width:0,height:0};var na=b.modifiersData["arrow#persistent"]?b.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0};A=na[A];u=na[u];D=L(0,V(r[w],D[w]));I=q?r[w]/2-E-D-A-h.mainAxis:I-D-A-h.mainAxis;q=q?-r[w]/2+E+D+u+h.mainAxis:p+D+u+h.mainAxis;w=(w=b.elements.arrow&&ra(b.elements.arrow))?"y"===n?w.clientTop||0:w.clientLeft||
|
||||||
|
0:0;E=null!=(v=null==t?void 0:t[n])?v:0;v=e+q-E;y=g?V(y,e+I-E-w):y;v=g?L(Y,v):Y;v=L(y,V(e,v));d[n]=v;l[n]=v-e}if(f){var J;f=d[c];e="y"===c?"height":"width";v=f+m["x"===n?"top":"left"];m=f-m["x"===n?"bottom":"right"];k=-1!==["top","left"].indexOf(k);n=null!=(J=null==t?void 0:t[c])?J:0;J=k?v:f-r[e]-x[e]-n+h.altAxis;r=k?f+r[e]+x[e]-n-h.altAxis:m;g&&k?(J=L(J,V(f,r)),J=J>r?r:J):J=L(g?J:v,V(f,g?r:m));d[c]=J;l[c]=J-f}b.modifiersData[a]=l}},requiresIfExists:["offset"]},{name:"arrow",enabled:!0,phase:"main",
|
||||||
|
fn:function(a){var b,c=a.state,d=a.name,e=a.options,f=c.elements.arrow,g=c.modifiersData.popperOffsets,l=N(c.placement);a=Ga(l);l=0<=["left","right"].indexOf(l)?"height":"width";if(f&&g){e=e.padding;e="function"===typeof e?e(Object.assign({},c.rects,{placement:c.placement})):e;e=Xa("number"!==typeof e?e:Ya(e,ua));var m=Fa(f),k="y"===a?"top":"left",p="y"===a?"bottom":"right",q=c.rects.reference[l]+c.rects.reference[a]-g[a]-c.rects.popper[l];g=g[a]-c.rects.reference[a];f=(f=ra(f))?"y"===a?f.clientHeight||
|
||||||
|
0:f.clientWidth||0:0;g=f/2-m[l]/2+(q/2-g/2);l=L(e[k],V(g,f-m[l]-e[p]));c.modifiersData[d]=(b={},b[a]=l,b.centerOffset=l-g,b)}},effect:function(a){var b=a.state;a=a.options.element;a=void 0===a?"[data-popper-arrow]":a;if(null!=a){if("string"===typeof a&&(a=b.elements.popper.querySelector(a),!a))return;Va(b.elements.popper,a)&&(b.elements.arrow=a)}},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]},{name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(a){var b=
|
||||||
|
a.state;a=a.name;var c=b.rects.reference,d=b.rects.popper,e=b.modifiersData.preventOverflow,f=ta(b,{elementContext:"reference"}),g=ta(b,{altBoundary:!0});c=fb(f,c);d=fb(g,d,e);e=gb(c);g=gb(d);b.modifiersData[a]={referenceClippingOffsets:c,popperEscapeOffsets:d,isReferenceHidden:e,hasPopperEscaped:g};b.attributes.popper=Object.assign({},b.attributes.popper,{"data-popper-reference-hidden":e,"data-popper-escaped":g})}}]});let R,va=[],ma=[],Aa=[],ob=[],Yb=Promise.resolve(),Pa=!1,Oa=new Set,Ba=0,Ca=new Set,
|
||||||
|
ba;class T{$destroy(){X(this,1);this.$destroy=G}$on(a,b){let c=this.$$.callbacks[a]||(this.$$.callbacks[a]=[]);c.push(b);return()=>{let d=c.indexOf(b);-1!==d&&c.splice(d,1)}}$set(a){this.$$set&&0!==Object.keys(a).length&&(this.$$.skip_bound=!0,this.$$set(a),this.$$.skip_bound=!1)}}class ac extends T{constructor(a){super();S(this,a,$b,Zb,Q,{config:6,step:7})}}class pc extends T{constructor(a){super();S(this,a,cc,bc,Q,{step:0})}}class ic extends T{constructor(a){super();S(this,a,ec,dc,Q,{cancelIcon:0,
|
||||||
|
step:2})}}class hc extends T{constructor(a){super();S(this,a,gc,fc,Q,{labelId:1,element:0,title:2})}}class nc extends T{constructor(a){super();S(this,a,kc,jc,Q,{labelId:0,step:1})}}class oc extends T{constructor(a){super();S(this,a,mc,lc,Q,{descriptionId:1,element:0,step:2})}}class tc extends T{constructor(a){super();S(this,a,rc,qc,Q,{descriptionId:0,labelId:1,step:2})}}class Bc extends T{constructor(a){super();S(this,a,uc,sc,Q,{classPrefix:11,element:0,descriptionId:2,firstFocusableElement:8,focusableElements:9,
|
||||||
|
labelId:3,lastFocusableElement:10,step:4,dataStepId:1,getElement:12})}get getElement(){return this.$$.ctx[12]}}var Bb=function(a,b){return b={exports:{}},a(b,b.exports),b.exports}(function(a,b){(function(){a.exports={polyfill:function(){function c(h,t){this.scrollLeft=h;this.scrollTop=t}function d(h){if(null===h||"object"!==typeof h||void 0===h.behavior||"auto"===h.behavior||"instant"===h.behavior)return!0;if("object"===typeof h&&"smooth"===h.behavior)return!1;throw new TypeError("behavior member of ScrollOptions "+
|
||||||
|
h.behavior+" is not a valid value for enumeration ScrollBehavior.");}function e(h,t){if("Y"===t)return h.clientHeight+x<h.scrollHeight;if("X"===t)return h.clientWidth+x<h.scrollWidth}function f(h,t){h=k.getComputedStyle(h,null)["overflow"+t];return"auto"===h||"scroll"===h}function g(h){var t=e(h,"Y")&&f(h,"Y");h=e(h,"X")&&f(h,"X");return t||h}function l(h){var t=(r()-h.startTime)/468;var v=.5*(1-Math.cos(Math.PI*(1<t?1:t)));t=h.startX+(h.x-h.startX)*v;v=h.startY+(h.y-h.startY)*v;h.method.call(h.scrollable,
|
||||||
|
t,v);t===h.x&&v===h.y||k.requestAnimationFrame(l.bind(k,h))}function m(h,t,v){var A=r();if(h===p.body){var u=k;var w=k.scrollX||k.pageXOffset;h=k.scrollY||k.pageYOffset;var y=n.scroll}else u=h,w=h.scrollLeft,h=h.scrollTop,y=c;l({scrollable:u,method:y,startTime:A,startX:w,startY:h,x:t,y:v})}var k=window,p=document;if(!("scrollBehavior"in p.documentElement.style&&!0!==k.__forceSmoothScrollPolyfill__)){var q=k.HTMLElement||k.Element,n={scroll:k.scroll||k.scrollTo,scrollBy:k.scrollBy,elementScroll:q.prototype.scroll||
|
||||||
|
c,scrollIntoView:q.prototype.scrollIntoView},r=k.performance&&k.performance.now?k.performance.now.bind(k.performance):Date.now,x=/MSIE |Trident\/|Edge\//.test(k.navigator.userAgent)?1:0;k.scroll=k.scrollTo=function(h,t){void 0!==h&&(!0===d(h)?n.scroll.call(k,void 0!==h.left?h.left:"object"!==typeof h?h:k.scrollX||k.pageXOffset,void 0!==h.top?h.top:void 0!==t?t:k.scrollY||k.pageYOffset):m.call(k,p.body,void 0!==h.left?~~h.left:k.scrollX||k.pageXOffset,void 0!==h.top?~~h.top:k.scrollY||k.pageYOffset))};
|
||||||
|
k.scrollBy=function(h,t){void 0!==h&&(d(h)?n.scrollBy.call(k,void 0!==h.left?h.left:"object"!==typeof h?h:0,void 0!==h.top?h.top:void 0!==t?t:0):m.call(k,p.body,~~h.left+(k.scrollX||k.pageXOffset),~~h.top+(k.scrollY||k.pageYOffset)))};q.prototype.scroll=q.prototype.scrollTo=function(h,t){if(void 0!==h)if(!0===d(h)){if("number"===typeof h&&void 0===t)throw new SyntaxError("Value could not be converted");n.elementScroll.call(this,void 0!==h.left?~~h.left:"object"!==typeof h?~~h:this.scrollLeft,void 0!==
|
||||||
|
h.top?~~h.top:void 0!==t?~~t:this.scrollTop)}else t=h.left,h=h.top,m.call(this,this,"undefined"===typeof t?this.scrollLeft:~~t,"undefined"===typeof h?this.scrollTop:~~h)};q.prototype.scrollBy=function(h,t){void 0!==h&&(!0===d(h)?n.elementScroll.call(this,void 0!==h.left?~~h.left+this.scrollLeft:~~h+this.scrollLeft,void 0!==h.top?~~h.top+this.scrollTop:~~t+this.scrollTop):this.scroll({left:~~h.left+this.scrollLeft,top:~~h.top+this.scrollTop,behavior:h.behavior}))};q.prototype.scrollIntoView=function(h){if(!0===
|
||||||
|
d(h))n.scrollIntoView.call(this,void 0===h?!0:h);else{for(h=this;h!==p.body&&!1===g(h);)h=h.parentNode||h.host;var t=h.getBoundingClientRect(),v=this.getBoundingClientRect();h!==p.body?(m.call(this,h,h.scrollLeft+v.left-t.left,h.scrollTop+v.top-t.top),"fixed"!==k.getComputedStyle(h).position&&k.scrollBy({left:t.left,top:t.top,behavior:"smooth"})):k.scrollBy({left:v.left,top:v.top,behavior:"smooth"})}}}}}})()});Bb.polyfill;Bb.polyfill();class Ra extends Qa{constructor(a,b){void 0===b&&(b={});super(a,
|
||||||
|
b);this.tour=a;this.classPrefix=this.tour.options?ib(this.tour.options.classPrefix):"";this.styles=a.styles;this._resolvedAttachTo=null;Ua(this);this._setOptions(b);return this}cancel(){this.tour.cancel();this.trigger("cancel")}complete(){this.tour.complete();this.trigger("complete")}destroy(){this.tooltip&&(this.tooltip.destroy(),this.tooltip=null);this.el instanceof HTMLElement&&this.el.parentNode&&(this.el.parentNode.removeChild(this.el),this.el=null);this._updateStepTargetOnHide();this.trigger("destroy")}getTour(){return this.tour}hide(){this.tour.modal.hide();
|
||||||
|
this.trigger("before-hide");this.el&&(this.el.hidden=!0);this._updateStepTargetOnHide();this.trigger("hide")}_resolveAttachToOptions(){let a=this.options.attachTo||{},b=Object.assign({},a);Z(b.element)&&(b.element=b.element.call(this));if(qa(b.element)){try{b.element=document.querySelector(b.element)}catch(c){}b.element||console.error(`The element for this Shepherd step was not found ${a.element}`)}return this._resolvedAttachTo=b}_getResolvedAttachToOptions(){return null===this._resolvedAttachTo?
|
||||||
|
this._resolveAttachToOptions():this._resolvedAttachTo}isOpen(){return!(!this.el||this.el.hidden)}show(){if(Z(this.options.beforeShowPromise)){let a=this.options.beforeShowPromise();if(void 0!==a)return a.then(()=>this._show())}this._show()}updateStepOptions(a){Object.assign(this.options,a);this.shepherdElementComponent&&this.shepherdElementComponent.$set({step:this})}getElement(){return this.el}getTarget(){return this.target}_createTooltipContent(){this.shepherdElementComponent=new Bc({target:this.tour.options.stepsContainer||
|
||||||
|
document.body,props:{classPrefix:this.classPrefix,descriptionId:`${this.id}-description`,labelId:`${this.id}-label`,step:this,styles:this.styles}});return this.shepherdElementComponent.getElement()}_scrollTo(a){let {element:b}=this._getResolvedAttachToOptions();Z(this.options.scrollToHandler)?this.options.scrollToHandler(b):b instanceof Element&&"function"===typeof b.scrollIntoView&&b.scrollIntoView(a)}_getClassOptions(a){var b=this.tour&&this.tour.options&&this.tour.options.defaultStepOptions;b=
|
||||||
|
b&&b.classes?b.classes:"";a=[...(a.classes?a.classes:"").split(" "),...b.split(" ")];a=new Set(a);return Array.from(a).join(" ").trim()}_setOptions(a){void 0===a&&(a={});let b=this.tour&&this.tour.options&&this.tour.options.defaultStepOptions;b=zc({},b||{});this.options=Object.assign({arrow:!0},b,a);let {when:c}=this.options;this.options.classes=this._getClassOptions(a);this.destroy();this.id=this.options.id||`step-${Ma()}`;c&&Object.keys(c).forEach(d=>{this.on(d,c[d],this)})}_setupElements(){void 0!==
|
||||||
|
this.el&&this.destroy();this.el=this._createTooltipContent();this.options.advanceOn&&Gb(this);this.tooltip&&this.tooltip.destroy();let a=this._getResolvedAttachToOptions(),b=a.element,c=Wb(a,this);void 0!==a&&null!==a&&a.element&&a.on||(b=document.body,this.shepherdElementComponent.getElement().classList.add("shepherd-centered"));this.tooltip=Ac(b,this.el,c);this.target=a.element}_show(){this.trigger("before-show");this._resolveAttachToOptions();this._setupElements();this.tour.modal||this.tour._setupModal();
|
||||||
|
this.tour.modal.setupForStep(this);this._styleTargetElementForStep(this);this.el.hidden=!1;this.options.scrollTo&&setTimeout(()=>{this._scrollTo(this.options.scrollTo)});this.el.hidden=!1;let a=this.shepherdElementComponent.getElement(),b=this.target||document.body;b.classList.add(`${this.classPrefix}shepherd-enabled`);b.classList.add(`${this.classPrefix}shepherd-target`);a.classList.add("shepherd-enabled");this.trigger("show")}_styleTargetElementForStep(a){let b=a.target;b&&(a.options.highlightClass&&
|
||||||
|
b.classList.add(a.options.highlightClass),b.classList.remove("shepherd-target-click-disabled"),!1===a.options.canClickTarget&&b.classList.add("shepherd-target-click-disabled"))}_updateStepTargetOnHide(){let a=this.target||document.body;this.options.highlightClass&&a.classList.remove(this.options.highlightClass);a.classList.remove("shepherd-target-click-disabled",`${this.classPrefix}shepherd-enabled`,`${this.classPrefix}shepherd-target`)}}class Cc extends T{constructor(a){super();S(this,a,xc,wc,Q,
|
||||||
|
{element:0,openingProperties:4,getElement:5,closeModalOpening:6,hide:7,positionModal:8,setupForStep:9,show:10})}get getElement(){return this.$$.ctx[5]}get closeModalOpening(){return this.$$.ctx[6]}get hide(){return this.$$.ctx[7]}get positionModal(){return this.$$.ctx[8]}get setupForStep(){return this.$$.ctx[9]}get show(){return this.$$.ctx[10]}}let oa=new Qa;class Dc extends Qa{constructor(a){void 0===a&&(a={});super(a);Ua(this);this.options=Object.assign({},{exitOnEsc:!0,keyboardNavigation:!0},
|
||||||
|
a);this.classPrefix=ib(this.options.classPrefix);this.steps=[];this.addSteps(this.options.steps);"active cancel complete inactive show start".split(" ").map(b=>{(c=>{this.on(c,d=>{d=d||{};d.tour=this;oa.trigger(c,d)})})(b)});this._setTourID();return this}addStep(a,b){a instanceof Ra?a.tour=this:a=new Ra(this,a);void 0!==b?this.steps.splice(b,0,a):this.steps.push(a);return a}addSteps(a){Array.isArray(a)&&a.forEach(b=>{this.addStep(b)});return this}back(){let a=this.steps.indexOf(this.currentStep);
|
||||||
|
this.show(a-1,!1)}cancel(){this.options.confirmCancel?window.confirm(this.options.confirmCancelMessage||"Are you sure you want to stop the tour?")&&this._done("cancel"):this._done("cancel")}complete(){this._done("complete")}getById(a){return this.steps.find(b=>b.id===a)}getCurrentStep(){return this.currentStep}hide(){let a=this.getCurrentStep();if(a)return a.hide()}isActive(){return oa.activeTour===this}next(){let a=this.steps.indexOf(this.currentStep);a===this.steps.length-1?this.complete():this.show(a+
|
||||||
|
1,!0)}removeStep(a){let b=this.getCurrentStep();this.steps.some((c,d)=>{if(c.id===a)return c.isOpen()&&c.hide(),c.destroy(),this.steps.splice(d,1),!0});b&&b.id===a&&(this.currentStep=void 0,this.steps.length?this.show(0):this.cancel())}show(a,b){void 0===a&&(a=0);void 0===b&&(b=!0);if(a=qa(a)?this.getById(a):this.steps[a])this._updateStateBeforeShow(),Z(a.options.showOn)&&!a.options.showOn()?this._skipStep(a,b):(this.trigger("show",{step:a,previous:this.currentStep}),this.currentStep=a,a.show())}start(){this.trigger("start");
|
||||||
|
this.focusedElBeforeOpen=document.activeElement;this.currentStep=null;this._setupModal();this._setupActiveTour();this.next()}_done(a){let b=this.steps.indexOf(this.currentStep);Array.isArray(this.steps)&&this.steps.forEach(c=>c.destroy());vc(this);this.trigger(a,{index:b});oa.activeTour=null;this.trigger("inactive",{tour:this});this.modal&&this.modal.hide();"cancel"!==a&&"complete"!==a||!this.modal||(a=document.querySelector(".shepherd-modal-overlay-container"))&&a.remove();this.focusedElBeforeOpen instanceof
|
||||||
|
HTMLElement&&this.focusedElBeforeOpen.focus()}_setupActiveTour(){this.trigger("active",{tour:this});oa.activeTour=this}_setupModal(){this.modal=new Cc({target:this.options.modalContainer||document.body,props:{classPrefix:this.classPrefix,styles:this.styles}})}_skipStep(a,b){a=this.steps.indexOf(a);a===this.steps.length-1?this.complete():this.show(b?a+1:a-1,b)}_updateStateBeforeShow(){this.currentStep&&this.currentStep.hide();this.isActive()||this._setupActiveTour()}_setTourID(){this.id=`${this.options.tourName||
|
||||||
|
"tour"}--${Ma()}`}}Object.assign(oa,{Tour:Dc,Step:Ra});return oa})
|
||||||
|
//# sourceMappingURL=shepherd.min.js.map
|
|
@ -2,15 +2,13 @@
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from bookwyrm import models
|
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"):
|
def create_generated_note(user, content, mention_books=None, privacy="public"):
|
||||||
"""a note created by the app about user activity"""
|
"""a note created by the app about user activity"""
|
||||||
# sanitize input html
|
# sanitize input html
|
||||||
parser = InputHtmlParser()
|
content = sanitizer.clean(content)
|
||||||
parser.feed(content)
|
|
||||||
content = parser.get_output()
|
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# create but don't save
|
# create but don't save
|
||||||
|
|
|
@ -7,7 +7,7 @@ from django.db.models import signals, Count, Q, Case, When, IntegerField
|
||||||
|
|
||||||
from bookwyrm import models
|
from bookwyrm import models
|
||||||
from bookwyrm.redis_store import RedisStore, r
|
from bookwyrm.redis_store import RedisStore, r
|
||||||
from bookwyrm.tasks import app
|
from bookwyrm.tasks import app, LOW, MEDIUM
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
@ -237,41 +237,41 @@ def domain_level_update(sender, instance, created, update_fields=None, **kwargs)
|
||||||
# ------------------- TASKS
|
# ------------------- TASKS
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def rerank_suggestions_task(user_id):
|
def rerank_suggestions_task(user_id):
|
||||||
"""do the hard work in celery"""
|
"""do the hard work in celery"""
|
||||||
suggested_users.rerank_user_suggestions(user_id)
|
suggested_users.rerank_user_suggestions(user_id)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def rerank_user_task(user_id, update_only=False):
|
def rerank_user_task(user_id, update_only=False):
|
||||||
"""do the hard work in celery"""
|
"""do the hard work in celery"""
|
||||||
user = models.User.objects.get(id=user_id)
|
user = models.User.objects.get(id=user_id)
|
||||||
suggested_users.rerank_obj(user, update_only=update_only)
|
suggested_users.rerank_obj(user, update_only=update_only)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def remove_user_task(user_id):
|
def remove_user_task(user_id):
|
||||||
"""do the hard work in celery"""
|
"""do the hard work in celery"""
|
||||||
user = models.User.objects.get(id=user_id)
|
user = models.User.objects.get(id=user_id)
|
||||||
suggested_users.remove_object_from_related_stores(user)
|
suggested_users.remove_object_from_related_stores(user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="medium_priority")
|
@app.task(queue=MEDIUM)
|
||||||
def remove_suggestion_task(user_id, suggested_user_id):
|
def remove_suggestion_task(user_id, suggested_user_id):
|
||||||
"""remove a specific user from a specific user's suggestions"""
|
"""remove a specific user from a specific user's suggestions"""
|
||||||
suggested_user = models.User.objects.get(id=suggested_user_id)
|
suggested_user = models.User.objects.get(id=suggested_user_id)
|
||||||
suggested_users.remove_suggestion(user_id, suggested_user)
|
suggested_users.remove_suggestion(user_id, suggested_user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def bulk_remove_instance_task(instance_id):
|
def bulk_remove_instance_task(instance_id):
|
||||||
"""remove a bunch of users from recs"""
|
"""remove a bunch of users from recs"""
|
||||||
for user in models.User.objects.filter(federated_server__id=instance_id):
|
for user in models.User.objects.filter(federated_server__id=instance_id):
|
||||||
suggested_users.remove_object_from_related_stores(user)
|
suggested_users.remove_object_from_related_stores(user)
|
||||||
|
|
||||||
|
|
||||||
@app.task(queue="low_priority")
|
@app.task(queue=LOW)
|
||||||
def bulk_add_instance_task(instance_id):
|
def bulk_add_instance_task(instance_id):
|
||||||
"""remove a bunch of users from recs"""
|
"""remove a bunch of users from recs"""
|
||||||
for user in models.User.objects.filter(federated_server__id=instance_id):
|
for user in models.User.objects.filter(federated_server__id=instance_id):
|
||||||
|
|
|
@ -23,7 +23,9 @@
|
||||||
<p class="subtitle notification has-background-primary-highlight">
|
<p class="subtitle notification has-background-primary-highlight">
|
||||||
{% blocktrans trimmed with site_name=site.name %}
|
{% blocktrans trimmed with site_name=site.name %}
|
||||||
{{ site_name }} is part of <em>BookWyrm</em>, a network of independent, self-directed communities for readers.
|
{{ site_name }} is part of <em>BookWyrm</em>, a network of independent, self-directed communities for readers.
|
||||||
While you can interact seamlessly with users anywhere in the <a href="https://joinbookwyrm.com/instances/" target="_blank">BookWyrm network</a>, this community is unique.
|
While you can interact seamlessly with users anywhere in the
|
||||||
|
<a href="https://joinbookwyrm.com/instances/" target="_blank" rel="nofollow noopener noreferrer">BookWyrm network</a>,
|
||||||
|
this community is unique.
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -88,7 +90,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
{% trans "Track your reading, talk about books, write reviews, and discover what to read next. Always ad-free, anti-corporate, and community-oriented, BookWyrm is human-scale software, designed to stay small and personal. If you have feature requests, bug reports, or grand dreams, <a href='https://joinbookwyrm.com/get-involved' target='_blank'>reach out</a> and make yourself heard." %}
|
{% blocktrans trimmed %}
|
||||||
|
Track your reading, talk about books, write reviews, and discover what to read next. Always ad-free, anti-corporate, and community-oriented, BookWyrm is human-scale software, designed to stay small and personal.
|
||||||
|
If you have feature requests, bug reports, or grand dreams, <a href="https://joinbookwyrm.com/get-involved" target="_blank" rel="nofollow noopener noreferrer">reach out</a> and make yourself heard.
|
||||||
|
{% endblocktrans %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
{% load markdown %}
|
{% load markdown %}
|
||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
{% load utilities %}
|
{% load utilities %}
|
||||||
|
{% load book_display_tags %}
|
||||||
|
|
||||||
{% block title %}{{ author.name }}{% endblock %}
|
{% block title %}{{ author.name }}{% endblock %}
|
||||||
|
|
||||||
|
@ -66,7 +67,7 @@
|
||||||
<div class="box">
|
<div class="box">
|
||||||
{% if author.wikipedia_link %}
|
{% if author.wikipedia_link %}
|
||||||
<div>
|
<div>
|
||||||
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="noopener noreferrer" target="_blank">
|
<a itemprop="sameAs" href="{{ author.wikipedia_link }}" rel="nofollow noopener noreferrer" target="_blank">
|
||||||
{% trans "Wikipedia" %}
|
{% trans "Wikipedia" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -74,7 +75,7 @@
|
||||||
|
|
||||||
{% if author.isni %}
|
{% if author.isni %}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<a itemprop="sameAs" href="{{ author.isni_link }}" rel="noopener noreferrer" target="_blank">
|
<a itemprop="sameAs" href="{{ author.isni_link }}" rel="nofollow noopener noreferrer" target="_blank">
|
||||||
{% trans "View ISNI record" %}
|
{% trans "View ISNI record" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -83,7 +84,7 @@
|
||||||
{% trans "Load data" as button_text %}
|
{% trans "Load data" as button_text %}
|
||||||
{% if author.openlibrary_key %}
|
{% if author.openlibrary_key %}
|
||||||
<div class="mt-1 is-flex">
|
<div class="mt-1 is-flex">
|
||||||
<a class="mr-3" itemprop="sameAs" href="{{ author.openlibrary_link }}" target="_blank" rel="noopener noreferrer">
|
<a class="mr-3" itemprop="sameAs" href="{{ author.openlibrary_link }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on OpenLibrary" %}
|
{% trans "View on OpenLibrary" %}
|
||||||
</a>
|
</a>
|
||||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||||
|
@ -98,7 +99,7 @@
|
||||||
|
|
||||||
{% if author.inventaire_id %}
|
{% if author.inventaire_id %}
|
||||||
<div class="mt-1 is-flex">
|
<div class="mt-1 is-flex">
|
||||||
<a class="mr-3" itemprop="sameAs" href="{{ author.inventaire_link }}" target="_blank" rel="noopener noreferrer">
|
<a class="mr-3" itemprop="sameAs" href="{{ author.inventaire_link }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on Inventaire" %}
|
{% trans "View on Inventaire" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@ -114,7 +115,7 @@
|
||||||
|
|
||||||
{% if author.librarything_key %}
|
{% if author.librarything_key %}
|
||||||
<div class="mt-1">
|
<div class="mt-1">
|
||||||
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="noopener noreferrer">
|
<a itemprop="sameAs" href="https://www.librarything.com/author/{{ author.librarything_key }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on LibraryThing" %}
|
{% trans "View on LibraryThing" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -122,7 +123,7 @@
|
||||||
|
|
||||||
{% if author.goodreads_key %}
|
{% if author.goodreads_key %}
|
||||||
<div>
|
<div>
|
||||||
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="noopener noreferrer">
|
<a itemprop="sameAs" href="https://www.goodreads.com/author/show/{{ author.goodreads_key }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on Goodreads" %}
|
{% trans "View on Goodreads" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -141,7 +142,7 @@
|
||||||
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
|
<h2 class="title is-4">{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}</h2>
|
||||||
<div class="columns is-multiline is-mobile">
|
<div class="columns is-multiline is-mobile">
|
||||||
{% for book in books %}
|
{% for book in books %}
|
||||||
{% with book=book.default_edition %}
|
{% with book=book|author_edition:author %}
|
||||||
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
|
<div class="column is-one-fifth-tablet is-half-mobile is-flex is-flex-direction-column">
|
||||||
<div class="is-flex-grow-1">
|
<div class="is-flex-grow-1">
|
||||||
{% include 'landing/small-book.html' with book=book %}
|
{% include 'landing/small-book.html' with book=book %}
|
||||||
|
|
|
@ -113,7 +113,7 @@
|
||||||
|
|
||||||
{% include 'snippets/rate_action.html' with user=request.user book=book %}
|
{% include 'snippets/rate_action.html' with user=request.user book=book %}
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3" id="tour-shelve-button">
|
||||||
{% include 'snippets/shelve_button/shelve_button.html' %}
|
{% include 'snippets/shelve_button/shelve_button.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -131,7 +131,7 @@
|
||||||
{% trans "Load data" as button_text %}
|
{% trans "Load data" as button_text %}
|
||||||
{% if book.openlibrary_key %}
|
{% if book.openlibrary_key %}
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ book.openlibrary_link }}" target="_blank" rel="noopener noreferrer">
|
<a href="{{ book.openlibrary_link }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on OpenLibrary" %}
|
{% trans "View on OpenLibrary" %}
|
||||||
</a>
|
</a>
|
||||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||||
|
@ -145,7 +145,7 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if book.inventaire_id %}
|
{% if book.inventaire_id %}
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ book.inventaire_link }}" target="_blank" rel="noopener noreferrer">
|
<a href="{{ book.inventaire_link }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||||
{% trans "View on Inventaire" %}
|
{% trans "View on Inventaire" %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
@ -210,7 +210,7 @@
|
||||||
|
|
||||||
{% with work=book.parent_work %}
|
{% with work=book.parent_work %}
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ work.local_path }}/editions">
|
<a href="{{ work.local_path }}/editions" id="tour-other-editions-link">
|
||||||
{% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %}
|
{% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %}
|
||||||
{{ count }} edition
|
{{ count }} edition
|
||||||
{% plural %}
|
{% plural %}
|
||||||
|
@ -254,7 +254,7 @@
|
||||||
<h2 class="title is-5">{% trans "Your reading activity" %}</h2>
|
<h2 class="title is-5">{% trans "Your reading activity" %}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-narrow">
|
<div class="column is-narrow">
|
||||||
<button class="button is-small" data-modal-open="add-readthrough">
|
<button class="button is-small" data-modal-open="add-readthrough" id="tour-add-readthrough">
|
||||||
<span class="icon icon-plus m-mobile-0" aria-hidden="true"></span>
|
<span class="icon icon-plus m-mobile-0" aria-hidden="true"></span>
|
||||||
<span class="is-sr-only-mobile">
|
<span class="is-sr-only-mobile">
|
||||||
{% trans "Add read dates" %}
|
{% trans "Add read dates" %}
|
||||||
|
@ -392,7 +392,7 @@
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<section class="content block">
|
<section class="content block" id="tour-book-file-links">
|
||||||
{% include "book/file_links/links.html" %}
|
{% include "book/file_links/links.html" %}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
@ -405,4 +405,7 @@
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{% static "js/tabs.js" %}?v={{ js_cache }}"></script>
|
<script src="{% static "js/tabs.js" %}?v={{ js_cache }}"></script>
|
||||||
<script src="{% static "js/autocomplete.js" %}?v={{ js_cache }}"></script>
|
<script src="{% static "js/autocomplete.js" %}?v={{ js_cache }}"></script>
|
||||||
|
{% if request.user.show_guided_tour %}
|
||||||
|
{% include 'guided_tour/book.html' %}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -78,9 +78,13 @@
|
||||||
<p class="help ml-5 mb-2">
|
<p class="help ml-5 mb-2">
|
||||||
{% with book_title=match.book_set.first.title alt_title=match.bio %}
|
{% with book_title=match.book_set.first.title alt_title=match.bio %}
|
||||||
{% if book_title %}
|
{% if book_title %}
|
||||||
<a href="{{ match.local_path }}" target="_blank">{% trans "Author of " %}<em>{{ book_title }}</em></a>
|
<a href="{{ match.local_path }}" target="_blank" rel="nofollow noopener noreferrer">{% blocktrans trimmed %}
|
||||||
|
Author of <em>{{ book_title }}</em>
|
||||||
|
{% endblocktrans %}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{{ match.id }}" target="_blank">{% if alt_title %}{% trans "Author of " %}<em>{{ alt_title }}</em>{% else %} {% trans "Find more information at isni.org" %}{% endif %}</a>
|
<a href="{{ match.id }}" target="_blank" rel="nofollow noopener noreferrer">{% if alt_title %}{% blocktrans trimmed %}
|
||||||
|
Author of <em>{{ alt_title }}</em>
|
||||||
|
{% endblocktrans %}{% else %}{% trans "Find more information at isni.org" %}{% endif %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -39,10 +39,14 @@
|
||||||
{% for link in links %}
|
{% for link in links %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="overflow-wrap-anywhere">
|
<td class="overflow-wrap-anywhere">
|
||||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer">{{ link.url }}</a>
|
<a href="{{ link.url }}" target="_blank" rel="nofollow noopener noreferrer">{{ link.url }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
{% if link.added_by %}
|
||||||
<a href="{% url 'user-feed' link.added_by.id %}">{{ link.added_by.display_name }}</a>
|
<a href="{% url 'user-feed' link.added_by.id %}">{{ link.added_by.display_name }}</a>
|
||||||
|
{% else %}
|
||||||
|
<em>{% trans "Unknown user" %}</em>
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{ link.filelink.filetype }}
|
{{ link.filelink.filetype }}
|
||||||
|
@ -50,7 +54,7 @@
|
||||||
<td>
|
<td>
|
||||||
{{ link.domain.name }}
|
{{ link.domain.name }}
|
||||||
<p>
|
<p>
|
||||||
<a href="{% url 'report-link' link.added_by.id link.id %}">{% trans "Report spam" %}</a>
|
<a href="{% url 'report-link' link.id %}">{% trans "Report spam" %}</a>
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
|
@ -28,7 +28,7 @@
|
||||||
{% for link in links.all %}
|
{% for link in links.all %}
|
||||||
{% join "verify" link.id as verify_modal %}
|
{% join "verify" link.id as verify_modal %}
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ link.url }}" rel="noopener noreferrer" target="_blank" title="{{ link.url }}" data-modal-open="{{ verify_modal }}">{{ link.name }}</a>
|
<a href="{{ link.url }}" rel="nofollow noopener noreferrer" target="_blank" title="{{ link.url }}" data-modal-open="{{ verify_modal }}">{{ link.name }}</a>
|
||||||
({{ link.filetype }})
|
({{ link.filetype }})
|
||||||
|
|
||||||
{% if link.availability != "free" %}
|
{% if link.availability != "free" %}
|
||||||
|
|
|
@ -19,11 +19,11 @@ Is that where you'd like to go?
|
||||||
{% block modal-footer %}
|
{% block modal-footer %}
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated %}
|
||||||
<div class="is-flex-grow-1">
|
<div class="is-flex-grow-1">
|
||||||
<a href="{% url 'report-link' link.added_by.id link.id %}">{% trans "Report spam" %}</a>
|
<a href="{% url 'report-link' link.id %}">{% trans "Report spam" %}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" class="button is-primary">{% trans "Continue" %}</a>
|
<a href="{{ link.url }}" target="_blank" rel="nofollow noopener noreferrer" noreferrer" class="button is-primary">{% trans "Continue" %}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -19,16 +19,8 @@
|
||||||
name="email"
|
name="email"
|
||||||
class="input"
|
class="input"
|
||||||
id="email"
|
id="email"
|
||||||
aria-described-by="id_email_errors"
|
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
{% if error %}
|
|
||||||
<div id="id_email_errors">
|
|
||||||
<p class="help is-danger">
|
|
||||||
{% trans "No user matching this email address found." %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -3,7 +3,19 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>
|
<p>
|
||||||
{% 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 %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% trans "View report" as text %}
|
{% trans "View report" as text %}
|
||||||
|
|
|
@ -2,7 +2,15 @@
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% block content %}
|
{% 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" %}
|
{% trans "View report" %}
|
||||||
{{ report_link }}
|
{{ report_link }}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
{% 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 %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="block">
|
<section class="block">
|
||||||
|
@ -30,3 +30,4 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
{% extends 'feed/layout.html' %}
|
{% extends 'feed/layout.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block panel %}
|
{% block panel %}
|
||||||
|
|
||||||
|
@ -73,3 +74,12 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{% static "js/tabs.js" %}?v={{ js_cache }}"></script>
|
||||||
|
|
||||||
|
{% if request.user.show_guided_tour %}
|
||||||
|
{% include 'guided_tour/home.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
{% extends 'layout.html' %}
|
{% extends 'layout.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}{% trans "Updates" %}{% endblock %}
|
{% block title %}{% trans "Updates" %}{% endblock %}
|
||||||
|
|
||||||
|
@ -30,6 +29,4 @@
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script src="{% static "js/tabs.js" %}?v={{ js_cache }}"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
{% load feed_page_tags %}
|
{% load feed_page_tags %}
|
||||||
|
|
||||||
{% suggested_books as suggested_books %}
|
{% suggested_books as suggested_books %}
|
||||||
<section class="block">
|
<section id="tour-suggested-books" class="block">
|
||||||
<h2 class="title is-4">{% trans "Your Books" %}</h2>
|
<h2 class="title is-4">{% trans "Your Books" %}</h2>
|
||||||
{% if not suggested_books %}
|
{% if not suggested_books %}
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<div class="column is-two-thirds">
|
<div class="column is-two-thirds">
|
||||||
<input type="hidden" name="user" value="{{ request.user.id }}" />
|
<input type="hidden" name="user" value="{{ request.user.id }}" />
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label" for="group_form_id_name">{% trans "Group Name:" %}</label>
|
<label class="label" for="group_form_id_name" id="tour-group-name">{% trans "Group Name:" %}</label>
|
||||||
{{ group_form.name }}
|
{{ group_form.name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|
|
@ -22,7 +22,7 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% if request.user.is_authenticated and group|is_member:request.user %}
|
{% if request.user.is_authenticated and group|is_member:request.user %}
|
||||||
<div class="column is-narrow is-flex">
|
<div class="column is-narrow is-flex" id="tour-create-list">
|
||||||
{% trans "Create List" as button_text %}
|
{% trans "Create List" as button_text %}
|
||||||
{% include 'snippets/toggle/open_button.html' with controls_text="create_list" icon_with_text="plus" text=button_text focus="create_list_header" %}
|
{% include 'snippets/toggle/open_button.html' with controls_text="create_list" icon_with_text="plus" text=button_text focus="create_list_header" %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -80,3 +80,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if request.user.show_guided_tour %}
|
||||||
|
{% include 'guided_tour/group.html' %}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
|
@ -10,7 +10,7 @@
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<input type="text" name="user_query" value="{{ request.GET.user_query }}" class="input" placeholder="{% trans 'Search to add a user' %}" aria-label="{% trans 'Search to add a user' %}">
|
<input type="text" name="user_query" value="{{ request.GET.user_query }}" class="input" placeholder="{% trans 'Search to add a user' %}" aria-label="{% trans 'Search to add a user' %}">
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control" id="tour-group-member-search">
|
||||||
<button class="button" type="submit">
|
<button class="button" type="submit">
|
||||||
<span class="icon icon-search" title="{% trans 'Search' %}">
|
<span class="icon icon-search" title="{% trans 'Search' %}">
|
||||||
<span class="is-sr-only">{% trans "Search" %}</span>
|
<span class="is-sr-only">{% trans "Search" %}</span>
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
<span title="@{{ member|username }}" class="is-block pb-3">@{{ member|username|truncatechars:8 }}</span>
|
<span title="@{{ member|username }}" class="is-block pb-3">@{{ member|username|truncatechars:8 }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% if group.user == member %}
|
{% if group.user == member %}
|
||||||
<span class="icon icon-star-full" title="Manager">
|
<span class="icon icon-star-full" title="Manager" id="tour-group-owner">
|
||||||
<span class="is-sr-only">Manager</span>
|
<span class="is-sr-only">Manager</span>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
303
bookwyrm/templates/guided_tour/book.html
Normal file
303
bookwyrm/templates/guided_tour/book.html
Normal file
|
@ -0,0 +1,303 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const tour = new Shepherd.Tour({
|
||||||
|
exitOnEsc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tour.addSteps([
|
||||||
|
{
|
||||||
|
text: `{% trans "This is home page of a book. Let's see what you can do while you're here!" %}`,
|
||||||
|
title: "{% trans 'Book page' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'End Tour' %}",
|
||||||
|
classes: "is-danger",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'This is where you can set a reading status for this book. You can press the button to move to the next stage, or use the drop down button to select the reading status you want to set.' %}",
|
||||||
|
title: "{% trans 'Reading status' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-shelve-button",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "You can also manually add reading dates here. Unlike changing the reading status using the previous method, adding dates manually will not automatically add them to your <strong>Read</strong> or <strong>Reading</strong> shelves." %}<br><br>{% trans "Got a favourite you re-read every year? We've got you covered - you can add multiple read dates for the same book 😀" %}`,
|
||||||
|
title: "{% trans 'Add read dates' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-add-readthrough",
|
||||||
|
on: "top",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'There can be multiple editions of a book, in various formats or languages. You can choose which edition you want to use.' %}",
|
||||||
|
title: "{% trans 'Other editions' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-other-editions-link",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'You can post a review, comment, or quote here.' %}",
|
||||||
|
title: "{% trans 'Share your thoughts' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: ".tour-review-comment-quote",
|
||||||
|
on: "top",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'If you have read this book you can post a review including an optional star rating' %}",
|
||||||
|
title: "{% trans 'Post a review' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "[id^=tab_review]",
|
||||||
|
on: "top",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'You can share your thoughts on this book generally with a simple comment' %}",
|
||||||
|
title: "{% trans 'Post a comment' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "[id^=tab_comment]",
|
||||||
|
on: "top",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Just read some perfect prose? Let the world know by sharing a quote!' %}",
|
||||||
|
title: "{% trans 'Share a quote' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "[id^=tab_quote]",
|
||||||
|
on: "top",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "If your review or comment might ruin the book for someone who hasn't read it yet, you can hide your post behind a <strong>spoiler alert</strong>" %}`,
|
||||||
|
title: "{% trans 'Spoiler alerts' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "",
|
||||||
|
element: "[id^=form_review] > .tour-spoiler-alert",
|
||||||
|
on: "top",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "Choose who can see your post here. Post privacy can be <strong>Public</strong> (everyone can see), <strong>Unlisted</strong> (everyone can see, but it doesn't appear in public feeds or discovery pages), <strong>Followers</strong> (only your followers can see), or <strong>Private</strong> (only you can see)" %}`,
|
||||||
|
title: "{% trans 'Post privacy' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "[id^=form_review] [id^=privacy_]",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Some ebooks can be downloaded for free from external sources. They will be shown here.' %}",
|
||||||
|
title: "{% trans 'Download links' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-book-file-links",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '<p class="notification is-warning is-light mt-3">{% trans "Continue the tour by selecting <strong>Your books</strong> from the drop down menu." %}</p>',
|
||||||
|
title: "{% trans 'Next' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: () => {
|
||||||
|
let menu = document.querySelector('#navbar-dropdown')
|
||||||
|
let display = window.getComputedStyle(menu).display;
|
||||||
|
return display == 'flex' ? '#navbar-dropdown' : '.navbar-burger';
|
||||||
|
},
|
||||||
|
on: "left-end",
|
||||||
|
},
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Ok' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
tour.start()
|
||||||
|
</script>
|
122
bookwyrm/templates/guided_tour/group.html
Normal file
122
bookwyrm/templates/guided_tour/group.html
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const tour = new Shepherd.Tour({
|
||||||
|
exitOnEsc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tour.addSteps([
|
||||||
|
{
|
||||||
|
text: "{% trans 'Welcome to the page for your group! This is where you can add and remove users, create user-curated lists, and edit the group details.' %}",
|
||||||
|
title: "{% trans 'Your group' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'End Tour' %}",
|
||||||
|
classes: "is-danger guided-tour-cancel-button",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Use this search box to find users to join your group. Currently users must be members of the same Bookwyrm instance and be invited by the group owner.' %}",
|
||||||
|
title: "{% trans 'Find users' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-group-member-search",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Your group members will appear here. The group owner is marked with a star symbol.' %}",
|
||||||
|
title: "{% trans 'Group members' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-group-owner",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "As well as creating lists from the Lists page, you can create a group-curated list here on the group's homepage. Any member of the group can create a list curated by group members." %}"`,
|
||||||
|
title: "{% trans 'Group lists' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-create-list",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "Congratulations, you've finished the tour! Now you know the basics, but there is lots more to explore on your own. Happy reading!" %}`,
|
||||||
|
title: "{% trans 'Finish' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'End tour' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
tour.start()
|
||||||
|
</script>
|
225
bookwyrm/templates/guided_tour/home.html
Normal file
225
bookwyrm/templates/guided_tour/home.html
Normal file
|
@ -0,0 +1,225 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const initiateTour = new Shepherd.Tour({
|
||||||
|
exitOnEsc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkResponsiveState(anchor) {
|
||||||
|
let menu = document.querySelector('#navbar-dropdown');
|
||||||
|
let display = window.getComputedStyle(menu).display;
|
||||||
|
return display == 'flex' ? anchor : '.navbar-burger';
|
||||||
|
}
|
||||||
|
|
||||||
|
initiateTour.addSteps([
|
||||||
|
{
|
||||||
|
text: "{% trans 'Welcome to Bookwyrm!<br><br>Would you like to take the guided tour to help you get started?' %}",
|
||||||
|
title: "{% trans 'Guided Tour' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'No thanks' %}",
|
||||||
|
classes: "is-danger",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
this.cancel();
|
||||||
|
return homeTour.start()
|
||||||
|
},
|
||||||
|
text: "{% trans 'Yes please!' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'If you ever change your mind, just click on the Guided Tour link to start your tour' %}",
|
||||||
|
title: "{% trans 'Guided Tour' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-begin",
|
||||||
|
on: "left-start",
|
||||||
|
},
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.complete()
|
||||||
|
},
|
||||||
|
text: "{% trans 'Ok' %}",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
const homeTour = new Shepherd.Tour({
|
||||||
|
exitOnEsc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
homeTour.addSteps([
|
||||||
|
{
|
||||||
|
text: "{% trans 'Search for books, users, or lists using this search box.' %}",
|
||||||
|
title: "{% trans 'Search box' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-search",
|
||||||
|
on: "bottom",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "Search book records by scanning an ISBN barcode using your device's camera - great when you're in the bookstore or library!" %}`,
|
||||||
|
title: "{% trans 'Barcode reader' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-barcode",
|
||||||
|
on: "bottom",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Use the <strong>Feed</strong>, <strong>Lists</strong> and <strong>Discover</strong> links to discover the latest news from your feed, lists of books by topic, and the latest happenings on this Bookwyrm server!' %}",
|
||||||
|
title: "{% trans 'Navigation Bar' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: checkResponsiveState('#tour-navbar-start'),
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Books on your reading status shelves will be shown here.' %}",
|
||||||
|
title: "{% trans 'Your Books' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-suggested-books",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Updates from people you are following will appear in your <strong>Home</strong> timeline.<br><br>The <strong>Books</strong> tab shows activity from anyone, related to your books.' %}",
|
||||||
|
title: "{% trans 'Timelines' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#feed",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'The bell will light up when you have a new notification. When it does, click on it to find out what exciting thing has happened!' %}",
|
||||||
|
title: "{% trans 'Notifications' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: checkResponsiveState('#tour-notifications'),
|
||||||
|
on: "left-end",
|
||||||
|
},
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "Your profile, books, direct messages, and settings can be accessed by clicking on your name in the menu here." %} <p class="notification is-warning is-light mt-3">{% trans "Try selecting <strong>Profile</strong> from the drop down menu to continue the tour." %}</p>`,
|
||||||
|
title: "{% trans 'Profile and settings menu' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: checkResponsiveState('#navbar-dropdown'),
|
||||||
|
on: "left-end",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Ok' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
initiateTour.start()
|
||||||
|
</script>
|
150
bookwyrm/templates/guided_tour/lists.html
Normal file
150
bookwyrm/templates/guided_tour/lists.html
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
{% load i18n %}
|
||||||
|
{% load utilities %}
|
||||||
|
{% load user_page_tags %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
const tour = new Shepherd.Tour({
|
||||||
|
exitOnEsc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tour.addSteps([
|
||||||
|
{
|
||||||
|
text: "{% trans 'This is the lists page where you can discover book lists created by any user. A List is a collection of books, similar to a shelf.' %}<br><br>{% trans 'Shelves are for organising books for yourself, whereas Lists are generally for sharing with others.' %}",
|
||||||
|
title: "{% trans 'Lists' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'End Tour' %}",
|
||||||
|
classes: "is-danger",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "Let's see how to create a new list." %}<p class="notification is-warning is-light mt-3">{% trans "Click the <strong>Create List</strong> button, then <strong>Next</strong> to continue the tour" %}</p>`,
|
||||||
|
title: "{% trans 'Creating a new list' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-create-list",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'You must give your list a name and can optionally give it a description to help other people understand what your list is about.' %}",
|
||||||
|
title: "{% trans 'Creating a new list' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-list-name",
|
||||||
|
on: "top",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Choose who can see your list here. List privacy options work just like we saw when posting book reviews. This is a common pattern throughout Bookwyrm.' %}",
|
||||||
|
title: "{% trans 'List privacy' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-privacy-select",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'You can also decide how your list is to be curated - only by you, by anyone, or by a group.' %}",
|
||||||
|
title: "{% trans 'List curation' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-list-curation",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Next in our tour we will explore Groups!' %}",
|
||||||
|
title: "{% trans 'Next: Groups' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
this.complete();
|
||||||
|
window.location = "{% url 'user-groups' user|username %}"
|
||||||
|
},
|
||||||
|
text: "{% trans 'Take me there' %}"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
tour.start()
|
||||||
|
</script>
|
167
bookwyrm/templates/guided_tour/search.html
Normal file
167
bookwyrm/templates/guided_tour/search.html
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
let localResult = document.querySelector(".local-book-search-result");
|
||||||
|
let remoteResult = document.querySelector(".remote-book-search-result");
|
||||||
|
let otherCatalogues = document.querySelector("#tour-load-from-other-catalogues");
|
||||||
|
let manuallyAdd = document.querySelector("#tour-manually-add-book");
|
||||||
|
const tour = new Shepherd.Tour({
|
||||||
|
exitOnEsc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (remoteResult) {
|
||||||
|
tour.addStep(
|
||||||
|
{
|
||||||
|
text: "{% trans 'If the book you are looking for is available on a remote catalogue such as Open Library, click on <strong>Import book</strong>.' %}",
|
||||||
|
title: "{% trans 'Searching' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-remote-search-result",
|
||||||
|
on: "top",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'End Tour' %}",
|
||||||
|
classes: "is-danger guided-tour-cancel-button",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else if (localResult) {
|
||||||
|
tour.addStep(
|
||||||
|
{
|
||||||
|
text: `{% trans "If the book you are looking for is already on this Bookwyrm instance, you can click on the title to go to the book's page." %}`,
|
||||||
|
title: "{% trans 'Searching' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-local-book-search-result",
|
||||||
|
on: "top",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'End Tour' %}",
|
||||||
|
classes: "is-danger guided-tour-cancel-button",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otherCatalogues) {
|
||||||
|
tour.addStep({
|
||||||
|
text: "{% trans 'If the book you are looking for is not listed, try loading more records from other sources like Open Library or Inventaire.' %}",
|
||||||
|
title: "{% trans 'Load more records' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-load-from-other-catalogues",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manuallyAdd) {
|
||||||
|
tour.addSteps([
|
||||||
|
{
|
||||||
|
text: "{% trans 'If your book is not in the results, try adjusting your search terms.' %}",
|
||||||
|
title: "{% trans 'Search again' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: '#tour-search-page-input',
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "If you still can't find your book, you can add a record manually." %}`,
|
||||||
|
title: "{% trans 'Add a record manually' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-manually-add-book",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
tour.addStep({
|
||||||
|
text: '<p class="notification is-warning is-light mt-3">{% trans "Import, manually add, or view an existing book to continue the tour." %}<p>',
|
||||||
|
title: "{% trans 'Continue the tour' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Ok' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
tour.start()
|
||||||
|
</script>
|
131
bookwyrm/templates/guided_tour/user_books.html
Normal file
131
bookwyrm/templates/guided_tour/user_books.html
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const tour = new Shepherd.Tour({
|
||||||
|
exitOnEsc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tour.addSteps([
|
||||||
|
{
|
||||||
|
text: "{% trans 'This is the page where your books are listed, organised into shelves.' %}",
|
||||||
|
title: "{% trans 'Your books' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'End Tour' %}",
|
||||||
|
classes: "is-danger",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans '<strong>To Read</strong>, <strong>Currently Reading</strong>, <strong>Read</strong>, and <strong>Stopped Reading</strong> are default shelves. When you change the reading status of a book it will automatically be moved to the matching shelf. A book can only be on one default shelf at a time.' %}",
|
||||||
|
title: "{% trans 'Reading status shelves' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-user-shelves",
|
||||||
|
on: "bottom-start",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'You can create additional custom shelves to organise your books. A book on a custom shelf can be on any number of other shelves simultaneously, including one of the default reading status shelves' %}",
|
||||||
|
title: "{% trans 'Adding custom shelves.' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-create-shelf",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'If you have an export file from another service like Goodreads or LibraryThing, you can import it here.' %}",
|
||||||
|
title: "{% trans 'Import from another service' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-import-books",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "Now that we've explored book shelves, let's take a look at a related concept: book lists!" %}<p class="notification is-warning is-light mt-3">{% trans "Click on the <strong>Lists</strong> link here to continue the tour." %}`,
|
||||||
|
title: "{% trans 'Lists' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: () => {
|
||||||
|
let menu = document.querySelector('#tour-navbar-start')
|
||||||
|
let display = window.getComputedStyle(menu).display;
|
||||||
|
return display == 'flex' ? '#tour-navbar-start' : '.navbar-burger';
|
||||||
|
},
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
this.complete();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Ok' %}"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
tour.start()
|
||||||
|
</script>
|
123
bookwyrm/templates/guided_tour/user_groups.html
Normal file
123
bookwyrm/templates/guided_tour/user_groups.html
Normal file
|
@ -0,0 +1,123 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const tour = new Shepherd.Tour({
|
||||||
|
exitOnEsc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tour.addSteps([
|
||||||
|
{
|
||||||
|
text: "{% trans 'You can create or join a group with other users. Groups can share group-curated book lists, and in future will be able to do other things.' %}",
|
||||||
|
title: "{% trans 'Groups' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'End Tour' %}",
|
||||||
|
classes: "is-danger",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "Let's create a new group!" %}<p class="notification is-warning is-light mt-3">{% trans "Click the <strong>Create group</strong> button, then <strong>Next</strong> to continue the tour" %}</p>`,
|
||||||
|
title: "{% trans 'Create group' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-create-group",
|
||||||
|
on: "left-start",
|
||||||
|
},
|
||||||
|
highlightClass: 'tour-element-highlight',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Give your group a name and describe what it is about. You can make user groups for any purpose - a reading group, a bunch of friends, whatever!' %}",
|
||||||
|
title: "{% trans 'Creating a group' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-group-name",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Groups have privacy settings just like posts and lists, except that group privacy cannot be <strong>Followers</strong>.' %}",
|
||||||
|
title: "{% trans 'Group visibility' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-privacy",
|
||||||
|
on: "left",
|
||||||
|
},
|
||||||
|
scrollTo: true,
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "Once you're happy with how everything is set up, click the <strong>Save</strong> button to create your new group." %}<p class="notification is-warning is-light mt-3">{% trans "Create and save a group to continue the tour." %}</p>`,
|
||||||
|
title: "{% trans 'Save your group' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Ok' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
tour.start()
|
||||||
|
</script>
|
148
bookwyrm/templates/guided_tour/user_profile.html
Normal file
148
bookwyrm/templates/guided_tour/user_profile.html
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const tour = new Shepherd.Tour({
|
||||||
|
exitOnEsc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
tour.addSteps([
|
||||||
|
{
|
||||||
|
text: "{% trans 'This is your user profile. All your latest activities will be listed here. Other Bookwyrm users can see parts of this page too - what they can see depends on your privacy settings.' %}",
|
||||||
|
title: "{% trans 'User Profile' %}",
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
disableGuidedTour(csrf_token);
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'End Tour' %}",
|
||||||
|
classes: "is-danger",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "This tab shows everything you have read towards your annual reading goal, or allows you to set one. You don't have to set a reading goal if that's not your thing!" %}`,
|
||||||
|
title: "{% trans 'Reading Goal' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-reading-goal",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'Here you can see your groups, or create a new one. A group brings together Bookwyrm users and allows them to curate lists together.' %}",
|
||||||
|
title: "{% trans 'Groups' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-groups-tab",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "{% trans 'You can see your lists, or create a new one, here. A list is a collection of books that have something in common.' %}",
|
||||||
|
title: "{% trans 'Lists' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-lists-tab",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "The Books tab shows your book shelves. We'll explore this later in the tour." %}`,
|
||||||
|
title: "{% trans 'Books' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-shelves-tab",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.next();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Next' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: `{% trans "Now you understand the basics of your profile page, let's add a book to your shelves." %}<p class="notification is-warning is-light mt-3">{% trans "Search for a title or author to continue the tour." %}</p>`,
|
||||||
|
title: "{% trans 'Find a book' %}",
|
||||||
|
attachTo: {
|
||||||
|
element: "#tour-search",
|
||||||
|
on: "right",
|
||||||
|
},
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.back();
|
||||||
|
},
|
||||||
|
secondary: true,
|
||||||
|
text: "{% trans 'Back' %}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action() {
|
||||||
|
return this.complete();
|
||||||
|
},
|
||||||
|
text: "{% trans 'Ok' %}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
tour.start()
|
||||||
|
</script>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue