Compare commits

..

1 commit

Author SHA1 Message Date
Mouse Reeve bb0357b38f Cache buster 2022-02-18 21:51:56 -08:00
420 changed files with 11463 additions and 36954 deletions

View file

@ -102,9 +102,6 @@ PREVIEW_DEFAULT_COVER_COLOR=#002549
# for sending prod and dev metrics to the same place and
# keeping them separate, for instance!
# API endpoint for your provider
OTEL_EXPORTER_OTLP_ENDPOINT=
# Any headers required, usually authentication info
OTEL_EXPORTER_OTLP_HEADERS=
# Service name to identify your app
OTEL_SERVICE_NAME=
OTEL_EXPORTER_OTLP_ENDPOINT= # API endpoint for your provider
OTEL_EXPORTER_OTLP_HEADERS= # Any headers required, usually authentication info
OTEL_SERVICE_NAME= # Service name to identify your app

View file

@ -27,7 +27,7 @@ jobs:
# See .stylelintignore for files that are not linted.
- name: Run stylelint
run: >
npx stylelint bookwyrm/static/css/*.scss bookwyrm/static/css/bookwyrm/**/*.scss \
npx stylelint bookwyrm/static/css/*.css \
--config dev-tools/.stylelintrc.js
# See .eslintignore for files that are not linted.

21
.github/workflows/lint-global.yaml vendored Normal file
View file

@ -0,0 +1,21 @@
# @url https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions
name: Lint project globally
on:
push:
branches: [ main, ci ]
pull_request:
branches: [ main, ci ]
jobs:
lint:
name: Lint with EditorConfig.
runs-on: ubuntu-20.04
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- name: EditorConfig
uses: greut/eclint-action@v0

View file

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

3
.gitignore vendored
View file

@ -16,9 +16,6 @@
# BookWyrm
.env
/images/
bookwyrm/static/css/bookwyrm.css
bookwyrm/static/css/themes/
!bookwyrm/static/css/themes/bookwyrm-*.scss
# Testing
.coverage

View file

@ -1 +0,0 @@
**/vendor/*

View file

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

View file

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

View file

@ -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://docs.joinbookwyrm.com/instances.html) 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).

View file

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

View file

@ -39,5 +39,4 @@ class Person(ActivityObject):
bookwyrmUser: bool = False
manuallyApprovesFollowers: str = False
discoverable: str = False
hideFollows: str = False
type: str = "Person"

View file

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

View file

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

View file

@ -148,8 +148,8 @@ class SearchResult:
def __repr__(self):
# pylint: disable=consider-using-f-string
return "<SearchResult key={!r} title={!r} author={!r} 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):

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,20 +8,8 @@ def site_settings(request): # pylint: disable=unused-argument
if not request.is_secure():
request_protocol = "http://"
site = models.SiteSettings.objects.get()
theme = "css/themes/bookwyrm-light.scss"
if (
hasattr(request, "user")
and request.user.is_authenticated
and request.user.theme
):
theme = request.user.theme.path
elif site.default_theme:
theme = site.default_theme.path
return {
"site": site,
"site_theme": theme,
"site": models.SiteSettings.objects.get(),
"active_announcements": models.Announcement.active_announcements(),
"thumbnail_generation_enabled": settings.ENABLE_THUMBNAIL_GENERATION,
"media_full_url": settings.MEDIA_FULL_URL,

552
bookwyrm/forms.py Normal file
View file

@ -0,0 +1,552 @@
""" 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",
"preferred_timezone",
"preferred_language",
]
help_texts = {f: None for f in fields}
widgets = {
"avatar": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_avatar"}
),
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
"email": forms.EmailInput(attrs={"aria-describedby": "desc_email"}),
"discoverable": forms.CheckboxInput(
attrs={"aria-describedby": "desc_discoverable"}
),
}
class LimitedEditUserForm(CustomForm):
class Meta:
model = models.User
fields = [
"avatar",
"name",
"summary",
"manually_approves_followers",
"discoverable",
]
help_texts = {f: None for f in fields}
widgets = {
"avatar": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_avatar"}
),
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"summary": forms.Textarea(attrs={"aria-describedby": "desc_summary"}),
"discoverable": forms.CheckboxInput(
attrs={"aria-describedby": "desc_discoverable"}
),
}
class DeleteUserForm(CustomForm):
class Meta:
model = models.User
fields = ["password"]
class UserGroupForm(CustomForm):
class Meta:
model = models.User
fields = ["groups"]
class FeedStatusTypesForm(CustomForm):
class Meta:
model = models.User
fields = ["feed_status_types"]
help_texts = {f: None for f in fields}
widgets = {
"feed_status_types": widgets.CheckboxSelectMultiple(
choices=FeedFilterChoices,
),
}
class CoverForm(CustomForm):
class Meta:
model = models.Book
fields = ["cover"]
help_texts = {f: None for f in fields}
class LinkDomainForm(CustomForm):
class Meta:
model = models.LinkDomain
fields = ["name"]
class FileLinkForm(CustomForm):
class Meta:
model = models.FileLink
fields = ["url", "filetype", "availability", "book", "added_by"]
def clean(self):
"""make sure the domain isn't blocked or pending"""
cleaned_data = super().clean()
url = cleaned_data.get("url")
filetype = cleaned_data.get("filetype")
book = cleaned_data.get("book")
domain = urlparse(url).netloc
if models.LinkDomain.objects.filter(domain=domain).exists():
status = models.LinkDomain.objects.get(domain=domain).status
if status == "blocked":
# pylint: disable=line-too-long
self.add_error(
"url",
_(
"This domain is blocked. Please contact your administrator if you think this is an error."
),
)
elif models.FileLink.objects.filter(
url=url, book=book, filetype=filetype
).exists():
# pylint: disable=line-too-long
self.add_error(
"url",
_(
"This link with file type has already been added for this book. If it is not visible, the domain is still pending."
),
)
class EditionForm(CustomForm):
class Meta:
model = models.Edition
exclude = [
"remote_id",
"origin_id",
"created_date",
"updated_date",
"edition_rank",
"authors",
"parent_work",
"shelves",
"connector",
"search_vector",
"links",
"file_links",
]
widgets = {
"title": forms.TextInput(attrs={"aria-describedby": "desc_title"}),
"subtitle": forms.TextInput(attrs={"aria-describedby": "desc_subtitle"}),
"description": forms.Textarea(
attrs={"aria-describedby": "desc_description"}
),
"series": forms.TextInput(attrs={"aria-describedby": "desc_series"}),
"series_number": forms.TextInput(
attrs={"aria-describedby": "desc_series_number"}
),
"languages": forms.TextInput(
attrs={"aria-describedby": "desc_languages_help desc_languages"}
),
"publishers": forms.TextInput(
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
),
"first_published_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_first_published_date"}
),
"published_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_published_date"}
),
"cover": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_cover"}
),
"physical_format": forms.Select(
attrs={"aria-describedby": "desc_physical_format"}
),
"physical_format_detail": forms.TextInput(
attrs={"aria-describedby": "desc_physical_format_detail"}
),
"pages": forms.NumberInput(attrs={"aria-describedby": "desc_pages"}),
"isbn_13": forms.TextInput(attrs={"aria-describedby": "desc_isbn_13"}),
"isbn_10": forms.TextInput(attrs={"aria-describedby": "desc_isbn_10"}),
"openlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_openlibrary_key"}
),
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}
),
"oclc_number": forms.TextInput(
attrs={"aria-describedby": "desc_oclc_number"}
),
"ASIN": forms.TextInput(attrs={"aria-describedby": "desc_ASIN"}),
}
class AuthorForm(CustomForm):
class Meta:
model = models.Author
fields = [
"last_edited_by",
"name",
"aliases",
"bio",
"wikipedia_link",
"born",
"died",
"openlibrary_key",
"inventaire_id",
"librarything_key",
"goodreads_key",
"isni",
]
widgets = {
"name": forms.TextInput(attrs={"aria-describedby": "desc_name"}),
"aliases": forms.TextInput(attrs={"aria-describedby": "desc_aliases"}),
"bio": forms.Textarea(attrs={"aria-describedby": "desc_bio"}),
"wikipedia_link": forms.TextInput(
attrs={"aria-describedby": "desc_wikipedia_link"}
),
"born": forms.SelectDateWidget(attrs={"aria-describedby": "desc_born"}),
"died": forms.SelectDateWidget(attrs={"aria-describedby": "desc_died"}),
"oepnlibrary_key": forms.TextInput(
attrs={"aria-describedby": "desc_oepnlibrary_key"}
),
"inventaire_id": forms.TextInput(
attrs={"aria-describedby": "desc_inventaire_id"}
),
"librarything_key": forms.TextInput(
attrs={"aria-describedby": "desc_librarything_key"}
),
"goodreads_key": forms.TextInput(
attrs={"aria-describedby": "desc_goodreads_key"}
),
}
class ImportForm(forms.Form):
csv_file = forms.FileField()
class ExpiryWidget(widgets.Select):
def value_from_datadict(self, data, files, name):
"""human-readable exiration time buckets"""
selected_string = super().value_from_datadict(data, files, name)
if selected_string == "day":
interval = datetime.timedelta(days=1)
elif selected_string == "week":
interval = datetime.timedelta(days=7)
elif selected_string == "month":
interval = datetime.timedelta(days=31) # Close enough?
elif selected_string == "forever":
return None
else:
return selected_string # This will raise
return timezone.now() + interval
class InviteRequestForm(CustomForm):
def clean(self):
"""make sure the email isn't in use by a registered user"""
cleaned_data = super().clean()
email = cleaned_data.get("email")
if email and models.User.objects.filter(email=email).exists():
self.add_error("email", _("A user with this email already exists."))
class Meta:
model = models.InviteRequest
fields = ["email"]
class CreateInviteForm(CustomForm):
class Meta:
model = models.SiteInvite
exclude = ["code", "user", "times_used", "invitees"]
widgets = {
"expiry": ExpiryWidget(
choices=[
("day", _("One Day")),
("week", _("One Week")),
("month", _("One Month")),
("forever", _("Does Not Expire")),
]
),
"use_limit": widgets.Select(
choices=[(i, _(f"{i} uses")) for i in [1, 5, 10, 25, 50, 100]]
+ [(None, _("Unlimited"))]
),
}
class ShelfForm(CustomForm):
class Meta:
model = models.Shelf
fields = ["user", "name", "privacy", "description"]
class GoalForm(CustomForm):
class Meta:
model = models.AnnualGoal
fields = ["user", "year", "goal", "privacy"]
class SiteForm(CustomForm):
class Meta:
model = models.SiteSettings
exclude = ["admin_code", "install_mode"]
widgets = {
"instance_short_description": forms.TextInput(
attrs={"aria-describedby": "desc_instance_short_description"}
),
"require_confirm_email": forms.CheckboxInput(
attrs={"aria-describedby": "desc_require_confirm_email"}
),
"invite_request_text": forms.Textarea(
attrs={"aria-describedby": "desc_invite_request_text"}
),
}
class AnnouncementForm(CustomForm):
class Meta:
model = models.Announcement
exclude = ["remote_id"]
widgets = {
"preview": forms.TextInput(attrs={"aria-describedby": "desc_preview"}),
"content": forms.Textarea(attrs={"aria-describedby": "desc_content"}),
"event_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_event_date"}
),
"start_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_start_date"}
),
"end_date": forms.SelectDateWidget(
attrs={"aria-describedby": "desc_end_date"}
),
"active": forms.CheckboxInput(attrs={"aria-describedby": "desc_active"}),
}
class ListForm(CustomForm):
class Meta:
model = models.List
fields = ["user", "name", "description", "curation", "privacy", "group"]
class ListItemForm(CustomForm):
class Meta:
model = models.ListItem
fields = ["user", "book", "book_list", "notes"]
class GroupForm(CustomForm):
class Meta:
model = models.Group
fields = ["user", "privacy", "name", "description"]
class ReportForm(CustomForm):
class Meta:
model = models.Report
fields = ["user", "reporter", "statuses", "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"]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -89,7 +89,7 @@ def init_connectors():
covers_url="https://inventaire.io",
search_url="https://inventaire.io/api/search?types=works&types=works&search=",
isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
priority=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":

View file

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

View file

@ -1,39 +0,0 @@
# Generated by Django 3.2.12 on 2022-02-24 18:59
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0137_alter_sitesettings_allow_registration"),
]
operations = [
migrations.CreateModel(
name="AutoMod",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("string_match", models.CharField(max_length=200, unique=True)),
("flag_users", models.BooleanField(default=True)),
("flag_statuses", models.BooleanField(default=True)),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View file

@ -1,45 +0,0 @@
# Generated by Django 3.2.12 on 2022-02-24 20:41
from django.db import migrations, models
import django.db.models.deletion
def set_report_statuses(apps, schema_editor):
"""copy over status fields"""
db_alias = schema_editor.connection.alias
report_model = apps.get_model("bookwyrm", "Report")
reports = report_model.objects.using(db_alias).filter(statuses__isnull=False)
for report in reports:
report.status = report.statuses.first()
report.save()
def set_reverse(apps, schema_editor):
"""copy over status fields"""
db_alias = schema_editor.connection.alias
report_model = apps.get_model("bookwyrm", "Report")
reports = report_model.objects.using(db_alias).filter(status__isnull=False)
for report in reports:
report.statuses.set(report.status)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0138_automod"),
]
operations = [
migrations.AddField(
model_name="report",
name="status",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="reports",
to="bookwyrm.status",
),
),
migrations.RunPython(set_report_statuses, reverse_code=set_reverse),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 3.2.12 on 2022-02-24 20:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0139_report_status"),
]
operations = [
migrations.RemoveField(
model_name="report",
name="statuses",
),
]

View file

@ -1,24 +0,0 @@
# Generated by Django 3.2.12 on 2022-02-24 20:50
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0140_remove_report_statuses"),
]
operations = [
migrations.AlterField(
model_name="report",
name="status",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="bookwyrm.status",
),
),
]

View file

@ -1,68 +0,0 @@
# Generated by Django 3.2.12 on 2022-02-27 17:52
from django.db import migrations, models
import django.db.models.deletion
def add_default_themes(apps, schema_editor):
"""add light and dark themes"""
db_alias = schema_editor.connection.alias
theme_model = apps.get_model("bookwyrm", "Theme")
theme_model.objects.using(db_alias).create(
name="BookWyrm Light",
path="css/themes/bookwyrm-light.scss",
)
theme_model.objects.using(db_alias).create(
name="BookWyrm Dark",
path="css/themes/bookwyrm-dark.scss",
)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0141_alter_report_status"),
]
operations = [
migrations.CreateModel(
name="Theme",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_date", models.DateTimeField(auto_now_add=True)),
("name", models.CharField(max_length=50, unique=True)),
("path", models.CharField(max_length=50, unique=True)),
],
),
migrations.AddField(
model_name="sitesettings",
name="default_theme",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="bookwyrm.theme",
),
),
migrations.AddField(
model_name="user",
name="theme",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="bookwyrm.theme",
),
),
migrations.RunPython(
add_default_themes, reverse_code=migrations.RunPython.noop
),
]

View file

@ -1,19 +0,0 @@
# Generated by Django 3.2.12 on 2022-02-28 19:44
import bookwyrm.models.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0141_alter_report_status"),
]
operations = [
migrations.AddField(
model_name="user",
name="hide_follows",
field=bookwyrm.models.fields.BooleanField(default=False),
),
]

View file

@ -1,13 +0,0 @@
# Generated by Django 3.2.12 on 2022-02-28 21:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0142_auto_20220227_1752"),
("bookwyrm", "0142_user_hide_follows"),
]
operations = []

View file

@ -1,39 +0,0 @@
# Generated by Django 3.2.12 on 2022-03-01 18:46
from django.db import migrations, models
def remove_white(apps, schema_editor):
"""don't hardcode white announcements"""
db_alias = schema_editor.connection.alias
announcement_model = apps.get_model("bookwyrm", "Announcement")
announcement_model.objects.using(db_alias).filter(display_type="white-ter").update(
display_type=None
)
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0143_merge_0142_auto_20220227_1752_0142_user_hide_follows"),
]
operations = [
migrations.AlterField(
model_name="announcement",
name="display_type",
field=models.CharField(
blank=True,
choices=[
("primary-light", "Primary"),
("success-light", "Success"),
("link-light", "Link"),
("warning-light", "Warning"),
("danger-light", "Danger"),
],
max_length=20,
null=True,
),
),
migrations.RunPython(remove_white, reverse_code=migrations.RunPython.noop),
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -26,10 +26,10 @@ from .group import Group, GroupMember, GroupMemberInvitation
from .import_job import ImportJob, ImportItem
from .site import SiteSettings, Theme, SiteInvite
from .site import SiteSettings, SiteInvite
from .site import PasswordReset, InviteRequest
from .announcement import Announcement
from .antispam import EmailBlocklist, IPBlocklist, AutoMod, automod_task
from .antispam import EmailBlocklist, IPBlocklist
from .notification import Notification

View file

@ -8,6 +8,7 @@ from .base_model import BookWyrmModel
DisplayTypes = [
("white-ter", _("None")),
("primary-light", _("Primary")),
("success-light", _("Success")),
("link-light", _("Link")),
@ -27,7 +28,11 @@ class Announcement(BookWyrmModel):
end_date = models.DateTimeField(blank=True, null=True)
active = models.BooleanField(default=True)
display_type = models.CharField(
max_length=20, choices=DisplayTypes, null=True, blank=True
max_length=20,
blank=False,
null=False,
choices=DisplayTypes,
default="white-ter",
)
@classmethod

View file

@ -1,13 +1,6 @@
""" Lets try NOT to sell viagra """
from functools import reduce
import operator
from django.apps import apps
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from bookwyrm.tasks import app
from .user import User
@ -40,107 +33,3 @@ class IPBlocklist(models.Model):
"""default sorting"""
ordering = ("-created_date",)
class AutoMod(models.Model):
"""rules to automatically flag suspicious activity"""
string_match = models.CharField(max_length=200, unique=True)
flag_users = models.BooleanField(default=True)
flag_statuses = models.BooleanField(default=True)
created_by = models.ForeignKey("User", on_delete=models.PROTECT)
@app.task(queue="low_priority")
def automod_task():
"""Create reports"""
if not AutoMod.objects.exists():
return
reporter = AutoMod.objects.first().created_by
reports = automod_users(reporter) + automod_statuses(reporter)
if reports:
admins = User.objects.filter(
models.Q(user_permissions__name__in=["moderate_user", "moderate_post"])
| models.Q(is_superuser=True)
).all()
notification_model = apps.get_model(
"bookwyrm", "Notification", require_ready=True
)
for admin in admins:
notification_model.objects.bulk_create(
[
notification_model(
user=admin,
related_report=r,
notification_type="REPORT",
)
for r in reports
]
)
def automod_users(reporter):
"""check users for moderation flags"""
user_rules = AutoMod.objects.filter(flag_users=True).values_list(
"string_match", flat=True
)
if not user_rules:
return []
filters = []
for field in ["username", "summary", "name"]:
filters += [{f"{field}__icontains": r} for r in user_rules]
users = User.objects.filter(
reduce(operator.or_, (Q(**f) for f in filters)),
is_active=True,
local=True,
report__isnull=True, # don't flag users that already have reports
).distinct()
report_model = apps.get_model("bookwyrm", "Report", require_ready=True)
return report_model.objects.bulk_create(
[
report_model(
reporter=reporter,
note=_("Automatically generated report"),
user=u,
)
for u in users
]
)
def automod_statuses(reporter):
"""check statues for moderation flags"""
status_rules = AutoMod.objects.filter(flag_statuses=True).values_list(
"string_match", flat=True
)
if not status_rules:
return []
filters = []
for field in ["content", "content_warning", "quotation__quote", "review__name"]:
filters += [{f"{field}__icontains": r} for r in status_rules]
status_model = apps.get_model("bookwyrm", "Status", require_ready=True)
statuses = status_model.objects.filter(
reduce(operator.or_, (Q(**f) for f in filters)),
deleted=False,
local=True,
report__isnull=True, # don't flag statuses that already have reports
).distinct()
report_model = apps.get_model("bookwyrm", "Report", require_ready=True)
return report_model.objects.bulk_create(
[
report_model(
reporter=reporter,
note=_("Automatically generated report"),
user=s.user,
status=s,
)
for s in statuses
]
)

View file

@ -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?"""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,12 +12,7 @@ class Report(BookWyrmModel):
)
note = models.TextField(null=True, blank=True)
user = models.ForeignKey("User", on_delete=models.PROTECT)
status = models.ForeignKey(
"Status",
null=True,
blank=True,
on_delete=models.PROTECT,
)
statuses = models.ManyToManyField("Status", blank=True)
links = models.ManyToManyField("Link", blank=True)
resolved = models.BooleanField(default=False)

View file

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

View file

@ -24,10 +24,6 @@ class SiteSettings(models.Model):
)
instance_description = models.TextField(default="This instance has no description.")
instance_short_description = models.CharField(max_length=255, blank=True, null=True)
default_theme = models.ForeignKey(
"Theme", null=True, blank=True, on_delete=models.SET_NULL
)
version = models.CharField(null=True, blank=True, max_length=10)
# admin setup options
install_mode = models.BooleanField(default=False)
@ -49,12 +45,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,29 +96,14 @@ 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)
class Theme(models.Model):
"""Theme files"""
created_date = models.DateTimeField(auto_now_add=True)
name = models.CharField(max_length=50, unique=True)
path = models.CharField(max_length=50, unique=True)
def __str__(self):
# pylint: disable=invalid-str-returned
return self.name
class SiteInvite(models.Model):
"""gives someone access to create an account on the instance"""
@ -157,7 +134,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)

View file

@ -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
@ -224,7 +227,7 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
@classmethod
def privacy_filter(cls, viewer, privacy_levels=None):
queryset = super().privacy_filter(viewer, privacy_levels=privacy_levels)
return queryset.filter(deleted=False, user__is_active=True)
return queryset.filter(deleted=False)
@classmethod
def direct_filter(cls, queryset, viewer):
@ -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)

View file

@ -136,8 +136,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
updated_date = models.DateTimeField(auto_now=True)
last_active_date = models.DateTimeField(default=timezone.now)
manually_approves_followers = fields.BooleanField(default=False)
theme = models.ForeignKey("Theme", null=True, blank=True, on_delete=models.SET_NULL)
hide_follows = fields.BooleanField(default=False)
# options to turn features on and off
show_goal = models.BooleanField(default=True)
@ -374,10 +372,6 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"name": "Read",
"identifier": "read",
},
{
"name": "Stopped Reading",
"identifier": "stopped-reading",
},
]
for shelf in shelves:
@ -484,13 +478,10 @@ def set_remote_server(user_id):
get_remote_reviews.delay(user.outbox)
def get_or_create_remote_server(domain, refresh=False):
def get_or_create_remote_server(domain):
"""get info on a remote server"""
server = FederatedServer()
try:
server = FederatedServer.objects.get(server_name=domain)
if not refresh:
return server
return FederatedServer.objects.get(server_name=domain)
except FederatedServer.DoesNotExist:
pass
@ -505,15 +496,13 @@ def get_or_create_remote_server(domain, refresh=False):
application_type = data.get("software", {}).get("name")
application_version = data.get("software", {}).get("version")
except ConnectorException:
if server.id:
return server
application_type = application_version = None
server.server_name = domain
server.application_type = application_type
server.application_version = application_version
server.save()
server = FederatedServer.objects.create(
server_name=domain,
application_type=application_type,
application_version=application_version,
)
return server

View file

@ -6,12 +6,10 @@ import requests
from django.utils.translation import gettext_lazy as _
# pylint: disable=line-too-long
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.4.0"
VERSION = "0.3.0"
RELEASE_API = env(
"RELEASE_API",
@ -21,7 +19,7 @@ RELEASE_API = env(
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "e678183b"
JS_CACHE = "3b2303af"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -57,6 +55,7 @@ PREVIEW_DEFAULT_COVER_COLOR = env.str("PREVIEW_DEFAULT_COVER_COLOR", "#002549")
PREVIEW_DEFAULT_FONT = env.str("PREVIEW_DEFAULT_FONT", "Source Han Sans")
FONTS = {
# pylint: disable=line-too-long
"Source Han Sans": {
"directory": "source_han_sans",
"filename": "SourceHanSans-VF.ttf.ttc",
@ -87,10 +86,9 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.humanize",
"sass_processor",
"django_rename_app",
"bookwyrm",
"celery",
"django_celery_beat",
"imagekit",
"storages",
]
@ -182,18 +180,6 @@ LOGGING = {
},
}
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
"sass_processor.finders.CssFinder",
]
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:
SASS_OUTPUT_STYLE = "compressed"
WSGI_APPLICATION = "bookwyrm.wsgi.application"
@ -212,7 +198,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))
@ -224,6 +210,7 @@ if env("USE_DUMMY_CACHE", False):
}
}
else:
# pylint: disable=line-too-long
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
@ -258,6 +245,7 @@ AUTH_USER_MODEL = "bookwyrm.User"
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
# pylint: disable=line-too-long
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
@ -284,13 +272,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)")),

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
@charset "utf-8";
@import "vendor/bulma/bulma.sass";
@import "bookwyrm/all.scss";

View file

@ -1,164 +0,0 @@
/** Imports
******************************************************************************/
@import "components/avatar";
@import "components/barcode";
@import "components/book_cover";
@import "components/book_grid";
@import "components/book_list";
@import "components/book_preview_table";
@import "components/breadcrumbs";
@import "components/copy";
@import "components/details";
@import "components/file_input";
@import "components/live_message";
@import "components/shelving";
@import "components/stars";
@import "components/status";
@import "components/tabs";
@import "components/toggle";
@import "overrides/bulma_overrides";
@import "utilities/a11y";
@import "utilities/alignments";
@import "utilities/colors";
@import "utilities/size";
@import "utilities/spacings";
@import "utilities/transitions";
html {
scroll-behavior: smooth;
}
body {
min-height: 100vh;
display: flex;
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;
padding: 0;
width: auto;
overflow: visible;
background: transparent;
/* inherit font, color & alignment from ancestor */
color: inherit;
font: inherit;
text-align: inherit;
/* Normalize `line-height`. Cannot be changed from `normal` in Firefox 4+. */
line-height: normal;
/* Corrects font smoothing for webkit */
-webkit-font-smoothing: inherit;
-moz-osx-font-smoothing: inherit;
/* Corrects inability to style clickable `input` types in iOS */
-webkit-appearance: none;
/* Generalizes pointer cursor */
cursor: pointer;
}
button::-moz-focus-inner {
/* Remove excess padding and border in Firefox 4+ */
border: 0;
padding: 0;
}
/* Better accessibility for keyboard users */
*:focus-visible {
outline-style: auto !important;
}
/** Utilities not covered by Bulma
******************************************************************************/
.tag.is-small {
height: auto;
}
.button.is-transparent {
background-color: transparent;
}
.card.is-stretchable {
display: flex;
flex-direction: column;
height: 100%;
}
.card.is-stretchable .card-content {
flex-grow: 1;
}
.preserve-whitespace p {
white-space: pre-wrap !important;
}
.display-inline p {
display: inline !important;
}
button .button-invisible-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 1rem;
box-sizing: border-box;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
background: $invisible-overlay-background-color;
color: white;
opacity: 0;
transition: opacity 0.2s ease;
}
button:hover .button-invisible-overlay,
button:active .button-invisible-overlay,
button:focus-visible .button-invisible-overlay {
opacity: 1;
}
/** States
******************************************************************************/
/* "disabled" for non-buttons */
.is-disabled {
background-color: $pagination-disabled-background-color;
border-color: $pagination-disabled-border-color;
box-shadow: none;
color: $pagination-disabled-color;
opacity: 0.5;
cursor: not-allowed;
}
/* Notifications page
******************************************************************************/
.notification a.icon {
text-decoration: none !important;
}

View file

@ -1,7 +0,0 @@
/** Avatars
******************************************************************************/
.avatar {
vertical-align: middle;
display: inline;
}

View file

@ -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;
}

View file

@ -1,70 +0,0 @@
/** Book covers
*
* - .is-cover gives the behaviour of the cover and its surrounding. (optional)
* - .cover-container gives the dimensions and position (for borders, image and other elements).
* - .book-cover is positioned and sized based on its container.
*
* To have the cover within specific dimensions, specify a width or height for
* standard bulmas named breapoints:
*
* `is-(w|h)-(auto|xs|s|m|l|xl|xxl)[-(mobile|tablet|desktop)]`
*
* The cover will be centered horizontally and vertically within those dimensions.
*
* When using `.column.is-N`, add `.is-w-auto` to the container so that the flex
* calculations are not biased by the default `max-content`.
******************************************************************************/
.column.is-cover {
flex-grow: 0 !important;
}
.column.is-cover,
.column.is-cover + .column {
flex-basis: auto !important;
}
.cover-container {
display: flex;
justify-content: center;
align-items: center;
position: relative;
width: max-content;
max-width: 100%;
overflow: hidden;
}
/* Book cover
* -------------------------------------------------------------------------- */
.book-cover {
display: block;
max-width: 100%;
max-height: 100%;
/* Useful when stretching under-sized images. */
image-rendering: optimizequality;
image-rendering: smooth;
}
/* Cover caption
* -------------------------------------------------------------------------- */
.no-cover .cover-caption {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
padding: 0.5em;
font-size: 0.75em;
color: white;
background-color: $no-cover-color;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 1em;
white-space: initial;
text-align: center;
}

View file

@ -1,36 +0,0 @@
/* Books grid
******************************************************************************/
.books-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
align-items: end;
justify-items: stretch;
}
.books-grid > .is-big {
grid-column: span 2;
grid-row: span 2;
justify-self: stretch;
}
.books-grid .book-cover {
width: 100%;
}
.books-grid .book-title {
--height-basis: 1.35rem;
display: block;
margin-top: 0.5rem;
line-height: var(--height-basis);
min-height: calc(2 * var(--height-basis));
}
@media only screen and (min-width: 769px) {
.books-grid {
gap: 1.5rem;
grid-template-columns: repeat(auto-fill, minmax(8em, 1fr));
}
}

View file

@ -1,47 +0,0 @@
/* Book list
******************************************************************************/
ol.ordered-list {
list-style: none;
counter-reset: list-counter;
}
ol.ordered-list li {
counter-increment: list-counter;
}
ol.ordered-list li::before {
content: counter(list-counter);
position: absolute;
left: -20px;
width: 20px;
height: 24px;
background-color: $scheme-main;
border: 1px solid $border;
border-right: 0;
border-top-left-radius: 2px;
border-top-right-radius: 2px;
display: flex;
justify-content: center;
align-items: center;
color: $text-light;
font-size: 0.8em;
font-weight: bold;
}
@media only screen and (max-width: 768px) {
ol.ordered-list li::before {
left: 0;
z-index: 1;
border: 0;
border-right: 1px solid $border;
border-bottom: 1px solid $border;
border-radius: 0;
border-bottom-right-radius: 2px;
}
}
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
min-width: 10em;
}

View file

@ -1,49 +0,0 @@
/* Book preview table
******************************************************************************/
.book-preview td {
vertical-align: middle;
}
@media only screen and (max-width: 768px) {
table.is-mobile,
table.is-mobile tbody {
display: block;
}
table.is-mobile tr {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
border-top: 1px solid $border;
}
table.is-mobile td {
display: block;
box-sizing: border-box;
flex: 1 0 100%;
order: 2;
border-bottom: 0;
}
table.is-mobile td.book-preview-top-row {
order: 1;
flex-basis: auto;
}
table.is-mobile td[data-title]:not(:empty)::before {
content: attr(data-title);
display: block;
font-size: 0.75em;
font-weight: bold;
}
table.is-mobile td:empty {
padding: 0;
}
table.is-mobile th,
table.is-mobile thead {
display: none;
}
}

View file

@ -1,13 +0,0 @@
/* Breadcrumbs
******************************************************************************/
.breadcrumb li:first-child * {
padding-left: 0;
}
.breadcrumb li > * {
align-items: center;
display: flex;
justify-content: center;
padding: 0 0.75em;
}

View file

@ -1,30 +0,0 @@
/* Copy
******************************************************************************/
.horizontal-copy {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.75rem;
}
.horizontal-copy textarea {
min-width: initial;
white-space: nowrap;
}
.horizontal-copy button {
align-self: stretch;
height: unset;
}
.vertical-copy {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.vertical-copy button {
width: 100%;
}

View file

@ -1,130 +0,0 @@
/** General `details` element styles
******************************************************************************/
details summary {
cursor: pointer;
}
summary::-webkit-details-marker {
display: none;
}
details summary::marker {
content: none;
}
details.detail-pinned-button summary {
position: absolute;
right: 0;
}
details.detail-pinned-button form {
float: left;
width: 100%;
margin-top: 1em;
}
/** Dropdown w/ Details element
******************************************************************************/
details.dropdown[open] summary.dropdown-trigger::before {
content: "";
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
details.dropdown .dropdown-menu {
display: block !important;
}
details.dropdown .dropdown-menu button {
/* Fix weird Safari defaults */
box-sizing: border-box;
}
details.dropdown .dropdown-menu button:focus-visible,
details.dropdown .dropdown-menu a:focus-visible {
outline-style: auto;
outline-offset: -2px;
}
@media only screen and (max-width: 768px) {
details.dropdown[open] summary.dropdown-trigger::before {
background-color: $modal-background-background-color;
z-index: 30;
}
details .dropdown-menu {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex !important;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 100;
}
details .dropdown-menu > * {
pointer-events: all;
}
}
/** Details panel
******************************************************************************/
details.details-panel {
box-shadow: 0 0 0 1px $border;
transition: box-shadow 0.2s ease;
padding: 0.75rem;
}
details[open].details-panel,
details.details-panel:hover {
box-shadow: 0 0 0 1px $border;
}
details.details-panel summary {
position: relative;
}
details summary .details-close {
position: absolute;
right: 0;
top: 0;
transform: rotate(45deg);
transition: transform 0.2s ease;
}
details[open] summary .details-close {
transform: rotate(0deg);
}
@media only screen and (min-width: 769px) {
.details-panel .filters-field:not(:last-child) {
border-right: 1px solid $border;
margin-top: 0.75rem;
margin-bottom: 0.75rem;
padding-top: 0.25rem;
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;
}

View file

@ -1,28 +0,0 @@
/** File input styles
******************************************************************************/
input[type="file"]::file-selector-button {
-moz-appearance: none;
-webkit-appearance: none;
background-color: $scheme-main;
border-radius: 4px;
border: 1px solid $border;
box-shadow: none;
color: $text;
cursor: pointer;
font-size: 1rem;
height: 2.5em;
justify-content: center;
line-height: 1.5;
padding-bottom: calc(0.5em - 1px);
padding-left: 1em;
padding-right: 1em;
padding-top: calc(0.5em - 1px);
text-align: center;
white-space: nowrap;
}
input[type="file"]::file-selector-button:hover {
border-color: $border-hover;
color: text;
}

View file

@ -1,8 +0,0 @@
/** Transient notification
******************************************************************************/
#live-messages {
position: fixed;
bottom: 1em;
right: 1em;
}

View file

@ -1,10 +0,0 @@
/** Shelving
******************************************************************************/
/** @todo Replace icons with SVG symbols.
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
.shelf-option:disabled > *::after {
font-family: icomoon; /* stylelint-disable font-family-no-missing-generic-family-keyword */
content: "\e919"; /* icon-check */
margin-left: 0.5em;
}

View file

@ -1,52 +0,0 @@
/** Stars
******************************************************************************/
.stars {
white-space: nowrap;
}
/** Stars in a review form
*
* Specificity makes hovering taking over checked inputs.
*
* \e9d9: filled star
* \e9d7: empty star;
* -------------------------------------------------------------------------- */
.form-rate-stars {
width: max-content;
}
/* All stars are visually filled by default. */
.form-rate-stars .icon::before {
content: "\e9d9"; /* icon-star-full */
}
/* Icons directly following half star inputs are marked as half */
.form-rate-stars input.half:checked ~ .icon::before {
content: "\e9d8"; /* icon-star-half */
}
/* stylelint-disable no-descending-specificity */
.form-rate-stars input.half:checked + input + .icon:hover::before {
content: "\e9d8" !important; /* icon-star-half */
}
/* Icons directly following half check inputs that follow the checked input are emptied. */
.form-rate-stars input.half:checked + input + .icon ~ .icon::before {
content: "\e9d7"; /* icon-star-empty */
}
/* Icons directly following inputs that follow the checked input are emptied. */
.form-rate-stars input:checked ~ input + .icon::before {
content: "\e9d7"; /* icon-star-empty */
}
/* When a label is hovered, repeat the fill-all-then-empty-following pattern. */
.form-rate-stars:hover .icon.icon::before {
content: "\e9d9" !important; /* icon-star-full */
}
.form-rate-stars .icon:hover ~ .icon::before {
content: "\e9d7" !important; /* icon-star-empty */
}

View file

@ -1,57 +0,0 @@
/** Statuses: Quotes
*
* \e906: icon-quote-open
* \e905: icon-quote-close
*
* The `content` class on the blockquote allows to apply styles to markdown
* generated HTML in the quote: https://bulma.io/documentation/elements/content/
*
* ```html
* <div class="quote block">
* <blockquote dir="auto" class="content mb-2">
* User generated quote in markdown
* </blockquote>
*
* <p> <a>Book Title</a> by <aclass="author">Author</a></p>
* </div>
* ```
******************************************************************************/
.quote > blockquote {
position: relative;
padding-left: 2em;
}
.quote > blockquote::before,
.quote > blockquote::after {
font-family: icomoon; /* stylelint-disable font-family-no-missing-generic-family-keyword */
position: absolute;
}
.quote > blockquote::before {
content: "\e907"; /* icon-quote-open */
top: 0;
left: 0;
}
.quote > blockquote::after {
content: "\e906"; /* icon-quote-close */
right: 0;
}
/* Threads
******************************************************************************/
.thread .is-main .card {
box-shadow: 0 0.5em 1em -0.125em rgba($link, 0.35), 0 0 0 1px rgba($link, 0.02);
}
.thread::after {
content: "";
position: absolute;
z-index: -1;
top: 0;
bottom: 0;
left: 2.5em;
border-left: 2px solid $border;
}

View file

@ -1,176 +0,0 @@
/** Bookwyrm Tabs
******************************************************************************/
.bw-tabs {
-webkit-overflow-scrolling: touch;
-webkit-touch-callout: none;
position: relative;
align-items: center;
display: flex;
font-size: 1rem;
justify-content: flex-start;
overflow-x: auto;
overflow-y: hidden;
user-select: none;
white-space: nowrap;
}
.bw-tabs::before {
border-bottom-color: $border;
border-bottom-style: solid;
border-bottom-width: 1px;
bottom: 0;
content: "";
position: absolute;
width: 100%;
}
.bw-tabs:not(:last-child) {
margin-bottom: 1.5rem;
}
.bw-tabs a {
align-items: center;
border-bottom-color: $border;
border-bottom-style: solid;
border-bottom-width: 1px;
color: $text;
display: flex;
justify-content: center;
margin-bottom: -1px;
padding: 0.5em 1em;
position: relative;
}
.bw-tabs a:hover {
border-bottom-color: transparent;
color: $text;
}
.bw-tabs a.is-active {
border-bottom-color: transparent;
color: $link;
}
.bw-tabs.is-left {
padding-right: 0.75em;
}
.bw-tabs.is-center {
flex: none;
justify-content: center;
padding-left: 0.75em;
padding-right: 0.75em;
}
.bw-tabs.is-right {
justify-content: flex-end;
padding-left: 0.75em;
}
.bw-tabs .icon:first-child {
margin-right: 0.5em;
}
.bw-tabs .icon:last-child {
margin-left: 0.5em;
}
.bw-tabs.is-centered {
justify-content: center;
}
.bw-tabs.is-boxed a {
border: 1px solid transparent;
border-radius: 4px 4px 0 0;
}
.bw-tabs.is-boxed a:hover {
background-color: $background-secondary;
border-bottom-color: $border-hover;
}
.bw-tabs.is-boxed a.is-active {
background-color: $background-body;
border-color: $border;
border-bottom-color: $border !important;
}
.bw-tabs.is-fullwidth a {
flex-grow: 1;
flex-shrink: 0;
}
.bw-tabs.is-toggle a {
border-color: $border;
border-style: solid;
border-width: 1px;
margin-bottom: 0;
position: relative;
}
.bw-tabs.is-toggle a:hover {
background-color: $background-secondary;
border-color: $border;
z-index: 2;
}
.bw-tabs.is-toggle a + a {
margin-left: -1px;
}
.bw-tabs.is-toggle a:first-child {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
.bw-tabs.is-toggle a:last-child {
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
}
.bw-tabs.is-toggle a.is-active {
background-color: $link-background;
border-color: $link;
color: $text;
z-index: 1;
}
.bw-tabs.is-toggle {
border-bottom: none;
}
.bw-tabs.is-toggle.is-toggle-rounded a:first-child {
border-bottom-left-radius: 290486px;
border-top-left-radius: 290486px;
padding-left: 1.25em;
}
.bw-tabs.is-toggle.is-toggle-rounded a:last-child {
border-bottom-right-radius: 290486px;
border-top-right-radius: 290486px;
padding-right: 1.25em;
}
.bw-tabs.is-small {
font-size: 0.75rem;
}
.bw-tabs.is-medium {
font-size: 1.25rem;
}
.bw-tabs.is-large {
font-size: 1.5rem;
}
.bw-tabs.has-aside-text a {
margin-top: 1.5rem;
}
.bw-tabs a .aside-text {
position: absolute;
top: calc(-0.75rem - 0.75rem);
left: 0;
color: $text;
}

View file

@ -1,45 +0,0 @@
/** Toggles
******************************************************************************/
.toggle-button[aria-pressed="true"],
.toggle-button[aria-pressed="true"]:hover {
background-color: $primary;
color: white;
}
.hide-active[aria-pressed="true"],
.hide-inactive[aria-pressed="false"] {
display: none;
}
.transition-x.is-hidden,
.transition-y.is-hidden {
display: block !important;
visibility: hidden !important;
height: 0 !important;
width: 0 !important;
margin: 0 !important;
padding: 0 !important;
overflow: auto;
}
.transition-x,
.transition-y {
transition-duration: 0.5s;
transition-timing-function: ease;
}
.transition-x {
transition-property: width, margin-left, margin-right, padding-left, padding-right;
}
.transition-y {
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
}
@media (prefers-reduced-motion: reduce) {
.transition-x,
.transition-y {
transition-duration: 0.001ms !important;
}
}

View file

@ -1,61 +0,0 @@
.image {
overflow: hidden;
}
.navbar .logo {
max-height: 50px;
}
.card {
overflow: visible;
}
.card.has-border {
border: 1px solid $border;
}
.scroll-x {
overflow: hidden;
overflow-x: auto;
}
.modal-card {
pointer-events: none;
}
.modal-card > * {
pointer-events: all;
}
/* stylelint-disable no-descending-specificity */
.modal-card:focus {
outline-style: auto;
}
.modal-card:focus:not(:focus-visible) {
outline-style: initial;
}
.modal-card:focus-visible {
outline-style: auto;
}
/* stylelint-enable no-descending-specificity */
.modal-card.is-fullwidth {
min-width: 75% !important;
}
@media only screen and (min-width: 769px) {
.modal-card.is-thin {
width: 350px !important;
}
}
.modal-card-body {
max-height: 70vh;
}
.clip-text {
max-height: 35em;
overflow: hidden;
}

View file

@ -1,33 +0,0 @@
@media only screen and (max-width: 768px) {
.is-sr-only-mobile {
border: none !important;
clip: rect(0, 0, 0, 0) !important;
height: 0.01em !important;
overflow: hidden !important;
padding: 0 !important;
position: absolute !important;
white-space: nowrap !important;
width: 0.01em !important;
}
.m-0-mobile {
margin: 0 !important;
}
.card-footer.is-stacked-mobile {
flex-direction: column;
}
.card-footer.is-stacked-mobile .card-footer-item:not(:last-child) {
border-bottom: 1px solid $background-tertiary;
border-right: 0;
}
.is-flex-direction-row-mobile {
flex-direction: row !important;
}
.is-flex-direction-column-mobile {
flex-direction: column !important;
}
}

View file

@ -1,76 +0,0 @@
/* Alignments
*
* Use them with `.align.to-(c|t|r|b|l)[-(mobile|tablet)]`
******************************************************************************/
/* Flex item position
* -------------------------------------------------------------------------- */
.align {
display: flex !important;
flex-direction: row !important;
}
.align.to-c {
justify-content: center !important;
}
.align.to-t {
align-items: flex-start !important;
}
.align.to-r {
justify-content: flex-end !important;
}
.align.to-b {
align-items: flex-end !important;
}
.align.to-l {
justify-content: flex-start !important;
}
@media screen and (max-width: 768px) {
.align.to-c-mobile {
justify-content: center !important;
}
.align.to-t-mobile {
align-items: flex-start !important;
}
.align.to-r-mobile {
justify-content: flex-end !important;
}
.align.to-b-mobile {
align-items: flex-end !important;
}
.align.to-l-mobile {
justify-content: flex-start !important;
}
}
@media screen and (min-width: 769px) {
.align.to-c-tablet {
justify-content: center !important;
}
.align.to-t-tablet {
align-items: flex-start !important;
}
.align.to-r-tablet {
justify-content: flex-end !important;
}
.align.to-b-tablet {
align-items: flex-end !important;
}
.align.to-l-tablet {
justify-content: flex-start !important;
}
}

View file

@ -1,30 +0,0 @@
/* Semantic color classes */
.has-background-primary-highlight {
background-color: $primary-highlight;
}
.has-background-info-highlight {
background-color: $info-highlight;
}
.has-background-success-highlight {
background-color: $success-highlight;
}
.has-background-body {
background-color: $background-body;
}
.has-background-secondary {
background-color: $background-secondary !important;
}
.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;
}

View file

@ -1,227 +0,0 @@
/* Dimensions
* @todo These could be in rem.
******************************************************************************/
.is-32x32 {
min-width: 32px !important;
min-height: 32px !important;
}
.is-96x96 {
min-width: 96px !important;
min-height: 96px !important;
}
.is-w-auto {
width: auto !important;
}
.is-w-xs {
width: 80px !important;
}
.is-w-s {
width: 100px !important;
}
.is-w-m {
width: 150px !important;
}
.is-w-l {
width: 200px !important;
}
.is-w-xl {
width: 250px !important;
}
.is-w-xxl {
width: 500px !important;
}
.is-h-xs {
height: 80px !important;
}
.is-h-s {
height: 100px !important;
}
.is-h-m {
height: 150px !important;
}
.is-h-l {
height: 200px !important;
}
.is-h-xl {
height: 250px !important;
}
.is-h-xxl {
height: 500px !important;
}
@media only screen and (max-width: 768px) {
.is-w-auto-mobile {
width: auto !important;
}
.is-w-xs-mobile {
width: 80px !important;
}
.is-w-s-mobile {
width: 100px !important;
}
.is-w-m-mobile {
width: 150px !important;
}
.is-w-l-mobile {
width: 200px !important;
}
.is-w-xl-mobile {
width: 250px !important;
}
.is-w-xxl-mobile {
width: 500px !important;
}
.is-h-xs-mobile {
height: 80px !important;
}
.is-h-s-mobile {
height: 100px !important;
}
.is-h-m-mobile {
height: 150px !important;
}
.is-h-l-mobile {
height: 200px !important;
}
.is-h-xl-mobile {
height: 250px !important;
}
.is-h-xxl-mobile {
height: 500px !important;
}
}
@media only screen and (min-width: 769px) {
.is-w-auto-tablet {
width: auto !important;
}
.is-w-xs-tablet {
width: 80px !important;
}
.is-w-s-tablet {
width: 100px !important;
}
.is-w-m-tablet {
width: 150px !important;
}
.is-w-l-tablet {
width: 200px !important;
}
.is-w-xl-tablet {
width: 250px !important;
}
.is-w-xxl-tablet {
width: 500px !important;
}
.is-h-xs-tablet {
height: 80px !important;
}
.is-h-s-tablet {
height: 100px !important;
}
.is-h-m-tablet {
height: 150px !important;
}
.is-h-l-tablet {
height: 200px !important;
}
.is-h-xl-tablet {
height: 250px !important;
}
.is-h-xxl-tablet {
height: 500px !important;
}
}
@media only screen and (min-width: 1024px) {
.is-w-auto-desktop {
width: auto !important;
}
.is-w-xs-desktop {
width: 80px !important;
}
.is-w-s-desktop {
width: 100px !important;
}
.is-w-m-desktop {
width: 150px !important;
}
.is-w-l-desktop {
width: 200px !important;
}
.is-w-xl-desktop {
width: 250px !important;
}
.is-w-xxl-desktop {
width: 500px !important;
}
.is-h-xs-desktop {
height: 80px !important;
}
.is-h-s-desktop {
height: 100px !important;
}
.is-h-m-desktop {
height: 150px !important;
}
.is-h-l-desktop {
height: 200px !important;
}
.is-h-xl-desktop {
height: 250px !important;
}
.is-h-xxl-desktop {
height: 500px !important;
}
}

View file

@ -1,167 +0,0 @@
/* Spacings
*
* Those are supplementary rules to Bulmas. They follow the same conventions.
* Add those youll need.
******************************************************************************/
.mr-auto {
margin-right: auto !important;
}
.ml-auto {
margin-left: auto !important;
}
@media screen and (max-width: 768px) {
.m-0-mobile {
margin: 0 !important;
}
.mr-auto-mobile {
margin-right: auto !important;
}
.ml-auto-mobile {
margin-left: auto !important;
}
.mt-3-mobile {
margin-top: 0.75rem !important;
}
.ml-3-mobile {
margin-left: 0.75rem !important;
}
.mx-3-mobile {
margin-right: 0.75rem !important;
margin-left: 0.75rem !important;
}
.my-3-mobile {
margin-top: 0.75rem !important;
margin-bottom: 0.75rem !important;
}
}
@media screen and (min-width: 769px) {
.m-0-tablet {
margin: 0 !important;
}
.mr-auto-tablet {
margin-right: auto !important;
}
.ml-auto-tablet {
margin-left: auto !important;
}
.mt-3-tablet {
margin-top: 0.75rem !important;
}
.ml-3-tablet {
margin-left: 0.75rem !important;
}
.mx-3-tablet {
margin-right: 0.75rem !important;
margin-left: 0.75rem !important;
}
.my-3-tablet {
margin-top: 0.75rem !important;
margin-bottom: 0.75rem !important;
}
}
/* Gaps (for Flexbox and Grid)
*
* Those are supplementary rules to Bulmas. They follow the same conventions.
* Add those youll need.
******************************************************************************/
.is-gap-0 {
gap: 0;
}
.is-gap-1 {
gap: 0.25rem;
}
.is-gap-2 {
gap: 0.5rem;
}
.is-gap-3 {
gap: 0.75rem;
}
.is-gap-4 {
gap: 1rem;
}
.is-gap-5 {
gap: 1.5rem;
}
.is-gap-6 {
gap: 3rem;
}
.is-row-gap-0 {
row-gap: 0;
}
.is-row-gap-1 {
row-gap: 0.25rem;
}
.is-row-gap-2 {
row-gap: 0.5rem;
}
.is-row-gap-3 {
row-gap: 0.75rem;
}
.is-row-gap-4 {
row-gap: 1rem;
}
.is-row-gap-5 {
row-gap: 1.5rem;
}
.is-row-gap-6 {
row-gap: 3rem;
}
.is-column-gap-0 {
column-gap: 0;
}
.is-column-gap-1 {
column-gap: 0.25rem;
}
.is-column-gap-2 {
column-gap: 0.5rem;
}
.is-column-gap-3 {
column-gap: 0.75rem;
}
.is-column-gap-4 {
column-gap: 1rem;
}
.is-column-gap-5 {
column-gap: 1.5rem;
}
.is-column-gap-6 {
column-gap: 3rem;
}

View file

@ -1,25 +0,0 @@
/** Animations and transitions
******************************************************************************/
@keyframes turning {
from { transform: rotateZ(0deg); }
to { transform: rotateZ(360deg); }
}
.is-processing .icon-spinner::before {
animation: turning 1.5s infinite linear;
}
.icon-spinner {
display: none;
}
.is-processing .icon-spinner {
display: flex;
}
@media (prefers-reduced-motion: reduce) {
.is-processing .icon::before {
transition-duration: 0.001ms !important;
}
}

View file

@ -39,7 +39,6 @@
<glyph unicode="&#xe91e;" 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="&#xe91f;" 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="&#xe920;" 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="&#xe937;" 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="&#xe97a;" 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="&#xe986;" 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="&#xe9d7;" 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

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