Merge branch 'main' into debug-toolbar

This commit is contained in:
Mouse Reeve 2022-08-02 13:11:51 -07:00
commit 55962f8e53
284 changed files with 33425 additions and 5851 deletions

View file

@ -55,5 +55,6 @@ jobs:
EMAIL_HOST_PASSWORD: ""
EMAIL_USE_TLS: true
ENABLE_PREVIEW_IMAGES: false
ENABLE_THUMBNAIL_GENERATION: true
run: |
pytest -n 3

View file

@ -21,8 +21,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pylint
- name: Analysing the code with pylint
run: |
pylint bookwyrm/ --ignore=migrations --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801
pylint bookwyrm/

9
.pylintrc Normal file
View file

@ -0,0 +1,9 @@
[MAIN]
ignore=migrations
load-plugins=pylint.extensions.no_self_use
[MESSAGES CONTROL]
disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801,C3001,import-error
[FORMAT]
max-line-length=88

View file

@ -6,6 +6,7 @@ RUN mkdir /app /app/static /app/images
WORKDIR /app
RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean
COPY requirements.txt /app/
RUN pip install -r requirements.txt --no-cache-dir
RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean

View file

@ -1,60 +1,45 @@
# 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
- [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.
BookWyrm is a social network for tracking your reading, talking about books, writing reviews, and discovering what to read next. Federation allows BookWyrm users to join small, trusted communities that can connect with one another, and with other ActivityPub services like [Mastodon](https://joinmastodon.org/) and [Pleroma](http://pleroma.social/).
## Contributing
See [contributing](https://docs.joinbookwyrm.com/contributing.html) for code, translation or monetary contributions.
## Links
[![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
### 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.
### The role of federation
## Federation
BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub, it inter-operates with different instances of BookWyrm, and other ActivityPub compliant services, like Mastodon. This means you can run an instance for your book club, and still follow your friend who posts on a server devoted to 20th century Russian speculative fiction. It also means that your friend on mastodon can read and comment on a book review that you post on your BookWyrm instance.
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
### Features
Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/bookwyrm-social/bookwyrm/issues) to get the conversation going!
- Posting about books
- Compose reviews, with or without ratings, which are aggregated in the book page
- Compose other kinds of statuses about books, such as:
- Comments on a book
- Quotes or excerpts
- Reply to statuses
- View aggregate reviews of a book across connected BookWyrm instances
- Differentiate local and federated reviews and rating in your activity feed
- Track reading activity
- Shelve books on default "to-read," "currently reading," and "read" shelves
- Create custom shelves
- Store started reading/finished reading dates, as well as progress updates along the way
- Update followers about reading activity (optionally, and with granular privacy controls)
- Create lists of books which can be open to submissions from anyone, curated, or only edited by the creator
- Federation with ActivityPub
- Broadcast and receive user statuses and activity
- Share book data between instances to create a networked database of metadata
- Identify shared books across instances and aggregate related content
- Follow and interact with users across BookWyrm instances
- Inter-operate with non-BookWyrm ActivityPub services (currently, Mastodon is supported)
- Granular privacy controls
- Private, followers-only, and public privacy levels for posting, shelves, and lists
- Option for users to manually approve followers
- Allow blocking and flagging for moderation
## Features
### The Tech Stack
### Post about books
Compose reviews, comment on what you're reading, and post quotes from books. You can converse with other BookWyrm users across the network about what they're reading.
### Track reading activity
Keep track of what books you've read, and what books you'd like to read in the future.
### Federation with ActivityPub
Federation allows you to interact with users on other instances and services, and also shares metadata about books and authors, which collaboratively builds a decentralized database of books.
### Privacy and moderation
Users and administrators can control who can see thier posts and what other instances to federate with.
## Tech Stack
Web backend
- [Django](https://www.djangoproject.com/) web server
- [PostgreSQL](https://www.postgresql.org/) database

5
SECURITY.md Normal file
View file

@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `mousereeve@riseup.net`

View file

@ -1,6 +1,7 @@
""" basics for an activitypub serializer """
from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder
import logging
from django.apps import apps
from django.db import IntegrityError, transaction
@ -8,6 +9,8 @@ from django.db import IntegrityError, transaction
from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
class ActivitySerializerError(ValueError):
"""routine problems serializing activitypub json"""
@ -65,7 +68,7 @@ class ActivityObject:
try:
value = kwargs[field.name]
if value in (None, MISSING, {}):
raise KeyError()
raise KeyError("Missing required field", field.name)
try:
is_subclass = issubclass(field.type, ActivityObject)
except TypeError:
@ -268,9 +271,9 @@ def resolve_remote_id(
try:
data = get_data(remote_id)
except ConnectorException:
raise ActivitySerializerError(
f"Could not connect to host for remote_id: {remote_id}"
)
logger.exception("Could not connect to host for remote_id: %s", remote_id)
return None
# determine the model implicitly, if not provided
# or if it's a model with subclasses like Status, check again
if not model or hasattr(model.objects, "select_subclasses"):

View file

@ -148,8 +148,8 @@ class SearchResult:
def __repr__(self):
# pylint: disable=consider-using-f-string
return "<SearchResult key={!r} title={!r} author={!r}>".format(
self.key, self.title, self.author
return "<SearchResult key={!r} title={!r} author={!r} confidence={!r}>".format(
self.key, self.title, self.author, self.confidence
)
def json(self):

View file

@ -1,9 +1,8 @@
""" functionality outline for a book data connector """
from abc import ABC, abstractmethod
import imghdr
import ipaddress
import logging
from urllib.parse import urlparse
import re
from django.core.files.base import ContentFile
from django.db import transaction
@ -11,7 +10,7 @@ import requests
from requests.exceptions import RequestException
from bookwyrm import activitypub, models, settings
from .connector_manager import load_more_data, ConnectorException
from .connector_manager import load_more_data, ConnectorException, raise_not_valid_url
from .format_mappings import format_mappings
@ -39,62 +38,34 @@ class AbstractMinimalConnector(ABC):
for field in self_fields:
setattr(self, field, getattr(info, field))
def search(self, query, min_confidence=None, timeout=settings.QUERY_TIMEOUT):
"""free text search"""
params = {}
if min_confidence:
params["min_confidence"] = min_confidence
def get_search_url(self, query):
"""format the query url"""
# Check if the query resembles an ISBN
if maybe_isbn(query) and self.isbn_search_url and self.isbn_search_url != "":
return f"{self.isbn_search_url}{query}"
data = self.get_search_data(
f"{self.search_url}{query}",
params=params,
timeout=timeout,
)
results = []
# NOTE: previously, we tried searching isbn and if that produces no results,
# searched as free text. This, instead, only searches isbn if it's isbn-y
return f"{self.search_url}{query}"
for doc in self.parse_search_data(data)[:10]:
results.append(self.format_search_result(doc))
return results
def isbn_search(self, query, timeout=settings.QUERY_TIMEOUT):
"""isbn search"""
params = {}
data = self.get_search_data(
f"{self.isbn_search_url}{query}",
params=params,
timeout=timeout,
)
results = []
# this shouldn't be returning mutliple results, but just in case
for doc in self.parse_isbn_search_data(data)[:10]:
results.append(self.format_isbn_search_result(doc))
return results
def get_search_data(self, remote_id, **kwargs): # pylint: disable=no-self-use
"""this allows connectors to override the default behavior"""
return get_data(remote_id, **kwargs)
def process_search_response(self, query, data, min_confidence):
"""Format the search results based on the formt of the query"""
if maybe_isbn(query):
return list(self.parse_isbn_search_data(data))[:10]
return list(self.parse_search_data(data, min_confidence))[:10]
@abstractmethod
def get_or_create_book(self, remote_id):
"""pull up a book record by whatever means possible"""
@abstractmethod
def parse_search_data(self, data):
def parse_search_data(self, data, min_confidence):
"""turn the result json from a search into a list"""
@abstractmethod
def format_search_result(self, search_result):
"""create a SearchResult obj from json"""
@abstractmethod
def parse_isbn_search_data(self, data):
"""turn the result json from a search into a list"""
@abstractmethod
def format_isbn_search_result(self, search_result):
"""create a SearchResult obj from json"""
class AbstractConnector(AbstractMinimalConnector):
"""generic book data connector"""
@ -254,9 +225,6 @@ def get_data(url, params=None, timeout=10):
# check if the url is blocked
raise_not_valid_url(url)
if models.FederatedServer.is_blocked(url):
raise ConnectorException(f"Attempting to load data from blocked url: {url}")
try:
resp = requests.get(
url,
@ -311,20 +279,6 @@ def get_image(url, timeout=10):
return image_content, extension
def raise_not_valid_url(url):
"""do some basic reality checks on the url"""
parsed = urlparse(url)
if not parsed.scheme in ["http", "https"]:
raise ConnectorException("Invalid scheme: ", url)
try:
ipaddress.ip_address(parsed.netloc)
raise ConnectorException("Provided url is an IP address: ", url)
except ValueError:
# it's not an IP address, which is good
pass
class Mapping:
"""associate a local database field with a field in an external dataset"""
@ -366,3 +320,9 @@ def unique_physical_format(format_text):
# try a direct match, so saving this would be redundant
return None
return format_text
def maybe_isbn(query):
"""check if a query looks like an isbn"""
isbn = re.sub(r"[\W_]", "", query) # removes filler characters
return len(isbn) in [10, 13] # ISBN10 or ISBN13

View file

@ -10,15 +10,12 @@ class Connector(AbstractMinimalConnector):
def get_or_create_book(self, remote_id):
return activitypub.resolve_remote_id(remote_id, model=models.Edition)
def parse_search_data(self, data):
return data
def format_search_result(self, search_result):
search_result["connector"] = self
return SearchResult(**search_result)
def parse_search_data(self, data, min_confidence):
for search_result in data:
search_result["connector"] = self
yield SearchResult(**search_result)
def parse_isbn_search_data(self, data):
return data
def format_isbn_search_result(self, search_result):
return self.format_search_result(search_result)
for search_result in data:
search_result["connector"] = self
yield SearchResult(**search_result)

View file

@ -1,17 +1,18 @@
""" interface with whatever connectors the app has """
from datetime import datetime
import asyncio
import importlib
import ipaddress
import logging
import re
from urllib.parse import urlparse
import aiohttp
from django.dispatch import receiver
from django.db.models import signals
from requests import HTTPError
from bookwyrm import book_search, models
from bookwyrm.settings import SEARCH_TIMEOUT
from bookwyrm.settings import SEARCH_TIMEOUT, USER_AGENT
from bookwyrm.tasks import app
logger = logging.getLogger(__name__)
@ -21,53 +22,85 @@ class ConnectorException(HTTPError):
"""when the connector can't do what was asked"""
async def get_results(session, url, min_confidence, query, connector):
"""try this specific connector"""
# pylint: disable=line-too-long
headers = {
"Accept": (
'application/json, application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'
),
"User-Agent": USER_AGENT,
}
params = {"min_confidence": min_confidence}
try:
async with session.get(url, headers=headers, params=params) as response:
if not response.ok:
logger.info("Unable to connect to %s: %s", url, response.reason)
return
try:
raw_data = await response.json()
except aiohttp.client_exceptions.ContentTypeError as err:
logger.exception(err)
return
return {
"connector": connector,
"results": connector.process_search_response(
query, raw_data, min_confidence
),
}
except asyncio.TimeoutError:
logger.info("Connection timed out for url: %s", url)
except aiohttp.ClientError as err:
logger.info(err)
async def async_connector_search(query, items, min_confidence):
"""Try a number of requests simultaneously"""
timeout = aiohttp.ClientTimeout(total=SEARCH_TIMEOUT)
async with aiohttp.ClientSession(timeout=timeout) as session:
tasks = []
for url, connector in items:
tasks.append(
asyncio.ensure_future(
get_results(session, url, min_confidence, query, connector)
)
)
results = await asyncio.gather(*tasks)
return results
def search(query, min_confidence=0.1, return_first=False):
"""find books based on arbitary keywords"""
if not query:
return []
results = []
# Have we got a ISBN ?
isbn = re.sub(r"[\W_]", "", query)
maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
start_time = datetime.now()
items = []
for connector in get_connectors():
result_set = None
if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url != "":
# Search on ISBN
try:
result_set = connector.isbn_search(isbn)
except Exception as err: # pylint: disable=broad-except
logger.info(err)
# if this fails, we can still try regular search
# get the search url from the connector before sending
url = connector.get_search_url(query)
try:
raise_not_valid_url(url)
except ConnectorException:
# if this URL is invalid we should skip it and move on
logger.info("Request denied to blocked domain: %s", url)
continue
items.append((url, connector))
# if no isbn search results, we fallback to generic search
if not result_set:
try:
result_set = connector.search(query, min_confidence=min_confidence)
except Exception as err: # pylint: disable=broad-except
# we don't want *any* error to crash the whole search page
logger.info(err)
continue
if return_first and result_set:
# if we found anything, return it
return result_set[0]
if result_set:
results.append(
{
"connector": connector,
"results": result_set,
}
)
if (datetime.now() - start_time).seconds >= SEARCH_TIMEOUT:
break
# load as many results as we can
results = asyncio.run(async_connector_search(query, items, min_confidence))
results = [r for r in results if r]
if return_first:
return None
# find the best result from all the responses and return that
all_results = [r for con in results for r in con["results"]]
all_results = sorted(all_results, key=lambda r: r.confidence, reverse=True)
return all_results[0] if all_results else None
# failed requests will return None, so filter those out
return results
@ -119,6 +152,15 @@ def load_more_data(connector_id, book_id):
connector.expand_book_data(book)
@app.task(queue="low_priority")
def create_edition_task(connector_id, work_id, data):
"""separate task for each of the 10,000 editions of LoTR"""
connector_info = models.Connector.objects.get(id=connector_id)
connector = load_connector(connector_info)
work = models.Work.objects.select_subclasses().get(id=work_id)
connector.create_edition_from_data(work, data)
def load_connector(connector_info):
"""instantiate the connector class"""
connector = importlib.import_module(
@ -133,3 +175,20 @@ def create_connector(sender, instance, created, *args, **kwargs):
"""create a connector to an external bookwyrm server"""
if instance.application_type == "bookwyrm":
get_or_create_connector(f"https://{instance.server_name}")
def raise_not_valid_url(url):
"""do some basic reality checks on the url"""
parsed = urlparse(url)
if not parsed.scheme in ["http", "https"]:
raise ConnectorException("Invalid scheme: ", url)
try:
ipaddress.ip_address(parsed.netloc)
raise ConnectorException("Provided url is an IP address: ", url)
except ValueError:
# it's not an IP address, which is good
pass
if models.FederatedServer.is_blocked(url):
raise ConnectorException(f"Attempting to load data from blocked url: {url}")

View file

@ -5,7 +5,7 @@ from bookwyrm import models
from bookwyrm.book_search import SearchResult
from .abstract_connector import AbstractConnector, Mapping
from .abstract_connector import get_data
from .connector_manager import ConnectorException
from .connector_manager import ConnectorException, create_edition_task
class Connector(AbstractConnector):
@ -77,53 +77,42 @@ class Connector(AbstractConnector):
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
}
def search(self, query, min_confidence=None): # pylint: disable=arguments-differ
"""overrides default search function with confidence ranking"""
results = super().search(query)
if min_confidence:
# filter the search results after the fact
return [r for r in results if r.confidence >= min_confidence]
return results
def parse_search_data(self, data):
return data.get("results")
def format_search_result(self, search_result):
images = search_result.get("image")
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
# a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999
return SearchResult(
title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=cover,
confidence=confidence,
connector=self,
)
def parse_search_data(self, data, min_confidence):
for search_result in data.get("results", []):
images = search_result.get("image")
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
# a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999
if confidence < min_confidence:
continue
yield SearchResult(
title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=cover,
confidence=confidence,
connector=self,
)
def parse_isbn_search_data(self, data):
"""got some daaaata"""
results = data.get("entities")
if not results:
return []
return list(results.values())
def format_isbn_search_result(self, search_result):
"""totally different format than a regular search result"""
title = search_result.get("claims", {}).get("wdt:P1476", [])
if not title:
return None
return SearchResult(
title=title[0],
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=self.get_cover_url(search_result.get("image")),
connector=self,
)
return
for search_result in list(results.values()):
title = search_result.get("claims", {}).get("wdt:P1476", [])
if not title:
continue
yield SearchResult(
title=title[0],
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=self.get_cover_url(search_result.get("image")),
connector=self,
)
def is_work_data(self, data):
return data.get("type") == "work"
@ -167,12 +156,17 @@ class Connector(AbstractConnector):
for edition_uri in edition_options.get("uris"):
remote_id = self.get_remote_id(edition_uri)
create_edition_task.delay(self.connector.id, work.id, remote_id)
def create_edition_from_data(self, work, edition_data, instance=None):
"""pass in the url as data and then call the version in abstract connector"""
if isinstance(edition_data, str):
try:
data = self.get_book_data(remote_id)
edition_data = self.get_book_data(edition_data)
except ConnectorException:
# who, indeed, knows
continue
self.create_edition_from_data(work, data)
return
super().create_edition_from_data(work, edition_data, instance=instance)
def get_cover_url(self, cover_blob, *_):
"""format the relative cover url into an absolute one:

View file

@ -5,7 +5,7 @@ from bookwyrm import models
from bookwyrm.book_search import SearchResult
from .abstract_connector import AbstractConnector, Mapping
from .abstract_connector import get_data, infer_physical_format, unique_physical_format
from .connector_manager import ConnectorException
from .connector_manager import ConnectorException, create_edition_task
from .openlibrary_languages import languages
@ -152,39 +152,41 @@ class Connector(AbstractConnector):
image_name = f"{cover_id}-{size}.jpg"
return f"{self.covers_url}/b/id/{image_name}"
def parse_search_data(self, data):
return data.get("docs")
def parse_search_data(self, data, min_confidence):
for idx, search_result in enumerate(data.get("docs")):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
author = search_result.get("author_name") or ["Unknown"]
cover_blob = search_result.get("cover_i")
cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
def format_search_result(self, search_result):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
author = search_result.get("author_name") or ["Unknown"]
cover_blob = search_result.get("cover_i")
cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
return SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author),
connector=self,
year=search_result.get("first_publish_year"),
cover=cover,
)
# OL doesn't provide confidence, but it does sort by an internal ranking, so
# this confidence value is relative to the list position
confidence = 1 / (idx + 1)
yield SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author),
connector=self,
year=search_result.get("first_publish_year"),
cover=cover,
confidence=confidence,
)
def parse_isbn_search_data(self, data):
return list(data.values())
def format_isbn_search_result(self, search_result):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
authors = search_result.get("authors") or [{"name": "Unknown"}]
author_names = [author.get("name") for author in authors]
return SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author_names),
connector=self,
year=search_result.get("publish_date"),
)
for search_result in list(data.values()):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
authors = search_result.get("authors") or [{"name": "Unknown"}]
author_names = [author.get("name") for author in authors]
yield SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author_names),
connector=self,
year=search_result.get("publish_date"),
)
def load_edition_data(self, olkey):
"""query openlibrary for editions of a work"""
@ -208,7 +210,7 @@ class Connector(AbstractConnector):
# does this edition have ANY interesting data?
if ignore_edition(edition_data):
continue
self.create_edition_from_data(work, edition_data)
create_edition_task.delay(self.connector.id, work.id, edition_data)
def ignore_edition(edition_data):

View file

@ -45,7 +45,8 @@ def moderation_report_email(report):
"""a report was created"""
data = email_data()
data["reporter"] = report.reporter.localname or report.reporter.username
data["reportee"] = report.user.localname or report.user.username
if report.user:
data["reportee"] = report.user.localname or report.user.username
data["report_link"] = report.remote_id
for admin in models.User.objects.filter(

View file

@ -10,3 +10,4 @@ from .landing import *
from .links import *
from .lists import *
from .status import *
from .user_admin import *

View file

@ -1,5 +1,8 @@
""" using django model forms """
from django import forms
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from bookwyrm import models
from bookwyrm.models.fields import ClearableFileInputWithWarning
@ -66,3 +69,33 @@ class DeleteUserForm(CustomForm):
class Meta:
model = models.User
fields = ["password"]
class ChangePasswordForm(CustomForm):
current_password = forms.CharField(widget=forms.PasswordInput)
confirm_password = forms.CharField(widget=forms.PasswordInput)
class Meta:
model = models.User
fields = ["password"]
widgets = {
"password": forms.PasswordInput(),
}
def clean(self):
"""Make sure passwords match and are valid"""
current_password = self.data.get("current_password")
if not self.instance.check_password(current_password):
self.add_error("current_password", _("Incorrect password"))
cleaned_data = super().clean()
new_password = cleaned_data.get("password")
confirm_password = self.data.get("confirm_password")
if new_password != confirm_password:
self.add_error("confirm_password", _("Password does not match"))
try:
validate_password(new_password)
except ValidationError as err:
self.add_error("password", err)

View file

@ -53,7 +53,12 @@ class ReadThroughForm(CustomForm):
self.add_error(
"finish_date", _("Reading finish date cannot be before start date.")
)
stopped_date = cleaned_data.get("stopped_date")
if start_date and stopped_date and start_date > stopped_date:
self.add_error(
"stopped_date", _("Reading stopped date cannot be before start date.")
)
class Meta:
model = models.ReadThrough
fields = ["user", "book", "start_date", "finish_date"]
fields = ["user", "book", "start_date", "finish_date", "stopped_date"]

View file

@ -4,12 +4,6 @@ from .custom_form import CustomForm
# pylint: disable=missing-class-docstring
class UserGroupForm(CustomForm):
class Meta:
model = models.User
fields = ["groups"]
class GroupForm(CustomForm):
class Meta:
model = models.Group

View file

@ -1,5 +1,7 @@
""" 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 bookwyrm import models
@ -13,7 +15,7 @@ class LoginForm(CustomForm):
fields = ["localname", "password"]
help_texts = {f: None for f in fields}
widgets = {
"password": PasswordInput(),
"password": forms.PasswordInput(),
}
@ -22,12 +24,16 @@ class RegisterForm(CustomForm):
model = models.User
fields = ["localname", "email", "password"]
help_texts = {f: None for f in fields}
widgets = {"password": PasswordInput()}
widgets = {"password": forms.PasswordInput()}
def clean(self):
"""Check if the username is taken"""
cleaned_data = super().clean()
localname = cleaned_data.get("localname").strip()
try:
validate_password(cleaned_data.get("password"))
except ValidationError as err:
self.add_error("password", err)
if models.User.objects.filter(localname=localname).first():
self.add_error("localname", _("User with this username already exists"))
@ -43,3 +49,28 @@ class InviteRequestForm(CustomForm):
class Meta:
model = models.InviteRequest
fields = ["email", "answer"]
class PasswordResetForm(CustomForm):
confirm_password = forms.CharField(widget=forms.PasswordInput)
class Meta:
model = models.User
fields = ["password"]
widgets = {
"password": forms.PasswordInput(),
}
def clean(self):
"""Make sure the passwords match and are valid"""
cleaned_data = super().clean()
new_password = cleaned_data.get("password")
confirm_password = self.data.get("confirm_password")
if new_password != confirm_password:
self.add_error("confirm_password", _("Password does not match"))
try:
validate_password(new_password)
except ValidationError as err:
self.add_error("password", err)

View 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"]

View file

@ -1,6 +1,7 @@
""" import classes """
from .importer import Importer
from .calibre_import import CalibreImporter
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
from .openlibrary_import import OpenLibraryImporter

View file

@ -0,0 +1,28 @@
""" handle reading a csv from calibre """
from bookwyrm.models import Shelf
from . import Importer
class CalibreImporter(Importer):
"""csv downloads from Calibre"""
service = "Calibre"
def __init__(self, *args, **kwargs):
# Add timestamp to row_mappings_guesses for date_added to avoid
# integrity error
row_mappings_guesses = []
for field, mapping in self.row_mappings_guesses:
if field in ("date_added",):
row_mappings_guesses.append((field, mapping + ["timestamp"]))
else:
row_mappings_guesses.append((field, mapping))
self.row_mappings_guesses = row_mappings_guesses
super().__init__(*args, **kwargs)
def get_shelf(self, normalized_row):
# Calibre export does not indicate which shelf to use. Use a default one for now
return Shelf.TO_READ

View file

@ -1,5 +1,8 @@
""" handle reading a tsv from librarything """
import re
from bookwyrm.models import Shelf
from . import Importer
@ -21,7 +24,7 @@ class LibrarythingImporter(Importer):
def get_shelf(self, normalized_row):
if normalized_row["date_finished"]:
return "read"
return Shelf.READ_FINISHED
if normalized_row["date_started"]:
return "reading"
return "to-read"
return Shelf.READING
return Shelf.TO_READ

View file

@ -114,12 +114,20 @@ class ListsStream(RedisStore):
@receiver(signals.post_save, sender=models.List)
# pylint: disable=unused-argument
def add_list_on_create(sender, instance, created, *args, **kwargs):
"""add newly created lists streamsstreams"""
if not created:
def add_list_on_create(sender, instance, created, *args, update_fields=None, **kwargs):
"""add newly created lists streams"""
if created:
# when creating new things, gotta wait on the transaction
transaction.on_commit(lambda: add_list_on_create_command(instance.id))
return
# when creating new things, gotta wait on the transaction
transaction.on_commit(lambda: add_list_on_create_command(instance.id))
# if update_fields was specified, we can check if privacy was updated, but if
# it wasn't specified (ie, by an activitypub update), there's no way to know
if update_fields and "privacy" not in update_fields:
return
# the privacy may have changed, so we need to re-do the whole thing
remove_list_task.delay(instance.id, re_add=True)
@receiver(signals.post_delete, sender=models.List)
@ -217,7 +225,7 @@ def populate_lists_task(user_id):
@app.task(queue=MEDIUM)
def remove_list_task(list_id):
def remove_list_task(list_id, re_add=False):
"""remove a list from any stream it might be in"""
stores = models.User.objects.filter(local=True, is_active=True).values_list(
"id", flat=True
@ -227,6 +235,9 @@ def remove_list_task(list_id):
stores = [ListsStream().stream_id(idx) for idx in stores]
ListsStream().remove_object_from_related_stores(list_id, stores=stores)
if re_add:
add_list_task.delay(list_id)
@app.task(queue=HIGH)
def add_list_task(list_id):

View file

@ -56,12 +56,17 @@ class Command(BaseCommand):
self.stdout.write(" OK 🖼")
# Books
books = models.Book.objects.select_subclasses().filter()
self.stdout.write(
" → Book preview images ({}): ".format(len(books)), ending=""
book_ids = (
models.Book.objects.select_subclasses()
.filter()
.values_list("id", flat=True)
)
for book in books:
preview_images.generate_edition_preview_image_task.delay(book.id)
self.stdout.write(
" → Book preview images ({}): ".format(len(book_ids)), ending=""
)
for book_id in book_ids:
preview_images.generate_edition_preview_image_task.delay(book_id)
self.stdout.write(".", ending="")
self.stdout.write(" OK 🖼")

View file

@ -89,7 +89,7 @@ def init_connectors():
covers_url="https://inventaire.io",
search_url="https://inventaire.io/api/search?types=works&types=works&search=",
isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
priority=3,
priority=1,
)
models.Connector.objects.create(
@ -101,7 +101,7 @@ def init_connectors():
covers_url="https://covers.openlibrary.org",
search_url="https://openlibrary.org/search?q=",
isbn_search_url="https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:",
priority=3,
priority=1,
)

View file

@ -14,6 +14,8 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="annualgoal",
name="year",
field=models.IntegerField(default=bookwyrm.models.user.get_current_year),
field=models.IntegerField(
default=bookwyrm.models.annual_goal.get_current_year
),
),
]

View file

@ -0,0 +1,80 @@
# Generated by Django 3.2.12 on 2022-03-16 23:20
import bookwyrm.models.fields
from django.db import migrations
from bookwyrm.models import Shelf
def add_shelves(apps, schema_editor):
"""add any superusers to the "admin" group"""
db_alias = schema_editor.connection.alias
shelf_model = apps.get_model("bookwyrm", "Shelf")
users = apps.get_model("bookwyrm", "User")
local_users = users.objects.using(db_alias).filter(local=True)
for user in local_users:
remote_id = f"{user.remote_id}/books/stopped"
shelf_model.objects.using(db_alias).create(
name="Stopped reading",
identifier=Shelf.STOPPED_READING,
user=user,
editable=False,
remote_id=remote_id,
)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0145_sitesettings_version"),
]
operations = [
migrations.AlterField(
model_name="comment",
name="reading_status",
field=bookwyrm.models.fields.CharField(
blank=True,
choices=[
("to-read", "To-Read"),
("reading", "Reading"),
("read", "Read"),
("stopped-reading", "Stopped-Reading"),
],
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="quotation",
name="reading_status",
field=bookwyrm.models.fields.CharField(
blank=True,
choices=[
("to-read", "To-Read"),
("reading", "Reading"),
("read", "Read"),
("stopped-reading", "Stopped-Reading"),
],
max_length=255,
null=True,
),
),
migrations.AlterField(
model_name="review",
name="reading_status",
field=bookwyrm.models.fields.CharField(
blank=True,
choices=[
("to-read", "To-Read"),
("reading", "Reading"),
("read", "Read"),
("stopped-reading", "Stopped-Reading"),
],
max_length=255,
null=True,
),
),
migrations.RunPython(add_shelves, reverse_code=migrations.RunPython.noop),
]

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.12 on 2022-03-26 20:06
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0146_auto_20220316_2320"),
("bookwyrm", "0147_alter_user_preferred_language"),
]
operations = []

View file

@ -0,0 +1,13 @@
# Generated by Django 3.2.13 on 2022-05-26 17:16
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0148_alter_user_preferred_language"),
("bookwyrm", "0148_merge_20220326_2006"),
]
operations = []

View file

@ -0,0 +1,18 @@
# Generated by Django 3.2.13 on 2022-05-26 18:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0149_merge_20220526_1716"),
]
operations = [
migrations.AddField(
model_name="readthrough",
name="stopped_date",
field=models.DateTimeField(blank=True, null=True),
),
]

View 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,
),
),
]

View 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",
),
]

View 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,
),
),
]

View file

@ -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",
),
]

View 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 = []

View 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,
),
),
]

View 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),
]

View 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,
),
),
]

View file

@ -17,7 +17,8 @@ from .attachment import Image
from .favorite import Favorite
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 .report import Report, ReportComment
from .federated_server import FederatedServer

View 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),
}

View file

@ -3,7 +3,7 @@ from functools import reduce
import operator
from django.apps import apps
from django.db import models
from django.db import models, transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
@ -58,25 +58,20 @@ def automod_task():
return
reporter = AutoMod.objects.first().created_by
reports = automod_users(reporter) + automod_statuses(reporter)
if reports:
admins = User.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| models.Q(is_superuser=True)
).all()
notification_model = apps.get_model(
"bookwyrm", "Notification", require_ready=True
)
if not reports:
return
admins = User.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| models.Q(is_superuser=True)
).all()
notification_model = apps.get_model("bookwyrm", "Notification", require_ready=True)
with transaction.atomic():
for admin in admins:
notification_model.objects.bulk_create(
[
notification_model(
user=admin,
related_report=r,
notification_type="REPORT",
)
for r in reports
]
notification, _ = notification_model.objects.get_or_create(
user=admin, notification_type=notification_model.REPORT, read=False
)
notification.related_repors.add(reports)
def automod_users(reporter):

View file

@ -132,7 +132,7 @@ class BookWyrmModel(models.Model):
return
# but generally moderators can delete other people's stuff
if self.user == viewer or viewer.has_perm("moderate_post"):
if self.user == viewer or viewer.has_perm("bookwyrm.moderate_post"):
return
raise PermissionDenied()

View file

@ -16,7 +16,7 @@ from django.utils.encoding import filepath_to_uri
from bookwyrm import activitypub
from bookwyrm.connectors import get_image
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.utils.sanitizer import clean
from bookwyrm.settings import MEDIA_FULL_URL
@ -497,9 +497,7 @@ class HtmlField(ActivitypubFieldMixin, models.TextField):
def field_from_activity(self, value):
if not value or value == MISSING:
return None
sanitizer = InputHtmlParser()
sanitizer.feed(value)
return sanitizer.get_output()
return clean(value)
class ArrayField(ActivitypubFieldMixin, DjangoArrayField):

View file

@ -140,16 +140,6 @@ class GroupMemberInvitation(models.Model):
# make an invitation
super().save(*args, **kwargs)
# now send the invite
model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_type = "INVITE"
model.objects.create(
user=self.user,
related_user=self.group.user,
related_group=self.group,
notification_type=notification_type,
)
@transaction.atomic
def accept(self):
"""turn this request into the real deal"""
@ -157,25 +147,24 @@ class GroupMemberInvitation(models.Model):
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# tell the group owner
model.objects.create(
user=self.group.user,
related_user=self.user,
model.notify(
self.group.user,
self.user,
related_group=self.group,
notification_type="ACCEPT",
notification_type=model.ACCEPT,
)
# let the other members know about it
for membership in self.group.memberships.all():
member = membership.user
if member not in (self.user, self.group.user):
model.objects.create(
user=member,
related_user=self.user,
model.notify(
member,
self.user,
related_group=self.group,
notification_type="JOIN",
notification_type=model.JOIN,
)
def reject(self):
"""generate a Reject for this membership request"""
self.delete()

View file

@ -175,9 +175,15 @@ class ImportItem(models.Model):
def date_added(self):
"""when the book was added to this dataset"""
if self.normalized_data.get("date_added"):
return timezone.make_aware(
dateutil.parser.parse(self.normalized_data.get("date_added"))
parsed_date_added = dateutil.parser.parse(
self.normalized_data.get("date_added")
)
if timezone.is_aware(parsed_date_added):
# Keep timezone if import already had one
return parsed_date_added
return timezone.make_aware(parsed_date_added)
return None
@property

View file

@ -84,7 +84,7 @@ class LinkDomain(BookWyrmModel):
)
def raise_not_editable(self, viewer):
if viewer.has_perm("moderate_post"):
if viewer.has_perm("bookwyrm.moderate_post"):
return
raise PermissionDenied()

View file

@ -1,7 +1,6 @@
""" make a list of books!! """
import uuid
from django.apps import apps
from django.core.exceptions import PermissionDenied
from django.db import models
from django.db.models import Q
@ -129,7 +128,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
"""on save, update embed_key and avoid clash with existing code"""
if not self.embed_key:
self.embed_key = uuid.uuid4()
return super().save(*args, **kwargs)
super().save(*args, **kwargs)
class ListItem(CollectionItemMixin, BookWyrmModel):
@ -151,33 +150,11 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
collection_field = "book_list"
def save(self, *args, **kwargs):
"""create a notification too"""
created = not bool(self.id)
"""Update the list's date"""
super().save(*args, **kwargs)
# tick the updated date on the parent list
self.book_list.updated_date = timezone.now()
self.book_list.save(broadcast=False)
list_owner = self.book_list.user
model = apps.get_model("bookwyrm.Notification", require_ready=True)
# create a notification if somoene ELSE added to a local user's list
if created and list_owner.local and list_owner != self.user:
model.objects.create(
user=list_owner,
related_user=self.user,
related_list_item=self,
notification_type="ADD",
)
if self.book_list.group:
for membership in self.book_list.group.memberships.all():
if membership.user != self.user:
model.objects.create(
user=membership.user,
related_user=self.user,
related_list_item=self,
notification_type="ADD",
)
self.book_list.save(broadcast=False, update_fields=["updated_date"])
def raise_not_deletable(self, viewer):
"""the associated user OR the list owner can delete"""

View file

@ -1,77 +1,125 @@
""" alert a user to activity """
from django.db import models
from django.db import models, transaction
from django.dispatch import receiver
from .base_model import BookWyrmModel
from . import Boost, Favorite, ImportJob, Report, Status, User
# pylint: disable=line-too-long
NotificationType = models.TextChoices(
"NotificationType",
"FAVORITE REPLY MENTION TAG FOLLOW FOLLOW_REQUEST BOOST IMPORT ADD REPORT INVITE ACCEPT JOIN LEAVE REMOVE GROUP_PRIVACY GROUP_NAME GROUP_DESCRIPTION",
)
from . import Boost, Favorite, GroupMemberInvitation, ImportJob, ListItem, Report
from . import Status, User, UserFollowRequest
class Notification(BookWyrmModel):
"""you've been tagged, liked, followed, etc"""
# Status interactions
FAVORITE = "FAVORITE"
BOOST = "BOOST"
REPLY = "REPLY"
MENTION = "MENTION"
TAG = "TAG"
# Relationships
FOLLOW = "FOLLOW"
FOLLOW_REQUEST = "FOLLOW_REQUEST"
# Imports
IMPORT = "IMPORT"
# List activity
ADD = "ADD"
# Admin
REPORT = "REPORT"
# Groups
INVITE = "INVITE"
ACCEPT = "ACCEPT"
JOIN = "JOIN"
LEAVE = "LEAVE"
REMOVE = "REMOVE"
GROUP_PRIVACY = "GROUP_PRIVACY"
GROUP_NAME = "GROUP_NAME"
GROUP_DESCRIPTION = "GROUP_DESCRIPTION"
# pylint: disable=line-too-long
NotificationType = models.TextChoices(
# there has got be a better way to do this
"NotificationType",
f"{FAVORITE} {REPLY} {MENTION} {TAG} {FOLLOW} {FOLLOW_REQUEST} {BOOST} {IMPORT} {ADD} {REPORT} {INVITE} {ACCEPT} {JOIN} {LEAVE} {REMOVE} {GROUP_PRIVACY} {GROUP_NAME} {GROUP_DESCRIPTION}",
)
user = models.ForeignKey("User", on_delete=models.CASCADE)
related_book = models.ForeignKey("Edition", on_delete=models.CASCADE, null=True)
related_user = models.ForeignKey(
"User", on_delete=models.CASCADE, null=True, related_name="related_user"
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices
)
related_users = models.ManyToManyField(
"User", symmetrical=False, related_name="notifications"
)
related_group = models.ForeignKey(
"Group", on_delete=models.CASCADE, null=True, related_name="notifications"
)
related_status = models.ForeignKey("Status", on_delete=models.CASCADE, null=True)
related_import = models.ForeignKey("ImportJob", on_delete=models.CASCADE, null=True)
related_list_item = models.ForeignKey(
"ListItem", on_delete=models.CASCADE, null=True
)
related_report = models.ForeignKey("Report", on_delete=models.CASCADE, null=True)
read = models.BooleanField(default=False)
notification_type = models.CharField(
max_length=255, choices=NotificationType.choices
related_list_items = models.ManyToManyField(
"ListItem", symmetrical=False, related_name="notifications"
)
related_reports = models.ManyToManyField("Report", symmetrical=False)
def save(self, *args, **kwargs):
"""save, but don't make dupes"""
# there's probably a better way to do this
if self.__class__.objects.filter(
user=self.user,
related_book=self.related_book,
related_user=self.related_user,
related_group=self.related_group,
related_status=self.related_status,
related_import=self.related_import,
related_list_item=self.related_list_item,
related_report=self.related_report,
notification_type=self.notification_type,
).exists():
@classmethod
@transaction.atomic
def notify(cls, user, related_user, **kwargs):
"""Create a notification"""
if related_user and (not user.local or user == related_user):
return
super().save(*args, **kwargs)
notification = cls.objects.filter(user=user, **kwargs).first()
if not notification:
notification = cls.objects.create(user=user, **kwargs)
if related_user:
notification.related_users.add(related_user)
notification.read = False
notification.save()
class Meta:
"""checks if notifcation is in enum list for valid types"""
constraints = [
models.CheckConstraint(
check=models.Q(notification_type__in=NotificationType.values),
name="notification_type_valid",
@classmethod
@transaction.atomic
def notify_list_item(cls, user, list_item):
"""Group the notifications around the list items, not the user"""
related_user = list_item.user
notification = cls.objects.filter(
user=user,
related_users=related_user,
related_list_items__book_list=list_item.book_list,
notification_type=Notification.ADD,
).first()
if not notification:
notification = cls.objects.create(
user=user, notification_type=Notification.ADD
)
]
notification.related_users.add(related_user)
notification.related_list_items.add(list_item)
notification.read = False
notification.save()
@classmethod
def unnotify(cls, user, related_user, **kwargs):
"""Remove a user from a notification and delete it if that was the only user"""
try:
notification = cls.objects.filter(user=user, **kwargs).get()
except Notification.DoesNotExist:
return
notification.related_users.remove(related_user)
if not notification.related_users.count():
notification.delete()
@receiver(models.signals.post_save, sender=Favorite)
# pylint: disable=unused-argument
def notify_on_fav(sender, instance, *args, **kwargs):
"""someone liked your content, you ARE loved"""
if not instance.status.user.local or instance.status.user == instance.user:
return
Notification.objects.create(
user=instance.status.user,
notification_type="FAVORITE",
related_user=instance.user,
Notification.notify(
instance.status.user,
instance.user,
related_status=instance.status,
notification_type=Notification.FAVORITE,
)
@ -81,15 +129,16 @@ def notify_on_unfav(sender, instance, *args, **kwargs):
"""oops, didn't like that after all"""
if not instance.status.user.local:
return
Notification.objects.filter(
user=instance.status.user,
related_user=instance.user,
Notification.unnotify(
instance.status.user,
instance.user,
related_status=instance.status,
notification_type="FAVORITE",
).delete()
notification_type=Notification.FAVORITE,
)
@receiver(models.signals.post_save)
@transaction.atomic
# pylint: disable=unused-argument
def notify_user_on_mention(sender, instance, *args, **kwargs):
"""creating and deleting statuses with @ mentions and replies"""
@ -105,22 +154,23 @@ def notify_user_on_mention(sender, instance, *args, **kwargs):
and instance.reply_parent.user != instance.user
and instance.reply_parent.user.local
):
Notification.objects.create(
user=instance.reply_parent.user,
notification_type="REPLY",
related_user=instance.user,
Notification.notify(
instance.reply_parent.user,
instance.user,
related_status=instance,
notification_type=Notification.REPLY,
)
for mention_user in instance.mention_users.all():
# avoid double-notifying about this status
if not mention_user.local or (
instance.reply_parent and mention_user == instance.reply_parent.user
):
continue
Notification.objects.create(
user=mention_user,
notification_type="MENTION",
related_user=instance.user,
Notification.notify(
mention_user,
instance.user,
notification_type=Notification.MENTION,
related_status=instance,
)
@ -135,11 +185,11 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
):
return
Notification.objects.create(
user=instance.boosted_status.user,
Notification.notify(
instance.boosted_status.user,
instance.user,
related_status=instance.boosted_status,
related_user=instance.user,
notification_type="BOOST",
notification_type=Notification.BOOST,
)
@ -147,12 +197,12 @@ def notify_user_on_boost(sender, instance, *args, **kwargs):
# pylint: disable=unused-argument
def notify_user_on_unboost(sender, instance, *args, **kwargs):
"""unboosting a status"""
Notification.objects.filter(
user=instance.boosted_status.user,
Notification.unnotify(
instance.boosted_status.user,
instance.user,
related_status=instance.boosted_status,
related_user=instance.user,
notification_type="BOOST",
).delete()
notification_type=Notification.BOOST,
)
@receiver(models.signals.post_save, sender=ImportJob)
@ -166,23 +216,94 @@ def notify_user_on_import_complete(
return
Notification.objects.create(
user=instance.user,
notification_type="IMPORT",
notification_type=Notification.IMPORT,
related_import=instance,
)
@receiver(models.signals.post_save, sender=Report)
@transaction.atomic
# pylint: disable=unused-argument
def notify_admins_on_report(sender, instance, *args, **kwargs):
def notify_admins_on_report(sender, instance, created, *args, **kwargs):
"""something is up, make sure the admins know"""
if not created:
# otherwise you'll get a notification when you resolve a report
return
# moderators and superusers should be notified
admins = User.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| models.Q(is_superuser=True)
).all()
for admin in admins:
Notification.objects.create(
notification, _ = Notification.objects.get_or_create(
user=admin,
related_report=instance,
notification_type="REPORT",
notification_type=Notification.REPORT,
read=False,
)
notification.related_reports.add(instance)
@receiver(models.signals.post_save, sender=GroupMemberInvitation)
# pylint: disable=unused-argument
def notify_user_on_group_invite(sender, instance, *args, **kwargs):
"""Cool kids club here we come"""
Notification.notify(
instance.user,
instance.group.user,
related_group=instance.group,
notification_type=Notification.INVITE,
)
@receiver(models.signals.post_save, sender=ListItem)
@transaction.atomic
# pylint: disable=unused-argument
def notify_user_on_list_item_add(sender, instance, created, *args, **kwargs):
"""Someone added to your list"""
if not created:
return
list_owner = instance.book_list.user
# create a notification if somoene ELSE added to a local user's list
if list_owner.local and list_owner != instance.user:
# keep the related_user singular, group the items
Notification.notify_list_item(list_owner, instance)
if instance.book_list.group:
for membership in instance.book_list.group.memberships.all():
if membership.user != instance.user:
Notification.notify_list_item(membership.user, instance)
@receiver(models.signals.post_save, sender=UserFollowRequest)
@transaction.atomic
# pylint: disable=unused-argument
def notify_user_on_follow(sender, instance, created, *args, **kwargs):
"""Someone added to your list"""
if not created or not instance.user_object.local:
return
manually_approves = instance.user_object.manually_approves_followers
if manually_approves:
# don't group notifications
notification = Notification.objects.filter(
user=instance.user_object,
related_users=instance.user_subject,
notification_type=Notification.FOLLOW_REQUEST,
).first()
if not notification:
notification = Notification.objects.create(
user=instance.user_object, notification_type=Notification.FOLLOW_REQUEST
)
notification.related_users.set([instance.user_subject])
notification.read = False
notification.save()
else:
# Only group unread follows
Notification.notify(
instance.user_object,
instance.user_subject,
notification_type=Notification.FOLLOW,
read=False,
)

View file

@ -27,6 +27,7 @@ class ReadThrough(BookWyrmModel):
)
start_date = models.DateTimeField(blank=True, null=True)
finish_date = models.DateTimeField(blank=True, null=True)
stopped_date = models.DateTimeField(blank=True, null=True)
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
@ -34,7 +35,7 @@ class ReadThrough(BookWyrmModel):
cache.delete(f"latest_read_through-{self.user.id}-{self.book.id}")
self.user.update_active_date()
# an active readthrough must have an unset finish date
if self.finish_date:
if self.finish_date or self.stopped_date:
self.is_active = False
super().save(*args, **kwargs)

View file

@ -1,5 +1,4 @@
""" defines relationships between users """
from django.apps import apps
from django.core.cache import cache
from django.db import models, transaction, IntegrityError
from django.db.models import Q
@ -148,14 +147,6 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
if not manually_approves:
self.accept()
model = apps.get_model("bookwyrm.Notification", require_ready=True)
notification_type = "FOLLOW_REQUEST" if manually_approves else "FOLLOW"
model.objects.create(
user=self.user_object,
related_user=self.user_subject,
notification_type=notification_type,
)
def get_accept_reject_id(self, status):
"""get id for sending an accept or reject of a local user"""
@ -218,7 +209,7 @@ def clear_cache(user_subject, user_object):
"""clear relationship cache"""
cache.delete_many(
[
f"relationship-{user_subject.id}-{user_object.id}",
f"relationship-{user_object.id}-{user_subject.id}",
f"cached-relationship-{user_subject.id}-{user_object.id}",
f"cached-relationship-{user_object.id}-{user_subject.id}",
]
)

View file

@ -11,7 +11,7 @@ class Report(BookWyrmModel):
"User", related_name="reporter", on_delete=models.PROTECT
)
note = models.TextField(null=True, blank=True)
user = models.ForeignKey("User", on_delete=models.PROTECT)
user = models.ForeignKey("User", on_delete=models.PROTECT, null=True, blank=True)
status = models.ForeignKey(
"Status",
null=True,

View file

@ -18,8 +18,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
TO_READ = "to-read"
READING = "reading"
READ_FINISHED = "read"
STOPPED_READING = "stopped-reading"
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED)
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED, STOPPED_READING)
name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100)
@ -102,12 +103,25 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
if not self.user:
self.user = self.shelf.user
if self.id and self.user.local:
cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
# remove all caches related to all editions of this book
cache.delete_many(
[
f"book-on-shelf-{book.id}-{self.shelf.id}"
for book in self.book.parent_work.editions.all()
]
)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
if self.id and self.user.local:
cache.delete(f"book-on-shelf-{self.book.id}-{self.shelf.id}")
cache.delete_many(
[
f"book-on-shelf-{book}-{self.shelf.id}"
for book in self.book.parent_work.editions.values_list(
"id", flat=True
)
]
)
super().delete(*args, **kwargs)
class Meta:

View file

@ -116,11 +116,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
"""keep notes if they are replies to existing statuses"""
if activity.type == "Announce":
try:
boosted = activitypub.resolve_remote_id(
activity.object, get_activity=True
)
except activitypub.ActivitySerializerError:
boosted = activitypub.resolve_remote_id(activity.object, get_activity=True)
if not boosted:
# if we can't load the status, definitely ignore it
return True
# keep the boost if we would keep the status
@ -221,7 +218,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
"""certain types of status aren't editable"""
# first, the standard raise
super().raise_not_editable(viewer)
if isinstance(self, (GeneratedNote, ReviewRating)):
# if it's an edit (not a create) you can only edit content statuses
if self.id and isinstance(self, (GeneratedNote, ReviewRating)):
raise PermissionDenied()
@classmethod
@ -265,7 +263,7 @@ class GeneratedNote(Status):
ReadingStatusChoices = models.TextChoices(
"ReadingStatusChoices", ["to-read", "reading", "read"]
"ReadingStatusChoices", ["to-read", "reading", "read", "stopped-reading"]
)
@ -306,10 +304,17 @@ class Comment(BookStatus):
@property
def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users"""
return (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>)</p>'
)
if self.progress_mode == "PG" and self.progress and (self.progress > 0):
return_value = (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>, page {self.progress})</p>'
)
else:
return_value = (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>)</p>'
)
return return_value
activity_serializer = activitypub.Comment
@ -335,10 +340,17 @@ class Quotation(BookStatus):
"""indicate the book in question for mastodon (or w/e) users"""
quote = re.sub(r"^<p>", '<p>"', self.quote)
quote = re.sub(r"</p>$", '"</p>', quote)
return (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a></p>{self.content}'
)
if self.position_mode == "PG" and self.position and (self.position > 0):
return_value = (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>, page {self.position}</p>{self.content}'
)
else:
return_value = (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a></p>{self.content}'
)
return return_value
activity_serializer = activitypub.Quotation
@ -377,7 +389,7 @@ class Review(BookStatus):
def save(self, *args, **kwargs):
"""clear rating caches"""
if self.book.parent_work:
cache.delete(f"book-rating-{self.book.parent_work.id}-*")
cache.delete(f"book-rating-{self.book.parent_work.id}")
super().save(*args, **kwargs)

View file

@ -5,7 +5,6 @@ from urllib.parse import urlparse
from django.apps import apps
from django.contrib.auth.models import AbstractUser, Group
from django.contrib.postgres.fields import ArrayField, CICharField
from django.core.validators import MinValueValidator
from django.dispatch import receiver
from django.db import models, transaction
from django.utils import timezone
@ -16,7 +15,7 @@ import pytz
from bookwyrm import activitypub
from bookwyrm.connectors import get_data, ConnectorException
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.settings import DOMAIN, ENABLE_PREVIEW_IMAGES, USE_HTTPS, LANGUAGES
from bookwyrm.signatures import create_key_pair
@ -25,7 +24,7 @@ from bookwyrm.utils import regex
from .activitypub_mixin import OrderedCollectionPageMixin, ActivitypubMixin
from .base_model import BookWyrmModel, DeactivationReason, new_access_code
from .federated_server import FederatedServer
from . import fields, Review
from . import fields
FeedFilterChoices = [
@ -143,6 +142,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
show_goal = models.BooleanField(default=True)
show_suggested_users = models.BooleanField(default=True)
discoverable = fields.BooleanField(default=False)
show_guided_tour = models.BooleanField(default=True)
# feed options
feed_status_types = ArrayField(
@ -174,6 +174,11 @@ class User(OrderedCollectionPageMixin, AbstractUser):
property_fields = [("following_link", "following")]
field_tracker = FieldTracker(fields=["name", "avatar"])
@property
def active_follower_requests(self):
"""Follow requests from active users"""
return self.follower_requests.filter(is_active=True)
@property
def confirmation_link(self):
"""helper for generating confirmation links"""
@ -374,6 +379,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"name": "Read",
"identifier": "read",
},
{
"name": "Stopped Reading",
"identifier": "stopped-reading",
},
]
for shelf in shelves:
@ -410,65 +419,6 @@ class KeyPair(ActivitypubMixin, BookWyrmModel):
return super().save(*args, **kwargs)
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),
}
@app.task(queue="low_priority")
def set_remote_server(user_id):
"""figure out the user's remote server in the background"""

View file

@ -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)

View file

@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.3.4"
VERSION = "0.4.4"
RELEASE_API = env(
"RELEASE_API",
@ -21,7 +21,7 @@ RELEASE_API = env(
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "bc93172a"
JS_CACHE = "e678183b"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -214,7 +214,7 @@ STREAMS = [
# Search configuration
# total time in seconds that the instance will spend searching connectors
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 15))
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 8))
# timeout for a query to an individual connector
QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5))
@ -282,6 +282,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = env("LANGUAGE_CODE", "en-us")
LANGUAGES = [
("en-us", _("English")),
("ca-es", _("Català (Catalan)")),
("de-de", _("Deutsch (German)")),
("es-es", _("Español (Spanish)")),
("gl-es", _("Galego (Galician)")),
@ -290,6 +291,7 @@ LANGUAGES = [
("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)")),

View file

@ -6,11 +6,11 @@ ol.ordered-list {
counter-reset: list-counter;
}
ol.ordered-list li {
ol.ordered-list > li {
counter-increment: list-counter;
}
ol.ordered-list li::before {
ol.ordered-list > li::before {
content: counter(list-counter);
position: absolute;
left: -20px;

View file

@ -94,3 +94,4 @@ $family-secondary: $family-sans-serif;
@import "../bookwyrm.scss";
@import "../vendor/icons.css";
@import "../vendor/shepherd.scss";

View file

@ -67,3 +67,4 @@ $family-secondary: $family-sans-serif;
@import "../bookwyrm.scss";
@import "../vendor/icons.css";
@import "../vendor/shepherd.scss";

View 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);
}

View 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",
});
}

View file

@ -203,6 +203,8 @@ let StatusCache = new (class {
.forEach((item) => (item.disabled = false));
next_identifier = next_identifier == "complete" ? "read" : next_identifier;
next_identifier =
next_identifier == "stopped-reading-complete" ? "stopped-reading" : next_identifier;
// Disable the current state
button.querySelector(

View 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

View file

@ -2,15 +2,13 @@
from django.db import transaction
from bookwyrm import models
from bookwyrm.sanitize_html import InputHtmlParser
from bookwyrm.utils import sanitizer
def create_generated_note(user, content, mention_books=None, privacy="public"):
"""a note created by the app about user activity"""
# sanitize input html
parser = InputHtmlParser()
parser.feed(content)
content = parser.get_output()
content = sanitizer.clean(content)
with transaction.atomic():
# create but don't save

View file

@ -50,7 +50,7 @@
</ul>
</nav>
<div class="column">
<div class="column is-clipped">
{% block about_content %}{% endblock %}
</div>
</div>

View file

@ -24,7 +24,7 @@
</div>
{% endif %}
<form class="block" name="edit-author" action="{{ author.local_path }}/edit" method="post">
<form class="block" name="edit-author" action="{% url 'edit-author' author.id %}" method="post">
{% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">

View file

@ -113,7 +113,7 @@
{% 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' %}
</div>
@ -210,7 +210,7 @@
{% with work=book.parent_work %}
<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 %}
{{ count }} edition
{% plural %}
@ -254,7 +254,7 @@
<h2 class="title is-5">{% trans "Your reading activity" %}</h2>
</div>
<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="is-sr-only-mobile">
{% trans "Add read dates" %}
@ -392,7 +392,7 @@
</section>
{% endif %}
<section class="content block">
<section class="content block" id="tour-book-file-links">
{% include "book/file_links/links.html" %}
</section>
</div>
@ -405,4 +405,7 @@
{% block scripts %}
<script src="{% static "js/tabs.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 %}

View file

@ -41,10 +41,18 @@
class="block"
{% if book.id %}
name="edit-book"
action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}"
{% if confirm_mode %}
action="{% url 'edit-book-confirm' book.id %}"
{% else %}
action="{% url 'edit-book' book.id %}"
{% endif %}
{% else %}
name="create-book"
action="/create-book{% if confirm_mode %}/confirm{% endif %}"
{% if confirm_mode %}
action="{% url 'create-book-confirm' %}"
{% else %}
action="{% url 'create-book' %}"
{% endif %}
{% endif %}
method="post"
enctype="multipart/form-data"

View file

@ -42,7 +42,11 @@
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer">{{ link.url }}</a>
</td>
<td>
{% if link.added_by %}
<a href="{% url 'user-feed' link.added_by.id %}">{{ link.added_by.display_name }}</a>
{% else %}
<em>{% trans "Unknown user" %}</em>
{% endif %}
</td>
<td>
{{ link.filelink.filetype }}
@ -50,7 +54,7 @@
<td>
{{ link.domain.name }}
<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>
</td>
<td>

View file

@ -19,7 +19,7 @@ Is that where you'd like to go?
{% block modal-footer %}
{% if request.user.is_authenticated %}
<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>
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>

View file

@ -19,16 +19,8 @@
name="email"
class="input"
id="email"
aria-described-by="id_email_errors"
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>
{% endblock %}

View file

@ -3,7 +3,19 @@
{% block content %}
<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>
{% trans "View report" as text %}

View file

@ -2,7 +2,15 @@
{% load i18n %}
{% block content %}
{% blocktrans %}@{{ reporter}} has flagged behavior by @{{ reportee }} for moderation. {% endblocktrans %}
{% if report_link %}
{% blocktrans trimmed %}
@{{ reporter }} has flagged a link domain for moderation.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
@{{ reporter }} has flagged behavior by @{{ reportee }} for moderation.
{% endblocktrans %}
{% endif %}
{% trans "View report" %}
{{ report_link }}

View file

@ -14,7 +14,7 @@
</header>
<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>
<section class="block">
@ -30,3 +30,4 @@
</section>
{% endblock %}

View file

@ -1,5 +1,6 @@
{% extends 'feed/layout.html' %}
{% load i18n %}
{% load static %}
{% block panel %}
@ -73,3 +74,12 @@
{% endfor %}
{% 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 %}

View file

@ -1,6 +1,5 @@
{% extends 'layout.html' %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Updates" %}{% endblock %}
@ -30,6 +29,4 @@
</div>
{% endblock %}
{% block scripts %}
<script src="{% static "js/tabs.js" %}?v={{ js_cache }}"></script>
{% endblock %}

View file

@ -2,7 +2,7 @@
{% load feed_page_tags %}
{% suggested_books as suggested_books %}
<section class="block">
<section id="tour-suggested-books" class="block">
<h2 class="title is-4">{% trans "Your Books" %}</h2>
{% if not suggested_books %}

View file

@ -10,6 +10,7 @@
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
{% elif shelf.identifier == 'stopped-reading' %}{% trans "Stopped Reading" %}
{% else %}{{ shelf.name }}{% endif %}
</option>
{% endfor %}

View file

@ -5,7 +5,7 @@
<div class="column is-two-thirds">
<input type="hidden" name="user" value="{{ request.user.id }}" />
<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 }}
</div>
<div class="field">

View file

@ -22,7 +22,7 @@
</p>
</div>
{% 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 %}
{% include 'snippets/toggle/open_button.html' with controls_text="create_list" icon_with_text="plus" text=button_text focus="create_list_header" %}
</div>
@ -80,3 +80,9 @@
</div>
{% endblock %}
{% block scripts %}
{% if request.user.show_guided_tour %}
{% include 'guided_tour/group.html' %}
{% endif %}
{% endblock %}

View file

@ -10,7 +10,7 @@
<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' %}">
</div>
<div class="control">
<div class="control" id="tour-group-member-search">
<button class="button" type="submit">
<span class="icon icon-search" title="{% trans 'Search' %}">
<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>
</a>
{% 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>
{% endif %}

View 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>

View 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>

View 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>

View 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>

View 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 manally' %}",
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>

View 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>

View 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>

View 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>

View file

@ -32,6 +32,9 @@
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
OpenLibrary (CSV)
</option>
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
Calibre (CSV)
</option>
</select>
</div>

View file

@ -26,7 +26,16 @@
{% trans "Password:" %}
</label>
<div class="control">
<input type="password" name="password" maxlength="128" class="input" required="" id="id_new_password" aria-describedby="form_errors">
<input
type="password"
name="password"
maxlength="128"
class="input"
required=""
id="id_new_password"
aria-describedby="desc_password"
>
{% include 'snippets/form_errors.html' with errors_list=form.password.errors id="desc_password" %}
</div>
</div>
<div class="field">
@ -34,7 +43,8 @@
{% trans "Confirm password:" %}
</label>
<div class="control">
<input type="password" name="confirm-password" maxlength="128" class="input" required="" id="id_confirm_password" aria-describedby="form_errors">
{{ form.confirm_password }}
{% include 'snippets/form_errors.html' with errors_list=form.confirm_password.errors id="desc_confirm_password" %}
</div>
</div>
<div class="field is-grouped">

View file

@ -9,7 +9,13 @@
<div class="block">
<h1 class="title">{% trans "Reset Password" %}</h1>
{% if message %}<p class="notification is-primary">{{ message }}</p>{% endif %}
{% if sent_message %}
<p class="notification is-primary">
{% blocktrans trimmed %}
A password reset link will be sent to <strong>{{ email }}</strong> if there is an account using that email address.
{% endblocktrans %}
</p>
{% endif %}
<p>{% trans "A link to reset your password will be sent to your email address" %}</p>
<form name="password-reset" method="post" action="/password-reset">

View file

@ -47,7 +47,7 @@
{% else %}
{% trans "Search for a book" as search_placeholder %}
{% endif %}
<input aria-label="{{ search_placeholder }}" id="search_input" class="input" type="text" name="q" placeholder="{{ search_placeholder }}" value="{{ query }}">
<input aria-label="{{ search_placeholder }}" id="tour-search" class="input" type="text" name="q" placeholder="{{ search_placeholder }}" value="{{ query }}">
</div>
<div class="control">
<button class="button" type="submit">
@ -58,7 +58,7 @@
</div>
<div class="control">
<button class="button" type="button" data-modal-open="barcode-scanner-modal">
<span class="icon icon-barcode" title="{% trans 'Scan Barcode' %}">
<span class="icon icon-barcode" title="{% trans 'Scan Barcode' %}" id="tour-barcode">
<span class="is-sr-only">{% trans "Scan Barcode" %}</span>
</span>
</button>
@ -74,7 +74,7 @@
</div>
<div class="navbar-menu" id="main_nav">
<div class="navbar-start">
<div class="navbar-start" id="tour-navbar-start">
{% if request.user.is_authenticated %}
<a href="/#feed" class="navbar-item mt-3 py-0">
{% trans "Feed" %}
@ -94,7 +94,7 @@
{% include 'user_menu.html' %}
</div>
<div class="navbar-item mt-3 py-0">
<a href="{% url 'notifications' %}" class="tags has-addons">
<a href="{% url 'notifications' %}" class="tags has-addons" id="tour-notifications">
<span class="tag is-medium">
<span class="icon icon-bell" title="{% trans 'Notifications' %}">
<span class="is-sr-only">{% trans "Notifications" %}</span>
@ -189,6 +189,12 @@
<p>
<a href="https://docs.joinbookwyrm.com/">{% trans "Documentation" %}</a>
</p>
{% if request.user.is_authenticated %}
<p id="tour-begin">
<a href="/guided-tour/True">{% trans "Guided Tour" %}</a>
<noscript>(requires JavaScript)</noscript>
</p>
{% endif %}
</div>
<div class="column content is-two-fifth">
{% if site.support_link %}
@ -219,6 +225,8 @@
<script src="{% static "js/localstorage.js" %}?v={{ js_cache }}"></script>
<script src="{% static "js/status_cache.js" %}?v={{ js_cache }}"></script>
<script src="{% static "js/vendor/quagga.min.js" %}?v={{ js_cache }}"></script>
<script src="{% static "js/vendor/shepherd.min.js" %}?v={{ js_cache }}"></script>
<script src="{% static "js/guided_tour.js" %}?v={{ js_cache }}"></script>
{% block scripts %}{% endblock %}

View file

@ -6,7 +6,7 @@
<div class="columns">
<div class="column is-two-thirds">
<div class="field">
<label class="label" for="id_name">{% trans "Name:" %}</label>
<label class="label" for="id_name" id="tour-list-name">{% trans "Name:" %}</label>
{{ list_form.name }}
</div>
<div class="field">
@ -16,7 +16,7 @@
</div>
<div class="column">
<fieldset class="field">
<legend class="label">{% trans "List curation:" %}</legend>
<legend class="label" id="tour-list-curation">{% trans "List curation:" %}</legend>
<div class="field" data-hides="list_group_selector">
<input
@ -102,7 +102,7 @@
{% with user|username as username %}
{% url 'user-groups' user|username as url %}
<div>
<p>{% trans "You don't have any Groups yet!" %}</p>
<p id="tour-no-groups-yet">{% trans "You don't have any Groups yet!" %}</p>
<p>
<a class="help has-text-weight-normal" href="{{ url }}">{% trans "Create a Group" %}</a>
</p>
@ -123,7 +123,7 @@
</div>
{% endif %}
<div class="field has-addons">
<div class="control">
<div class="control" id="tour-privacy-select">
{% include 'snippets/privacy_select.html' with current=list.privacy %}
</div>
<div class="control">

View file

@ -16,7 +16,7 @@
</h1>
</div>
{% if request.user.is_authenticated %}
<div class="column is-narrow">
<div class="column is-narrow" id="tour-create-list">
{% 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" %}
</div>
@ -54,3 +54,9 @@
{% endif %}
{% endblock %}
{% block scripts %}
{% if request.user.show_guided_tour %}
{% include 'guided_tour/lists.html' %}
{% endif %}
{% endblock %}

View file

@ -13,8 +13,34 @@
{% block description %}
{% if other_user_count == 0 %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
accepted your invitation to join group "<a href="{{ group_path }}">{{ group_name }}</a>"
<a href="{{ related_user_link }}">{{ related_user }}</a>
accepted your invitation to join group
"<a href="{{ group_path }}">{{ group_name }}</a>"
{% endblocktrans %}
{% elif other_user_count == 1 %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
<a href="{{ second_user_link }}">{{ second_user }}</a>
accepted your invitation to join group
"<a href="{{ group_path }}">{{ group_name }}</a>"
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with group_name=notification.related_group.name group_path=notification.related_group.local_path %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
{{ other_user_display_count }} others
accepted your invitation to join group
"<a href="{{ group_path }}">{{ group_name }}</a>"
{% endblocktrans %}
{% endif %}
{% endblock %}

View file

@ -1,14 +1,16 @@
{% extends 'notifications/items/layout.html' %}
{% load i18n %}
{% load utilities %}
{% load humanize %}
{% block primary_link %}{% spaceless %}
{% if notification.related_list_item.approved %}
{{ notification.related_list_item.book_list.local_path }}
{% with related_list=notification.related_list_items.first.book_list %}
{% if related_list.curation != "curated" %}
{{ related_list.local_path }}
{% else %}
{% url 'list-curate' notification.related_list_item.book_list.id %}
{% url 'list-curate' related_list.id %}
{% endif %}
{% endwith %}
{% endspaceless %}{% endblock %}
{% block icon %}
@ -16,25 +18,89 @@
{% endblock %}
{% block description %}
{% with book_path=notification.related_list_item.book.local_path %}
{% with book_title=notification.related_list_item.book|book_title %}
{% with list_name=notification.related_list_item.book_list.name %}
{% with related_list=notification.related_list_items.first.book_list %}
{% with book_path=notification.related_list_items.first.book.local_path %}
{% with book_title=notification.related_list_items.first.book|book_title %}
{% with second_book_path=notification.related_list_items.all.1.book.local_path %}
{% with second_book_title=notification.related_list_items.all.1.book|book_title %}
{% with list_name=related_list.name %}
{% if notification.related_list_item.approved %}
{% blocktrans trimmed with list_path=notification.related_list_item.book_list.local_path %}
{% url 'list' related_list.id as list_path %}
{% url 'list-curate' related_list.id as list_curate_path %}
added <em><a href="{{ book_path }}">{{ book_title }}</a></em> to your list "<a href="{{ list_path }}">{{ list_name }}</a>"
{% endblocktrans %}
{% if notification.related_list_items.count == 1 %}
{% if related_list.curation != "curated" %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
added <em><a href="{{ book_path }}">{{ book_title }}</a></em>
to your list "<a href="{{ list_path }}">{{ list_name }}</a>"
{% endblocktrans %}
{% else %}
{% url 'list-curate' notification.related_list_item.book_list.id as list_path %}
{% blocktrans trimmed with list_path=list_path %}
suggested adding <em><a href="{{ book_path }}">{{ book_title }}</a></em> to your list "<a href="{{ list_path }}">{{ list_name }}</a>"
{% endblocktrans %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
suggested adding <em><a href="{{ book_path }}">{{ book_title }}</a></em>
to your list "<a href="{{ list_curate_path }}">{{ list_name }}</a>"
{% endblocktrans %}
{% endif %}
{% elif notification.related_list_items.count == 2 %}
{% if related_list.curation != "curated" %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
added <em><a href="{{ book_path }}">{{ book_title }}</a></em>
and <em><a href="{{ second_book_path }}">{{ second_book_title }}</a></em>
to your list "<a href="{{ list_path }}">{{ list_name }}</a>"
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
suggested adding <em><a href="{{ book_path }}">{{ book_title }}</a></em>
and <em><a href="{{ second_book_path }}">{{ second_book_title }}</a></em>
to your list "<a href="{{ list_curate_path }}">{{ list_name }}</a>"
{% endblocktrans %}
{% endif %}
{% else %}
{% with count=notification.related_list_items.count|add:"-2" %}
{% with display_count=count|intcomma %}
{% if related_list.curation != "curated" %}
{% blocktrans trimmed count counter=count %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
added <em><a href="{{ book_path }}">{{ book_title }}</a></em>,
<em><a href="{{ second_book_path }}">{{ second_book_title }}</a></em>,
and {{ display_count }} other book
to your list "<a href="{{ list_path }}">{{ list_name }}</a>"
{% plural %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
added <em><a href="{{ book_path }}">{{ book_title }}</a></em>,
<em><a href="{{ second_book_path }}">{{ second_book_title }}</a></em>,
and {{ display_count }} other books
to your list "<a href="{{ list_path }}">{{ list_name }}</a>"
{% endblocktrans %}
{% else %}
{% blocktrans trimmed count counter=count %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
suggested adding <em><a href="{{ book_path }}">{{ book_title }}</a></em>,
<em><a href="{{ second_book_path }}">{{ second_book_title }}</a></em>,
and {{ display_count }} other book
to your list "<a href="{{ list_curate_path }}">{{ list_name }}</a>"
{% plural %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
suggested adding <em><a href="{{ book_path }}">{{ book_title }}</a></em>,
<em><a href="{{ second_book_path }}">{{ second_book_title }}</a></em>,
and {{ display_count }} other books
to your list "<a href="{{ list_curate_path }}">{{ list_name }}</a>"
{% endblocktrans %}
{% endif %}
{% endwith %}
{% endwith %}
{% endif %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}

View file

@ -16,29 +16,97 @@
{% with related_status.local_path as related_path %}
{% if related_status.status_type == 'Review' %}
{% blocktrans trimmed %}
{% if other_user_count == 0 %}
boosted your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> boosted your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endblocktrans %}
{% elif other_user_count == 1 %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
<a href="{{ second_user_link }}">{{ second_user }}</a>
boosted your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> and {{ other_user_display_count }} others boosted your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endif %}
{% elif related_status.status_type == 'Comment' %}
{% blocktrans trimmed %}
{% if other_user_count == 0 %}
boosted your <a href="{{ related_path }}">comment on<em>{{ book_title }}</em></a>
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> boosted your <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endblocktrans %}
{% elif other_user_count == 1 %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
<a href="{{ second_user_link }}">{{ second_user }}</a>
boosted your <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> and {{ other_user_display_count }} others boosted your <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endif %}
{% elif related_status.status_type == 'Quotation' %}
{% blocktrans trimmed %}
{% if other_user_count == 0 %}
boosted your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> boosted your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endblocktrans %}
{% elif other_user_count == 1 %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
<a href="{{ second_user_link }}">{{ second_user }}</a>
boosted your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> and {{ other_user_display_count }} others boosted your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endif %}
{% else %}
{% blocktrans trimmed %}
{% if other_user_count == 0 %}
boosted your <a href="{{ related_path }}">status</a>
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> boosted your <a href="{{ related_path }}">status</a>
{% endblocktrans %}
{% endblocktrans %}
{% elif other_user_count == 1 %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
<a href="{{ second_user_link }}">{{ second_user }}</a>
boosted your <a href="{{ related_path }}">status</a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> and {{ other_user_display_count }} others boosted your <a href="{{ related_path }}">status</a>
{% endblocktrans %}
{% endif %}
{% endif %}
{% endwith %}
@ -50,7 +118,7 @@
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-muted{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
{% include 'notifications/items/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-text-muted">
{{ related_status.published_date|timesince }}

View file

@ -16,29 +16,98 @@
{% with related_status.local_path as related_path %}
{% if related_status.status_type == 'Review' %}
{% blocktrans trimmed %}
{% if other_user_count == 0 %}
liked your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> liked your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endblocktrans %}
{% elif other_user_count == 1 %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
<a href="{{ second_user_link }}">{{ second_user }}</a>
liked your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> and {{ other_user_display_count }} others liked your <a href="{{ related_path }}">review of <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endif %}
{% elif related_status.status_type == 'Comment' %}
{% blocktrans trimmed %}
{% if other_user_count == 0 %}
liked your <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> liked your <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endblocktrans %}
{% elif other_user_count == 1 %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
<a href="{{ second_user_link }}">{{ second_user }}</a>
liked your <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> and {{ other_user_display_count }} others liked your <a href="{{ related_path }}">comment on <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endif %}
{% elif related_status.status_type == 'Quotation' %}
{% blocktrans trimmed %}
{% if other_user_count == 0 %}
liked your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> liked your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endblocktrans %}
{% elif other_user_count == 1 %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
<a href="{{ second_user_link }}">{{ second_user }}</a>
liked your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> and {{ other_user_display_count }} others liked your <a href="{{ related_path }}">quote from <em>{{ book_title }}</em></a>
{% endblocktrans %}
{% endif %}
{% else %}
{% blocktrans trimmed %}
{% if other_user_count == 0 %}
liked your <a href="{{ related_path }}">status</a>
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> liked your <a href="{{ related_path }}">status</a>
{% endblocktrans %}
{% elif other_user_count == 1 %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a>
and
<a href="{{ second_user_link }}">{{ second_user }}</a>
liked your <a href="{{ related_path }}">status</a>
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<a href="{{ related_user_link }}">{{ related_user }}</a> and {{ other_user_display_count }} others liked your <a href="{{ related_path }}">status</a>
{% endblocktrans %}
{% endif %}
{% endblocktrans %}
{% endif %}
{% endwith %}
@ -50,7 +119,7 @@
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-muted{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
{% include 'notifications/items/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-text-muted">
{{ related_status.published_date|timesince }}

Some files were not shown because too many files have changed in this diff Show more