forked from mirrors/bookwyrm
Compare commits
2 commits
main
...
inline-for
Author | SHA1 | Date | |
---|---|---|---|
|
61588e25dc | ||
|
b9eb40924a |
284 changed files with 7152 additions and 21712 deletions
3
.github/workflows/pylint.yml
vendored
3
.github/workflows/pylint.yml
vendored
|
@ -21,7 +21,8 @@ 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/
|
||||
pylint bookwyrm/ --ignore=migrations,tests --disable=E1101,E1135,E1136,R0903,R0901,R0902,W0707,W0511,W0406,R0401,R0801
|
||||
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
**/vendor/*
|
|
@ -1,6 +0,0 @@
|
|||
[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
|
|
@ -6,7 +6,6 @@ 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
|
||||
|
|
18
README.md
18
README.md
|
@ -9,18 +9,21 @@ Social reading and reviewing, decentralized with ActivityPub
|
|||
- [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)
|
||||
- [Book data](#book-data)
|
||||
- [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 still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://joinbookwyrm.com/instances/) list.
|
||||
|
||||
You can request an invite by entering your email address at https://bookwyrm.social.
|
||||
|
||||
|
||||
## Contributing
|
||||
See [contributing](https://docs.joinbookwyrm.com/contributing.html) for code, translation or monetary contributions.
|
||||
See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions.
|
||||
|
||||
## About BookWyrm
|
||||
### What it is and isn't
|
||||
BookWyrm is a platform for social reading. You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree.
|
||||
BookWyrm is a platform for social reading! You can use it to track what you're reading, review books, and follow your friends. It isn't primarily meant for cataloguing or as a data-source for books, but it does do both of those things to some degree.
|
||||
|
||||
### The role of federation
|
||||
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.
|
||||
|
@ -75,5 +78,8 @@ Deployment
|
|||
- [Nginx](https://nginx.org/en/) HTTP server
|
||||
|
||||
|
||||
## Set up BookWyrm
|
||||
The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up BookWyrm in a [developer environment](https://docs.joinbookwyrm.com/install-dev.html) or [production](https://docs.joinbookwyrm.com/install-prod.html).
|
||||
## Book data
|
||||
The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
|
||||
|
||||
## Set up Bookwyrm
|
||||
The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up Bookwyrm in a [developer environment](https://docs.joinbookwyrm.com/developer-environment.html) or [production](https://docs.joinbookwyrm.com/installing-in-production.html).
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
""" 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
|
||||
|
@ -9,8 +8,6 @@ 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"""
|
||||
|
@ -42,12 +39,12 @@ def naive_parse(activity_objects, activity_json, serializer=None):
|
|||
activity_json["type"] = "PublicKey"
|
||||
|
||||
activity_type = activity_json.get("type")
|
||||
if activity_type in ["Question", "Article"]:
|
||||
return None
|
||||
try:
|
||||
serializer = activity_objects[activity_type]
|
||||
except KeyError as err:
|
||||
# we know this exists and that we can't handle it
|
||||
if activity_type in ["Question"]:
|
||||
return None
|
||||
raise ActivitySerializerError(err)
|
||||
|
||||
return serializer(activity_objects=activity_objects, **activity_json)
|
||||
|
@ -68,7 +65,7 @@ class ActivityObject:
|
|||
try:
|
||||
value = kwargs[field.name]
|
||||
if value in (None, MISSING, {}):
|
||||
raise KeyError("Missing required field", field.name)
|
||||
raise KeyError()
|
||||
try:
|
||||
is_subclass = issubclass(field.type, ActivityObject)
|
||||
except TypeError:
|
||||
|
@ -271,9 +268,9 @@ def resolve_remote_id(
|
|||
try:
|
||||
data = get_data(remote_id)
|
||||
except ConnectorException:
|
||||
logger.exception("Could not connect to host for remote_id: %s", remote_id)
|
||||
return None
|
||||
|
||||
raise ActivitySerializerError(
|
||||
f"Could not connect to host for remote_id: {remote_id}"
|
||||
)
|
||||
# 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"):
|
||||
|
|
|
@ -298,9 +298,8 @@ def add_status_on_create_command(sender, instance, created):
|
|||
priority = HIGH
|
||||
# check if this is an old status, de-prioritize if so
|
||||
# (this will happen if federation is very slow, or, more expectedly, on csv import)
|
||||
if instance.published_date < timezone.now() - timedelta(
|
||||
days=1
|
||||
) or instance.created_date < instance.published_date - timedelta(days=1):
|
||||
one_day = 60 * 60 * 24
|
||||
if (instance.created_date - instance.published_date).seconds > one_day:
|
||||
priority = LOW
|
||||
|
||||
add_status_task.apply_async(
|
||||
|
|
|
@ -19,11 +19,11 @@ def download_file(url, destination):
|
|||
with open(destination, "b+w") as outfile:
|
||||
outfile.write(stream.read())
|
||||
except (urllib.error.HTTPError, urllib.error.URLError):
|
||||
logger.info("Failed to download file %s", url)
|
||||
logger.error("Failed to download file %s", url)
|
||||
except OSError:
|
||||
logger.info("Couldn't open font file %s for writing", destination)
|
||||
logger.error("Couldn't open font file %s for writing", destination)
|
||||
except: # pylint: disable=bare-except
|
||||
logger.info("Unknown error in file download")
|
||||
logger.exception("Unknown error in file download")
|
||||
|
||||
|
||||
class BookwyrmConfig(AppConfig):
|
||||
|
|
|
@ -148,8 +148,8 @@ class SearchResult:
|
|||
|
||||
def __repr__(self):
|
||||
# pylint: disable=consider-using-f-string
|
||||
return "<SearchResult key={!r} title={!r} author={!r} confidence={!r}>".format(
|
||||
self.key, self.title, self.author, self.confidence
|
||||
return "<SearchResult key={!r} title={!r} author={!r}>".format(
|
||||
self.key, self.title, self.author
|
||||
)
|
||||
|
||||
def json(self):
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
""" functionality outline for a book data connector """
|
||||
from abc import ABC, abstractmethod
|
||||
import imghdr
|
||||
import ipaddress
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db import transaction
|
||||
|
@ -10,7 +11,7 @@ import requests
|
|||
from requests.exceptions import RequestException
|
||||
|
||||
from bookwyrm import activitypub, models, settings
|
||||
from .connector_manager import load_more_data, ConnectorException, raise_not_valid_url
|
||||
from .connector_manager import load_more_data, ConnectorException
|
||||
from .format_mappings import format_mappings
|
||||
|
||||
|
||||
|
@ -38,34 +39,62 @@ class AbstractMinimalConnector(ABC):
|
|||
for field in self_fields:
|
||||
setattr(self, field, getattr(info, field))
|
||||
|
||||
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}"
|
||||
def search(self, query, min_confidence=None, timeout=settings.QUERY_TIMEOUT):
|
||||
"""free text search"""
|
||||
params = {}
|
||||
if min_confidence:
|
||||
params["min_confidence"] = min_confidence
|
||||
|
||||
# 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}"
|
||||
data = self.get_search_data(
|
||||
f"{self.search_url}{query}",
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
)
|
||||
results = []
|
||||
|
||||
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]
|
||||
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)
|
||||
|
||||
@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, min_confidence):
|
||||
def parse_search_data(self, data):
|
||||
"""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"""
|
||||
|
@ -102,7 +131,7 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
try:
|
||||
work_data = self.get_work_from_edition_data(data)
|
||||
except (KeyError, ConnectorException) as err:
|
||||
logger.info(err)
|
||||
logger.exception(err)
|
||||
work_data = data
|
||||
|
||||
if not work_data or not edition_data:
|
||||
|
@ -225,6 +254,9 @@ 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,
|
||||
|
@ -238,7 +270,7 @@ def get_data(url, params=None, timeout=10):
|
|||
timeout=timeout,
|
||||
)
|
||||
except RequestException as err:
|
||||
logger.info(err)
|
||||
logger.exception(err)
|
||||
raise ConnectorException(err)
|
||||
|
||||
if not resp.ok:
|
||||
|
@ -246,7 +278,7 @@ def get_data(url, params=None, timeout=10):
|
|||
try:
|
||||
data = resp.json()
|
||||
except ValueError as err:
|
||||
logger.info(err)
|
||||
logger.exception(err)
|
||||
raise ConnectorException(err)
|
||||
|
||||
return data
|
||||
|
@ -264,7 +296,7 @@ def get_image(url, timeout=10):
|
|||
timeout=timeout,
|
||||
)
|
||||
except RequestException as err:
|
||||
logger.info(err)
|
||||
logger.exception(err)
|
||||
return None, None
|
||||
|
||||
if not resp.ok:
|
||||
|
@ -273,12 +305,26 @@ def get_image(url, timeout=10):
|
|||
image_content = ContentFile(resp.content)
|
||||
extension = imghdr.what(None, image_content.read())
|
||||
if not extension:
|
||||
logger.info("File requested was not an image: %s", url)
|
||||
logger.exception("File requested was not an image: %s", url)
|
||||
return None, None
|
||||
|
||||
return image_content, extension
|
||||
|
||||
|
||||
def raise_not_valid_url(url):
|
||||
"""do some basic reality checks on the url"""
|
||||
parsed = urlparse(url)
|
||||
if not parsed.scheme in ["http", "https"]:
|
||||
raise ConnectorException("Invalid scheme: ", url)
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(parsed.netloc)
|
||||
raise ConnectorException("Provided url is an IP address: ", url)
|
||||
except ValueError:
|
||||
# it's not an IP address, which is good
|
||||
pass
|
||||
|
||||
|
||||
class Mapping:
|
||||
"""associate a local database field with a field in an external dataset"""
|
||||
|
||||
|
@ -320,9 +366,3 @@ 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
|
||||
|
|
|
@ -10,12 +10,15 @@ 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, min_confidence):
|
||||
for search_result in data:
|
||||
search_result["connector"] = self
|
||||
yield SearchResult(**search_result)
|
||||
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_isbn_search_data(self, data):
|
||||
for search_result in data:
|
||||
search_result["connector"] = self
|
||||
yield SearchResult(**search_result)
|
||||
return data
|
||||
|
||||
def format_isbn_search_result(self, search_result):
|
||||
return self.format_search_result(search_result)
|
||||
|
|
|
@ -1,18 +1,17 @@
|
|||
""" interface with whatever connectors the app has """
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
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, USER_AGENT
|
||||
from bookwyrm.settings import SEARCH_TIMEOUT
|
||||
from bookwyrm.tasks import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -22,85 +21,53 @@ 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.exception(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 = []
|
||||
|
||||
items = []
|
||||
for connector in get_connectors():
|
||||
# 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))
|
||||
# Have we got a ISBN ?
|
||||
isbn = re.sub(r"[\W_]", "", query)
|
||||
maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13
|
||||
|
||||
# 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]
|
||||
start_time = datetime.now()
|
||||
for connector in get_connectors():
|
||||
result_set = None
|
||||
if maybe_isbn and connector.isbn_search_url and connector.isbn_search_url != "":
|
||||
# Search on ISBN
|
||||
try:
|
||||
result_set = connector.isbn_search(isbn)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
logger.exception(err)
|
||||
# if this fails, we can still try regular search
|
||||
|
||||
# if no isbn search results, we fallback to generic search
|
||||
if not result_set:
|
||||
try:
|
||||
result_set = connector.search(query, min_confidence=min_confidence)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
# we don't want *any* error to crash the whole search page
|
||||
logger.exception(err)
|
||||
continue
|
||||
|
||||
if return_first and result_set:
|
||||
# if we found anything, return it
|
||||
return result_set[0]
|
||||
|
||||
if result_set:
|
||||
results.append(
|
||||
{
|
||||
"connector": connector,
|
||||
"results": result_set,
|
||||
}
|
||||
)
|
||||
if (datetime.now() - start_time).seconds >= SEARCH_TIMEOUT:
|
||||
break
|
||||
|
||||
if return_first:
|
||||
# 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
|
||||
return None
|
||||
|
||||
# failed requests will return None, so filter those out
|
||||
return results
|
||||
|
||||
|
||||
|
@ -166,20 +133,3 @@ 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}")
|
||||
|
|
|
@ -77,42 +77,53 @@ class Connector(AbstractConnector):
|
|||
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
|
||||
}
|
||||
|
||||
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 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_isbn_search_data(self, data):
|
||||
"""got some daaaata"""
|
||||
results = data.get("entities")
|
||||
if not results:
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
def is_work_data(self, data):
|
||||
return data.get("type") == "work"
|
||||
|
|
|
@ -152,41 +152,39 @@ 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, 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 parse_search_data(self, data):
|
||||
return data.get("docs")
|
||||
|
||||
# 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 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,
|
||||
)
|
||||
|
||||
def parse_isbn_search_data(self, data):
|
||||
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"),
|
||||
)
|
||||
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"),
|
||||
)
|
||||
|
||||
def load_edition_data(self, olkey):
|
||||
"""query openlibrary for editions of a work"""
|
||||
|
|
576
bookwyrm/forms.py
Normal file
576
bookwyrm/forms.py
Normal file
|
@ -0,0 +1,576 @@
|
|||
""" using django model forms """
|
||||
import datetime
|
||||
from collections import defaultdict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django import forms
|
||||
from django.forms import ModelForm, PasswordInput, widgets, ChoiceField
|
||||
from django.forms.widgets import Textarea
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||
from bookwyrm.models.user import FeedFilterChoices
|
||||
|
||||
|
||||
class CustomForm(ModelForm):
|
||||
"""add css classes to the forms"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
css_classes = defaultdict(lambda: "")
|
||||
css_classes["text"] = "input"
|
||||
css_classes["password"] = "input"
|
||||
css_classes["email"] = "input"
|
||||
css_classes["number"] = "input"
|
||||
css_classes["checkbox"] = "checkbox"
|
||||
css_classes["textarea"] = "textarea"
|
||||
# pylint: disable=super-with-arguments
|
||||
super(CustomForm, self).__init__(*args, **kwargs)
|
||||
for visible in self.visible_fields():
|
||||
if hasattr(visible.field.widget, "input_type"):
|
||||
input_type = visible.field.widget.input_type
|
||||
if isinstance(visible.field.widget, Textarea):
|
||||
input_type = "textarea"
|
||||
visible.field.widget.attrs["rows"] = 5
|
||||
visible.field.widget.attrs["class"] = css_classes[input_type]
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class LoginForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["localname", "password"]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {
|
||||
"password": PasswordInput(),
|
||||
}
|
||||
|
||||
|
||||
class RegisterForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["localname", "email", "password"]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {"password": PasswordInput()}
|
||||
|
||||
def clean(self):
|
||||
"""Check if the username is taken"""
|
||||
cleaned_data = super().clean()
|
||||
localname = cleaned_data.get("localname").strip()
|
||||
if models.User.objects.filter(localname=localname).first():
|
||||
self.add_error("localname", _("User with this username already exists"))
|
||||
|
||||
|
||||
class RatingForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.ReviewRating
|
||||
fields = ["user", "book", "rating", "privacy"]
|
||||
|
||||
|
||||
class ReviewForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Review
|
||||
fields = [
|
||||
"user",
|
||||
"book",
|
||||
"name",
|
||||
"content",
|
||||
"rating",
|
||||
"content_warning",
|
||||
"sensitive",
|
||||
"privacy",
|
||||
]
|
||||
|
||||
|
||||
class CommentForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Comment
|
||||
fields = [
|
||||
"user",
|
||||
"book",
|
||||
"content",
|
||||
"content_warning",
|
||||
"sensitive",
|
||||
"privacy",
|
||||
"progress",
|
||||
"progress_mode",
|
||||
"reading_status",
|
||||
]
|
||||
|
||||
|
||||
class QuotationForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Quotation
|
||||
fields = [
|
||||
"user",
|
||||
"book",
|
||||
"quote",
|
||||
"content",
|
||||
"content_warning",
|
||||
"sensitive",
|
||||
"privacy",
|
||||
"position",
|
||||
"position_mode",
|
||||
]
|
||||
|
||||
|
||||
class ReplyForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = [
|
||||
"user",
|
||||
"content",
|
||||
"content_warning",
|
||||
"sensitive",
|
||||
"reply_parent",
|
||||
"privacy",
|
||||
]
|
||||
|
||||
|
||||
class StatusForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
||||
|
||||
|
||||
class DirectForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
||||
|
||||
|
||||
class EditUserForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"avatar",
|
||||
"name",
|
||||
"email",
|
||||
"summary",
|
||||
"show_goal",
|
||||
"show_suggested_users",
|
||||
"manually_approves_followers",
|
||||
"default_post_privacy",
|
||||
"discoverable",
|
||||
"hide_follows",
|
||||
"preferred_timezone",
|
||||
"preferred_language",
|
||||
"theme",
|
||||
]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {
|
||||
"avatar": ClearableFileInputWithWarning(
|
||||
attrs={"aria-describedby": "desc_avatar"}
|
||||
),
|
||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
||||
"email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
|
||||
"discoverable": forms.CheckboxInput(
|
||||
attrs={"aria-describedby": "desc_discoverable"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class LimitedEditUserForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"avatar",
|
||||
"name",
|
||||
"summary",
|
||||
"manually_approves_followers",
|
||||
"discoverable",
|
||||
]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {
|
||||
"avatar": ClearableFileInputWithWarning(
|
||||
attrs={"aria-describedby": "desc_avatar"}
|
||||
),
|
||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
||||
"discoverable": forms.CheckboxInput(
|
||||
attrs={"aria-describedby": "desc_discoverable"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class DeleteUserForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["password"]
|
||||
|
||||
|
||||
class UserGroupForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["groups"]
|
||||
|
||||
|
||||
class FeedStatusTypesForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["feed_status_types"]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {
|
||||
"feed_status_types": widgets.CheckboxSelectMultiple(
|
||||
choices=FeedFilterChoices,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class CoverForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Book
|
||||
fields = ["cover"]
|
||||
help_texts = {f: None for f in fields}
|
||||
|
||||
|
||||
class LinkDomainForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.LinkDomain
|
||||
fields = ["name"]
|
||||
|
||||
|
||||
class FileLinkForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.FileLink
|
||||
fields = ["url", "filetype", "availability", "book", "added_by"]
|
||||
|
||||
def clean(self):
|
||||
"""make sure the domain isn't blocked or pending"""
|
||||
cleaned_data = super().clean()
|
||||
url = cleaned_data.get("url")
|
||||
filetype = cleaned_data.get("filetype")
|
||||
book = cleaned_data.get("book")
|
||||
domain = urlparse(url).netloc
|
||||
if models.LinkDomain.objects.filter(domain=domain).exists():
|
||||
status = models.LinkDomain.objects.get(domain=domain).status
|
||||
if status == "blocked":
|
||||
# pylint: disable=line-too-long
|
||||
self.add_error(
|
||||
"url",
|
||||
_(
|
||||
"This domain is blocked. Please contact your administrator if you think this is an error."
|
||||
),
|
||||
)
|
||||
elif models.FileLink.objects.filter(
|
||||
url=url, book=book, filetype=filetype
|
||||
).exists():
|
||||
# pylint: disable=line-too-long
|
||||
self.add_error(
|
||||
"url",
|
||||
_(
|
||||
"This link with file type has already been added for this book. If it is not visible, the domain is still pending."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class EditionForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Edition
|
||||
exclude = [
|
||||
"remote_id",
|
||||
"origin_id",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"edition_rank",
|
||||
"authors",
|
||||
"parent_work",
|
||||
"shelves",
|
||||
"connector",
|
||||
"search_vector",
|
||||
"links",
|
||||
"file_links",
|
||||
]
|
||||
widgets = {
|
||||
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
|
||||
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
|
||||
"description": forms.Textarea(
|
||||
attrs={"aria-describedby": "desc_description"}
|
||||
),
|
||||
"series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
|
||||
"series_number": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_series_number"}
|
||||
),
|
||||
"languages": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_languages_help desc_languages"}
|
||||
),
|
||||
"publishers": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
|
||||
),
|
||||
"first_published_date": forms.SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_first_published_date"}
|
||||
),
|
||||
"published_date": forms.SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_published_date"}
|
||||
),
|
||||
"cover": ClearableFileInputWithWarning(
|
||||
attrs={"aria-describedby": "desc_cover"}
|
||||
),
|
||||
"physical_format": forms.Select(
|
||||
attrs={"aria-describedby": "desc_physical_format"}
|
||||
),
|
||||
"physical_format_detail": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_physical_format_detail"}
|
||||
),
|
||||
"pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
|
||||
"isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
|
||||
"isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
|
||||
"openlibrary_key": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_openlibrary_key"}
|
||||
),
|
||||
"inventaire_id": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_inventaire_id"}
|
||||
),
|
||||
"oclc_number": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_oclc_number"}
|
||||
),
|
||||
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
|
||||
}
|
||||
|
||||
|
||||
class AuthorForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Author
|
||||
fields = [
|
||||
"last_edited_by",
|
||||
"name",
|
||||
"aliases",
|
||||
"bio",
|
||||
"wikipedia_link",
|
||||
"born",
|
||||
"died",
|
||||
"openlibrary_key",
|
||||
"inventaire_id",
|
||||
"librarything_key",
|
||||
"goodreads_key",
|
||||
"isni",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||
"aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
|
||||
"bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
|
||||
"wikipedia_link": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_wikipedia_link"}
|
||||
),
|
||||
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
|
||||
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
|
||||
"oepnlibrary_key": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_oepnlibrary_key"}
|
||||
),
|
||||
"inventaire_id": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_inventaire_id"}
|
||||
),
|
||||
"librarything_key": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_librarything_key"}
|
||||
),
|
||||
"goodreads_key": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_goodreads_key"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ImportForm(forms.Form):
|
||||
csv_file = forms.FileField()
|
||||
|
||||
|
||||
class ExpiryWidget(widgets.Select):
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""human-readable exiration time buckets"""
|
||||
selected_string = super().value_from_datadict(data, files, name)
|
||||
|
||||
if selected_string == "day":
|
||||
interval = datetime.timedelta(days=1)
|
||||
elif selected_string == "week":
|
||||
interval = datetime.timedelta(days=7)
|
||||
elif selected_string == "month":
|
||||
interval = datetime.timedelta(days=31) # Close enough?
|
||||
elif selected_string == "forever":
|
||||
return None
|
||||
else:
|
||||
return selected_string # This will raise
|
||||
|
||||
return timezone.now() + interval
|
||||
|
||||
|
||||
class InviteRequestForm(CustomForm):
|
||||
def clean(self):
|
||||
"""make sure the email isn't in use by a registered user"""
|
||||
cleaned_data = super().clean()
|
||||
email = cleaned_data.get("email")
|
||||
if email and models.User.objects.filter(email=email).exists():
|
||||
self.add_error("email", _("A user with this email already exists."))
|
||||
|
||||
class Meta:
|
||||
model = models.InviteRequest
|
||||
fields = ["email"]
|
||||
|
||||
|
||||
class CreateInviteForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SiteInvite
|
||||
exclude = ["code", "user", "times_used", "invitees"]
|
||||
widgets = {
|
||||
"expiry": ExpiryWidget(
|
||||
choices=[
|
||||
("day", _("One Day")),
|
||||
("week", _("One Week")),
|
||||
("month", _("One Month")),
|
||||
("forever", _("Does Not Expire")),
|
||||
]
|
||||
),
|
||||
"use_limit": widgets.Select(
|
||||
choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
|
||||
+ [(None, _("Unlimited"))]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ShelfForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Shelf
|
||||
fields = ["user", "name", "privacy", "description"]
|
||||
|
||||
|
||||
class GoalForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.AnnualGoal
|
||||
fields = ["user", "year", "goal", "privacy"]
|
||||
|
||||
|
||||
class SiteForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SiteSettings
|
||||
exclude = ["admin_code", "install_mode"]
|
||||
widgets = {
|
||||
"instance_short_description": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_instance_short_description"}
|
||||
),
|
||||
"require_confirm_email": forms.CheckboxInput(
|
||||
attrs={"aria-describedby": "desc_require_confirm_email"}
|
||||
),
|
||||
"invite_request_text": forms.Textarea(
|
||||
attrs={"aria-describedby": "desc_invite_request_text"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SiteThemeForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SiteSettings
|
||||
fields = ["default_theme"]
|
||||
|
||||
|
||||
class ThemeForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Theme
|
||||
fields = ["name", "path"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||
"path": forms.Select(attrs={"aria-describedby": "desc_path"}),
|
||||
}
|
||||
|
||||
|
||||
class AnnouncementForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Announcement
|
||||
exclude = ["remote_id"]
|
||||
widgets = {
|
||||
"preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
|
||||
"content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
|
||||
"event_date": forms.SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_event_date"}
|
||||
),
|
||||
"start_date": forms.SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_start_date"}
|
||||
),
|
||||
"end_date": forms.SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_end_date"}
|
||||
),
|
||||
"active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
|
||||
}
|
||||
|
||||
|
||||
class ListForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.List
|
||||
fields = ["user", "name", "description", "curation", "privacy", "group"]
|
||||
|
||||
|
||||
class ListItemForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.ListItem
|
||||
fields = ["user", "book", "book_list", "notes"]
|
||||
|
||||
|
||||
class GroupForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Group
|
||||
fields = ["user", "privacy", "name", "description"]
|
||||
|
||||
|
||||
class ReportForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Report
|
||||
fields = ["user", "reporter", "status", "links", "note"]
|
||||
|
||||
|
||||
class EmailBlocklistForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.EmailBlocklist
|
||||
fields = ["domain"]
|
||||
widgets = {
|
||||
"avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
|
||||
}
|
||||
|
||||
|
||||
class IPBlocklistForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.IPBlocklist
|
||||
fields = ["address"]
|
||||
|
||||
|
||||
class ServerForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.FederatedServer
|
||||
exclude = ["remote_id"]
|
||||
|
||||
|
||||
class SortListForm(forms.Form):
|
||||
sort_by = ChoiceField(
|
||||
choices=(
|
||||
("order", _("List Order")),
|
||||
("title", _("Book Title")),
|
||||
("rating", _("Rating")),
|
||||
),
|
||||
label=_("Sort By"),
|
||||
)
|
||||
direction = ChoiceField(
|
||||
choices=(
|
||||
("ascending", _("Ascending")),
|
||||
("descending", _("Descending")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ReadThroughForm(CustomForm):
|
||||
def clean(self):
|
||||
"""make sure the email isn't in use by a registered user"""
|
||||
cleaned_data = super().clean()
|
||||
start_date = cleaned_data.get("start_date")
|
||||
finish_date = cleaned_data.get("finish_date")
|
||||
if start_date and finish_date and start_date > finish_date:
|
||||
self.add_error(
|
||||
"finish_date", _("Reading finish date cannot be before start date.")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.ReadThrough
|
||||
fields = ["user", "book", "start_date", "finish_date"]
|
||||
|
||||
|
||||
class AutoModRuleForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.AutoMod
|
||||
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
|
|
@ -1,12 +0,0 @@
|
|||
""" make forms available to the app """
|
||||
# site admin
|
||||
from .admin import *
|
||||
from .author import *
|
||||
from .books import *
|
||||
from .edit_user import *
|
||||
from .forms import *
|
||||
from .groups import *
|
||||
from .landing import *
|
||||
from .links import *
|
||||
from .lists import *
|
||||
from .status import *
|
|
@ -1,141 +0,0 @@
|
|||
""" using django model forms """
|
||||
import datetime
|
||||
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_celery_beat.models import IntervalSchedule
|
||||
|
||||
from bookwyrm import models
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class ExpiryWidget(widgets.Select):
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""human-readable exiration time buckets"""
|
||||
selected_string = super().value_from_datadict(data, files, name)
|
||||
|
||||
if selected_string == "day":
|
||||
interval = datetime.timedelta(days=1)
|
||||
elif selected_string == "week":
|
||||
interval = datetime.timedelta(days=7)
|
||||
elif selected_string == "month":
|
||||
interval = datetime.timedelta(days=31) # Close enough?
|
||||
elif selected_string == "forever":
|
||||
return None
|
||||
else:
|
||||
return selected_string # This will raise
|
||||
|
||||
return timezone.now() + interval
|
||||
|
||||
|
||||
class CreateInviteForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SiteInvite
|
||||
exclude = ["code", "user", "times_used", "invitees"]
|
||||
widgets = {
|
||||
"expiry": ExpiryWidget(
|
||||
choices=[
|
||||
("day", _("One Day")),
|
||||
("week", _("One Week")),
|
||||
("month", _("One Month")),
|
||||
("forever", _("Does Not Expire")),
|
||||
]
|
||||
),
|
||||
"use_limit": widgets.Select(
|
||||
choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
|
||||
+ [(None, _("Unlimited"))]
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class SiteForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.SiteSettings
|
||||
exclude = ["admin_code", "install_mode"]
|
||||
widgets = {
|
||||
"instance_short_description": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_instance_short_description"}
|
||||
),
|
||||
"require_confirm_email": forms.CheckboxInput(
|
||||
attrs={"aria-describedby": "desc_require_confirm_email"}
|
||||
),
|
||||
"invite_request_text": forms.Textarea(
|
||||
attrs={"aria-describedby": "desc_invite_request_text"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ThemeForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Theme
|
||||
fields = ["name", "path"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||
"path": forms.TextInput(
|
||||
attrs={
|
||||
"aria-describedby": "desc_path",
|
||||
"placeholder": "css/themes/theme-name.scss",
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class AnnouncementForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Announcement
|
||||
exclude = ["remote_id"]
|
||||
widgets = {
|
||||
"preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
|
||||
"content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
|
||||
"event_date": forms.SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_event_date"}
|
||||
),
|
||||
"start_date": forms.SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_start_date"}
|
||||
),
|
||||
"end_date": forms.SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_end_date"}
|
||||
),
|
||||
"active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
|
||||
}
|
||||
|
||||
|
||||
class EmailBlocklistForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.EmailBlocklist
|
||||
fields = ["domain"]
|
||||
widgets = {
|
||||
"avatar": forms.TextInput(attrs={"aria-describedby": "desc_domain"}),
|
||||
}
|
||||
|
||||
|
||||
class IPBlocklistForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.IPBlocklist
|
||||
fields = ["address"]
|
||||
|
||||
|
||||
class ServerForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.FederatedServer
|
||||
exclude = ["remote_id"]
|
||||
|
||||
|
||||
class AutoModRuleForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.AutoMod
|
||||
fields = ["string_match", "flag_users", "flag_statuses", "created_by"]
|
||||
|
||||
|
||||
class IntervalScheduleForm(CustomForm):
|
||||
class Meta:
|
||||
model = IntervalSchedule
|
||||
fields = ["every", "period"]
|
||||
|
||||
widgets = {
|
||||
"every": forms.NumberInput(attrs={"aria-describedby": "desc_every"}),
|
||||
"period": forms.Select(attrs={"aria-describedby": "desc_period"}),
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
""" using django model forms """
|
||||
from django import forms
|
||||
|
||||
from bookwyrm import models
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class AuthorForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Author
|
||||
fields = [
|
||||
"last_edited_by",
|
||||
"name",
|
||||
"aliases",
|
||||
"bio",
|
||||
"wikipedia_link",
|
||||
"born",
|
||||
"died",
|
||||
"openlibrary_key",
|
||||
"inventaire_id",
|
||||
"librarything_key",
|
||||
"goodreads_key",
|
||||
"isni",
|
||||
]
|
||||
widgets = {
|
||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||
"aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
|
||||
"bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
|
||||
"wikipedia_link": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_wikipedia_link"}
|
||||
),
|
||||
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
|
||||
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
|
||||
"oepnlibrary_key": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_oepnlibrary_key"}
|
||||
),
|
||||
"inventaire_id": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_inventaire_id"}
|
||||
),
|
||||
"librarything_key": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_librarything_key"}
|
||||
),
|
||||
"goodreads_key": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_goodreads_key"}
|
||||
),
|
||||
}
|
|
@ -1,104 +0,0 @@
|
|||
""" using django model forms """
|
||||
from django import forms
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||
from .custom_form import CustomForm
|
||||
from .widgets import ArrayWidget, SelectDateWidget, Select
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class CoverForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Book
|
||||
fields = ["cover"]
|
||||
help_texts = {f: None for f in fields}
|
||||
|
||||
|
||||
class EditionForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Edition
|
||||
exclude = [
|
||||
"remote_id",
|
||||
"origin_id",
|
||||
"created_date",
|
||||
"updated_date",
|
||||
"edition_rank",
|
||||
"authors",
|
||||
"parent_work",
|
||||
"shelves",
|
||||
"connector",
|
||||
"search_vector",
|
||||
"links",
|
||||
"file_links",
|
||||
]
|
||||
widgets = {
|
||||
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
|
||||
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
|
||||
"description": forms.Textarea(
|
||||
attrs={"aria-describedby": "desc_description"}
|
||||
),
|
||||
"series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
|
||||
"series_number": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_series_number"}
|
||||
),
|
||||
"subjects": ArrayWidget(),
|
||||
"languages": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_languages_help desc_languages"}
|
||||
),
|
||||
"publishers": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
|
||||
),
|
||||
"first_published_date": SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_first_published_date"}
|
||||
),
|
||||
"published_date": SelectDateWidget(
|
||||
attrs={"aria-describedby": "desc_published_date"}
|
||||
),
|
||||
"cover": ClearableFileInputWithWarning(
|
||||
attrs={"aria-describedby": "desc_cover"}
|
||||
),
|
||||
"physical_format": Select(
|
||||
attrs={"aria-describedby": "desc_physical_format"}
|
||||
),
|
||||
"physical_format_detail": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_physical_format_detail"}
|
||||
),
|
||||
"pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
|
||||
"isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
|
||||
"isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
|
||||
"openlibrary_key": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_openlibrary_key"}
|
||||
),
|
||||
"inventaire_id": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_inventaire_id"}
|
||||
),
|
||||
"oclc_number": forms.TextInput(
|
||||
attrs={"aria-describedby": "desc_oclc_number"}
|
||||
),
|
||||
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
|
||||
}
|
||||
|
||||
|
||||
class EditionFromWorkForm(CustomForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# make all fields hidden
|
||||
for visible in self.visible_fields():
|
||||
visible.field.widget = forms.HiddenInput()
|
||||
|
||||
class Meta:
|
||||
model = models.Work
|
||||
fields = [
|
||||
"title",
|
||||
"subtitle",
|
||||
"authors",
|
||||
"description",
|
||||
"languages",
|
||||
"series",
|
||||
"series_number",
|
||||
"subjects",
|
||||
"subject_places",
|
||||
"cover",
|
||||
"first_published_date",
|
||||
]
|
|
@ -1,26 +0,0 @@
|
|||
""" Overrides django's default form class """
|
||||
from collections import defaultdict
|
||||
from django.forms import ModelForm
|
||||
from django.forms.widgets import Textarea
|
||||
|
||||
|
||||
class CustomForm(ModelForm):
|
||||
"""add css classes to the forms"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
css_classes = defaultdict(lambda: "")
|
||||
css_classes["text"] = "input"
|
||||
css_classes["password"] = "input"
|
||||
css_classes["email"] = "input"
|
||||
css_classes["number"] = "input"
|
||||
css_classes["checkbox"] = "checkbox"
|
||||
css_classes["textarea"] = "textarea"
|
||||
# pylint: disable=super-with-arguments
|
||||
super(CustomForm, self).__init__(*args, **kwargs)
|
||||
for visible in self.visible_fields():
|
||||
if hasattr(visible.field.widget, "input_type"):
|
||||
input_type = visible.field.widget.input_type
|
||||
if isinstance(visible.field.widget, Textarea):
|
||||
input_type = "textarea"
|
||||
visible.field.widget.attrs["rows"] = 5
|
||||
visible.field.widget.attrs["class"] = css_classes[input_type]
|
|
@ -1,68 +0,0 @@
|
|||
""" using django model forms """
|
||||
from django import forms
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models.fields import ClearableFileInputWithWarning
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class EditUserForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"avatar",
|
||||
"name",
|
||||
"email",
|
||||
"summary",
|
||||
"show_goal",
|
||||
"show_suggested_users",
|
||||
"manually_approves_followers",
|
||||
"default_post_privacy",
|
||||
"discoverable",
|
||||
"hide_follows",
|
||||
"preferred_timezone",
|
||||
"preferred_language",
|
||||
"theme",
|
||||
]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {
|
||||
"avatar": ClearableFileInputWithWarning(
|
||||
attrs={"aria-describedby": "desc_avatar"}
|
||||
),
|
||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
||||
"email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
|
||||
"discoverable": forms.CheckboxInput(
|
||||
attrs={"aria-describedby": "desc_discoverable"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class LimitedEditUserForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [
|
||||
"avatar",
|
||||
"name",
|
||||
"summary",
|
||||
"manually_approves_followers",
|
||||
"discoverable",
|
||||
]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {
|
||||
"avatar": ClearableFileInputWithWarning(
|
||||
attrs={"aria-describedby": "desc_avatar"}
|
||||
),
|
||||
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
|
||||
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
|
||||
"discoverable": forms.CheckboxInput(
|
||||
attrs={"aria-describedby": "desc_discoverable"}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class DeleteUserForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["password"]
|
|
@ -1,64 +0,0 @@
|
|||
""" using django model forms """
|
||||
from django import forms
|
||||
from django.forms import widgets
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.models.user import FeedFilterChoices
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class FeedStatusTypesForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["feed_status_types"]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {
|
||||
"feed_status_types": widgets.CheckboxSelectMultiple(
|
||||
choices=FeedFilterChoices,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ImportForm(forms.Form):
|
||||
csv_file = forms.FileField()
|
||||
|
||||
|
||||
class ShelfForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Shelf
|
||||
fields = ["user", "name", "privacy", "description"]
|
||||
|
||||
|
||||
class GoalForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.AnnualGoal
|
||||
fields = ["user", "year", "goal", "privacy"]
|
||||
|
||||
|
||||
class ReportForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Report
|
||||
fields = ["user", "reporter", "status", "links", "note"]
|
||||
|
||||
|
||||
class ReadThroughForm(CustomForm):
|
||||
def clean(self):
|
||||
"""don't let readthroughs end before they start"""
|
||||
cleaned_data = super().clean()
|
||||
start_date = cleaned_data.get("start_date")
|
||||
finish_date = cleaned_data.get("finish_date")
|
||||
if start_date and finish_date and start_date > finish_date:
|
||||
self.add_error(
|
||||
"finish_date", _("Reading finish date cannot be before start date.")
|
||||
)
|
||||
stopped_date = cleaned_data.get("stopped_date")
|
||||
if start_date and stopped_date and start_date > stopped_date:
|
||||
self.add_error(
|
||||
"stopped_date", _("Reading stopped date cannot be before start date.")
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.ReadThrough
|
||||
fields = ["user", "book", "start_date", "finish_date", "stopped_date"]
|
|
@ -1,16 +0,0 @@
|
|||
""" 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"]
|
||||
|
||||
|
||||
class GroupForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Group
|
||||
fields = ["user", "privacy", "name", "description"]
|
|
@ -1,45 +0,0 @@
|
|||
""" Forms for the landing pages """
|
||||
from django.forms import PasswordInput
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm import models
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class LoginForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["localname", "password"]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {
|
||||
"password": PasswordInput(),
|
||||
}
|
||||
|
||||
|
||||
class RegisterForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["localname", "email", "password"]
|
||||
help_texts = {f: None for f in fields}
|
||||
widgets = {"password": PasswordInput()}
|
||||
|
||||
def clean(self):
|
||||
"""Check if the username is taken"""
|
||||
cleaned_data = super().clean()
|
||||
localname = cleaned_data.get("localname").strip()
|
||||
if models.User.objects.filter(localname=localname).first():
|
||||
self.add_error("localname", _("User with this username already exists"))
|
||||
|
||||
|
||||
class InviteRequestForm(CustomForm):
|
||||
def clean(self):
|
||||
"""make sure the email isn't in use by a registered user"""
|
||||
cleaned_data = super().clean()
|
||||
email = cleaned_data.get("email")
|
||||
if email and models.User.objects.filter(email=email).exists():
|
||||
self.add_error("email", _("A user with this email already exists."))
|
||||
|
||||
class Meta:
|
||||
model = models.InviteRequest
|
||||
fields = ["email", "answer"]
|
|
@ -1,48 +0,0 @@
|
|||
""" using django model forms """
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm import models
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class LinkDomainForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.LinkDomain
|
||||
fields = ["name"]
|
||||
|
||||
|
||||
class FileLinkForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.FileLink
|
||||
fields = ["url", "filetype", "availability", "book", "added_by"]
|
||||
|
||||
def clean(self):
|
||||
"""make sure the domain isn't blocked or pending"""
|
||||
cleaned_data = super().clean()
|
||||
url = cleaned_data.get("url")
|
||||
filetype = cleaned_data.get("filetype")
|
||||
book = cleaned_data.get("book")
|
||||
domain = urlparse(url).netloc
|
||||
if models.LinkDomain.objects.filter(domain=domain).exists():
|
||||
status = models.LinkDomain.objects.get(domain=domain).status
|
||||
if status == "blocked":
|
||||
# pylint: disable=line-too-long
|
||||
self.add_error(
|
||||
"url",
|
||||
_(
|
||||
"This domain is blocked. Please contact your administrator if you think this is an error."
|
||||
),
|
||||
)
|
||||
elif models.FileLink.objects.filter(
|
||||
url=url, book=book, filetype=filetype
|
||||
).exists():
|
||||
# pylint: disable=line-too-long
|
||||
self.add_error(
|
||||
"url",
|
||||
_(
|
||||
"This link with file type has already been added for this book. If it is not visible, the domain is still pending."
|
||||
),
|
||||
)
|
|
@ -1,37 +0,0 @@
|
|||
""" using django model forms """
|
||||
from django import forms
|
||||
from django.forms import ChoiceField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from bookwyrm import models
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class ListForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.List
|
||||
fields = ["user", "name", "description", "curation", "privacy", "group"]
|
||||
|
||||
|
||||
class ListItemForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.ListItem
|
||||
fields = ["user", "book", "book_list", "notes"]
|
||||
|
||||
|
||||
class SortListForm(forms.Form):
|
||||
sort_by = ChoiceField(
|
||||
choices=(
|
||||
("order", _("List Order")),
|
||||
("title", _("Book Title")),
|
||||
("rating", _("Rating")),
|
||||
),
|
||||
label=_("Sort By"),
|
||||
)
|
||||
direction = ChoiceField(
|
||||
choices=(
|
||||
("ascending", _("Ascending")),
|
||||
("descending", _("Descending")),
|
||||
),
|
||||
)
|
|
@ -1,82 +0,0 @@
|
|||
""" using django model forms """
|
||||
from bookwyrm import models
|
||||
from .custom_form import CustomForm
|
||||
|
||||
|
||||
# pylint: disable=missing-class-docstring
|
||||
class RatingForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.ReviewRating
|
||||
fields = ["user", "book", "rating", "privacy"]
|
||||
|
||||
|
||||
class ReviewForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Review
|
||||
fields = [
|
||||
"user",
|
||||
"book",
|
||||
"name",
|
||||
"content",
|
||||
"rating",
|
||||
"content_warning",
|
||||
"sensitive",
|
||||
"privacy",
|
||||
]
|
||||
|
||||
|
||||
class CommentForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Comment
|
||||
fields = [
|
||||
"user",
|
||||
"book",
|
||||
"content",
|
||||
"content_warning",
|
||||
"sensitive",
|
||||
"privacy",
|
||||
"progress",
|
||||
"progress_mode",
|
||||
"reading_status",
|
||||
]
|
||||
|
||||
|
||||
class QuotationForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Quotation
|
||||
fields = [
|
||||
"user",
|
||||
"book",
|
||||
"quote",
|
||||
"content",
|
||||
"content_warning",
|
||||
"sensitive",
|
||||
"privacy",
|
||||
"position",
|
||||
"position_mode",
|
||||
]
|
||||
|
||||
|
||||
class ReplyForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = [
|
||||
"user",
|
||||
"content",
|
||||
"content_warning",
|
||||
"sensitive",
|
||||
"reply_parent",
|
||||
"privacy",
|
||||
]
|
||||
|
||||
|
||||
class StatusForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
||||
|
||||
|
||||
class DirectForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Status
|
||||
fields = ["user", "content", "content_warning", "sensitive", "privacy"]
|
|
@ -1,70 +0,0 @@
|
|||
""" using django model forms """
|
||||
from django import forms
|
||||
|
||||
|
||||
class ArrayWidget(forms.widgets.TextInput):
|
||||
"""Inputs for postgres array fields"""
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=no-self-use
|
||||
def value_from_datadict(self, data, files, name):
|
||||
"""get all values for this name"""
|
||||
return [i for i in data.getlist(name) if i]
|
||||
|
||||
|
||||
class Select(forms.Select):
|
||||
"""custom template for select widget"""
|
||||
|
||||
template_name = "widgets/select.html"
|
||||
|
||||
|
||||
class SelectDateWidget(forms.SelectDateWidget):
|
||||
"""
|
||||
A widget that splits date input into two <select> boxes and a numerical year.
|
||||
"""
|
||||
|
||||
template_name = "widgets/addon_multiwidget.html"
|
||||
select_widget = Select
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
"""sets individual widgets"""
|
||||
context = super().get_context(name, value, attrs)
|
||||
date_context = {}
|
||||
year_name = self.year_field % name
|
||||
date_context["year"] = forms.NumberInput().get_context(
|
||||
name=year_name,
|
||||
value=context["widget"]["value"]["year"],
|
||||
attrs={
|
||||
**context["widget"]["attrs"],
|
||||
"id": f"id_{year_name}",
|
||||
"class": "input",
|
||||
},
|
||||
)
|
||||
month_choices = list(self.months.items())
|
||||
if not self.is_required:
|
||||
month_choices.insert(0, self.month_none_value)
|
||||
month_name = self.month_field % name
|
||||
date_context["month"] = self.select_widget(
|
||||
attrs, choices=month_choices
|
||||
).get_context(
|
||||
name=month_name,
|
||||
value=context["widget"]["value"]["month"],
|
||||
attrs={**context["widget"]["attrs"], "id": f"id_{month_name}"},
|
||||
)
|
||||
day_choices = [(i, i) for i in range(1, 32)]
|
||||
if not self.is_required:
|
||||
day_choices.insert(0, self.day_none_value)
|
||||
day_name = self.day_field % name
|
||||
date_context["day"] = self.select_widget(
|
||||
attrs,
|
||||
choices=day_choices,
|
||||
).get_context(
|
||||
name=day_name,
|
||||
value=context["widget"]["value"]["day"],
|
||||
attrs={**context["widget"]["attrs"], "id": f"id_{day_name}"},
|
||||
)
|
||||
subwidgets = []
|
||||
for field in self._parse_date_fmt():
|
||||
subwidgets.append(date_context[field]["widget"])
|
||||
context["widget"]["subwidgets"] = subwidgets
|
||||
return context
|
|
@ -1,7 +1,6 @@
|
|||
""" 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
|
||||
|
|
|
@ -1,28 +0,0 @@
|
|||
""" 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. Go with a default one for now
|
||||
return Shelf.TO_READ
|
|
@ -1,8 +1,5 @@
|
|||
""" handle reading a tsv from librarything """
|
||||
import re
|
||||
|
||||
from bookwyrm.models import Shelf
|
||||
|
||||
from . import Importer
|
||||
|
||||
|
||||
|
@ -24,7 +21,7 @@ class LibrarythingImporter(Importer):
|
|||
|
||||
def get_shelf(self, normalized_row):
|
||||
if normalized_row["date_finished"]:
|
||||
return Shelf.READ_FINISHED
|
||||
return "read"
|
||||
if normalized_row["date_started"]:
|
||||
return Shelf.READING
|
||||
return Shelf.TO_READ
|
||||
return "reading"
|
||||
return "to-read"
|
||||
|
|
32
bookwyrm/management/commands/compilethemes.py
Normal file
32
bookwyrm/management/commands/compilethemes.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
""" Compile themes """
|
||||
import os
|
||||
from django.contrib.staticfiles.utils import get_files
|
||||
from django.contrib.staticfiles.storage import StaticFilesStorage
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
import sass
|
||||
from sass_processor.processor import SassProcessor
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
class Command(BaseCommand):
|
||||
"""Compile themes"""
|
||||
|
||||
help = "Compile theme scss files"
|
||||
|
||||
# pylint: disable=no-self-use,unused-argument
|
||||
def handle(self, *args, **options):
|
||||
"""compile themes"""
|
||||
storage = StaticFilesStorage()
|
||||
theme_files = list(get_files(storage, location="css/themes"))
|
||||
theme_files = [t for t in theme_files if t[-5:] == ".scss"]
|
||||
for filename in theme_files:
|
||||
path = storage.path(filename)
|
||||
content = sass.compile(
|
||||
filename=path,
|
||||
include_paths=SassProcessor.include_paths,
|
||||
)
|
||||
basename, _ = os.path.splitext(path)
|
||||
destination_filename = basename + ".css"
|
||||
print(f"saving f{destination_filename}")
|
||||
storage.save(destination_filename, ContentFile(content))
|
|
@ -56,17 +56,12 @@ class Command(BaseCommand):
|
|||
self.stdout.write(" OK 🖼")
|
||||
|
||||
# Books
|
||||
book_ids = (
|
||||
models.Book.objects.select_subclasses()
|
||||
.filter()
|
||||
.values_list("id", flat=True)
|
||||
)
|
||||
|
||||
books = models.Book.objects.select_subclasses().filter()
|
||||
self.stdout.write(
|
||||
" → Book preview images ({}): ".format(len(book_ids)), ending=""
|
||||
" → Book preview images ({}): ".format(len(books)), ending=""
|
||||
)
|
||||
for book_id in book_ids:
|
||||
preview_images.generate_edition_preview_image_task.delay(book_id)
|
||||
for book in books:
|
||||
preview_images.generate_edition_preview_image_task.delay(book.id)
|
||||
self.stdout.write(".", ending="")
|
||||
self.stdout.write(" OK 🖼")
|
||||
|
||||
|
|
|
@ -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=1,
|
||||
priority=3,
|
||||
)
|
||||
|
||||
models.Connector.objects.create(
|
||||
|
@ -101,10 +101,20 @@ 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=1,
|
||||
priority=3,
|
||||
)
|
||||
|
||||
|
||||
def init_federated_servers():
|
||||
"""big no to nazis"""
|
||||
built_in_blocks = ["gab.ai", "gab.com"]
|
||||
for server in built_in_blocks:
|
||||
models.FederatedServer.objects.create(
|
||||
server_name=server,
|
||||
status="blocked",
|
||||
)
|
||||
|
||||
|
||||
def init_settings():
|
||||
"""info about the instance"""
|
||||
models.SiteSettings.objects.create(
|
||||
|
@ -153,6 +163,7 @@ class Command(BaseCommand):
|
|||
"group",
|
||||
"permission",
|
||||
"connector",
|
||||
"federatedserver",
|
||||
"settings",
|
||||
"linkdomain",
|
||||
]
|
||||
|
@ -165,6 +176,8 @@ class Command(BaseCommand):
|
|||
init_permissions()
|
||||
if not limit or limit == "connector":
|
||||
init_connectors()
|
||||
if not limit or limit == "federatedserver":
|
||||
init_federated_servers()
|
||||
if not limit or limit == "settings":
|
||||
init_settings()
|
||||
if not limit or limit == "linkdomain":
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
""" Get your admin code to allow install """
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from bookwyrm import models
|
||||
from bookwyrm.settings import VERSION
|
||||
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
class Command(BaseCommand):
|
||||
"""command-line options"""
|
||||
|
||||
help = "What version is this?"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
"""specify which function to run"""
|
||||
parser.add_argument(
|
||||
"--current",
|
||||
action="store_true",
|
||||
help="Version stored in database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
action="store_true",
|
||||
help="Version stored in settings",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update",
|
||||
action="store_true",
|
||||
help="Update database version",
|
||||
)
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def handle(self, *args, **options):
|
||||
"""execute init"""
|
||||
site = models.SiteSettings.objects.get()
|
||||
current = site.version or "0.0.1"
|
||||
target = VERSION
|
||||
if options.get("current"):
|
||||
print(current)
|
||||
return
|
||||
|
||||
if options.get("target"):
|
||||
print(target)
|
||||
return
|
||||
|
||||
if options.get("update"):
|
||||
site.version = target
|
||||
site.save()
|
||||
return
|
||||
|
||||
if current != target:
|
||||
print(f"{current}/{target}")
|
||||
else:
|
||||
print(current)
|
|
@ -1,18 +0,0 @@
|
|||
# Generated by Django 3.2.12 on 2022-03-16 18:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0144_alter_announcement_display_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="version",
|
||||
field=models.CharField(blank=True, max_length=10, null=True),
|
||||
),
|
||||
]
|
|
@ -1,80 +0,0 @@
|
|||
# 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),
|
||||
]
|
|
@ -1,30 +0,0 @@
|
|||
# Generated by Django 3.2.12 on 2022-03-16 23:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0145_sitesettings_version"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="inviterequest",
|
||||
name="answer",
|
||||
field=models.TextField(blank=True, max_length=50, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="invite_question_text",
|
||||
field=models.CharField(
|
||||
blank=True, default="What is your favourite book?", max_length=255
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="sitesettings",
|
||||
name="invite_request_question",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
|
@ -1,38 +0,0 @@
|
|||
# Generated by Django 3.2.12 on 2022-03-26 16:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0146_auto_20220316_2352"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="preferred_language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("de-de", "Deutsch (German)"),
|
||||
("es-es", "Español (Spanish)"),
|
||||
("gl-es", "Galego (Galician)"),
|
||||
("it-it", "Italiano (Italian)"),
|
||||
("fr-fr", "Français (French)"),
|
||||
("lt-lt", "Lietuvių (Lithuanian)"),
|
||||
("no-no", "Norsk (Norwegian)"),
|
||||
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
|
||||
("pt-pt", "Português Europeu (European Portuguese)"),
|
||||
("ro-ro", "Română (Romanian)"),
|
||||
("sv-se", "Svenska (Swedish)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,39 +0,0 @@
|
|||
# Generated by Django 3.2.12 on 2022-03-31 14:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0147_alter_user_preferred_language"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="preferred_language",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("en-us", "English"),
|
||||
("de-de", "Deutsch (German)"),
|
||||
("es-es", "Español (Spanish)"),
|
||||
("gl-es", "Galego (Galician)"),
|
||||
("it-it", "Italiano (Italian)"),
|
||||
("fi-fi", "Suomi (Finnish)"),
|
||||
("fr-fr", "Français (French)"),
|
||||
("lt-lt", "Lietuvių (Lithuanian)"),
|
||||
("no-no", "Norsk (Norwegian)"),
|
||||
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
|
||||
("pt-pt", "Português Europeu (European Portuguese)"),
|
||||
("ro-ro", "Română (Romanian)"),
|
||||
("sv-se", "Svenska (Swedish)"),
|
||||
("zh-hans", "简体中文 (Simplified Chinese)"),
|
||||
("zh-hant", "繁體中文 (Traditional Chinese)"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -1,13 +0,0 @@
|
|||
# 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 = []
|
|
@ -1,13 +0,0 @@
|
|||
# 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 = []
|
|
@ -1,18 +0,0 @@
|
|||
# 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),
|
||||
),
|
||||
]
|
|
@ -8,7 +8,6 @@ from django.db.models import Q
|
|||
from django.dispatch import receiver
|
||||
from django.http import Http404
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.text import slugify
|
||||
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .fields import RemoteIdField
|
||||
|
@ -36,11 +35,10 @@ class BookWyrmModel(models.Model):
|
|||
remote_id = RemoteIdField(null=True, activitypub_field="id")
|
||||
|
||||
def get_remote_id(self):
|
||||
"""generate the url that resolves to the local object, without a slug"""
|
||||
"""generate a url that resolves to the local object"""
|
||||
base_path = f"https://{DOMAIN}"
|
||||
if hasattr(self, "user"):
|
||||
base_path = f"{base_path}{self.user.local_path}"
|
||||
|
||||
model_name = type(self).__name__.lower()
|
||||
return f"{base_path}/{model_name}/{self.id}"
|
||||
|
||||
|
@ -51,20 +49,8 @@ class BookWyrmModel(models.Model):
|
|||
|
||||
@property
|
||||
def local_path(self):
|
||||
"""how to link to this object in the local app, with a slug"""
|
||||
local = self.get_remote_id().replace(f"https://{DOMAIN}", "")
|
||||
|
||||
name = None
|
||||
if hasattr(self, "name_field"):
|
||||
name = getattr(self, self.name_field)
|
||||
elif hasattr(self, "name"):
|
||||
name = self.name
|
||||
|
||||
if name:
|
||||
slug = slugify(name)
|
||||
local = f"{local}/s/{slug}"
|
||||
|
||||
return local
|
||||
"""how to link to this object in the local app"""
|
||||
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
|
||||
|
||||
def raise_visible_to_user(self, viewer):
|
||||
"""is a user authorized to view an object?"""
|
||||
|
|
|
@ -176,8 +176,8 @@ class Book(BookDataModel):
|
|||
"""properties of this edition, as a string"""
|
||||
items = [
|
||||
self.physical_format if hasattr(self, "physical_format") else None,
|
||||
f"{self.languages[0]} language"
|
||||
if self.languages and self.languages[0] and self.languages[0] != "English"
|
||||
self.languages[0] + " language"
|
||||
if self.languages and self.languages[0] != "English"
|
||||
else None,
|
||||
str(self.published_date.year) if self.published_date else None,
|
||||
", ".join(self.publishers) if hasattr(self, "publishers") else None,
|
||||
|
|
|
@ -125,7 +125,7 @@ class ActivitypubFieldMixin:
|
|||
"""model_field_name to activitypubFieldName"""
|
||||
if self.activitypub_field:
|
||||
return self.activitypub_field
|
||||
name = self.name.rsplit(".", maxsplit=1)[-1]
|
||||
name = self.name.split(".")[-1]
|
||||
components = name.split("_")
|
||||
return components[0] + "".join(x.title() for x in components[1:])
|
||||
|
||||
|
@ -389,7 +389,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
|
|||
self.alt_field = alt_field
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# pylint: disable=arguments-differ,arguments-renamed
|
||||
# pylint: disable=arguments-differ
|
||||
def set_field_from_activity(self, instance, data, save=True, overwrite=True):
|
||||
"""helper function for assinging a value to the field"""
|
||||
value = getattr(data, self.get_activitypub_field())
|
||||
|
|
|
@ -175,15 +175,9 @@ class ImportItem(models.Model):
|
|||
def date_added(self):
|
||||
"""when the book was added to this dataset"""
|
||||
if self.normalized_data.get("date_added"):
|
||||
parsed_date_added = dateutil.parser.parse(
|
||||
self.normalized_data.get("date_added")
|
||||
return timezone.make_aware(
|
||||
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
|
||||
|
|
|
@ -27,7 +27,6 @@ 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):
|
||||
|
@ -35,7 +34,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 or self.stopped_date:
|
||||
if self.finish_date:
|
||||
self.is_active = False
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
|
|
@ -39,14 +39,15 @@ class UserRelationship(BookWyrmModel):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
"""clear the template cache"""
|
||||
clear_cache(self.user_subject, self.user_object)
|
||||
# invalidate the template cache
|
||||
cache.delete_many(
|
||||
[
|
||||
f"relationship-{self.user_subject.id}-{self.user_object.id}",
|
||||
f"relationship-{self.user_object.id}-{self.user_subject.id}",
|
||||
]
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""clear the template cache"""
|
||||
clear_cache(self.user_subject, self.user_object)
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
"""relationships should be unique"""
|
||||
|
||||
|
@ -89,9 +90,7 @@ class UserFollows(ActivityMixin, UserRelationship):
|
|||
user_object=self.user_subject,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError(
|
||||
"Attempting to follow blocked user", self.user_subject, self.user_object
|
||||
)
|
||||
raise IntegrityError()
|
||||
# don't broadcast this type of relationship -- accepts and requests
|
||||
# are handled by the UserFollowRequest model
|
||||
super().save(*args, broadcast=False, **kwargs)
|
||||
|
@ -99,12 +98,11 @@ class UserFollows(ActivityMixin, UserRelationship):
|
|||
@classmethod
|
||||
def from_request(cls, follow_request):
|
||||
"""converts a follow request into a follow relationship"""
|
||||
obj, _ = cls.objects.get_or_create(
|
||||
return cls.objects.create(
|
||||
user_subject=follow_request.user_subject,
|
||||
user_object=follow_request.user_object,
|
||||
remote_id=follow_request.remote_id,
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
||||
|
@ -135,9 +133,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
user_object=self.user_subject,
|
||||
)
|
||||
).exists():
|
||||
raise IntegrityError(
|
||||
"Attempting to follow blocked user", self.user_subject, self.user_object
|
||||
)
|
||||
raise IntegrityError()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if broadcast and self.user_subject.local and not self.user_object.local:
|
||||
|
@ -178,8 +174,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
|
||||
with transaction.atomic():
|
||||
UserFollows.from_request(self)
|
||||
if self.id:
|
||||
self.delete()
|
||||
self.delete()
|
||||
|
||||
def reject(self):
|
||||
"""generate a Reject for this follow request"""
|
||||
|
@ -212,13 +207,3 @@ class UserBlocks(ActivityMixin, UserRelationship):
|
|||
Q(user_subject=self.user_subject, user_object=self.user_object)
|
||||
| Q(user_subject=self.user_object, user_object=self.user_subject)
|
||||
).delete()
|
||||
|
||||
|
||||
def clear_cache(user_subject, user_object):
|
||||
"""clear relationship cache"""
|
||||
cache.delete_many(
|
||||
[
|
||||
f"relationship-{user_subject.id}-{user_object.id}",
|
||||
f"relationship-{user_object.id}-{user_subject.id}",
|
||||
]
|
||||
)
|
||||
|
|
|
@ -6,7 +6,6 @@ from django.db import models
|
|||
from django.utils import timezone
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
@ -18,9 +17,8 @@ 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, STOPPED_READING)
|
||||
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED)
|
||||
|
||||
name = fields.CharField(max_length=100)
|
||||
identifier = models.CharField(max_length=100)
|
||||
|
@ -67,11 +65,6 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
identifier = self.identifier or self.get_identifier()
|
||||
return f"{base_path}/books/{identifier}"
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
"""No slugs"""
|
||||
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
|
||||
|
||||
def raise_not_deletable(self, viewer):
|
||||
"""don't let anyone delete a default shelf"""
|
||||
super().raise_not_deletable(viewer)
|
||||
|
|
|
@ -27,7 +27,6 @@ class SiteSettings(models.Model):
|
|||
default_theme = models.ForeignKey(
|
||||
"Theme", null=True, blank=True, on_delete=models.SET_NULL
|
||||
)
|
||||
version = models.CharField(null=True, blank=True, max_length=10)
|
||||
|
||||
# admin setup options
|
||||
install_mode = models.BooleanField(default=False)
|
||||
|
@ -49,12 +48,8 @@ class SiteSettings(models.Model):
|
|||
# registration
|
||||
allow_registration = models.BooleanField(default=False)
|
||||
allow_invite_requests = models.BooleanField(default=True)
|
||||
invite_request_question = models.BooleanField(default=False)
|
||||
require_confirm_email = models.BooleanField(default=True)
|
||||
|
||||
invite_question_text = models.CharField(
|
||||
max_length=255, blank=True, default="What is your favourite book?"
|
||||
)
|
||||
# images
|
||||
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
|
||||
logo_small = models.ImageField(upload_to="logos/", null=True, blank=True)
|
||||
|
@ -104,14 +99,11 @@ class SiteSettings(models.Model):
|
|||
return urljoin(STATIC_FULL_URL, default_path)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""if require_confirm_email is disabled, make sure no users are pending,
|
||||
if enabled, make sure invite_question_text is not empty"""
|
||||
"""if require_confirm_email is disabled, make sure no users are pending"""
|
||||
if not self.require_confirm_email:
|
||||
User.objects.filter(is_active=False, deactivation_reason="pending").update(
|
||||
is_active=True, deactivation_reason=None
|
||||
)
|
||||
if not self.invite_question_text:
|
||||
self.invite_question_text = "What is your favourite book?"
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
|
@ -157,7 +149,6 @@ class InviteRequest(BookWyrmModel):
|
|||
invite = models.ForeignKey(
|
||||
SiteInvite, on_delete=models.SET_NULL, null=True, blank=True
|
||||
)
|
||||
answer = models.TextField(max_length=50, unique=False, null=True, blank=True)
|
||||
invite_sent = models.BooleanField(default=False)
|
||||
ignored = models.BooleanField(default=False)
|
||||
|
||||
|
|
|
@ -116,8 +116,11 @@ 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":
|
||||
boosted = activitypub.resolve_remote_id(activity.object, get_activity=True)
|
||||
if not boosted:
|
||||
try:
|
||||
boosted = activitypub.resolve_remote_id(
|
||||
activity.object, get_activity=True
|
||||
)
|
||||
except activitypub.ActivitySerializerError:
|
||||
# if we can't load the status, definitely ignore it
|
||||
return True
|
||||
# keep the boost if we would keep the status
|
||||
|
@ -262,7 +265,7 @@ class GeneratedNote(Status):
|
|||
|
||||
|
||||
ReadingStatusChoices = models.TextChoices(
|
||||
"ReadingStatusChoices", ["to-read", "reading", "read", "stopped-reading"]
|
||||
"ReadingStatusChoices", ["to-read", "reading", "read"]
|
||||
)
|
||||
|
||||
|
||||
|
@ -303,17 +306,10 @@ class Comment(BookStatus):
|
|||
@property
|
||||
def pure_content(self):
|
||||
"""indicate the book in question for mastodon (or w/e) users"""
|
||||
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
|
||||
return (
|
||||
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
|
||||
f'"{self.book.title}"</a>)</p>'
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Comment
|
||||
|
||||
|
@ -339,17 +335,10 @@ 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)
|
||||
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
|
||||
return (
|
||||
f'{quote} <p>-- <a href="{self.book.remote_id}">'
|
||||
f'"{self.book.title}"</a></p>{self.content}'
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Quotation
|
||||
|
||||
|
@ -388,7 +377,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)
|
||||
|
||||
|
||||
|
|
|
@ -374,10 +374,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
"name": "Read",
|
||||
"identifier": "read",
|
||||
},
|
||||
{
|
||||
"name": "Stopped Reading",
|
||||
"identifier": "stopped-reading",
|
||||
},
|
||||
]
|
||||
|
||||
for shelf in shelves:
|
||||
|
|
|
@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
|
|||
env = Env()
|
||||
env.read_env()
|
||||
DOMAIN = env("DOMAIN")
|
||||
VERSION = "0.4.0"
|
||||
VERSION = "0.3.2"
|
||||
|
||||
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 = "e678183b"
|
||||
JS_CACHE = "c7154efb"
|
||||
|
||||
# email
|
||||
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
|
||||
|
@ -90,7 +90,6 @@ INSTALLED_APPS = [
|
|||
"sass_processor",
|
||||
"bookwyrm",
|
||||
"celery",
|
||||
"django_celery_beat",
|
||||
"imagekit",
|
||||
"storages",
|
||||
]
|
||||
|
@ -189,7 +188,6 @@ STATICFILES_FINDERS = [
|
|||
]
|
||||
|
||||
SASS_PROCESSOR_INCLUDE_FILE_PATTERN = r"^.+\.[s]{0,1}(?:a|c)ss$"
|
||||
SASS_PROCESSOR_ENABLED = True
|
||||
|
||||
# minify css is production but not dev
|
||||
if not DEBUG:
|
||||
|
@ -212,7 +210,7 @@ STREAMS = [
|
|||
|
||||
# Search configuration
|
||||
# total time in seconds that the instance will spend searching connectors
|
||||
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 8))
|
||||
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 15))
|
||||
# timeout for a query to an individual connector
|
||||
QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5))
|
||||
|
||||
|
@ -284,13 +282,11 @@ LANGUAGES = [
|
|||
("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)")),
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
/** Imports
|
||||
******************************************************************************/
|
||||
@import "components/avatar";
|
||||
@import "components/barcode";
|
||||
@import "components/book_cover";
|
||||
@import "components/book_grid";
|
||||
@import "components/book_list";
|
||||
|
@ -36,18 +35,6 @@ body {
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: $scrollbar-thumb;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: $scrollbar-track;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
margin: 0;
|
||||
|
@ -141,6 +128,14 @@ button:focus-visible .button-invisible-overlay {
|
|||
}
|
||||
|
||||
|
||||
|
||||
/** Tooltips
|
||||
******************************************************************************/
|
||||
|
||||
.tooltip {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/** States
|
||||
******************************************************************************/
|
||||
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
/* Barcode scanner CSS */
|
||||
#barcode-scanner {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
height: calc(70vh - 200px);
|
||||
|
||||
video {
|
||||
height: calc(70vh - 200px);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: auto;
|
||||
height: calc(70vh - 200px);
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#barcode-camera-list {
|
||||
float: right;
|
||||
}
|
|
@ -114,17 +114,3 @@ details[open] summary .details-close {
|
|||
padding-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/** Navbar details
|
||||
******************************************************************************/
|
||||
|
||||
#navbar-dropdown .navbar-item {
|
||||
color: $text;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 3rem 0.375rem 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#navbar-dropdown .navbar-item:hover {
|
||||
background-color: $background-secondary;
|
||||
}
|
||||
|
|
|
@ -23,8 +23,3 @@
|
|||
.has-background-tertiary {
|
||||
background-color: $background-tertiary !important;
|
||||
}
|
||||
|
||||
/* Workaround for dark theme as .has-text-black doesn't give desired effect. */
|
||||
.has-text-default {
|
||||
color: $text !important;
|
||||
}
|
||||
|
|
Binary file not shown.
|
@ -39,7 +39,6 @@
|
|||
<glyph unicode="" glyph-name="graphic-heart" d="M934.176 791.52c-116.128 115.072-301.824 117.472-422.112 9.216-120.32 108.256-305.952 105.856-422.144-9.216-119.712-118.528-119.712-310.688 0-429.28 34.208-33.888 353.696-350.112 353.696-350.112 37.856-37.504 99.072-37.504 136.896 0 0 0 349.824 346.304 353.696 350.112 119.744 118.592 119.744 310.752-0.032 429.28zM888.576 407.424l-353.696-350.112c-12.576-12.512-33.088-12.512-45.6 0l-353.696 350.112c-94.4 93.44-94.4 245.472 0 338.912 91.008 90.080 237.312 93.248 333.088 7.104l43.392-39.040 43.36 39.040c95.808 86.144 242.112 83.008 333.12-7.104 94.4-93.408 94.4-245.44 0.032-338.912zM296.096 719.968c8.864 0 16-7.168 16-16s-7.168-16-16-16h-0.032c-57.408 0-103.968-46.56-103.968-103.968v-0.032c0-8.832-7.168-16-16-16s-16 7.168-16 16v0c0 75.072 60.832 135.904 135.872 135.968 0.064 0 0.064 0.032 0.128 0.032z" />
|
||||
<glyph unicode="" glyph-name="graphic-paperplane" d="M1009.376 954.88c-5.312 3.424-11.36 5.12-17.376 5.12-6.176 0-12.384-1.76-17.76-5.376l-960-640c-9.888-6.56-15.328-18.112-14.048-29.952 1.216-11.808 8.896-22.016 19.936-26.368l250.368-100.192 117.728-206.016c5.632-9.888 16.096-16 27.424-16.128 0.128 0 0.224 0 0.352 0 11.232 0 21.664 5.952 27.424 15.552l66.464 110.816 310.24-124.064c3.808-1.536 7.808-2.272 11.872-2.272 5.44 0 10.816 1.376 15.68 4.128 8.448 4.736 14.24 13.056 15.872 22.624l160 960c2.080 12.576-3.488 25.184-14.176 32.128zM100.352 295.136l741.6 494.432-539.2-577.184c-2.848 1.696-5.376 3.936-8.512 5.184l-193.888 77.568zM326.048 189.888c-0.064 0.128-0.16 0.192-0.224 0.32l606.176 648.8-516.768-805.184-89.184 156.064zM806.944 12.512l-273.312 109.312c-6.496 2.56-13.248 3.424-19.936 3.808l420.864 652.416-127.616-765.536z" />
|
||||
<glyph unicode="" glyph-name="graphic-banknote" d="M1005.28 621.248l-320 320c-15.872 15.872-38.88 22.24-60.672 16.864-11.488-2.816-21.76-8.736-29.888-16.864-7.264-7.264-12.736-16.256-15.872-26.304-14.496-47.008-39.552-87.872-76.64-124.928-49.536-49.504-114.048-87.008-182.304-126.656-72.448-41.984-147.296-85.504-208.64-146.816-52.128-52.192-87.616-110.24-108.416-177.632-7.008-22.752-0.896-47.36 15.872-64.192l320-320c15.872-15.872 38.88-22.24 60.672-16.864 11.488 2.88 21.76 8.736 29.888 16.864 7.264 7.264 12.736 16.256 15.872 26.368 14.528 47.008 39.584 87.872 76.704 124.928 49.504 49.504 113.984 86.944 182.304 126.56 72.384 42.048 147.264 85.568 208.576 146.88 52.128 52.128 87.616 110.24 108.448 177.632 6.976 22.72 0.832 47.424-15.904 64.16zM384 0c-105.984 105.984-214.016 214.048-320 320 90.944 294.432 485.12 281.568 576 576 105.984-105.952 214.048-214.016 320.064-320-90.976-294.368-485.152-281.568-576.064-576zM625.984 483.2c-10.432 8.736-20.928 14.688-31.488 17.632-10.496 2.944-20.992 4.128-31.616 3.36-10.496-0.8-21.248-3.2-32-7.328-10.752-4.192-21.568-8.736-32.448-14.016-17.184 19.744-34.368 39.264-51.552 57.376 7.744 7.008 15.264 10.56 22.496 10.816 7.264 0.32 14.24-0.448 20.864-2.112 6.752-1.696 12.928-3.136 18.624-4.256 5.76-1.12 10.752 0.128 15.136 3.808 4.64 4 7.2 9.184 7.552 15.424 0.32 6.304-2.048 12.448-7.328 18.432-6.752 7.744-14.88 12.448-24.64 14.176-9.632 1.696-19.488 1.568-29.76-0.672-10.112-2.304-19.744-6.112-28.864-11.488s-16.448-10.88-21.888-16.256c-2.080 1.984-4.16 3.936-6.24 5.888-2.304 2.112-5.184 3.264-8.64 3.2-3.488 0-6.368-1.504-8.736-4.256-2.304-2.688-3.36-5.824-2.944-9.12 0.32-3.424 1.696-6.048 4.064-8.064 2.080-1.76 4.16-3.488 6.24-5.312-8.192-9.888-14.944-20.8-20.256-32.32-5.376-11.552-8.576-23.008-9.76-34.112-1.248-11.2-0.064-21.44 3.36-30.944 3.424-9.568 9.76-17.696 19.008-25.376 15.072-12.512 32.8-17.824 53.376-16.64 20.512 1.248 42.624 7.36 66.4 20.128 18.88-21.824 37.824-43.488 56.736-63.616-8-6.752-15.008-10.624-21.184-11.872-6.176-1.312-11.68-1.184-16.672 0.32-4.992 1.568-9.632 3.808-13.888 6.688-4.256 2.944-8.448 5.44-12.64 7.488-4.128 2.048-8.384 3.2-12.736 3.264s-8.992-2.048-14.112-6.432c-5.248-4.576-7.872-9.888-7.872-15.872 0-5.952 2.752-12 8.128-18.112 5.44-6.112 12.512-11.264 21.056-15.328s18.208-6.624 28.832-7.328c10.624-0.736 21.824 0.864 33.632 5.248 11.872 4.32 23.616 12.128 35.2 23.744 5.568-5.44 11.2-10.624 16.8-15.616 2.368-2.048 5.248-3.072 8.736-2.816 3.36 0.128 6.304 1.696 8.64 4.512 2.368 2.88 3.36 6.048 3.008 9.376-0.32 3.36-1.696 5.952-4 7.808-5.632 4.512-11.264 9.248-16.864 14.24 9.568 11.744 17.248 24.128 22.944 36.384 5.696 12.32 9.056 24.192 10.176 35.2 1.12 11.072-0.192 21.056-3.808 30.112-3.584 9.184-9.952 17.056-19.072 24.64zM447.072 461.504c-9.056-0.384-16.96 2.624-23.872 9.312-2.944 2.816-4.992 6.24-6.24 10.304-1.312 4.064-1.76 8.512-1.248 13.376 0.448 4.8 1.888 9.824 4.384 14.88 2.368 5.056 5.888 10.112 10.368 15.008 16.224-16.128 32.416-33.824 48.64-52.128-12.288-6.752-22.976-10.368-32.032-10.752zM598.016 397.44c-2.88-5.312-6.176-10.048-10.048-14.176-17.952 18.112-35.872 38.016-53.76 58.432 4.576 2.048 9.376 4.192 14.56 6.368s10.368 3.616 15.552 4.512c5.312 0.8 10.56 0.576 15.808-0.672 5.184-1.312 10.112-4.128 14.688-8.576 4.512-4.512 7.36-9.184 8.512-14.24 1.248-5.12 1.312-10.304 0.448-15.616-0.928-5.344-2.816-10.656-5.76-16.032zM470.944 250.24c6.304 5.088 15.584 4.832 21.376-1.056 6.272-6.24 6.272-16.448 0-22.688-0.512-0.512-1.056-0.864-1.632-1.312l0.064-0.064c-20.256-15.392-36.896-29.248-54.848-47.2-16.224-16.192-30.88-33.248-43.552-50.56l-20.448-28c-0.64-1.152-1.408-2.208-2.368-3.2-6.272-6.24-16.48-6.24-22.72 0-5.44 5.44-6.112 13.824-2.112 20.064l-0.064 0.064 21.888 29.888c13.664 18.688 29.376 36.992 46.752 54.368 18.080 18.144 37.6 34.336 57.6 49.696h0.064zM588.096 713.12c16.192 16.192 30.816 33.184 43.52 50.592l21.248 29.12c0.768 1.376 1.632 2.752 2.816 3.936 6.304 6.304 16.512 6.304 22.816 0 5.984-6.016 6.24-15.52 0.8-21.888l0.064-0.064-21.888-30.016c-13.696-18.688-29.376-36.928-46.752-54.304-18.080-18.080-37.568-34.336-57.568-49.696l-0.128 0.064c-6.368-5.856-16.256-5.728-22.368 0.448-6.304 6.304-6.304 16.576 0 22.88 1.12 1.184 2.432 2.016 3.744 2.752 18.816 14.368 36.96 29.44 53.696 46.176z" />
|
||||
<glyph unicode="" glyph-name="barcode" d="M0 832h128v-640h-128zM192 832h64v-640h-64zM320 832h64v-640h-64zM512 832h64v-640h-64zM768 832h64v-640h-64zM960 832h64v-640h-64zM640 832h32v-640h-32zM448 832h32v-640h-32zM864 832h32v-640h-32zM0 128h64v-64h-64zM192 128h64v-64h-64zM320 128h64v-64h-64zM640 128h64v-64h-64zM960 128h64v-64h-64zM768 128h128v-64h-128zM448 128h128v-64h-128z" />
|
||||
<glyph unicode="" glyph-name="spinner" d="M384 832c0 70.692 57.308 128 128 128s128-57.308 128-128c0-70.692-57.308-128-128-128s-128 57.308-128 128zM655.53 719.53c0 70.692 57.308 128 128 128s128-57.308 128-128c0-70.692-57.308-128-128-128s-128 57.308-128 128zM832 448c0 35.346 28.654 64 64 64s64-28.654 64-64c0-35.346-28.654-64-64-64s-64 28.654-64 64zM719.53 176.47c0 35.346 28.654 64 64 64s64-28.654 64-64c0-35.346-28.654-64-64-64s-64 28.654-64 64zM448.002 64c0 0 0 0 0 0 0 35.346 28.654 64 64 64s64-28.654 64-64c0 0 0 0 0 0 0-35.346-28.654-64-64-64s-64 28.654-64 64zM176.472 176.47c0 0 0 0 0 0 0 35.346 28.654 64 64 64s64-28.654 64-64c0 0 0 0 0 0 0-35.346-28.654-64-64-64s-64 28.654-64 64zM144.472 719.53c0 0 0 0 0 0 0 53.019 42.981 96 96 96s96-42.981 96-96c0 0 0 0 0 0 0-53.019-42.981-96-96-96s-96 42.981-96 96zM56 448c0 39.765 32.235 72 72 72s72-32.235 72-72c0-39.765-32.235-72-72-72s-72 32.235-72 72z" />
|
||||
<glyph unicode="" glyph-name="search" d="M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256z" />
|
||||
<glyph unicode="" glyph-name="star-empty" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-223.462-117.48 42.676 248.83-180.786 176.222 249.84 36.304 111.732 226.396 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
|
||||
|
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
Binary file not shown.
Binary file not shown.
|
@ -8,9 +8,7 @@ $primary: #005e50;
|
|||
$primary-light: #1d2b28;
|
||||
$info: #1f4666;
|
||||
$success: #246447;
|
||||
$success-light: #0d2f1e;
|
||||
$warning: #8b6c15;
|
||||
$warning-light: #372e13;
|
||||
$danger: #872538;
|
||||
$danger-light: #481922;
|
||||
$light: #393939;
|
||||
|
@ -28,8 +26,6 @@ $background-body: rgb(24, 27, 28);
|
|||
$background-secondary: rgb(28, 30, 32);
|
||||
$background-tertiary: rgb(32, 34, 36);
|
||||
$modal-background-background-color: rgba($black, 0.8);
|
||||
$scrollbar-track: $background-secondary;
|
||||
$scrollbar-thumb: $light;
|
||||
|
||||
/* highlight colors */
|
||||
$primary-highlight: $primary;
|
||||
|
@ -53,7 +49,6 @@ $link-hover: $white-bis;
|
|||
$link-hover-border: #51595d;
|
||||
$link-focus: $white-bis;
|
||||
$link-active: $white-bis;
|
||||
$link-light: #0d1c26;
|
||||
|
||||
/* bulma overrides */
|
||||
$background: $background-secondary;
|
||||
|
@ -84,13 +79,6 @@ $progress-value-background-color: $border-light;
|
|||
$family-primary: $family-sans-serif;
|
||||
$family-secondary: $family-sans-serif;
|
||||
|
||||
.has-text-muted {
|
||||
color: $grey-lighter !important;
|
||||
}
|
||||
|
||||
.has-text-more-muted {
|
||||
color: $grey-light !important;
|
||||
}
|
||||
|
||||
@import "../bookwyrm.scss";
|
||||
@import "../vendor/icons.css";
|
||||
|
|
|
@ -19,8 +19,6 @@ $scheme-main: $white-bis;
|
|||
$background-body: $white;
|
||||
$background-secondary: $white-ter;
|
||||
$background-tertiary: $white-bis;
|
||||
$scrollbar-track: $background-secondary;
|
||||
$scrollbar-thumb: $grey-lighter;
|
||||
|
||||
/* highlight colors */
|
||||
$primary-highlight: $primary-light;
|
||||
|
@ -57,13 +55,5 @@ $invisible-overlay-background-color: rgba($scheme-invert, 0.66);
|
|||
$family-primary: $family-sans-serif;
|
||||
$family-secondary: $family-sans-serif;
|
||||
|
||||
.has-text-muted {
|
||||
color: $grey-dark !important;
|
||||
}
|
||||
|
||||
.has-text-more-muted {
|
||||
color: $grey !important;
|
||||
}
|
||||
|
||||
@import "../bookwyrm.scss";
|
||||
@import "../vendor/icons.css";
|
||||
|
|
3
bookwyrm/static/css/vendor/icons.css
vendored
3
bookwyrm/static/css/vendor/icons.css
vendored
|
@ -149,6 +149,3 @@
|
|||
.icon-download:before {
|
||||
content: "\ea36";
|
||||
}
|
||||
.icon-barcode:before {
|
||||
content: "\e937";
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
/* exported BookWyrm */
|
||||
/* globals TabGroup, Quagga */
|
||||
/* globals TabGroup */
|
||||
|
||||
let BookWyrm = new (class {
|
||||
constructor() {
|
||||
|
@ -38,15 +38,15 @@ let BookWyrm = new (class {
|
|||
.querySelectorAll("[data-modal-open]")
|
||||
.forEach((node) => node.addEventListener("click", this.handleModalButton.bind(this)));
|
||||
|
||||
document
|
||||
.querySelectorAll("[data-duplicate]")
|
||||
.forEach((node) => node.addEventListener("click", this.duplicateInput.bind(this)));
|
||||
|
||||
document
|
||||
.querySelectorAll("details.dropdown")
|
||||
.forEach((node) =>
|
||||
node.addEventListener("toggle", this.handleDetailsDropdown.bind(this))
|
||||
);
|
||||
|
||||
document
|
||||
.querySelector("#barcode-scanner-modal")
|
||||
.addEventListener("open", this.openBarcodeScanner.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -427,11 +427,9 @@ let BookWyrm = new (class {
|
|||
});
|
||||
|
||||
modalElement.addEventListener("keydown", handleFocusTrap);
|
||||
modalElement.dispatchEvent(new Event("open"));
|
||||
}
|
||||
|
||||
function handleModalClose(modalElement) {
|
||||
modalElement.dispatchEvent(new Event("close"));
|
||||
modalElement.removeEventListener("keydown", handleFocusTrap);
|
||||
htmlElement.classList.remove("is-clipped");
|
||||
modalElement.classList.remove("is-active");
|
||||
|
@ -491,6 +489,26 @@ let BookWyrm = new (class {
|
|||
window.open(url, windowName, "left=100,top=100,width=430,height=600");
|
||||
}
|
||||
|
||||
duplicateInput(event) {
|
||||
const trigger = event.currentTarget;
|
||||
const input_id = trigger.dataset.duplicate;
|
||||
const orig = document.getElementById(input_id);
|
||||
const parent = orig.parentNode;
|
||||
const new_count = parent.querySelectorAll("input").length + 1;
|
||||
|
||||
let input = orig.cloneNode();
|
||||
|
||||
input.id += "-" + new_count;
|
||||
input.value = "";
|
||||
|
||||
let label = parent.querySelector("label").cloneNode();
|
||||
|
||||
label.setAttribute("for", input.id);
|
||||
|
||||
parent.appendChild(label);
|
||||
parent.appendChild(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a "click-to-copy" component from a textarea element
|
||||
* with `data-copytext`, `data-copytext-label`, `data-copytext-success`
|
||||
|
@ -614,174 +632,4 @@ let BookWyrm = new (class {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
openBarcodeScanner(event) {
|
||||
const scannerNode = document.getElementById("barcode-scanner");
|
||||
const statusNode = document.getElementById("barcode-status");
|
||||
const cameraListNode = document.querySelector("#barcode-camera-list > select");
|
||||
|
||||
cameraListNode.addEventListener("change", onChangeCamera);
|
||||
|
||||
function onChangeCamera(event) {
|
||||
initBarcodes(event.target.value);
|
||||
}
|
||||
|
||||
function toggleStatus(status) {
|
||||
for (const child of statusNode.children) {
|
||||
BookWyrm.toggleContainer(child, !child.classList.contains(status));
|
||||
}
|
||||
}
|
||||
|
||||
function initBarcodes(cameraId = null) {
|
||||
toggleStatus("grant-access");
|
||||
|
||||
if (!cameraId) {
|
||||
cameraId = sessionStorage.getItem("preferredCam");
|
||||
} else {
|
||||
sessionStorage.setItem("preferredCam", cameraId);
|
||||
}
|
||||
|
||||
scannerNode.replaceChildren();
|
||||
Quagga.stop();
|
||||
Quagga.init(
|
||||
{
|
||||
inputStream: {
|
||||
name: "Live",
|
||||
type: "LiveStream",
|
||||
target: scannerNode,
|
||||
constraints: {
|
||||
facingMode: "environment",
|
||||
deviceId: cameraId,
|
||||
},
|
||||
},
|
||||
decoder: {
|
||||
readers: [
|
||||
"ean_reader",
|
||||
{
|
||||
format: "ean_reader",
|
||||
config: {
|
||||
supplements: ["ean_2_reader", "ean_5_reader"],
|
||||
},
|
||||
},
|
||||
],
|
||||
multiple: false,
|
||||
},
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
scannerNode.replaceChildren();
|
||||
console.log(err);
|
||||
toggleStatus("access-denied");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let activeId = null;
|
||||
const track = Quagga.CameraAccess.getActiveTrack();
|
||||
|
||||
if (track) {
|
||||
activeId = track.getSettings().deviceId;
|
||||
}
|
||||
|
||||
Quagga.CameraAccess.enumerateVideoDevices().then((devices) => {
|
||||
cameraListNode.replaceChildren();
|
||||
|
||||
for (const device of devices) {
|
||||
const child = document.createElement("option");
|
||||
|
||||
child.value = device.deviceId;
|
||||
child.innerText = device.label.slice(0, 30);
|
||||
|
||||
if (activeId === child.value) {
|
||||
child.selected = true;
|
||||
}
|
||||
|
||||
cameraListNode.appendChild(child);
|
||||
}
|
||||
});
|
||||
|
||||
toggleStatus("scanning");
|
||||
Quagga.start();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function cleanup(clearDrawing = true) {
|
||||
Quagga.stop();
|
||||
cameraListNode.removeEventListener("change", onChangeCamera);
|
||||
|
||||
if (clearDrawing) {
|
||||
scannerNode.replaceChildren();
|
||||
}
|
||||
}
|
||||
|
||||
Quagga.onProcessed((result) => {
|
||||
const drawingCtx = Quagga.canvas.ctx.overlay;
|
||||
const drawingCanvas = Quagga.canvas.dom.overlay;
|
||||
|
||||
if (result) {
|
||||
if (result.boxes) {
|
||||
drawingCtx.clearRect(
|
||||
0,
|
||||
0,
|
||||
parseInt(drawingCanvas.getAttribute("width")),
|
||||
parseInt(drawingCanvas.getAttribute("height"))
|
||||
);
|
||||
result.boxes
|
||||
.filter((box) => box !== result.box)
|
||||
.forEach((box) => {
|
||||
Quagga.ImageDebug.drawPath(box, { x: 0, y: 1 }, drawingCtx, {
|
||||
color: "green",
|
||||
lineWidth: 2,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (result.box) {
|
||||
Quagga.ImageDebug.drawPath(result.box, { x: 0, y: 1 }, drawingCtx, {
|
||||
color: "#00F",
|
||||
lineWidth: 2,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.codeResult && result.codeResult.code) {
|
||||
Quagga.ImageDebug.drawPath(result.line, { x: "x", y: "y" }, drawingCtx, {
|
||||
color: "red",
|
||||
lineWidth: 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let lastDetection = null;
|
||||
let numDetected = 0;
|
||||
|
||||
Quagga.onDetected((result) => {
|
||||
// Detect the same code 3 times as an extra check to avoid bogus scans.
|
||||
if (lastDetection === null || lastDetection !== result.codeResult.code) {
|
||||
numDetected = 1;
|
||||
lastDetection = result.codeResult.code;
|
||||
|
||||
return;
|
||||
} else if (numDetected++ < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = result.codeResult.code;
|
||||
|
||||
statusNode.querySelector(".isbn").innerText = code;
|
||||
toggleStatus("found");
|
||||
|
||||
const search = new URL("/search", document.location);
|
||||
|
||||
search.searchParams.set("q", code);
|
||||
|
||||
cleanup(false);
|
||||
location.assign(search);
|
||||
});
|
||||
|
||||
event.target.addEventListener("close", cleanup, { once: true });
|
||||
|
||||
initBarcodes();
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -1,49 +0,0 @@
|
|||
(function () {
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Remoev input field
|
||||
*
|
||||
* @param {event} the button click event
|
||||
*/
|
||||
function removeInput(event) {
|
||||
const trigger = event.currentTarget;
|
||||
const input_id = trigger.dataset.remove;
|
||||
const input = document.getElementById(input_id);
|
||||
|
||||
input.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an input field
|
||||
*
|
||||
* @param {event} the click even on the associated button
|
||||
*/
|
||||
function duplicateInput(event) {
|
||||
const trigger = event.currentTarget;
|
||||
const input_id = trigger.dataset.duplicate;
|
||||
const orig = document.getElementById(input_id);
|
||||
const parent = orig.parentNode;
|
||||
const new_count = parent.querySelectorAll("input").length + 1;
|
||||
|
||||
let input = orig.cloneNode();
|
||||
|
||||
input.id += "-" + new_count;
|
||||
input.value = "";
|
||||
|
||||
let label = parent.querySelector("label").cloneNode();
|
||||
|
||||
label.setAttribute("for", input.id);
|
||||
|
||||
parent.appendChild(label);
|
||||
parent.appendChild(input);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll("[data-duplicate]")
|
||||
.forEach((node) => node.addEventListener("click", duplicateInput));
|
||||
|
||||
document
|
||||
.querySelectorAll("[data-remove]")
|
||||
.forEach((node) => node.addEventListener("click", removeInput));
|
||||
})();
|
|
@ -203,8 +203,6 @@ 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(
|
||||
|
|
3
bookwyrm/static/js/vendor/quagga.min.js
vendored
3
bookwyrm/static/js/vendor/quagga.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -99,7 +99,7 @@
|
|||
<p>
|
||||
{% url "conduct" as coc_path %}
|
||||
{% blocktrans trimmed with site_name=site.name %}
|
||||
{{ site_name }}'s moderators and administrators keep the site up and running, enforce the <a href="{{ coc_path }}">code of conduct</a>, and respond when users report spam and bad behavior.
|
||||
{{ site_name }}'s moderators and administrators keep the site up and running, enforce the <a href="coc_path">code of conduct</a>, and respond when users report spam and bad behavior.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</header>
|
||||
|
|
|
@ -50,7 +50,7 @@
|
|||
</ul>
|
||||
</nav>
|
||||
|
||||
<div class="column is-clipped">
|
||||
<div class="column">
|
||||
{% block about_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="block" name="edit-author" action="{% url 'edit-author' author.id %}" method="post">
|
||||
<form class="block" name="edit-author" action="{{ author.local_path }}/edit" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
|
||||
|
||||
|
|
|
@ -19,10 +19,8 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<div class="buttons is-right is-flex-grow-1">
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-form-close %}</form>{% endblock %}
|
||||
|
|
|
@ -12,15 +12,6 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if update_error %}
|
||||
<div class="notification is-danger is-light">
|
||||
<span class="icon icon-x" aria-hidden="true"></span>
|
||||
<span>
|
||||
{% trans "Unable to connect to remote source." %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
|
||||
<div class="block" itemscope itemtype="https://schema.org/Book">
|
||||
<div class="columns is-mobile">
|
||||
|
@ -208,17 +199,9 @@
|
|||
{% endif %}
|
||||
|
||||
|
||||
{% with work=book.parent_work %}
|
||||
<p>
|
||||
<a href="{{ work.local_path }}/editions">
|
||||
{% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %}
|
||||
{{ count }} edition
|
||||
{% plural %}
|
||||
{{ count }} editions
|
||||
{% endblocktrans %}
|
||||
</a>
|
||||
</p>
|
||||
{% endwith %}
|
||||
{% if book.parent_work.editions.count > 1 %}
|
||||
<p>{% blocktrans with path=book.parent_work.local_path count=book.parent_work.editions.count %}<a href="{{ path }}/editions">{{ count }} editions</a>{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# user's relationship to the book #}
|
||||
|
@ -284,7 +267,7 @@
|
|||
{% if user_statuses.review_count or user_statuses.comment_count or user_statuses.quotation_count %}
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
{% url 'book' book.id book.name|slugify as tab_url %}
|
||||
{% url 'book' book.id as tab_url %}
|
||||
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
|
||||
<a href="{{ tab_url }}#reviews">{% trans "Reviews" %} ({{ review_count }})</a>
|
||||
</li>
|
||||
|
|
|
@ -28,10 +28,8 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<div class="buttons is-right is-flex-grow-1">
|
||||
<button class="button is-primary" type="submit">{% trans "Add" %}</button>
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">{% trans "Add" %}</button>
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-form-close %}</form>{% endblock %}
|
||||
|
|
|
@ -3,24 +3,18 @@
|
|||
{% load humanize %}
|
||||
{% load utilities %}
|
||||
|
||||
{% block title %}
|
||||
{% if book.title %}
|
||||
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans "Add Book" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="block">
|
||||
<h1 class="title level-left">
|
||||
{% if book.title %}
|
||||
{% if book %}
|
||||
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
|
||||
{% else %}
|
||||
{% trans "Add Book" %}
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if book.created_date %}
|
||||
{% if book %}
|
||||
<dl>
|
||||
<dt class="is-pulled-left mr-5 has-text-weight-semibold">{% trans "Added:" %}</dt>
|
||||
<dd class="ml-2">{{ book.created_date | naturaltime }}</dd>
|
||||
|
@ -39,20 +33,12 @@
|
|||
|
||||
<form
|
||||
class="block"
|
||||
{% if book.id %}
|
||||
{% if book %}
|
||||
name="edit-book"
|
||||
{% if confirm_mode %}
|
||||
action="{% url 'edit-book-confirm' book.id %}"
|
||||
{% else %}
|
||||
action="{% url 'edit-book' book.id %}"
|
||||
{% endif %}
|
||||
action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}"
|
||||
{% else %}
|
||||
name="create-book"
|
||||
{% if confirm_mode %}
|
||||
action="{% url 'create-book-confirm' %}"
|
||||
{% else %}
|
||||
action="{% url 'create-book' %}"
|
||||
{% endif %}
|
||||
action="/create-book{% if confirm_mode %}/confirm{% endif %}"
|
||||
{% endif %}
|
||||
method="post"
|
||||
enctype="multipart/form-data"
|
||||
|
@ -111,7 +97,7 @@
|
|||
<input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
<label class="label mt-2">
|
||||
<label>
|
||||
<input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
@ -133,7 +119,7 @@
|
|||
{% if not confirm_mode %}
|
||||
<div class="block">
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
{% if book.id %}
|
||||
{% if book %}
|
||||
<a class="button" href="{{ book.local_path }}">{% trans "Cancel" %}</a>
|
||||
{% else %}
|
||||
<a href="/" class="button" data-back>
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="block">
|
||||
|
@ -10,8 +9,6 @@
|
|||
{% csrf_token %}
|
||||
|
||||
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
|
||||
<input type="hidden" name="parent_work" value="{% firstof book.parent_work.id form.parent_work %}">
|
||||
|
||||
<div class="columns">
|
||||
<div class="column is-half">
|
||||
<section class="block">
|
||||
|
@ -24,7 +21,7 @@
|
|||
{% trans "Title:" %}
|
||||
</label>
|
||||
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title" aria-describedby="desc_title">
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.title.errors id="desc_title" %}
|
||||
</div>
|
||||
|
||||
|
@ -33,7 +30,7 @@
|
|||
{% trans "Subtitle:" %}
|
||||
</label>
|
||||
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle" aria-describedby="desc_subtitle">
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.subtitle.errors id="desc_subtitle" %}
|
||||
</div>
|
||||
|
||||
|
@ -42,7 +39,7 @@
|
|||
{% trans "Description:" %}
|
||||
</label>
|
||||
{{ form.description }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.description.errors id="desc_description" %}
|
||||
</div>
|
||||
|
||||
|
@ -53,7 +50,7 @@
|
|||
{% trans "Series:" %}
|
||||
</label>
|
||||
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}" aria-describedby="desc_series">
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.series.errors id="desc_series" %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -63,7 +60,7 @@
|
|||
{% trans "Series number:" %}
|
||||
</label>
|
||||
{{ form.series_number }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.series_number.errors id="desc_series_number" %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -77,60 +74,9 @@
|
|||
<span class="help" id="desc_languages_help">
|
||||
{% trans "Separate multiple values with commas." %}
|
||||
</span>
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.languages.errors id="desc_languages" %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label" for="id_add_subjects">
|
||||
{% trans "Subjects:" %}
|
||||
</label>
|
||||
{% for subject in book.subjects %}
|
||||
<label class="label is-sr-only" for="id_add_subject={% if not forloop.first %}-{{forloop.counter}}{% endif %}">
|
||||
{% trans "Add subject" %}
|
||||
</label>
|
||||
<div class="field has-addons" id="subject_field_wrapper_{{ forloop.counter }}">
|
||||
<div class="control is-expanded">
|
||||
<input
|
||||
id="id_add_subject-{{ forloop.counter }}"
|
||||
type="text"
|
||||
name="subjects"
|
||||
value="{{ subject }}"
|
||||
class="input"
|
||||
>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button
|
||||
class="button is-danger is-light"
|
||||
type="button"
|
||||
data-remove="subject_field_wrapper_{{ forloop.counter }}"
|
||||
>
|
||||
{% trans "Remove subject" as text %}
|
||||
<span class="icon icon-x" title="{{ text }}">
|
||||
<span class="is-sr-only">{{ text }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
name="subjects"
|
||||
id="id_add_subject"
|
||||
value="{{ subject }}"
|
||||
{% if confirm_mode %}readonly{% endif %}
|
||||
>
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.subjects.errors id="desc_subjects" %}
|
||||
</div>
|
||||
|
||||
<span class="help">
|
||||
<button class="button is-small" type="button" data-duplicate="id_add_subject" id="another_subject_field">
|
||||
<span class="icon icon-plus" aria-hidden="true"></span>
|
||||
<span>{% trans "Add Another Subject" %}</span>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
@ -147,7 +93,7 @@
|
|||
<span class="help" id="desc_publishers_help">
|
||||
{% trans "Separate multiple values with commas." %}
|
||||
</span>
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.publishers.errors id="desc_publishers" %}
|
||||
</div>
|
||||
|
||||
|
@ -155,7 +101,8 @@
|
|||
<label class="label" for="id_first_published_date">
|
||||
{% trans "First published date:" %}
|
||||
</label>
|
||||
{{ form.first_published_date }}
|
||||
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %} aria-describedby="desc_first_published_date">
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}
|
||||
</div>
|
||||
|
||||
|
@ -163,8 +110,8 @@
|
|||
<label class="label" for="id_published_date">
|
||||
{% trans "Published date:" %}
|
||||
</label>
|
||||
{{ form.published_date }}
|
||||
|
||||
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %} aria-describedby="desc_published_date">
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -176,8 +123,6 @@
|
|||
</h2>
|
||||
<div class="box">
|
||||
{% if book.authors.exists %}
|
||||
{# preserve authors if the book is unsaved #}
|
||||
<input type="hidden" name="authors" value="{% for author in book.authors.all %}{{ author.id }},{% endfor %}">
|
||||
<fieldset>
|
||||
{% for author in book.authors.all %}
|
||||
<div class="is-flex is-justify-content-space-between">
|
||||
|
@ -204,12 +149,7 @@
|
|||
<input class="input" type="text" name="add_author" id="id_add_author" placeholder="{% trans 'Jane Doe' %}" value="{{ author }}" {% if confirm_mode %}readonly{% endif %}>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<span class="help">
|
||||
<button class="button is-small" type="button" data-duplicate="id_add_author" id="another_author_field">
|
||||
<span class="icon icon-plus" aria-hidden="true"></span>
|
||||
<span>{% trans "Add Another Author" %}</span>
|
||||
</button>
|
||||
</span>
|
||||
<span class="help"><button class="button is-small" type="button" data-duplicate="id_add_author" id="another_author_field">{% trans "Add Another Author" %}</button></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
@ -240,7 +180,7 @@
|
|||
</label>
|
||||
<input class="input" name="cover-url" id="id_cover_url" type="url" value="{{ cover_url|default:'' }}" aria-describedby="desc_cover">
|
||||
</div>
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.cover.errors id="desc_cover" %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -258,8 +198,10 @@
|
|||
<label class="label" for="id_physical_format">
|
||||
{% trans "Format:" %}
|
||||
</label>
|
||||
{{ form.physical_format }}
|
||||
|
||||
<div class="select">
|
||||
{{ form.physical_format }}
|
||||
</div>
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -269,7 +211,7 @@
|
|||
{% trans "Format details:" %}
|
||||
</label>
|
||||
{{ form.physical_format_detail }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.physical_format_detail.errors id="desc_physical_format_detail" %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -280,7 +222,7 @@
|
|||
{% trans "Pages:" %}
|
||||
</label>
|
||||
{{ form.pages }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.pages.errors id="desc_pages" %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -296,7 +238,7 @@
|
|||
{% trans "ISBN 13:" %}
|
||||
</label>
|
||||
{{ form.isbn_13 }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.isbn_13.errors id="desc_isbn_13" %}
|
||||
</div>
|
||||
|
||||
|
@ -305,7 +247,7 @@
|
|||
{% trans "ISBN 10:" %}
|
||||
</label>
|
||||
{{ form.isbn_10 }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.isbn_10.errors id="desc_isbn_10" %}
|
||||
</div>
|
||||
|
||||
|
@ -314,7 +256,7 @@
|
|||
{% trans "Openlibrary ID:" %}
|
||||
</label>
|
||||
{{ form.openlibrary_key }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.openlibrary_key.errors id="desc_openlibrary_key" %}
|
||||
</div>
|
||||
|
||||
|
@ -323,7 +265,7 @@
|
|||
{% trans "Inventaire ID:" %}
|
||||
</label>
|
||||
{{ form.inventaire_id }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.inventaire_id.errors id="desc_inventaire_id" %}
|
||||
</div>
|
||||
|
||||
|
@ -332,7 +274,7 @@
|
|||
{% trans "OCLC Number:" %}
|
||||
</label>
|
||||
{{ form.oclc_number }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.oclc_number.errors id="desc_oclc_number" %}
|
||||
</div>
|
||||
|
||||
|
@ -341,14 +283,10 @@
|
|||
{% trans "ASIN:" %}
|
||||
</label>
|
||||
{{ form.asin }}
|
||||
|
||||
|
||||
{% include 'snippets/form_errors.html' with errors_list=form.ASIN.errors id="desc_ASIN" %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static "js/forms.js" %}"></script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
|
||||
<div class="column my-3-mobile ml-3-tablet mr-auto">
|
||||
<h2 class="title is-5 mb-1">
|
||||
<a href="{{ book.local_path }}" class="has-text-default">
|
||||
<a href="{{ book.local_path }}" class="has-text-black">
|
||||
{{ book|book_title }}
|
||||
</a>
|
||||
</h2>
|
||||
|
@ -46,36 +46,7 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<div>
|
||||
{% include 'snippets/pagination.html' with page=editions path=request.path %}
|
||||
</div>
|
||||
|
||||
<div class="block has-text-centered help">
|
||||
<p>
|
||||
{% trans "Can't find the edition you're looking for?" %}
|
||||
</p>
|
||||
|
||||
<form action="{% url 'create-book-data' %}" method="POST" name="add-edition-form">
|
||||
{% csrf_token %}
|
||||
{{ work_form.title }}
|
||||
{{ work_form.subtitle }}
|
||||
{{ work_form.authors }}
|
||||
{{ work_form.description }}
|
||||
{{ work_form.languages }}
|
||||
{{ work_form.series }}
|
||||
{{ work_form.cover }}
|
||||
{{ work_form.first_published_date }}
|
||||
{% for subject in work.subjects %}
|
||||
<input type="hidden" name="subjects" value="{{ subject }}">
|
||||
{% endfor %}
|
||||
|
||||
<input type="hidden" name="parent_work" value="{{ work.id }}">
|
||||
<div>
|
||||
<button class="button is-small" type="submit">
|
||||
{% trans "Add another edition" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -55,10 +55,8 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<div class="buttons is-right is-flex-grow-1">
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
|
||||
{% endblock %}
|
||||
{% block modal-form-close %}</form>{% endblock %}
|
||||
|
|
|
@ -8,14 +8,14 @@
|
|||
|
||||
<header class="block">
|
||||
<h1 class="title">
|
||||
{% blocktrans trimmed with title=book|book_title %}
|
||||
{% blocktrans with title=book|book_title %}
|
||||
Links for "<em>{{ title }}</em>"
|
||||
{% endblocktrans %}
|
||||
</h1>
|
||||
|
||||
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
|
||||
<ul>
|
||||
<li><a href="{% url 'book' book.id book.name|slugify %}">{{ book|book_title }}</a></li>
|
||||
<li><a href="{% url 'book' book.id %}">{{ book|book_title }}</a></li>
|
||||
<li class="is-active">
|
||||
<a href="#" aria-current="page">
|
||||
{% trans "Edit links" %}
|
||||
|
|
|
@ -17,13 +17,13 @@ Is that where you'd like to go?
|
|||
|
||||
|
||||
{% block modal-footer %}
|
||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" class="button is-primary">{% trans "Continue" %}</a>
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="is-flex-grow-1">
|
||||
<div class="has-text-right is-flex-grow-1">
|
||||
<a href="{% url 'report-link' link.added_by.id link.id %}">{% trans "Report spam" %}</a>
|
||||
</div>
|
||||
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" class="button is-primary">{% trans "Continue" %}</a>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
|
@ -19,10 +19,8 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<div class="buttons is-right is-flex-grow-1">
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
|
||||
</div>
|
||||
<button class="button is-primary" type="submit">{% trans "Confirm" %}</button>
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-form-close %}</form>{% endblock %}
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
{% load i18n %}
|
||||
<section class="card {% if not visible %}is-hidden {% endif %}{{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||
<header class="card-header has-background-secondary">
|
||||
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}_header">
|
||||
{% block header %}{% endblock %}
|
||||
</h2>
|
||||
<span class="card-header-icon">
|
||||
{% trans "Close" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with label=button_text class="delete" nonbutton=True controls_text=controls_text %}
|
||||
</span>
|
||||
</header>
|
||||
<section class="card-content content">
|
||||
<details
|
||||
class="details-panel box"
|
||||
{% if visible %}open{% endif %}
|
||||
>
|
||||
<summary role="heading" aria-level="2">
|
||||
<span class="title is-5">{% block header %}{% endblock %}</span>
|
||||
<span class="details-close icon icon-x" aria-hidden="true"></span>
|
||||
</summary>
|
||||
|
||||
<section class="{{ class }} mt-2">
|
||||
{% block form %}{% endblock %}
|
||||
</section>
|
||||
</section>
|
||||
</details>
|
||||
|
|
11
bookwyrm/templates/components/tooltip.html
Normal file
11
bookwyrm/templates/components/tooltip.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% trans "Help" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text class="ml-3 is-rounded is-small has-background-body p-0 pb-1" icon="question-circle is-size-6" controls_text=controls_text controls_uid=controls_uid %}
|
||||
|
||||
<aside class="tooltip notification is-hidden transition-y is-pulled-left mb-2" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||
{% trans "Close" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with label=button_text class="delete" nonbutton=True controls_text=controls_text controls_uid=controls_uid %}
|
||||
|
||||
{% block tooltip_content %}{% endblock %}
|
||||
</aside>
|
|
@ -29,16 +29,9 @@
|
|||
</section>
|
||||
|
||||
<section class="block">
|
||||
<form name="fallback" method="GET" action="{% url 'resend-link' %}" autocomplete="off">
|
||||
<button
|
||||
type="submit"
|
||||
class="button"
|
||||
data-modal-open="resend_form"
|
||||
>
|
||||
{% trans "Can't find your code?" %}
|
||||
</button>
|
||||
</form>
|
||||
{% include "confirm_email/resend_modal.html" with id="resend_form" %}
|
||||
{% trans "Can't find your code?" as button_text %}
|
||||
{% include "snippets/toggle/open_button.html" with text=button_text controls_text="resend_form" focus="resend_form_header" %}
|
||||
{% include "confirm_email/resend_form.html" with controls_text="resend_form" %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
{% extends 'landing/layout.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
{% trans "Resend confirmation link" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include "confirm_email/resend_modal.html" with active=True static=True id="resend-modal" %}
|
||||
{% endblock %}
|
20
bookwyrm/templates/confirm_email/resend_form.html
Normal file
20
bookwyrm/templates/confirm_email/resend_form.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends "components/inline_form.html" %}
|
||||
{% load i18n %}
|
||||
{% block header %}
|
||||
{% trans "Resend confirmation link" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
<form name="resend" method="post" action="{% url 'resend-link' %}">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="email">{% trans "Email address:" %}</label>
|
||||
<div class="control">
|
||||
<input type="text" name="email" class="input" required id="email">
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button is-link">{% trans "Resend link" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -1,44 +0,0 @@
|
|||
{% extends "components/modal.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block modal-title %}
|
||||
{% trans "Resend confirmation link" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-form-open %}
|
||||
<form name="resend" method="post" action="{% url 'resend-link' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="email">{% trans "Email address:" %}</label>
|
||||
<div class="control">
|
||||
<input
|
||||
type="email"
|
||||
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 %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<div class="control">
|
||||
<button class="button is-link">{% trans "Resend link" %}</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-form-close %}
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -43,7 +43,7 @@
|
|||
{% endif %}
|
||||
<p>
|
||||
<a href="https://joinbookwyrm.com/">
|
||||
{% trans "Join BookWyrm" %}
|
||||
{% trans "Join Bookwyrm" %}
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
|
|
|
@ -5,19 +5,7 @@
|
|||
<section class="block">
|
||||
<h2 class="title is-4">{% trans "Your Books" %}</h2>
|
||||
{% if not suggested_books %}
|
||||
|
||||
<div class="content">
|
||||
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
|
||||
|
||||
<div class="box has-background-link-light">
|
||||
<p>{% trans "Do you have book data from another service like GoodReads?" %}</p>
|
||||
<a href="{% url 'import' %}">
|
||||
<span class="icon icon-list" aria-hidden="true"></span>
|
||||
{% trans "Import your reading history" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
|
||||
{% else %}
|
||||
{% with active_book=request.GET.book %}
|
||||
<div class="tab-group">
|
||||
|
|
|
@ -10,7 +10,6 @@
|
|||
{% 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 %}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Create group" %}
|
||||
{% trans "Create Group" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block form %}
|
||||
|
|
|
@ -8,15 +8,13 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<form name="delete-group-{{ group.id }}" action="{% url 'delete-group' group.id %}" method="POST" class="is-flex-grow-1">
|
||||
<form name="delete-group-{{ group.id }}" action="{% url 'delete-group' group.id %}" method="POST">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="id" value="{{ group.id }}">
|
||||
<div class="buttons is-right is-flex-grow-1">
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
<button class="button is-danger" type="submit">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
</div>
|
||||
<button class="button is-danger" type="submit">
|
||||
{% trans "Delete" %}
|
||||
</button>
|
||||
<button type="button" class="button" data-modal-close>{% trans "Cancel" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -16,21 +16,18 @@
|
|||
</div>
|
||||
<div class="is-flex">
|
||||
{% if group.id %}
|
||||
<div>
|
||||
<div class="is-flex-grow-1">
|
||||
<button type="button" data-modal-open="delete_group" class="button is-danger">
|
||||
{% trans "Delete group" %}
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="is-flex is-flex-grow-1 is-justify-content-flex-end">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
{% include 'snippets/privacy_select_no_followers.html' with current=group.privacy %}
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
{% include 'snippets/privacy_select_no_followers.html' with current=group.privacy %}
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
{% for membership in group.memberships.all %}
|
||||
{% with member=membership.user %}
|
||||
<div class="box has-text-centered is-shadowless has-background-tertiary my-2 mx-2 member_{{ member.id }}">
|
||||
<a href="{{ member.local_path }}" class="has-text-default">
|
||||
<a href="{{ member.local_path }}" class="has-text-black">
|
||||
{% include 'snippets/avatar.html' with user=member large=True %}
|
||||
<span title="{{ member.display_name }}" class="is-block is-6 has-text-weight-bold">{{ member.display_name|truncatechars:10 }}</span>
|
||||
<span title="@{{ member|username }}" class="is-block pb-3">@{{ member|username|truncatechars:8 }}</span>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<div class="column is-flex is-flex-grow-0">
|
||||
{% for user in suggested_users %}
|
||||
<div class="box has-text-centered is-shadowless has-background-tertiary m-2">
|
||||
<a href="{{ user.local_path }}" class="has-text-default">
|
||||
<a href="{{ user.local_path }}" class="has-text-black">
|
||||
{% include 'snippets/avatar.html' with user=user large=True %}
|
||||
<span title="{{ user.display_name }}" class="is-block is-6 has-text-weight-bold">{{ user.display_name|truncatechars:10 }}</span>
|
||||
<span title="@{{ user|username }}" class="is-block pb-3">@{{ user|username|truncatechars:8 }}</span>
|
||||
|
|
|
@ -14,35 +14,28 @@
|
|||
<div class="column is-half">
|
||||
|
||||
<div class="field">
|
||||
<label class="label" for="source">
|
||||
<label class="label is-pulled-left" for="source">
|
||||
{% trans "Data source:" %}
|
||||
</label>
|
||||
|
||||
<div class="select">
|
||||
<select name="source" id="source" aria-describedby="desc_source">
|
||||
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
|
||||
Goodreads (CSV)
|
||||
</option>
|
||||
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
|
||||
Storygraph (CSV)
|
||||
</option>
|
||||
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
|
||||
LibraryThing (TSV)
|
||||
</option>
|
||||
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
|
||||
OpenLibrary (CSV)
|
||||
</option>
|
||||
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
|
||||
Calibre (CSV)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p class="help" id="desc_source">
|
||||
{% trans 'You can download your Goodreads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener noreferrer">Import/Export page</a> of your Goodreads account.' %}
|
||||
</p>
|
||||
{% include 'import/tooltip.html' with controls_text="goodreads-tooltip" %}
|
||||
</div>
|
||||
|
||||
<div class="select block">
|
||||
<select name="source" id="source">
|
||||
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
|
||||
Goodreads (CSV)
|
||||
</option>
|
||||
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
|
||||
Storygraph (CSV)
|
||||
</option>
|
||||
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
|
||||
LibraryThing (TSV)
|
||||
</option>
|
||||
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
|
||||
OpenLibrary (CSV)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
|
||||
{{ import_form.csv_file }}
|
||||
|
@ -70,7 +63,7 @@
|
|||
<div class="content block">
|
||||
<h2 class="title">{% trans "Recent Imports" %}</h2>
|
||||
{% if not jobs %}
|
||||
<p><em>{% trans "No recent imports" %}</em></p>
|
||||
<p>{% trans "No recent imports" %}</p>
|
||||
{% endif %}
|
||||
<ul>
|
||||
{% for job in jobs %}
|
||||
|
|
8
bookwyrm/templates/import/tooltip.html
Normal file
8
bookwyrm/templates/import/tooltip.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends 'components/tooltip.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block tooltip_content %}
|
||||
|
||||
{% trans 'You can download your Goodreads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener noreferrer">Import/Export page</a> of your Goodreads account.' %}
|
||||
|
||||
{% endblock %}
|
|
@ -70,14 +70,6 @@
|
|||
|
||||
{% include 'snippets/form_errors.html' with errors_list=request_form.email.errors id="desc_request_email" %}
|
||||
</div>
|
||||
{% if site.invite_request_question %}
|
||||
<div class="block">
|
||||
<label for="id_answer_register" class="label">{{ site.invite_question_text }}</label>
|
||||
<input type="answer" name="answer" maxlength="50" class="input" required="true" id="id_answer_register" aria-describedby="desc_answer_register">
|
||||
{% include 'snippets/form_errors.html' with errors_list=request_form.answer.errors id="desc_answer_register" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="button is-link">{% trans "Submit" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
|
|
@ -56,16 +56,8 @@
|
|||
</span>
|
||||
</button>
|
||||
</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="is-sr-only">{% trans "Scan Barcode" %}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% include "search/barcode_modal.html" with id="barcode-scanner-modal" %}
|
||||
|
||||
<button type="button" tabindex="0" class="navbar-burger pulldown-menu my-4" data-controls="main_nav" aria-expanded="false">
|
||||
<i class="icon icon-dots-three-vertical" aria-hidden="true"></i>
|
||||
|
@ -90,8 +82,64 @@
|
|||
|
||||
<div class="navbar-end">
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="navbar-item mt-3 py-0">
|
||||
{% include 'user_menu.html' %}
|
||||
<div class="navbar-item mt-3 py-0 has-dropdown is-hoverable">
|
||||
<a
|
||||
href="{{ request.user.local_path }}"
|
||||
class="navbar-link pulldown-menu"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
tabindex="0"
|
||||
aria-haspopup="true"
|
||||
aria-controls="navbar-dropdown"
|
||||
>
|
||||
{% include 'snippets/avatar.html' with user=request.user %}
|
||||
<span class="ml-2">{{ request.user.display_name }}</span>
|
||||
</a>
|
||||
<ul class="navbar-dropdown" id="navbar_dropdown">
|
||||
<li>
|
||||
<a href="{% url 'directory' %}" class="navbar-item">
|
||||
{% trans "Directory" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
|
||||
{% trans 'Your Books' %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'direct-messages' %}" class="navbar-item">
|
||||
{% trans "Direct Messages" %}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'prefs-profile' %}" class="navbar-item">
|
||||
{% trans 'Settings' %}
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.bookwyrm.create_invites or perms.moderate_user %}
|
||||
<li class="navbar-divider" role="presentation"> </li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.create_invites and not site.allow_registration %}
|
||||
<li>
|
||||
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
|
||||
{% trans 'Invites' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.bookwyrm.moderate_user %}
|
||||
<li>
|
||||
<a href="{% url 'settings-dashboard' %}" class="navbar-item">
|
||||
{% trans 'Admin' %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="navbar-divider" role="presentation"> </li>
|
||||
<li>
|
||||
<a href="{% url 'logout' %}" class="navbar-item">
|
||||
{% trans 'Log out' %}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="navbar-item mt-3 py-0">
|
||||
<a href="{% url 'notifications' %}" class="tags has-addons">
|
||||
|
@ -218,7 +266,6 @@
|
|||
<script src="{% static "js/bookwyrm.js" %}?v={{ js_cache }}"></script>
|
||||
<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>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue