Merge branch 'main' into bw-dev-npm-fix

This commit is contained in:
Mouse Reeve 2022-06-11 20:39:18 -07:00 committed by GitHub
commit 51f445bc72
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
220 changed files with 17881 additions and 4815 deletions

View file

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

6
.pylintrc Normal file
View file

@ -0,0 +1,6 @@
[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,6 +6,7 @@ RUN mkdir /app /app/static /app/images
WORKDIR /app
RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean
COPY requirements.txt /app/
RUN pip install -r requirements.txt --no-cache-dir
RUN apt-get update && apt-get install -y gettext libgettextpo-dev tidy && apt-get clean

View file

@ -9,21 +9,18 @@ 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)
- [Book data](#book-data)
- [Set up Bookwyrm](#set-up-bookwyrm)
- [Set up BookWyrm](#set-up-bookwyrm)
## Joining BookWyrm
BookWyrm is still a young piece of software, and isn't at the level of stability and feature-richness that you'd find in a production-ready application. But it does what it says on the box! If you'd like to join an instance, you can check out the [instances](https://joinbookwyrm.com/instances/) list.
You can request an invite by entering your email address at https://bookwyrm.social.
If you'd like to join an instance, you can check out the [instances](https://joinbookwyrm.com/instances/) list.
## Contributing
See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions.
See [contributing](https://docs.joinbookwyrm.com/contributing.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.
@ -78,8 +75,5 @@ Deployment
- [Nginx](https://nginx.org/en/) HTTP server
## Book data
The application is set up to share book and author data between instances, and get book data from arbitrary outside sources. Right now, the only connector is to OpenLibrary, but other connectors could be written.
## Set up Bookwyrm
The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up Bookwyrm in a [developer environment](https://docs.joinbookwyrm.com/developer-environment.html) or [production](https://docs.joinbookwyrm.com/installing-in-production.html).
## Set up BookWyrm
The [documentation website](https://docs.joinbookwyrm.com/) has instruction on how to set up BookWyrm in a [developer environment](https://docs.joinbookwyrm.com/install-dev.html) or [production](https://docs.joinbookwyrm.com/install-prod.html).

View file

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

View file

@ -298,8 +298,9 @@ def add_status_on_create_command(sender, instance, created):
priority = HIGH
# check if this is an old status, de-prioritize if so
# (this will happen if federation is very slow, or, more expectedly, on csv import)
one_day = 60 * 60 * 24
if (instance.created_date - instance.published_date).seconds > one_day:
if instance.published_date < timezone.now() - timedelta(
days=1
) or instance.created_date < instance.published_date - timedelta(days=1):
priority = LOW
add_status_task.apply_async(

View file

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

View file

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

View file

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

View file

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

View file

@ -77,53 +77,42 @@ class Connector(AbstractConnector):
**{k: data.get(k) for k in ["uri", "image", "labels", "sitelinks", "type"]},
}
def search(self, query, min_confidence=None): # pylint: disable=arguments-differ
"""overrides default search function with confidence ranking"""
results = super().search(query)
if min_confidence:
# filter the search results after the fact
return [r for r in results if r.confidence >= min_confidence]
return results
def parse_search_data(self, data):
return data.get("results")
def format_search_result(self, search_result):
images = search_result.get("image")
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
# a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999
return SearchResult(
title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=cover,
confidence=confidence,
connector=self,
)
def parse_search_data(self, data, min_confidence):
for search_result in data.get("results", []):
images = search_result.get("image")
cover = f"{self.covers_url}/img/entities/{images[0]}" if images else None
# a deeply messy translation of inventaire's scores
confidence = float(search_result.get("_score", 0.1))
confidence = 0.1 if confidence < 150 else 0.999
if confidence < min_confidence:
continue
yield SearchResult(
title=search_result.get("label"),
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=cover,
confidence=confidence,
connector=self,
)
def parse_isbn_search_data(self, data):
"""got some daaaata"""
results = data.get("entities")
if not results:
return []
return list(results.values())
def format_isbn_search_result(self, search_result):
"""totally different format than a regular search result"""
title = search_result.get("claims", {}).get("wdt:P1476", [])
if not title:
return None
return SearchResult(
title=title[0],
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=self.get_cover_url(search_result.get("image")),
connector=self,
)
return
for search_result in list(results.values()):
title = search_result.get("claims", {}).get("wdt:P1476", [])
if not title:
continue
yield SearchResult(
title=title[0],
key=self.get_remote_id(search_result.get("uri")),
author=search_result.get("description"),
view_link=f"{self.base_url}/entity/{search_result.get('uri')}",
cover=self.get_cover_url(search_result.get("image")),
connector=self,
)
def is_work_data(self, data):
return data.get("type") == "work"

View file

@ -152,39 +152,41 @@ class Connector(AbstractConnector):
image_name = f"{cover_id}-{size}.jpg"
return f"{self.covers_url}/b/id/{image_name}"
def parse_search_data(self, data):
return data.get("docs")
def parse_search_data(self, data, min_confidence):
for idx, search_result in enumerate(data.get("docs")):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
author = search_result.get("author_name") or ["Unknown"]
cover_blob = search_result.get("cover_i")
cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
def format_search_result(self, search_result):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
author = search_result.get("author_name") or ["Unknown"]
cover_blob = search_result.get("cover_i")
cover = self.get_cover_url([cover_blob], size="M") if cover_blob else None
return SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author),
connector=self,
year=search_result.get("first_publish_year"),
cover=cover,
)
# OL doesn't provide confidence, but it does sort by an internal ranking, so
# this confidence value is relative to the list position
confidence = 1 / (idx + 1)
yield SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author),
connector=self,
year=search_result.get("first_publish_year"),
cover=cover,
confidence=confidence,
)
def parse_isbn_search_data(self, data):
return list(data.values())
def format_isbn_search_result(self, search_result):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
authors = search_result.get("authors") or [{"name": "Unknown"}]
author_names = [author.get("name") for author in authors]
return SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author_names),
connector=self,
year=search_result.get("publish_date"),
)
for search_result in list(data.values()):
# build the remote id from the openlibrary key
key = self.books_url + search_result["key"]
authors = search_result.get("authors") or [{"name": "Unknown"}]
author_names = [author.get("name") for author in authors]
yield SearchResult(
title=search_result.get("title"),
key=key,
author=", ".join(author_names),
connector=self,
year=search_result.get("publish_date"),
)
def load_edition_data(self, olkey):
"""query openlibrary for editions of a work"""

View file

@ -4,6 +4,7 @@ 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
@ -14,14 +15,6 @@ class CoverForm(CustomForm):
help_texts = {f: None for f in fields}
class ArrayWidget(forms.widgets.TextInput):
# 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 EditionForm(CustomForm):
class Meta:
model = models.Edition
@ -56,16 +49,16 @@ class EditionForm(CustomForm):
"publishers": forms.TextInput(
attrs={"aria-describedby": "desc_publishers_help desc_publishers"}
),
"first_published_date": forms.SelectDateWidget(
"first_published_date": SelectDateWidget(
attrs={"aria-describedby": "desc_first_published_date"}
),
"published_date": forms.SelectDateWidget(
"published_date": SelectDateWidget(
attrs={"aria-describedby": "desc_published_date"}
),
"cover": ClearableFileInputWithWarning(
attrs={"aria-describedby": "desc_cover"}
),
"physical_format": forms.Select(
"physical_format": Select(
attrs={"aria-describedby": "desc_physical_format"}
),
"physical_format_detail": forms.TextInput(
@ -85,3 +78,27 @@ class EditionForm(CustomForm):
),
"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

@ -45,7 +45,7 @@ class ReportForm(CustomForm):
class ReadThroughForm(CustomForm):
def clean(self):
"""make sure the email isn't in use by a registered user"""
"""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")
@ -53,7 +53,12 @@ class ReadThroughForm(CustomForm):
self.add_error(
"finish_date", _("Reading finish date cannot be before start date.")
)
stopped_date = cleaned_data.get("stopped_date")
if start_date and stopped_date and start_date > stopped_date:
self.add_error(
"stopped_date", _("Reading stopped date cannot be before start date.")
)
class Meta:
model = models.ReadThrough
fields = ["user", "book", "start_date", "finish_date"]
fields = ["user", "book", "start_date", "finish_date", "stopped_date"]

70
bookwyrm/forms/widgets.py Normal file
View file

@ -0,0 +1,70 @@
""" using django model forms """
from django import forms
class ArrayWidget(forms.widgets.TextInput):
"""Inputs for postgres array fields"""
# pylint: disable=unused-argument
# pylint: disable=no-self-use
def value_from_datadict(self, data, files, name):
"""get all values for this name"""
return [i for i in data.getlist(name) if i]
class Select(forms.Select):
"""custom template for select widget"""
template_name = "widgets/select.html"
class SelectDateWidget(forms.SelectDateWidget):
"""
A widget that splits date input into two <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,6 +1,7 @@
""" import classes """
from .importer import Importer
from .calibre_import import CalibreImporter
from .goodreads_import import GoodreadsImporter
from .librarything_import import LibrarythingImporter
from .openlibrary_import import OpenLibraryImporter

View file

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

View file

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

View file

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

View file

@ -89,7 +89,7 @@ def init_connectors():
covers_url="https://inventaire.io",
search_url="https://inventaire.io/api/search?types=works&types=works&search=",
isbn_search_url="https://inventaire.io/api/entities?action=by-uris&uris=isbn%3A",
priority=3,
priority=1,
)
models.Connector.objects.create(
@ -101,20 +101,10 @@ def init_connectors():
covers_url="https://covers.openlibrary.org",
search_url="https://openlibrary.org/search?q=",
isbn_search_url="https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:",
priority=3,
priority=1,
)
def init_federated_servers():
"""big no to nazis"""
built_in_blocks = ["gab.ai", "gab.com"]
for server in built_in_blocks:
models.FederatedServer.objects.create(
server_name=server,
status="blocked",
)
def init_settings():
"""info about the instance"""
models.SiteSettings.objects.create(
@ -163,7 +153,6 @@ class Command(BaseCommand):
"group",
"permission",
"connector",
"federatedserver",
"settings",
"linkdomain",
]
@ -176,8 +165,6 @@ class Command(BaseCommand):
init_permissions()
if not limit or limit == "connector":
init_connectors()
if not limit or limit == "federatedserver":
init_federated_servers()
if not limit or limit == "settings":
init_settings()
if not limit or limit == "linkdomain":

View file

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

View file

@ -0,0 +1,38 @@
# Generated by Django 3.2.12 on 2022-03-26 16:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0146_auto_20220316_2352"),
]
operations = [
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "Deutsch (German)"),
("es-es", "Español (Spanish)"),
("gl-es", "Galego (Galician)"),
("it-it", "Italiano (Italian)"),
("fr-fr", "Français (French)"),
("lt-lt", "Lietuvių (Lithuanian)"),
("no-no", "Norsk (Norwegian)"),
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
("pt-pt", "Português Europeu (European Portuguese)"),
("ro-ro", "Română (Romanian)"),
("sv-se", "Svenska (Swedish)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
]

View file

@ -0,0 +1,39 @@
# Generated by Django 3.2.12 on 2022-03-31 14:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookwyrm", "0147_alter_user_preferred_language"),
]
operations = [
migrations.AlterField(
model_name="user",
name="preferred_language",
field=models.CharField(
blank=True,
choices=[
("en-us", "English"),
("de-de", "Deutsch (German)"),
("es-es", "Español (Spanish)"),
("gl-es", "Galego (Galician)"),
("it-it", "Italiano (Italian)"),
("fi-fi", "Suomi (Finnish)"),
("fr-fr", "Français (French)"),
("lt-lt", "Lietuvių (Lithuanian)"),
("no-no", "Norsk (Norwegian)"),
("pt-br", "Português do Brasil (Brazilian Portuguese)"),
("pt-pt", "Português Europeu (European Portuguese)"),
("ro-ro", "Română (Romanian)"),
("sv-se", "Svenska (Swedish)"),
("zh-hans", "简体中文 (Simplified Chinese)"),
("zh-hant", "繁體中文 (Traditional Chinese)"),
],
max_length=255,
null=True,
),
),
]

View file

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

View file

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

View file

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

View file

@ -8,6 +8,7 @@ from django.db.models import Q
from django.dispatch import receiver
from django.http import Http404
from django.utils.translation import gettext_lazy as _
from django.utils.text import slugify
from bookwyrm.settings import DOMAIN
from .fields import RemoteIdField
@ -35,10 +36,11 @@ class BookWyrmModel(models.Model):
remote_id = RemoteIdField(null=True, activitypub_field="id")
def get_remote_id(self):
"""generate a url that resolves to the local object"""
"""generate the url that resolves to the local object, without a slug"""
base_path = f"https://{DOMAIN}"
if hasattr(self, "user"):
base_path = f"{base_path}{self.user.local_path}"
model_name = type(self).__name__.lower()
return f"{base_path}/{model_name}/{self.id}"
@ -49,8 +51,20 @@ class BookWyrmModel(models.Model):
@property
def local_path(self):
"""how to link to this object in the local app"""
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
"""how to link to this object in the local app, with a slug"""
local = self.get_remote_id().replace(f"https://{DOMAIN}", "")
name = None
if hasattr(self, "name_field"):
name = getattr(self, self.name_field)
elif hasattr(self, "name"):
name = self.name
if name:
slug = slugify(name)
local = f"{local}/s/{slug}"
return local
def raise_visible_to_user(self, viewer):
"""is a user authorized to view an object?"""

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,
self.languages[0] + " language"
if self.languages and self.languages[0] != "English"
f"{self.languages[0]} language"
if self.languages and self.languages[0] and self.languages[0] != "English"
else None,
str(self.published_date.year) if self.published_date else None,
", ".join(self.publishers) if hasattr(self, "publishers") else None,

View file

@ -125,7 +125,7 @@ class ActivitypubFieldMixin:
"""model_field_name to activitypubFieldName"""
if self.activitypub_field:
return self.activitypub_field
name = self.name.split(".")[-1]
name = self.name.rsplit(".", maxsplit=1)[-1]
components = name.split("_")
return components[0] + "".join(x.title() for x in components[1:])
@ -389,7 +389,7 @@ class ImageField(ActivitypubFieldMixin, models.ImageField):
self.alt_field = alt_field
super().__init__(*args, **kwargs)
# pylint: disable=arguments-differ
# pylint: disable=arguments-differ,arguments-renamed
def set_field_from_activity(self, instance, data, save=True, overwrite=True):
"""helper function for assinging a value to the field"""
value = getattr(data, self.get_activitypub_field())

View file

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

View file

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

View file

@ -39,15 +39,14 @@ class UserRelationship(BookWyrmModel):
def save(self, *args, **kwargs):
"""clear the template cache"""
# invalidate the template cache
cache.delete_many(
[
f"relationship-{self.user_subject.id}-{self.user_object.id}",
f"relationship-{self.user_object.id}-{self.user_subject.id}",
]
)
clear_cache(self.user_subject, self.user_object)
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""clear the template cache"""
clear_cache(self.user_subject, self.user_object)
super().delete(*args, **kwargs)
class Meta:
"""relationships should be unique"""
@ -90,7 +89,9 @@ class UserFollows(ActivityMixin, UserRelationship):
user_object=self.user_subject,
)
).exists():
raise IntegrityError()
raise IntegrityError(
"Attempting to follow blocked user", self.user_subject, self.user_object
)
# don't broadcast this type of relationship -- accepts and requests
# are handled by the UserFollowRequest model
super().save(*args, broadcast=False, **kwargs)
@ -98,11 +99,12 @@ class UserFollows(ActivityMixin, UserRelationship):
@classmethod
def from_request(cls, follow_request):
"""converts a follow request into a follow relationship"""
return cls.objects.create(
obj, _ = cls.objects.get_or_create(
user_subject=follow_request.user_subject,
user_object=follow_request.user_object,
remote_id=follow_request.remote_id,
)
return obj
class UserFollowRequest(ActivitypubMixin, UserRelationship):
@ -133,7 +135,9 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
user_object=self.user_subject,
)
).exists():
raise IntegrityError()
raise IntegrityError(
"Attempting to follow blocked user", self.user_subject, self.user_object
)
super().save(*args, **kwargs)
if broadcast and self.user_subject.local and not self.user_object.local:
@ -174,7 +178,8 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
with transaction.atomic():
UserFollows.from_request(self)
self.delete()
if self.id:
self.delete()
def reject(self):
"""generate a Reject for this follow request"""
@ -207,3 +212,13 @@ class UserBlocks(ActivityMixin, UserRelationship):
Q(user_subject=self.user_subject, user_object=self.user_object)
| Q(user_subject=self.user_object, user_object=self.user_subject)
).delete()
def clear_cache(user_subject, user_object):
"""clear relationship cache"""
cache.delete_many(
[
f"relationship-{user_subject.id}-{user_object.id}",
f"relationship-{user_object.id}-{user_subject.id}",
]
)

View file

@ -6,6 +6,7 @@ from django.db import models
from django.utils import timezone
from bookwyrm import activitypub
from bookwyrm.settings import DOMAIN
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
from .base_model import BookWyrmModel
from . import fields
@ -17,8 +18,9 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
TO_READ = "to-read"
READING = "reading"
READ_FINISHED = "read"
STOPPED_READING = "stopped-reading"
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED)
READ_STATUS_IDENTIFIERS = (TO_READ, READING, READ_FINISHED, STOPPED_READING)
name = fields.CharField(max_length=100)
identifier = models.CharField(max_length=100)
@ -65,6 +67,11 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
identifier = self.identifier or self.get_identifier()
return f"{base_path}/books/{identifier}"
@property
def local_path(self):
"""No slugs"""
return self.get_remote_id().replace(f"https://{DOMAIN}", "")
def raise_not_deletable(self, viewer):
"""don't let anyone delete a default shelf"""
super().raise_not_deletable(viewer)

View file

@ -116,11 +116,8 @@ class Status(OrderedCollectionPageMixin, BookWyrmModel):
def ignore_activity(cls, activity): # pylint: disable=too-many-return-statements
"""keep notes if they are replies to existing statuses"""
if activity.type == "Announce":
try:
boosted = activitypub.resolve_remote_id(
activity.object, get_activity=True
)
except activitypub.ActivitySerializerError:
boosted = activitypub.resolve_remote_id(activity.object, get_activity=True)
if not boosted:
# if we can't load the status, definitely ignore it
return True
# keep the boost if we would keep the status
@ -265,7 +262,7 @@ class GeneratedNote(Status):
ReadingStatusChoices = models.TextChoices(
"ReadingStatusChoices", ["to-read", "reading", "read"]
"ReadingStatusChoices", ["to-read", "reading", "read", "stopped-reading"]
)
@ -306,10 +303,17 @@ class Comment(BookStatus):
@property
def pure_content(self):
"""indicate the book in question for mastodon (or w/e) users"""
return (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>)</p>'
)
if self.progress_mode == "PG" and self.progress and (self.progress > 0):
return_value = (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>, page {self.progress})</p>'
)
else:
return_value = (
f'{self.content}<p>(comment on <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>)</p>'
)
return return_value
activity_serializer = activitypub.Comment
@ -335,10 +339,17 @@ class Quotation(BookStatus):
"""indicate the book in question for mastodon (or w/e) users"""
quote = re.sub(r"^<p>", '<p>"', self.quote)
quote = re.sub(r"</p>$", '"</p>', quote)
return (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a></p>{self.content}'
)
if self.position_mode == "PG" and self.position and (self.position > 0):
return_value = (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a>, page {self.position}</p>{self.content}'
)
else:
return_value = (
f'{quote} <p>-- <a href="{self.book.remote_id}">'
f'"{self.book.title}"</a></p>{self.content}'
)
return return_value
activity_serializer = activitypub.Quotation
@ -377,7 +388,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

@ -374,6 +374,10 @@ class User(OrderedCollectionPageMixin, AbstractUser):
"name": "Read",
"identifier": "read",
},
{
"name": "Stopped Reading",
"identifier": "stopped-reading",
},
]
for shelf in shelves:

View file

@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _
env = Env()
env.read_env()
DOMAIN = env("DOMAIN")
VERSION = "0.3.4"
VERSION = "0.4.0"
RELEASE_API = env(
"RELEASE_API",
@ -21,7 +21,7 @@ RELEASE_API = env(
PAGE_LENGTH = env("PAGE_LENGTH", 15)
DEFAULT_LANGUAGE = env("DEFAULT_LANGUAGE", "English")
JS_CACHE = "bc93172a"
JS_CACHE = "e678183b"
# email
EMAIL_BACKEND = env("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend")
@ -212,7 +212,7 @@ STREAMS = [
# Search configuration
# total time in seconds that the instance will spend searching connectors
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 15))
SEARCH_TIMEOUT = int(env("SEARCH_TIMEOUT", 8))
# timeout for a query to an individual connector
QUERY_TIMEOUT = int(env("QUERY_TIMEOUT", 5))
@ -284,11 +284,13 @@ 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)")),

View file

@ -34,6 +34,18 @@ body {
flex-direction: column;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-thumb {
background: $scrollbar-thumb;
border-radius: 0.5em;
}
::-webkit-scrollbar-track {
background: $scrollbar-track;
}
button {
border: none;
margin: 0;
@ -125,13 +137,6 @@ button:focus-visible .button-invisible-overlay {
opacity: 1;
}
/** Tooltips
******************************************************************************/
.tooltip {
width: 100%;
}
/** States
******************************************************************************/

View file

@ -114,3 +114,17 @@ details[open] summary .details-close {
padding-bottom: 0.25rem;
}
}
/** Navbar details
******************************************************************************/
#navbar-dropdown .navbar-item {
color: $text;
font-size: 0.875rem;
padding: 0.375rem 3rem 0.375rem 1rem;
white-space: nowrap;
}
#navbar-dropdown .navbar-item:hover {
background-color: $background-secondary;
}

View file

@ -23,3 +23,8 @@
.has-background-tertiary {
background-color: $background-tertiary !important;
}
/* Workaround for dark theme as .has-text-black doesn't give desired effect. */
.has-text-default {
color: $text !important;
}

View file

@ -8,7 +8,9 @@ $primary: #005e50;
$primary-light: #1d2b28;
$info: #1f4666;
$success: #246447;
$success-light: #0d2f1e;
$warning: #8b6c15;
$warning-light: #372e13;
$danger: #872538;
$danger-light: #481922;
$light: #393939;
@ -26,6 +28,8 @@ $background-body: rgb(24, 27, 28);
$background-secondary: rgb(28, 30, 32);
$background-tertiary: rgb(32, 34, 36);
$modal-background-background-color: rgba($black, 0.8);
$scrollbar-track: $background-secondary;
$scrollbar-thumb: $light;
/* highlight colors */
$primary-highlight: $primary;
@ -49,6 +53,7 @@ $link-hover: $white-bis;
$link-hover-border: #51595d;
$link-focus: $white-bis;
$link-active: $white-bis;
$link-light: #0d1c26;
/* bulma overrides */
$background: $background-secondary;
@ -79,5 +84,13 @@ $progress-value-background-color: $border-light;
$family-primary: $family-sans-serif;
$family-secondary: $family-sans-serif;
.has-text-muted {
color: $grey-lighter !important;
}
.has-text-more-muted {
color: $grey-light !important;
}
@import "../bookwyrm";
@import "../vendor/icons.css";

View file

@ -19,6 +19,8 @@ $scheme-main: $white-bis;
$background-body: $white;
$background-secondary: $white-ter;
$background-tertiary: $white-bis;
$scrollbar-track: $background-secondary;
$scrollbar-thumb: $grey-lighter;
/* highlight colors */
$primary-highlight: $primary-light;
@ -55,5 +57,14 @@ $invisible-overlay-background-color: rgba($scheme-invert, 0.66);
$family-primary: $family-sans-serif;
$family-secondary: $family-sans-serif;
.has-text-muted {
color: $grey-dark !important;
}
.has-text-more-muted {
color: $grey !important;
}
@import "../bookwyrm";
@import "../vendor/icons.css";

View file

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

View file

@ -99,7 +99,7 @@
<p>
{% url "conduct" as coc_path %}
{% blocktrans trimmed with site_name=site.name %}
{{ site_name }}'s moderators and administrators keep the site up and running, enforce the <a href="coc_path">code of conduct</a>, and respond when users report spam and bad behavior.
{{ site_name }}'s moderators and administrators keep the site up and running, enforce the <a href="{{ coc_path }}">code of conduct</a>, and respond when users report spam and bad behavior.
{% endblocktrans %}
</p>
</header>

View file

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

View file

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

View file

@ -208,9 +208,17 @@
{% endif %}
{% if book.parent_work.editions.count > 1 %}
<p>{% blocktrans with path=book.parent_work.local_path count=book.parent_work.editions.count %}<a href="{{ path }}/editions">{{ count }} editions</a>{% endblocktrans %}</p>
{% endif %}
{% with work=book.parent_work %}
<p>
<a href="{{ work.local_path }}/editions">
{% blocktrans trimmed count counter=work.editions.count with count=work.editions.count|intcomma %}
{{ count }} edition
{% plural %}
{{ count }} editions
{% endblocktrans %}
</a>
</p>
{% endwith %}
</div>
{# user's relationship to the book #}
@ -276,7 +284,7 @@
{% if user_statuses.review_count or user_statuses.comment_count or user_statuses.quotation_count %}
<nav class="tabs">
<ul>
{% url 'book' book.id as tab_url %}
{% url 'book' book.id book.name|slugify as tab_url %}
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
<a href="{{ tab_url }}#reviews">{% trans "Reviews" %} ({{ review_count }})</a>
</li>

View file

@ -3,18 +3,24 @@
{% load humanize %}
{% load utilities %}
{% block title %}{% if book %}{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}{% else %}{% trans "Add Book" %}{% endif %}{% endblock %}
{% block title %}
{% if book.title %}
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
{% else %}
{% trans "Add Book" %}
{% endif %}
{% endblock %}
{% block content %}
<header class="block">
<h1 class="title level-left">
{% if book %}
{% if book.title %}
{% blocktrans with book_title=book.title %}Edit "{{ book_title }}"{% endblocktrans %}
{% else %}
{% trans "Add Book" %}
{% endif %}
</h1>
{% if book %}
{% if book.created_date %}
<dl>
<dt class="is-pulled-left mr-5 has-text-weight-semibold">{% trans "Added:" %}</dt>
<dd class="ml-2">{{ book.created_date | naturaltime }}</dd>
@ -33,12 +39,20 @@
<form
class="block"
{% if book %}
{% if book.id %}
name="edit-book"
action="{{ book.local_path }}/{% if confirm_mode %}confirm{% else %}edit{% endif %}"
{% if confirm_mode %}
action="{% url 'edit-book-confirm' book.id %}"
{% else %}
action="{% url 'edit-book' book.id %}"
{% endif %}
{% else %}
name="create-book"
action="/create-book{% if confirm_mode %}/confirm{% endif %}"
{% if confirm_mode %}
action="{% url 'create-book-confirm' %}"
{% else %}
action="{% url 'create-book' %}"
{% endif %}
{% endif %}
method="post"
enctype="multipart/form-data"
@ -97,7 +111,7 @@
<input type="radio" name="parent_work" value="{{ match.parent_work.id }}"> {{ match.parent_work.title }}
</label>
{% endfor %}
<label>
<label class="label mt-2">
<input type="radio" name="parent_work" value="0" required> {% trans "This is a new work" %}
</label>
</fieldset>
@ -119,7 +133,7 @@
{% if not confirm_mode %}
<div class="block">
<button class="button is-primary" type="submit">{% trans "Save" %}</button>
{% if book %}
{% if book.id %}
<a class="button" href="{{ book.local_path }}">{% trans "Cancel" %}</a>
{% else %}
<a href="/" class="button" data-back>

View file

@ -10,6 +10,8 @@
{% csrf_token %}
<input type="hidden" name="last_edited_by" value="{{ request.user.id }}">
<input type="hidden" name="parent_work" value="{% firstof book.parent_work.id form.parent_work %}">
<div class="columns">
<div class="column is-half">
<section class="block">
@ -153,8 +155,7 @@
<label class="label" for="id_first_published_date">
{% trans "First published date:" %}
</label>
<input type="date" name="first_published_date" class="input" id="id_first_published_date"{% if form.first_published_date.value %} value="{{ form.first_published_date.value|date:'Y-m-d' }}"{% endif %} aria-describedby="desc_first_published_date">
{{ form.first_published_date }}
{% include 'snippets/form_errors.html' with errors_list=form.first_published_date.errors id="desc_first_published_date" %}
</div>
@ -162,7 +163,7 @@
<label class="label" for="id_published_date">
{% trans "Published date:" %}
</label>
<input type="date" name="published_date" class="input" id="id_published_date"{% if form.published_date.value %} value="{{ form.published_date.value|date:'Y-m-d'}}"{% endif %} aria-describedby="desc_published_date">
{{ form.published_date }}
{% include 'snippets/form_errors.html' with errors_list=form.published_date.errors id="desc_published_date" %}
</div>
@ -175,6 +176,8 @@
</h2>
<div class="box">
{% if book.authors.exists %}
{# preserve authors if the book is unsaved #}
<input type="hidden" name="authors" value="{% for author in book.authors.all %}{{ author.id }},{% endfor %}">
<fieldset>
{% for author in book.authors.all %}
<div class="is-flex is-justify-content-space-between">
@ -255,9 +258,7 @@
<label class="label" for="id_physical_format">
{% trans "Format:" %}
</label>
<div class="select">
{{ form.physical_format }}
</div>
{{ form.physical_format }}
{% include 'snippets/form_errors.html' with errors_list=form.physical_format.errors id="desc_physical_format" %}
</div>

View file

@ -21,7 +21,7 @@
<div class="column my-3-mobile ml-3-tablet mr-auto">
<h2 class="title is-5 mb-1">
<a href="{{ book.local_path }}" class="has-text-black">
<a href="{{ book.local_path }}" class="has-text-default">
{{ book|book_title }}
</a>
</h2>
@ -46,7 +46,36 @@
{% endfor %}
</div>
<div>
<div class="block">
{% include 'snippets/pagination.html' with page=editions path=request.path %}
</div>
<div class="block has-text-centered help">
<p>
{% trans "Can't find the edition you're looking for?" %}
</p>
<form action="{% url 'create-book-data' %}" method="POST" name="add-edition-form">
{% csrf_token %}
{{ work_form.title }}
{{ work_form.subtitle }}
{{ work_form.authors }}
{{ work_form.description }}
{{ work_form.languages }}
{{ work_form.series }}
{{ work_form.cover }}
{{ work_form.first_published_date }}
{% for subject in work.subjects %}
<input type="hidden" name="subjects" value="{{ subject }}">
{% endfor %}
<input type="hidden" name="parent_work" value="{{ work.id }}">
<div>
<button class="button is-small" type="submit">
{% trans "Add another edition" %}
</button>
</div>
</form>
</div>
{% endblock %}

View file

@ -15,7 +15,7 @@
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul>
<li><a href="{% url 'book' book.id %}">{{ book|book_title }}</a></li>
<li><a href="{% url 'book' book.id book.name|slugify %}">{{ book|book_title }}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{% trans "Edit links" %}

View file

@ -1,11 +0,0 @@
{% load i18n %}
{% trans "Help" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text class="ml-3 is-rounded is-small has-background-body p-0 pb-1" icon="question-circle is-size-6" controls_text=controls_text controls_uid=controls_uid %}
<aside class="tooltip notification is-hidden transition-y is-pulled-left mb-2" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
{% trans "Close" as button_text %}
{% include 'snippets/toggle/close_button.html' with label=button_text class="delete" nonbutton=True controls_text=controls_text controls_uid=controls_uid %}
{% block tooltip_content %}{% endblock %}
</aside>

View file

@ -29,9 +29,16 @@
</section>
<section class="block">
{% trans "Can't find your code?" as button_text %}
{% include "snippets/toggle/open_button.html" with text=button_text controls_text="resend_form" focus="resend_form_header" %}
{% include "confirm_email/resend_form.html" with controls_text="resend_form" %}
<form name="fallback" method="GET" action="{% url 'resend-link' %}" autocomplete="off">
<button
type="submit"
class="button"
data-modal-open="resend_form"
>
{% trans "Can't find your code?" %}
</button>
</form>
{% include "confirm_email/resend_modal.html" with id="resend_form" %}
</section>
</div>
</div>

View file

@ -0,0 +1,10 @@
{% extends 'landing/layout.html' %}
{% load i18n %}
{% block title %}
{% trans "Resend confirmation link" %}
{% endblock %}
{% block content %}
{% include "confirm_email/resend_modal.html" with active=True static=True id="resend-modal" %}
{% endblock %}

View file

@ -1,20 +0,0 @@
{% extends "components/inline_form.html" %}
{% load i18n %}
{% block header %}
{% trans "Resend confirmation link" %}
{% endblock %}
{% block form %}
<form name="resend" method="post" action="{% url 'resend-link' %}">
{% csrf_token %}
<div class="field">
<label class="label" for="email">{% trans "Email address:" %}</label>
<div class="control">
<input type="text" name="email" class="input" required id="email">
</div>
</div>
<div class="control">
<button class="button is-link">{% trans "Resend link" %}</button>
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,44 @@
{% extends "components/modal.html" %}
{% load i18n %}
{% block modal-title %}
{% trans "Resend confirmation link" %}
{% endblock %}
{% block modal-form-open %}
<form name="resend" method="post" action="{% url 'resend-link' %}">
{% endblock %}
{% block modal-body %}
{% csrf_token %}
<div class="field">
<label class="label" for="email">{% trans "Email address:" %}</label>
<div class="control">
<input
type="email"
name="email"
class="input"
id="email"
aria-described-by="id_email_errors"
required
>
{% if error %}
<div id="id_email_errors">
<p class="help is-danger">
{% trans "No user matching this email address found." %}
</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block modal-footer %}
<div class="control">
<button class="button is-link">{% trans "Resend link" %}</button>
</div>
{% endblock %}
{% block modal-form-close %}
</form>
{% endblock %}

View file

@ -43,7 +43,7 @@
{% endif %}
<p>
<a href="https://joinbookwyrm.com/">
{% trans "Join Bookwyrm" %}
{% trans "Join BookWyrm" %}
</a>
</p>
</footer>

View file

@ -5,7 +5,19 @@
<section class="block">
<h2 class="title is-4">{% trans "Your Books" %}</h2>
{% if not suggested_books %}
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
<div class="content">
<p>{% trans "There are no books here right now! Try searching for a book to get started" %}</p>
<div class="box has-background-link-light">
<p>{% trans "Do you have book data from another service like GoodReads?" %}</p>
<a href="{% url 'import' %}">
<span class="icon icon-list" aria-hidden="true"></span>
{% trans "Import your reading history" %}
</a>
</div>
</div>
{% else %}
{% with active_book=request.GET.book %}
<div class="tab-group">

View file

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

View file

@ -38,7 +38,7 @@
{% for membership in group.memberships.all %}
{% with member=membership.user %}
<div class="box has-text-centered is-shadowless has-background-tertiary my-2 mx-2 member_{{ member.id }}">
<a href="{{ member.local_path }}" class="has-text-black">
<a href="{{ member.local_path }}" class="has-text-default">
{% include 'snippets/avatar.html' with user=member large=True %}
<span title="{{ member.display_name }}" class="is-block is-6 has-text-weight-bold">{{ member.display_name|truncatechars:10 }}</span>
<span title="@{{ member|username }}" class="is-block pb-3">@{{ member|username|truncatechars:8 }}</span>

View file

@ -9,7 +9,7 @@
<div class="column is-flex is-flex-grow-0">
{% for user in suggested_users %}
<div class="box has-text-centered is-shadowless has-background-tertiary m-2">
<a href="{{ user.local_path }}" class="has-text-black">
<a href="{{ user.local_path }}" class="has-text-default">
{% include 'snippets/avatar.html' with user=user large=True %}
<span title="{{ user.display_name }}" class="is-block is-6 has-text-weight-bold">{{ user.display_name|truncatechars:10 }}</span>
<span title="@{{ user|username }}" class="is-block pb-3">@{{ user|username|truncatechars:8 }}</span>

View file

@ -14,28 +14,35 @@
<div class="column is-half">
<div class="field">
<label class="label is-pulled-left" for="source">
<label class="label" for="source">
{% trans "Data source:" %}
</label>
{% include 'import/tooltip.html' with controls_text="goodreads-tooltip" %}
<div class="select">
<select name="source" id="source" aria-describedby="desc_source">
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
Goodreads (CSV)
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
Storygraph (CSV)
</option>
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
LibraryThing (TSV)
</option>
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
OpenLibrary (CSV)
</option>
<option value="Calibre" {% if current == 'Calibre' %}selected{% endif %}>
Calibre (CSV)
</option>
</select>
</div>
<p class="help" id="desc_source">
{% trans 'You can download your Goodreads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener noreferrer">Import/Export page</a> of your Goodreads account.' %}
</p>
</div>
<div class="select block">
<select name="source" id="source">
<option value="Goodreads" {% if current == 'Goodreads' %}selected{% endif %}>
Goodreads (CSV)
</option>
<option value="Storygraph" {% if current == 'Storygraph' %}selected{% endif %}>
Storygraph (CSV)
</option>
<option value="LibraryThing" {% if current == 'LibraryThing' %}selected{% endif %}>
LibraryThing (TSV)
</option>
<option value="OpenLibrary" {% if current == 'OpenLibrary' %}selected{% endif %}>
OpenLibrary (CSV)
</option>
</select>
</div>
<div class="field">
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
{{ import_form.csv_file }}
@ -63,7 +70,7 @@
<div class="content block">
<h2 class="title">{% trans "Recent Imports" %}</h2>
{% if not jobs %}
<p>{% trans "No recent imports" %}</p>
<p><em>{% trans "No recent imports" %}</em></p>
{% endif %}
<ul>
{% for job in jobs %}

View file

@ -1,8 +0,0 @@
{% extends 'components/tooltip.html' %}
{% load i18n %}
{% block tooltip_content %}
{% trans 'You can download your Goodreads data from the <a href="https://www.goodreads.com/review/import" target="_blank" rel="noopener noreferrer">Import/Export page</a> of your Goodreads account.' %}
{% endblock %}

View file

@ -90,64 +90,8 @@
<div class="navbar-end">
{% if request.user.is_authenticated %}
<div class="navbar-item mt-3 py-0 has-dropdown is-hoverable">
<a
href="{{ request.user.local_path }}"
class="navbar-link pulldown-menu"
role="button"
aria-expanded="false"
tabindex="0"
aria-haspopup="true"
aria-controls="navbar-dropdown"
>
{% include 'snippets/avatar.html' with user=request.user %}
<span class="ml-2">{{ request.user.display_name }}</span>
</a>
<ul class="navbar-dropdown" id="navbar_dropdown">
<li>
<a href="{% url 'directory' %}" class="navbar-item">
{% trans "Directory" %}
</a>
</li>
<li>
<a href="{% url 'user-shelves' request.user.localname %}" class="navbar-item">
{% trans 'Your Books' %}
</a>
</li>
<li>
<a href="{% url 'direct-messages' %}" class="navbar-item">
{% trans "Direct Messages" %}
</a>
</li>
<li>
<a href="{% url 'prefs-profile' %}" class="navbar-item">
{% trans 'Settings' %}
</a>
</li>
{% if perms.bookwyrm.create_invites or perms.moderate_user %}
<li class="navbar-divider" role="presentation">&nbsp;</li>
{% endif %}
{% if perms.bookwyrm.create_invites and not site.allow_registration %}
<li>
<a href="{% url 'settings-invite-requests' %}" class="navbar-item">
{% trans 'Invites' %}
</a>
</li>
{% endif %}
{% if perms.bookwyrm.moderate_user %}
<li>
<a href="{% url 'settings-dashboard' %}" class="navbar-item">
{% trans 'Admin' %}
</a>
</li>
{% endif %}
<li class="navbar-divider" role="presentation">&nbsp;</li>
<li>
<a href="{% url 'logout' %}" class="navbar-item">
{% trans 'Log out' %}
</a>
</li>
</ul>
<div class="navbar-item mt-3 py-0">
{% include 'user_menu.html' %}
</div>
<div class="navbar-item mt-3 py-0">
<a href="{% url 'notifications' %}" class="tags has-addons">

View file

@ -6,7 +6,7 @@
<nav class="breadcrumb subtitle" aria-label="breadcrumbs">
<ul>
<li><a href="{% url 'lists' %}">{% trans "Lists" %}</a></li>
<li><a href="{% url 'list' list.id %}">{{ list.name|truncatechars:30 }}</a></li>
<li><a href="{% url 'list' list_id=list.id slug=list.name|slugify %}">{{ list.name|truncatechars:30 }}</a></li>
<li class="is-active">
<a href="#" aria-current="page">
{% trans "Curate" %}

View file

@ -180,7 +180,7 @@
<h2 class="title is-5">
{% trans "Sort List" %}
</h2>
<form name="sort" action="{% url 'list' list.id %}" method="GET" class="block">
<form name="sort" action="{% url 'list' list_id=list.id slug=list.name|slugify %}" method="GET" class="block">
<div class="field">
<label class="label" for="id_sort_by">{% trans "Sort By" %}</label>
<div class="select is-fullwidth">
@ -207,7 +207,7 @@
{% trans "Suggest Books" %}
{% endif %}
</h2>
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
<form name="search" action="{% url 'list' list_id=list.id slug=list.name|slugify %}" method="GET" class="block">
<div class="field has-addons">
<div class="control">
<input aria-label="{% trans 'Search for a book' %}" class="input" type="text" name="q" placeholder="{% trans 'Search for a book' %}" value="{{ query }}">
@ -221,7 +221,7 @@
</div>
</div>
{% if query %}
<p class="help"><a href="{% url 'list' list.id %}">{% trans "Clear search" %}</a></p>
<p class="help"><a href="{% url 'list' list_id=list.id slug=list.name|slugify %}">{% trans "Clear search" %}</a></p>
{% endif %}
</form>
{% if not suggested_books %}

View file

@ -47,12 +47,12 @@
{% block preview %}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-grey-dark{% endif %}">
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-muted{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-grey-dark">
<div class="column is-narrow has-text-muted">
{{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>

View file

@ -47,12 +47,12 @@
{% block preview %}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-grey-dark{% endif %}">
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-muted{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-grey-dark">
<div class="column is-narrow has-text-muted">
{{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>

View file

@ -1,7 +1,7 @@
{% load notification_page_tags %}
{% related_status notification as related_status %}
<div class="notification {% if notification.id in unread %}has-background-primary{% endif %}">
<div class="columns is-mobile {% if notification.id in unread %}has-text-white{% else %}has-text-grey{% endif %}">
<div class="columns is-mobile {% if notification.id in unread %}has-text-white{% else %}has-text-more-muted{% endif %}">
<div class="column is-narrow is-size-3">
<a class="icon" href="{% block primary_link %}{% endblock %}">
{% block icon %}{% endblock %}

View file

@ -48,12 +48,12 @@
{% block preview %}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-black{% endif %}">
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-default{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-text-black">
<div class="column is-narrow has-text-default">
{{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>

View file

@ -51,12 +51,12 @@
{% block preview %}
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-black{% endif %}">
<div class="notification py-2 {% if notification.id in unread %}is-primary is-light{% else %}has-background-body has-text-default{% endif %}">
<div class="columns">
<div class="column is-clipped">
{% include 'snippets/status_preview.html' with status=related_status %}
</div>
<div class="column is-narrow has-text-black">
<div class="column is-narrow has-text-default">
{{ related_status.published_date|timesince }}
{% include 'snippets/privacy-icons.html' with item=related_status %}
</div>

View file

@ -0,0 +1,22 @@
{% extends 'preferences/layout.html' %}
{% load i18n %}
{% block title %}{% trans "CSV Export" %}{% endblock %}
{% block header %}
{% trans "CSV Export" %}
{% endblock %}
{% block panel %}
<div class="block content">
<p class="notification">
{% trans "Your export will include all the books on your shelves, books you have reviewed, and books with reading activity." %}
</p>
<p>
<a href="{% url 'prefs-export-file' %}" class="button">
<span class="icon icon-download" aria-hidden="true"></span>
<span>Download file</span>
</a>
</p>
</div>
{% endblock %}

View file

@ -24,6 +24,17 @@
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Delete Account" %}</a>
</li>
</ul>
<h2 class="menu-label">{% trans "Data" %}</h2>
<ul class="menu-list">
<li>
{% url 'import' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "Import" %}</a>
</li>
<li>
{% url 'prefs-export' as url %}
<a href="{{ url }}"{% if url in request.path %} class="is-active" aria-selected="true"{% endif %}>{% trans "CSV export" %}</a>
</li>
</ul>
<h2 class="menu-label">{% trans "Relationships" %}</h2>
<ul class="menu-list">
<li>

View file

@ -0,0 +1,14 @@
{% extends 'layout.html' %}
{% load i18n %}
{% block title %}
{% blocktrans trimmed with book_title=book.title %}
Stop Reading "{{ book_title }}"
{% endblocktrans %}
{% endblock %}
{% block content %}
{% include "snippets/reading_modals/stop_reading_modal.html" with book=book active=True static=True %}
{% endblock %}

View file

@ -19,6 +19,7 @@
</label>
{% include "snippets/progress_field.html" with id=field_id %}
{% endif %}
<div class="field">
<label class="label" for="id_finish_date_{{ readthrough.id }}">
{% trans "Finished reading" %}

View file

@ -8,10 +8,12 @@
<div class="column">
{% trans "Progress Updates:" %}
<ul>
{% if readthrough.finish_date or readthrough.progress %}
{% if readthrough.finish_date or readthrough.stopped_date or readthrough.progress %}
<li>
{% if readthrough.finish_date %}
{{ readthrough.finish_date | localtime | naturalday }}: {% trans "finished" %}
{% elif readthrough.stopped_date %}
{{ readthrough.stopped_date | localtime | naturalday }}: {% trans "stopped" %}
{% else %}
{% if readthrough.progress_mode == 'PG' %}

View file

@ -17,7 +17,14 @@
{% endblock %}
{% block modal-form-open %}
<form name="add-readthrough-{{ readthrough.id }}" action="/create-readthrough" method="post">
<form
name="add-readthrough-{{ readthrough.id }}"
{% if readthrough.id %}
action="{% url 'edit-readthrough' %}"
{% else %}
action="{% url 'create-readthrough' %}"
{% endif %}
method="POST">
{% endblock %}
{% block modal-body %}

View file

@ -36,7 +36,7 @@
{% if result_set.results %}
<section class="mb-5">
{% if not result_set.connector.local %}
<details class="details-panel box" {% if forloop.first %}open{% endif %}>
<details class="details-panel box" open>
{% endif %}
{% if not result_set.connector.local %}
<summary class="is-flex is-align-items-center is-flex-wrap-wrap is-gap-2">

View file

@ -14,7 +14,7 @@
{% block panel %}
<div class="block table-container">
<table class="table is-striped">
<table class="table is-striped is-fullwidth">
<tr>
<th>
{% url 'settings-announcements' as url %}

View file

@ -154,7 +154,7 @@
</summary>
<div class="table-container">
<table class="table is-striped">
<table class="table is-striped is-fullwidth">
<tr>
<th>
<label for="id_string_match">{% trans "String match" %}</label>

View file

@ -10,26 +10,26 @@
{% block panel %}
<div class="columns block has-text-centered is-mobile is-multiline">
<div class="column is-3-desktop is-6-mobile">
<div class="notification">
<div class="column is-3-desktop is-6-mobile is-flex">
<div class="notification is-flex-grow-1">
<h3>{% trans "Total users" %}</h3>
<p class="title is-5">{{ users|intcomma }}</p>
</div>
</div>
<div class="column is-3-desktop is-6-mobile">
<div class="notification">
<div class="column is-3-desktop is-6-mobil is-flexe">
<div class="notification is-flex-grow-1">
<h3>{% trans "Active this month" %}</h3>
<p class="title is-5">{{ active_users|intcomma }}</p>
</div>
</div>
<div class="column is-3-desktop is-6-mobile">
<div class="notification">
<div class="column is-3-desktop is-6-mobile is-flex">
<div class="notification is-flex-grow-1">
<h3>{% trans "Statuses" %}</h3>
<p class="title is-5">{{ statuses|intcomma }}</p>
</div>
</div>
<div class="column is-3-desktop is-6-mobile">
<div class="notification">
<div class="column is-3-desktop is-6-mobile is-flex">
<div class="notification is-flex-grow-1">
<h3>{% trans "Works" %}</h3>
<p class="title is-5">{{ works|intcomma }}</p>
</div>
@ -38,8 +38,8 @@
<div class="columns block is-multiline">
{% if reports %}
<div class="column">
<a href="{% url 'settings-reports' %}" class="notification is-warning is-block">
<div class="column is-flex">
<a href="{% url 'settings-reports' %}" class="notification is-warning is-block is-flex-grow-1">
{% blocktrans trimmed count counter=reports with display_count=reports|intcomma %}
{{ display_count }} open report
{% plural %}
@ -50,8 +50,8 @@
{% endif %}
{% if pending_domains %}
<div class="column">
<a href="{% url 'settings-link-domain' %}" class="notification is-primary is-block">
<div class="column is-flex">
<a href="{% url 'settings-link-domain' %}" class="notification is-primary is-block is-flex-grow-1">
{% blocktrans trimmed count counter=pending_domains with display_count=pending_domains|intcomma %}
{{ display_count }} domain needs review
{% plural %}
@ -62,8 +62,8 @@
{% endif %}
{% if not site.allow_registration and site.allow_invite_requests and invite_requests %}
<div class="column">
<a href="{% url 'settings-invite-requests' %}" class="notification is-block is-success">
<div class="column is-flex">
<a href="{% url 'settings-invite-requests' %}" class="notification is-block is-success is-flex-grow-1">
{% blocktrans trimmed count counter=invite_requests with display_count=invite_requests|intcomma %}
{{ display_count }} invite request
{% plural %}
@ -74,8 +74,8 @@
{% endif %}
{% if current_version %}
<div class="column">
<a href="https://docs.joinbookwyrm.com/updating-your-instance.html" class="notification is-block is-warning" target="_blank">
<div class="column is-flex">
<a href="https://docs.joinbookwyrm.com/updating-your-instance.html" class="notification is-block is-warning is-flex-grow-1" target="_blank">
{% blocktrans trimmed with current=current_version available=available_version %}
An update is available! You're running v{{ current }} and the latest release is {{ available }}.
{% endblocktrans %}

View file

@ -0,0 +1,7 @@
{% extends 'snippets/filters_panel/filters_panel.html' %}
{% block filter_fields %}
{% include 'settings/federation/software_filter.html' %}
{% endblock %}

View file

@ -12,6 +12,9 @@
{% endblock %}
{% block panel %}
{% include 'settings/federation/instance_filters.html' %}
<div class="tabs">
<ul>
{% url 'settings-federation' status='federated' as url %}
@ -25,7 +28,7 @@
</ul>
</div>
<table class="table is-striped">
<table class="table is-striped is-fullwidth">
<tr>
{% url 'settings-federation' as url %}
<th>
@ -36,6 +39,10 @@
{% trans "Date added" as text %}
{% include 'snippets/table-sort-header.html' with field="created_date" sort=sort text=text %}
</th>
<th>
{% trans "Last updated" as text %}
{% include 'snippets/table-sort-header.html' with field="updated_date" sort=sort text=text %}
</th>
<th>
{% trans "Software" as text %}
{% include 'snippets/table-sort-header.html' with field="application_type" sort=sort text=text %}
@ -43,12 +50,12 @@
<th>
{% trans "Users" %}
</th>
<th>{% trans "Status" %}</th>
</tr>
{% for server in servers %}
<tr>
<td><a href="{% url 'settings-federated-server' server.id %}">{{ server.server_name }}</a></td>
<td>{{ server.created_date }}</td>
<td>{{ server.created_date|date:'Y-m-d' }}</td>
<td>{{ server.updated_date|date:'Y-m-d' }}</td>
<td>
{% if server.application_type %}
{{ server.application_type }}
@ -56,7 +63,6 @@
{% endif %}
</td>
<td>{{ server.user_set.count }}</td>
<td>{{ server.get_status_display }}</td>
</tr>
{% endfor %}
{% if not servers %}

View file

@ -0,0 +1,19 @@
{% extends 'snippets/filters_panel/filter_field.html' %}
{% load i18n %}
{% block filter %}
<label class="label" for="id_server">{% trans "Software" %}</label>
<div class="control">
<div class="select">
<select name="application_type">
<option value="">-----</option>
{% for option in software_options %}
{% if option %}
<option value="{{ option }}">{{ option }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
{% endblock %}

View file

@ -21,6 +21,7 @@
<div class="field">
<input type="text" name="address" maxlength="255" class="input" required="" id="id_address" placeholder="190.0.2.0/24" aria-describedby="desc_address">
<p class="help">{% trans "You can block IP ranges using CIDR syntax." %}</p>
</div>
{% include 'snippets/form_errors.html' with errors_list=form.address.errors id="desc_address" %}

View file

@ -1,8 +0,0 @@
{% extends 'components/tooltip.html' %}
{% load i18n %}
{% block tooltip_content %}
{% trans "You can block IP ranges using CIDR syntax." %}
{% endblock %}

View file

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

View file

@ -44,5 +44,6 @@
{% endfor %}
</div>
{% include 'snippets/pagination.html' with page=reports path=request.path %}
{% endblock %}

View file

@ -139,6 +139,13 @@
{% trans "Allow registration" %}
</label>
</div>
<div class="field">
<label class="label mb-0" for="id_require_confirm_email">
{{ site_form.require_confirm_email }}
{% trans "Require users to confirm email address" %}
</label>
<p class="help" id="desc_require_confirm_email">{% trans "(Recommended if registration is open)" %}</p>
</div>
<div class="field">
<label class="label" for="id_allow_invite_requests">
{{ site_form.allow_invite_requests }}
@ -157,13 +164,6 @@
{{ site_form.invite_question_text }}
</label>
</div>
<div class="field">
<label class="label mb-0" for="id_require_confirm_email">
{{ site_form.require_confirm_email }}
{% trans "Require users to confirm email address" %}
</label>
<p class="help" id="desc_require_confirm_email">{% trans "(Recommended if registration is open)" %}</p>
</div>
<div class="field">
<label class="label" for="id_registration_closed_text">{% trans "Registration closed text:" %}</label>
{{ site_form.registration_closed_text }}
@ -171,7 +171,7 @@
<div class="field">
<label class="label" for="id_invite_request_text">{% trans "Invite request text:" %}</label>
{{ site_form.invite_request_text }}
{% include 'snippets/form_errors.html' with errors_list=site_form.invite_request_text.errors id="desc_invite_request_text" %}
</div>
</div>

View file

@ -88,7 +88,7 @@
<section class="block content">
<h2 class="title is-4">{% trans "Available Themes" %}</h2>
<div class="table-container">
<table class="table is-striped">
<table class="table is-striped is-fullwidth">
<tr>
<th>
{% trans "Theme name" %}

View file

@ -33,7 +33,7 @@
</div>
<div class="table-container block">
<table class="table is-striped">
<table class="table is-striped is-fullwidth">
<tr>
{% url 'settings-users' as url %}
<th>
@ -61,10 +61,25 @@
</tr>
{% for user in users %}
<tr>
<td><a href="{% url 'settings-user' user.id %}">{{ user|username }}</a></td>
<td class="overflow-wrap-anywhere">
<a href="{% url 'settings-user' user.id %}">{{ user|username }}</a>
</td>
<td>{{ user.created_date }}</td>
<td>{{ user.last_active_date }}</td>
<td>{% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Inactive" %}{% endif %}</td>
<td>
{% if user.is_active %}
<span class="tag is-success" aria-hidden="true">
<span class="icon icon-check"></span>
</span>
{% trans "Active" %}
{% else %}
<span class="tag is-warning" aria-hidden="true">
<span class="icon icon-x"></span>
</span>
{% trans "Inactive" %}
<span class="help">({{ user.get_deactivation_reason_display }})</span>
{% endif %}
</td>
{% if status != "local" %}
<td>
{% if user.federated_server %}

View file

@ -6,7 +6,7 @@
<div class="column is-flex is-flex-direction-column">
<h4 class="title is-4">{% trans "Profile" %}</h4>
<div class="box is-flex-grow-1">
{% include 'user/user_preview.html' with user=user %}
{% include 'user/user_preview.html' with user=user admin_mode=True %}
{% if user.summary %}
<div class="box content has-background-secondary is-shadowless">
{{ user.summary|to_markdown|safe }}
@ -14,6 +14,10 @@
{% endif %}
<p class="mt-2"><a href="{{ user.local_path }}">{% trans "View user profile" %}</a></p>
{% url 'settings-user' user.id as url %}
{% if not request.path == url %}
<p class="mt-2"><a href="{{ url }}">{% trans "Go to user admin" %}</a></p>
{% endif %}
</div>
</div>
<div class="column is-flex is-flex-direction-column is-4">
@ -67,6 +71,9 @@
<dt class="is-pulled-left mr-5">{% trans "Blocked by count:" %}</dt>
<dd>{{ user.blocked_by.count }}</dd>
<dt class="is-pulled-left mr-5">{% trans "Date added:" %}</dt>
<dd>{{ user.created_date }}</dd>
<dt class="is-pulled-left mr-5">{% trans "Last active date:" %}</dt>
<dd>{{ user.last_active_date }}</dd>

View file

@ -86,6 +86,7 @@
{% if shelf.identifier == 'to-read' %}{% trans "To Read" %}
{% elif shelf.identifier == 'reading' %}{% trans "Currently Reading" %}
{% elif shelf.identifier == 'read' %}{% trans "Read" %}
{% elif shelf.identifier == 'stopped-reading' %}{% trans "Stopped Reading" %}
{% else %}{{ shelf.name }}{% endif %}
<span class="subtitle">
{% include 'snippets/privacy-icons.html' with item=shelf %}
@ -150,7 +151,7 @@
{% if is_self %}
<th>{% trans "Shelved" as text %}{% include 'snippets/table-sort-header.html' with field="shelved_date" sort=sort text=text %}</th>
<th>{% trans "Started" as text %}{% include 'snippets/table-sort-header.html' with field="start_date" sort=sort text=text %}</th>
<th>{% trans "Finished" as text %}{% include 'snippets/table-sort-header.html' with field="finish_date" sort=sort text=text %}</th>
<th>{% if shelf.identifier == 'read' %}{% trans "Finished" as text %}{% else %}{% trans "Until" as text %}{% endif %}{% include 'snippets/table-sort-header.html' with field="finish_date" sort=sort text=text %}</th>
{% endif %}
<th>{% trans "Rating" as text %}{% include 'snippets/table-sort-header.html' with field="rating" sort=sort text=text %}</th>
{% endif %}
@ -180,7 +181,7 @@
<td data-title="{% trans "Started" %}">
{{ book.start_date|naturalday|default_if_none:""}}
</td>
<td data-title="{% trans "Finished" %}">
<td data-title="{% if shelf.identifier == 'read' %}{% trans "Finished" as text %}{% else %}{% trans "Until" as text %}{% endif %}">
{{ book.finish_date|naturalday|default_if_none:""}}
</td>
{% endif %}

View file

@ -14,6 +14,11 @@
{% blocktrans with username=goal.user.display_name read_count=progress.count|intcomma goal_count=goal.goal|intcomma path=goal.local_path %}{{ username }} has read <a href="{{ path }}">{{ read_count }} of {{ goal_count}} books</a>.{% endblocktrans %}
{% endif %}
</p>
<progress class="progress is-large" value="{{ progress.count }}" max="{{ goal.goal }}" aria-hidden="true">{{ progress.percent }}%</progress>
<progress
class="progress is-large is-primary"
value="{{ progress.count }}"
max="{{ goal.goal }}"
aria-hidden="true"
>{{ progress.percent }}%</progress>
{% endwith %}

View file

@ -10,6 +10,7 @@
<form name="reading-progress-{{ uuid }}" action="{% url 'reading-status-update' book.id %}" method="POST" class="submit-status">
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<input type="hidden" name="start_date" value="{{ readthrough.start_date|date:'Y-m-d' }}">
{% endblock %}
{% block reading-dates %}

View file

@ -0,0 +1,42 @@
{% extends 'snippets/reading_modals/layout.html' %}
{% load i18n %}
{% load utilities %}
{% block modal-title %}
{% blocktrans trimmed with book_title=book|book_title %}
Stop Reading "<em>{{ book_title }}</em>"
{% endblocktrans %}
{% endblock %}
{% block modal-form-open %}
<form name="stop-reading-{{ uuid }}" action="{% url 'reading-status' 'stop' book.id %}" method="post" {% if not refresh %}class="submit-status"{% endif %}>
{% csrf_token %}
<input type="hidden" name="id" value="{{ readthrough.id }}">
<input type="hidden" name="reading_status" value="stopped-reading">
<input type="hidden" name="shelf" value="{{ move_from }}">
{% endblock %}
{% block reading-dates %}
<div class="columns">
<div class="column is-half">
<div class="field">
<label class="label" for="stop_id_start_date_{{ uuid }}">
{% trans "Started reading" %}
</label>
<input type="date" name="start_date" class="input" id="stop_id_start_date_{{ uuid }}" value="{{ readthrough.start_date | date:"Y-m-d" }}">
</div>
</div>
<div class="column is-half">
<div class="field">
<label class="label" for="id_read_until_date_{{ uuid }}">
{% trans "Stopped reading" %}
</label>
<input type="date" name="stopped_date" class="input" id="id_read_until_date_{{ uuid }}" value="{% now "Y-m-d" %}">
</div>
</div>
</div>
{% endblock %}
{% block form %}
{% include "snippets/reading_modals/form.html" with optional=True type="stop_modal" %}
{% endblock %}

View file

@ -49,6 +49,13 @@
{% join "finish_reading" uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=button_class fallback_url=fallback_url %}
{% elif shelf.identifier == 'stopped-reading' %}
{% trans "Stopped reading" as button_text %}
{% url 'reading-status' 'stop' book.id as fallback_url %}
{% join "stop_reading" uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=button_class fallback_url=fallback_url %}
{% elif shelf.identifier == 'to-read' %}
{% trans "Want to read" as button_text %}
@ -99,5 +106,8 @@
{% join "finish_reading" uuid as modal_id %}
{% include 'snippets/reading_modals/finish_reading_modal.html' with book=active_shelf.book id=modal_id move_from=current.id readthrough=readthrough refresh=True class="" %}
{% join "stop_reading" uuid as modal_id %}
{% include 'snippets/reading_modals/stop_reading_modal.html' with book=active_shelf.book id=modal_id move_from=current.id readthrough=readthrough refresh=True class="" %}
{% endwith %}
{% endblock %}

View file

@ -29,6 +29,9 @@
{% join "finish_reading" uuid as modal_id %}
{% include 'snippets/reading_modals/finish_reading_modal.html' with book=active_shelf_book id=modal_id readthrough=readthrough class="" %}
{% join "stop_reading" uuid as modal_id %}
{% include 'snippets/reading_modals/stop_reading_modal.html' with book=active_shelf_book id=modal_id readthrough=readthrough class="" %}
{% join "progress_update" uuid as modal_id %}
{% include 'snippets/reading_modals/progress_update_modal.html' with book=active_shelf_book id=modal_id readthrough=readthrough class="" %}

View file

@ -8,7 +8,7 @@
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
<li role="menuitem" class="dropdown-item p-0">
<div
class="{% if next_shelf_identifier == shelf.identifier %}is-hidden{% endif %}"
class="{% if is_current or next_shelf_identifier == shelf.identifier %}is-hidden{% elif shelf.identifier == 'stopped-reading' and active_shelf.shelf.identifier != "reading" %}is-hidden{% endif %}"
data-shelf-dropdown-identifier="{{ shelf.identifier }}"
data-shelf-next="{{ shelf.identifier|next_shelf }}"
>
@ -26,6 +26,13 @@
{% join "finish_reading" button_uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=class fallback_url=fallback_url %}
{% elif shelf.identifier == 'stopped-reading' %}
{% trans "Stop reading" as button_text %}
{% url 'reading-status' 'stop' book.id as fallback_url %}
{% join "stop_reading" button_uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=class fallback_url=fallback_url %}
{% elif shelf.identifier == 'to-read' %}
{% trans "Want to read" as button_text %}

View file

@ -13,6 +13,15 @@
</button>
</div>
<div
class="{% if next_shelf_identifier != 'stopped-reading-complete' %}is-hidden{% endif %}"
data-shelf-identifier="stopped-reading-complete"
>
<button type="button" class="button {{ class }}" disabled>
<span>{% trans "Stopped reading" %}</span>
</button>
</div>
{% for shelf in shelves %}
<div
class="{% if next_shelf_identifier != shelf.identifier %}is-hidden{% endif %}"
@ -33,6 +42,14 @@
{% join "finish_reading" button_uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=class fallback_url=fallback_url %}
{% elif shelf.identifier == 'stopped-reading' %}
{% trans "Stop reading" as button_text %}
{% url 'reading-status' 'finish' book.id as fallback_url %}
{% join "stop_reading" button_uuid as modal_id %}
{% include 'snippets/shelve_button/modal_button.html' with class=class fallback_url=fallback_url %}
{% elif shelf.identifier == 'to-read' %}
{% trans "Want to read" as button_text %}

View file

@ -37,7 +37,7 @@
{% endwith %}
{% endif %}
<article class="column ml-3-tablet my-3-mobile">
<article class="column ml-3-tablet my-3-mobile is-clipped">
{% if status_type == 'Review' %}
<header class="mb-2">
<h3
@ -112,6 +112,9 @@
{% with full=status.content|safe no_trim=status.content_warning itemprop="reviewBody" %}
{% include 'snippets/trimmed_text.html' %}
{% endwith %}
{% if status.progress %}
<div class="is-small is-italic has-text-right mr-3">{% trans "page" %} {{ status.progress }}</div>
{% endif %}
{% endif %}
{% if status.attachments.exists %}

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