mirror of
https://github.com/bookwyrm-social/bookwyrm.git
synced 2024-11-26 11:31:08 +00:00
Merge branch 'main' into inventaire
This commit is contained in:
commit
64c2313a5d
280 changed files with 20693 additions and 9991 deletions
|
@ -23,6 +23,7 @@ max_line_length = off
|
|||
[*.{csv,json,html,md,po,py,svg,tsv}]
|
||||
max_line_length = off
|
||||
|
||||
# ` ` at the end of a line is a line-break in markdown
|
||||
[*.{md,markdown}]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
|
@ -30,7 +31,9 @@ trim_trailing_whitespace = false
|
|||
indent_size = 2
|
||||
max_line_length = off
|
||||
|
||||
[{package.json,yarn.lock}]
|
||||
# Computer generated files
|
||||
[{package.json,*.lock,*.mo}]
|
||||
indent_size = unset
|
||||
indent_style = unset
|
||||
max_line_length = unset
|
||||
insert_final_newline = unset
|
||||
|
|
|
@ -5,6 +5,7 @@ SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr"
|
|||
DEBUG=true
|
||||
|
||||
DOMAIN=your.domain.here
|
||||
#EMAIL=your@email.here
|
||||
|
||||
## Leave unset to allow all hosts
|
||||
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
|
||||
|
@ -26,13 +27,24 @@ POSTGRES_HOST=db
|
|||
MAX_STREAM_LENGTH=200
|
||||
REDIS_ACTIVITY_HOST=redis_activity
|
||||
REDIS_ACTIVITY_PORT=6379
|
||||
#REDIS_ACTIVITY_PASSWORD=redispassword345
|
||||
|
||||
# Celery config with redis broker
|
||||
# Redis as celery broker
|
||||
#REDIS_BROKER_PORT=6379
|
||||
#REDIS_BROKER_PASSWORD=redispassword123
|
||||
CELERY_BROKER=redis://redis_broker:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis_broker:6379/0
|
||||
|
||||
FLOWER_PORT=8888
|
||||
#FLOWER_USER=mouse
|
||||
#FLOWER_PASSWORD=changeme
|
||||
|
||||
EMAIL_HOST="smtp.mailgun.org"
|
||||
EMAIL_PORT=587
|
||||
EMAIL_HOST_USER=mail@your.domain.here
|
||||
EMAIL_HOST_PASSWORD=emailpassword123
|
||||
EMAIL_USE_TLS=true
|
||||
EMAIL_USE_SSL=false
|
||||
|
||||
# Set this to true when initializing certbot for domain, false when not
|
||||
CERTBOT_INIT=false
|
50
.env.prod.example
Normal file
50
.env.prod.example
Normal file
|
@ -0,0 +1,50 @@
|
|||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY="7(2w1sedok=aznpq)ta1mc4i%4h=xx@hxwx*o57ctsuml0x%fr"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG=false
|
||||
|
||||
DOMAIN=your.domain.here
|
||||
EMAIL=your@email.here
|
||||
|
||||
## Leave unset to allow all hosts
|
||||
# ALLOWED_HOSTS="localhost,127.0.0.1,[::1]"
|
||||
|
||||
OL_URL=https://openlibrary.org
|
||||
|
||||
## Database backend to use.
|
||||
## Default is postgres, sqlite is for dev quickstart only (NOT production!!!)
|
||||
BOOKWYRM_DATABASE_BACKEND=postgres
|
||||
|
||||
MEDIA_ROOT=images/
|
||||
|
||||
POSTGRES_PASSWORD=securedbpassword123
|
||||
POSTGRES_USER=fedireads
|
||||
POSTGRES_DB=fedireads
|
||||
POSTGRES_HOST=db
|
||||
|
||||
# Redis activity stream manager
|
||||
MAX_STREAM_LENGTH=200
|
||||
REDIS_ACTIVITY_HOST=redis_activity
|
||||
REDIS_ACTIVITY_PORT=6379
|
||||
REDIS_ACTIVITY_PASSWORD=redispassword345
|
||||
|
||||
# Redis as celery broker
|
||||
REDIS_BROKER_PORT=6379
|
||||
REDIS_BROKER_PASSWORD=redispassword123
|
||||
CELERY_BROKER=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0
|
||||
CELERY_RESULT_BACKEND=redis://:${REDIS_BROKER_PASSWORD}@redis_broker:${REDIS_BROKER_PORT}/0
|
||||
|
||||
FLOWER_PORT=8888
|
||||
FLOWER_USER=mouse
|
||||
FLOWER_PASSWORD=changeme
|
||||
|
||||
EMAIL_HOST="smtp.mailgun.org"
|
||||
EMAIL_PORT=587
|
||||
EMAIL_HOST_USER=mail@your.domain.here
|
||||
EMAIL_HOST_PASSWORD=emailpassword123
|
||||
EMAIL_USE_TLS=true
|
||||
EMAIL_USE_SSL=false
|
||||
|
||||
# Set this to true when initializing certbot for domain, false when not
|
||||
CERTBOT_INIT=false
|
1
.eslintignore
Normal file
1
.eslintignore
Normal file
|
@ -0,0 +1 @@
|
|||
**/vendor/**
|
82
.eslintrc.js
82
.eslintrc.js
|
@ -6,5 +6,85 @@ module.exports = {
|
|||
"es6": true
|
||||
},
|
||||
|
||||
"extends": "eslint:recommended"
|
||||
"extends": "eslint:recommended",
|
||||
|
||||
"rules": {
|
||||
// Possible Errors
|
||||
"no-async-promise-executor": "error",
|
||||
"no-await-in-loop": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-confusing-arrow": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-duplicate-imports": "error",
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-rename": "error",
|
||||
"require-atomic-updates": "error",
|
||||
|
||||
// Best practices
|
||||
"strict": "error",
|
||||
"no-var": "error",
|
||||
|
||||
// Stylistic Issues
|
||||
"arrow-spacing": "error",
|
||||
"capitalized-comments": [
|
||||
"warn",
|
||||
"always",
|
||||
{
|
||||
"ignoreConsecutiveComments": true
|
||||
},
|
||||
],
|
||||
"keyword-spacing": "error",
|
||||
"lines-around-comment": [
|
||||
"error",
|
||||
{
|
||||
"beforeBlockComment": true,
|
||||
"beforeLineComment": true,
|
||||
"allowBlockStart": true,
|
||||
"allowClassStart": true,
|
||||
"allowObjectStart": true,
|
||||
"allowArrayStart": true,
|
||||
},
|
||||
],
|
||||
"no-multiple-empty-lines": [
|
||||
"error",
|
||||
{
|
||||
"max": 1,
|
||||
},
|
||||
],
|
||||
"padded-blocks": [
|
||||
"error",
|
||||
"never",
|
||||
],
|
||||
"padding-line-between-statements": [
|
||||
"error",
|
||||
{
|
||||
// always before return
|
||||
"blankLine": "always",
|
||||
"prev": "*",
|
||||
"next": "return",
|
||||
},
|
||||
{
|
||||
// always before block-like expressions
|
||||
"blankLine": "always",
|
||||
"prev": "*",
|
||||
"next": "block-like",
|
||||
},
|
||||
{
|
||||
// always after variable declaration
|
||||
"blankLine": "always",
|
||||
"prev": [ "const", "let", "var" ],
|
||||
"next": "*",
|
||||
},
|
||||
{
|
||||
// not necessary between variable declaration
|
||||
"blankLine": "any",
|
||||
"prev": [ "const", "let", "var" ],
|
||||
"next": [ "const", "let", "var" ],
|
||||
},
|
||||
],
|
||||
"space-before-blocks": "error",
|
||||
}
|
||||
};
|
||||
|
|
2
.github/workflows/black.yml
vendored
2
.github/workflows/black.yml
vendored
|
@ -8,6 +8,6 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
- uses: psf/black@20.8b1
|
||||
- uses: psf/black@stable
|
||||
with:
|
||||
args: ". --check -l 80 -S"
|
||||
|
|
19
.github/workflows/lint-frontend.yaml
vendored
19
.github/workflows/lint-frontend.yaml
vendored
|
@ -3,12 +3,14 @@ name: Lint Frontend
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, ci ]
|
||||
branches: [ main, ci, frontend ]
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
- 'static/**'
|
||||
- '.eslintrc'
|
||||
- '.stylelintrc'
|
||||
pull_request:
|
||||
branches: [ main, ci ]
|
||||
branches: [ main, ci, frontend ]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
|
@ -16,14 +18,21 @@ jobs:
|
|||
runs-on: ubuntu-20.04
|
||||
|
||||
steps:
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
|
||||
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it.
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Install modules
|
||||
run: yarn
|
||||
|
||||
# See .stylelintignore for files that are not linted.
|
||||
- name: Run stylelint
|
||||
run: yarn stylelint **/static/**/*.css --report-needless-disables --report-invalid-scope-disables
|
||||
run: >
|
||||
yarn stylelint bookwyrm/static/**/*.css \
|
||||
--report-needless-disables \
|
||||
--report-invalid-scope-disables
|
||||
|
||||
# See .eslintignore for files that are not linted.
|
||||
- name: Run ESLint
|
||||
run: yarn eslint . --ext .js,.jsx,.ts,.tsx
|
||||
run: >
|
||||
yarn eslint bookwyrm/static \
|
||||
--ext .js,.jsx,.ts,.tsx
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -24,3 +24,6 @@
|
|||
|
||||
#Node tools
|
||||
/node_modules/
|
||||
|
||||
#nginx
|
||||
nginx/default.conf
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
bookwyrm/static/css/bulma.*.css*
|
||||
bookwyrm/static/css/icons.css
|
||||
**/vendor/**
|
||||
|
|
271
README.md
271
README.md
|
@ -9,29 +9,17 @@ 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)
|
||||
- [Setting up the developer environment](#setting-up-the-developer-environment)
|
||||
- [Installing in Production](#installing-in-production)
|
||||
- [Book data](#book-data)
|
||||
- [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://github.com/mouse-reeve/bookwyrm/blob/main/instances.md) 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
|
||||
There are many ways you can contribute to this project, regardless of your level of technical expertise.
|
||||
|
||||
### Feedback and feature requests
|
||||
Please feel encouraged and welcome to point out bugs, suggestions, feature requests, and ideas for how things ought to work using [GitHub issues](https://github.com/mouse-reeve/bookwyrm/issues).
|
||||
|
||||
### Code contributions
|
||||
Code contributions are gladly welcomed! If you're not sure where to start, take a look at the ["Good first issue"](https://github.com/mouse-reeve/bookwyrm/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22) tag. Because BookWyrm is a small project, there isn't a lot of formal structure, but there is a huge capacity for one-on-one support, which can look like asking questions as you go, pair programming, video chats, et cetera, so please feel free to reach out.
|
||||
|
||||
If you have questions about the project or contributing, you can set up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min).
|
||||
|
||||
### Translation
|
||||
Do you speak a language besides English? BookWyrm needs localization! If you're comfortable using git and want to get into the code, there are [instructions](#working-with-translations-and-locale-files) on how to create and edit localization files. If you feel more comfortable working in a regular text editor and would prefer not to run the application, get in touch directly and we can figure out a system, like emailing a text file, that works best.
|
||||
|
||||
### Financial Support
|
||||
BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo).
|
||||
See [contributing](https://docs.joinbookwyrm.com/how-to-contribute.html) for code, translation or monetary contributions.
|
||||
|
||||
## About BookWyrm
|
||||
### What it is and isn't
|
||||
|
@ -43,7 +31,7 @@ BookWyrm is built on [ActivityPub](http://activitypub.rocks/). With ActivityPub,
|
|||
Federation makes it possible to have small, self-determining communities, in contrast to the monolithic service you find on GoodReads or Twitter. An instance can be focused on a particular interest, be just for a group of friends, or anything else that brings people together. Each community can choose which other instances they want to federate with, and moderate and run their community autonomously. Check out https://runyourown.social/ to get a sense of the philosophy and logistics behind small, high-trust social networks.
|
||||
|
||||
### Features
|
||||
Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/mouse-reeve/bookwyrm/issues) to get the conversation going!
|
||||
Since the project is still in its early stages, the features are growing every day, and there is plenty of room for suggestions and ideas. Open an [issue](https://github.com/bookwyrm-social/bookwyrm/issues) to get the conversation going!
|
||||
- Posting about books
|
||||
- Compose reviews, with or without ratings, which are aggregated in the book page
|
||||
- Compose other kinds of statuses about books, such as:
|
||||
|
@ -73,8 +61,8 @@ Since the project is still in its early stages, the features are growing every d
|
|||
Web backend
|
||||
- [Django](https://www.djangoproject.com/) web server
|
||||
- [PostgreSQL](https://www.postgresql.org/) database
|
||||
- [ActivityPub](http://activitypub.rocks/) federation
|
||||
- [Celery](http://celeryproject.org/) task queuing
|
||||
- [ActivityPub](https://activitypub.rocks/) federation
|
||||
- [Celery](https://docs.celeryproject.org/) task queuing
|
||||
- [Redis](https://redis.io/) task backend
|
||||
- [Redis (again)](https://redis.io/) activity stream manager
|
||||
|
||||
|
@ -89,246 +77,9 @@ Deployment
|
|||
- [Flower](https://github.com/mher/flower) celery monitoring
|
||||
- [Nginx](https://nginx.org/en/) HTTP server
|
||||
|
||||
## Setting up the developer environment
|
||||
|
||||
Set up the environment file:
|
||||
|
||||
``` bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
For most testing, you'll want to use ngrok. Remember to set the DOMAIN in `.env` to your ngrok domain.
|
||||
|
||||
You'll have to install the Docker and docker-compose. When you're ready, run:
|
||||
|
||||
```bash
|
||||
docker-compose build
|
||||
docker-compose run --rm web python manage.py migrate
|
||||
docker-compose run --rm web python manage.py initdb
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
Once the build is complete, you can access the instance at `localhost:1333`
|
||||
|
||||
### Editing static files
|
||||
If you edit the CSS or JavaScript, you will need to run Django's `collectstatic` command in order for your changes to have effect. You can do this by running:
|
||||
``` bash
|
||||
./bw-dev collectstatic
|
||||
```
|
||||
|
||||
### Working with translations and locale files
|
||||
Text in the html files are wrapped in translation tags (`{% trans %}` and `{% blocktrans %}`), and Django generates locale files for all the strings in which you can add translations for the text. You can find existing translations in the `locale/` directory.
|
||||
|
||||
The application's language is set by a request header sent by your browser to the application, so to change the language of the application, you can change the default language requested by your browser.
|
||||
|
||||
#### Adding a locale
|
||||
To start translation into a language which is currently supported, run the django-admin `makemessages` command with the language code for the language you want to add (like `de` for German, or `en-gb` for British English):
|
||||
``` bash
|
||||
./bw-dev makemessages -l <language code>
|
||||
```
|
||||
|
||||
#### Editing a locale
|
||||
When you have a locale file, open the `django.po` in the directory for the language (for example, if you were adding German, `locale/de/LC_MESSAGES/django.po`. All the the text in the application will be shown in paired strings, with `msgid` as the original text, and `msgstr` as the translation (by default, this is set to an empty string, and will display the original text).
|
||||
|
||||
Add your translations to the `msgstr` strings. As the messages in the application are updated, `gettext` will sometimes add best-guess fuzzy matched options for those translations. When a message is marked as fuzzy, it will not be used in the application, so be sure to remove it when you translate that line.
|
||||
|
||||
When you're done, compile the locale by running:
|
||||
|
||||
``` bash
|
||||
./bw-dev compilemessages
|
||||
```
|
||||
|
||||
You can add the `-l <language code>` to only compile one language. When you refresh the application, you should see your translations at work.
|
||||
|
||||
## Installing in Production
|
||||
|
||||
This project is still young and isn't, at the moment, very stable, so please proceed with caution when running in production.
|
||||
|
||||
### Server setup
|
||||
- Get a domain name and set up DNS for your server
|
||||
- Set your server up with appropriate firewalls for running a web application (this instruction set is tested against Ubuntu 20.04)
|
||||
- Set up an email service (such as mailgun) and the appropriate SMTP/DNS settings
|
||||
- Install Docker and docker-compose
|
||||
|
||||
### Install and configure BookWyrm
|
||||
|
||||
The `production` branch of BookWyrm contains a number of tools not on the `main` branch that are suited for running in production, such as `docker-compose` changes to update the default commands or configuration of containers, and individual changes to container config to enable things like SSL or regular backups.
|
||||
|
||||
Instructions for running BookWyrm in production:
|
||||
|
||||
- Get the application code:
|
||||
`git clone git@github.com:mouse-reeve/bookwyrm.git`
|
||||
- Switch to the `production` branch
|
||||
`git checkout production`
|
||||
- Create your environment variables file
|
||||
`cp .env.example .env`
|
||||
- Add your domain, email address, SMTP credentials
|
||||
- Set a secure redis password and secret key
|
||||
- Set a secure database password for postgres
|
||||
- Update your nginx configuration in `nginx/default.conf`
|
||||
- Replace `your-domain.com` with your domain name
|
||||
- Run the application (this should also set up a Certbot ssl cert for your domain) with
|
||||
`docker-compose up --build`, and make sure all the images build successfully
|
||||
- When docker has built successfully, stop the process with `CTRL-C`
|
||||
- Comment out the `command: certonly...` line in `docker-compose.yml`
|
||||
- Run docker-compose in the background with: `docker-compose up -d`
|
||||
- Initialize the database with: `./bw-dev initdb`
|
||||
- Set up schedule backups with cron that runs that `docker-compose exec db pg_dump -U <databasename>` and saves the backup to a safe location
|
||||
- Get the application code:
|
||||
`git clone git@github.com:mouse-reeve/bookwyrm.git`
|
||||
- Switch to the `production` branch
|
||||
`git checkout production`
|
||||
- Create your environment variables file
|
||||
`cp .env.example .env`
|
||||
- Add your domain, email address, SMTP credentials
|
||||
- Set a secure redis password and secret key
|
||||
- Set a secure database password for postgres
|
||||
- Update your nginx configuration in `nginx/default.conf`
|
||||
- Replace `your-domain.com` with your domain name
|
||||
- If you aren't using the `www` subdomain, remove the www.your-domain.com version of the domain from the `server_name` in the first server block in `nginx/default.conf` and remove the `-d www.${DOMAIN}` flag at the end of the `certbot` command in `docker-compose.yml`.
|
||||
- If you are running another web-server on your host machine, you will need to follow the [reverse-proxy instructions](#running-bookwyrm-behind-a-reverse-proxy)
|
||||
- Run the application (this should also set up a Certbot ssl cert for your domain) with
|
||||
`docker-compose up --build`, and make sure all the images build successfully
|
||||
- If you are running other services on your host machine, you may run into errors where services fail when attempting to bind to a port.
|
||||
See the [troubleshooting guide](#port-conflicts) for advice on resolving this.
|
||||
- When docker has built successfully, stop the process with `CTRL-C`
|
||||
- Comment out the `command: certonly...` line in `docker-compose.yml`, and uncomment the following line (`command: renew ...`) so that the certificate will be automatically renewed.
|
||||
- Uncomment the https redirect and `server` block in `nginx/default.conf` (lines 17-48).
|
||||
- Run docker-compose in the background with: `docker-compose up -d`
|
||||
- Initialize the database with: `./bw-dev initdb`
|
||||
|
||||
Congrats! You did it, go to your domain and enjoy the fruits of your labors.
|
||||
|
||||
### Configure your instance
|
||||
- Register a user account in the application UI
|
||||
- Make your account a superuser (warning: do *not* use django's `createsuperuser` command)
|
||||
- On your server, open the django shell
|
||||
`./bw-dev shell`
|
||||
- Load your user and make it a superuser
|
||||
```python
|
||||
from bookwyrm import models
|
||||
user = models.User.objects.get(id=1)
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
user.save()
|
||||
```
|
||||
- Go to the site settings (`/settings/site-settings` on your domain) and configure your instance name, description, code of conduct, and toggle whether registration is open on your instance
|
||||
|
||||
### Backups
|
||||
|
||||
BookWyrm's db service dumps a backup copy of its database to its `/backups` directory daily at midnight UTC.
|
||||
Backups are named `backup__%Y-%m-%d.sql`.
|
||||
|
||||
The db service has an optional script for periodically pruning the backups directory so that all recent daily backups are kept, but for older backups, only weekly or monthly backups are kept.
|
||||
To enable this script:
|
||||
- Uncomment the final line in `postgres-docker/cronfile`
|
||||
- rebuild your instance `docker-compose up --build`
|
||||
|
||||
You can copy backups from the backups volume to your host machine with `docker cp`:
|
||||
- Run `docker-compose ps` to confirm the db service's full name (it's probably `bookwyrm_db_1`.
|
||||
- Run `docker cp <container_name>:/backups <host machine path>`
|
||||
|
||||
### Updating your instance
|
||||
|
||||
When there are changes available in the production branch, you can install and get them running on your instance using the command `./bw-dev update`. This does a number of things:
|
||||
- `git pull` gets the updated code from the git repository. If there are conflicts, you may need to run `git pull` separately and resolve the conflicts before trying the `./bw-dev update` script again.
|
||||
- `docker-compose build` rebuilds the images, which ensures that the correct packages are installed. This step takes a long time and is only needed when the dependencies (including pip `requirements.txt` packages) have changed, so you can comment it out if you want a quicker update path and don't mind un-commenting it as needed.
|
||||
- `docker-compose exec web python manage.py migrate` runs the database migrations in Django
|
||||
- `docker-compose exec web python manage.py collectstatic --no-input` loads any updated static files (such as the JavaScript and CSS)
|
||||
- `docker-compose restart` reloads the docker containers
|
||||
|
||||
### Re-building activity streams
|
||||
|
||||
If something goes awry with user timelines, and you want to re-create them en mass, there's a management command for that:
|
||||
`docker-compose run --rm web python manage.py rebuild_feeds`
|
||||
|
||||
### Port Conflicts
|
||||
|
||||
BookWyrm has multiple services that run on their default ports.
|
||||
This means that, depending on what else you are running on your host machine, you may run into errors when building or running BookWyrm when attempts to bind to those ports fail.
|
||||
|
||||
If this occurs, you will need to change your configuration to run services on different ports.
|
||||
This may require one or more changes the following files:
|
||||
- `docker-compose.yml`
|
||||
- `nginx/default.conf`
|
||||
- `.env` (You create this file yourself during setup)
|
||||
|
||||
E.g., If you need Redis to run on a different port:
|
||||
- In `docker-compose.yml`:
|
||||
- In `services` -> `redis` -> `command`, add `--port YOUR_PORT` to the command
|
||||
- In `services` -> `redis` -> `ports`, change `6379:6379` to your port
|
||||
- In `.env`, update `REDIS_PORT`
|
||||
|
||||
If you are already running a web-server on your machine, you will need to set up a reverse-proxy.
|
||||
|
||||
#### Running BookWyrm Behind a Reverse-Proxy
|
||||
|
||||
If you are running another web-server on your machine, you should have it handle proxying web requests to BookWyrm.
|
||||
|
||||
The default BookWyrm configuration already has an nginx server that proxies requests to the django app that handles SSL and directly serves static files.
|
||||
The static files are stored in a Docker volume that several BookWyrm services access, so it is not recommended to remove this server completely.
|
||||
|
||||
To run BookWyrm behind a reverse-proxy, make the following changes:
|
||||
- In `nginx/default.conf`:
|
||||
- Comment out the two default servers
|
||||
- Uncomment the server labeled Reverse-Proxy server
|
||||
- Replace `your-domain.com` with your domain name
|
||||
- In `docker-compose.yml`:
|
||||
- In `services` -> `nginx` -> `ports`, comment out the default ports and add `- 8001:8001`
|
||||
- In `services` -> `nginx` -> `volumes`, comment out the two volumes that begin `./certbot/`
|
||||
- In `services`, comment out the `certbot` service
|
||||
|
||||
At this point, you can follow, the [setup](#server-setup) instructions as listed.
|
||||
Once docker is running, you can access your BookWyrm instance at `http://localhost:8001` (**NOTE:** your server is not accessible over `https`).
|
||||
|
||||
Steps for setting up a reverse-proxy are server dependent.
|
||||
|
||||
##### Nginx
|
||||
|
||||
Before you can set up nginx, you will need to locate your nginx configuration directory, which is dependent on your platform and how you installed nginx.
|
||||
See [nginx's guide](http://nginx.org/en/docs/beginners_guide.html) for details.
|
||||
|
||||
To set up your server:
|
||||
- In you `nginx.conf` file, ensure that `include servers/*;` isn't commented out.
|
||||
- In your nginx `servers` directory, create a new file named after your domain containing the following information:
|
||||
```nginx
|
||||
server {
|
||||
server_name your-domain.com www.your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8000;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /images/ {
|
||||
proxy_pass http://localhost:8001;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
proxy_pass http://localhost:8001;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
listen [::]:80 ssl;
|
||||
listen 80 ssl;
|
||||
}
|
||||
```
|
||||
- run `sudo certbot run --nginx --email YOUR_EMAIL -d your-domain.com -d www.your-domain.com`
|
||||
- restart nginx
|
||||
|
||||
If everything worked correctly, your BookWyrm instance should now be externally accessible.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
There are three concepts in the book data model:
|
||||
- `Book`, an abstract, high-level concept that could mean either a `Work` or an `Edition`. No data is saved as a `Book`, it serves as shared model for `Work` and `Edition`
|
||||
- `Work`, the theoretical umbrella concept of a book that encompasses every edition of the book, and
|
||||
- `Edition`, a concrete, actually published version of a book
|
||||
|
||||
Whenever a user interacts with a book, they are interacting with a specific edition. Every work has a default edition, but the user can select other editions. Reviews aggregated for all editions of a work when you view an edition's page.
|
||||
## 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).
|
||||
|
|
|
@ -5,11 +5,12 @@ import sys
|
|||
from .base_activity import ActivityEncoder, Signature, naive_parse
|
||||
from .base_activity import Link, Mention
|
||||
from .base_activity import ActivitySerializerError, resolve_remote_id
|
||||
from .image import Image
|
||||
from .image import Document, Image
|
||||
from .note import Note, GeneratedNote, Article, Comment, Quotation
|
||||
from .note import Review, Rating
|
||||
from .note import Tombstone
|
||||
from .ordered_collection import OrderedCollection, OrderedCollectionPage
|
||||
from .ordered_collection import CollectionItem, ListItem, ShelfItem
|
||||
from .ordered_collection import BookList, Shelf
|
||||
from .person import Person, PublicKey
|
||||
from .response import ActivitypubResponse
|
||||
|
|
|
@ -52,10 +52,14 @@ def naive_parse(activity_objects, activity_json, serializer=None):
|
|||
if activity_json.get("publicKeyPem"):
|
||||
# ugh
|
||||
activity_json["type"] = "PublicKey"
|
||||
|
||||
activity_type = activity_json.get("type")
|
||||
try:
|
||||
activity_type = activity_json["type"]
|
||||
serializer = activity_objects[activity_type]
|
||||
except KeyError as e:
|
||||
# we know this exists and that we can't handle it
|
||||
if activity_type in ["Question"]:
|
||||
return None
|
||||
raise ActivitySerializerError(e)
|
||||
|
||||
return serializer(activity_objects=activity_objects, **activity_json)
|
||||
|
@ -111,7 +115,7 @@ class ActivityObject:
|
|||
and hasattr(model, "ignore_activity")
|
||||
and model.ignore_activity(self)
|
||||
):
|
||||
raise ActivitySerializerError()
|
||||
return None
|
||||
|
||||
# check for an existing instance
|
||||
instance = instance or model.find_existing(self.serialize())
|
||||
|
|
|
@ -3,7 +3,7 @@ from dataclasses import dataclass, field
|
|||
from typing import List
|
||||
|
||||
from .base_activity import ActivityObject
|
||||
from .image import Image
|
||||
from .image import Document
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
|
@ -15,6 +15,7 @@ class BookData(ActivityObject):
|
|||
librarythingKey: str = None
|
||||
goodreadsKey: str = None
|
||||
bnfId: str = None
|
||||
lastEditedBy: str = None
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
|
@ -35,7 +36,7 @@ class Book(BookData):
|
|||
firstPublishedDate: str = ""
|
||||
publishedDate: str = ""
|
||||
|
||||
cover: Image = None
|
||||
cover: Document = None
|
||||
type: str = "Book"
|
||||
|
||||
|
||||
|
|
|
@ -4,10 +4,17 @@ from .base_activity import ActivityObject
|
|||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Image(ActivityObject):
|
||||
""" image block """
|
||||
class Document(ActivityObject):
|
||||
"""a document"""
|
||||
|
||||
url: str
|
||||
name: str = ""
|
||||
type: str = "Document"
|
||||
id: str = None
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Image(Document):
|
||||
"""an image"""
|
||||
|
||||
type: str = "Image"
|
||||
|
|
|
@ -4,7 +4,7 @@ from typing import Dict, List
|
|||
from django.apps import apps
|
||||
|
||||
from .base_activity import ActivityObject, Link
|
||||
from .image import Image
|
||||
from .image import Document
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
|
@ -32,7 +32,7 @@ class Note(ActivityObject):
|
|||
inReplyTo: str = ""
|
||||
summary: str = ""
|
||||
tag: List[Link] = field(default_factory=lambda: [])
|
||||
attachment: List[Image] = field(default_factory=lambda: [])
|
||||
attachment: List[Document] = field(default_factory=lambda: [])
|
||||
sensitive: bool = False
|
||||
type: str = "Note"
|
||||
|
||||
|
|
|
@ -50,3 +50,30 @@ class OrderedCollectionPage(ActivityObject):
|
|||
next: str = None
|
||||
prev: str = None
|
||||
type: str = "OrderedCollectionPage"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class CollectionItem(ActivityObject):
|
||||
"""an item in a collection"""
|
||||
|
||||
actor: str
|
||||
type: str = "CollectionItem"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class ListItem(CollectionItem):
|
||||
"""a book on a list"""
|
||||
|
||||
book: str
|
||||
notes: str = None
|
||||
approved: bool = True
|
||||
order: int = None
|
||||
type: str = "ListItem"
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class ShelfItem(CollectionItem):
|
||||
"""a book on a list"""
|
||||
|
||||
book: str
|
||||
type: str = "ShelfItem"
|
||||
|
|
|
@ -23,6 +23,7 @@ class Person(ActivityObject):
|
|||
inbox: str
|
||||
publicKey: PublicKey
|
||||
followers: str = None
|
||||
following: str = None
|
||||
outbox: str = None
|
||||
endpoints: Dict = None
|
||||
name: str = None
|
||||
|
|
|
@ -1,22 +1,24 @@
|
|||
""" undo wrapper activity """
|
||||
""" activities that do things """
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List
|
||||
from django.apps import apps
|
||||
|
||||
from .base_activity import ActivityObject, Signature, resolve_remote_id
|
||||
from .book import Edition
|
||||
from .ordered_collection import CollectionItem
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Verb(ActivityObject):
|
||||
"""generic fields for activities - maybe an unecessary level of
|
||||
abstraction but w/e"""
|
||||
"""generic fields for activities"""
|
||||
|
||||
actor: str
|
||||
object: ActivityObject
|
||||
|
||||
def action(self):
|
||||
""" usually we just want to save, this can be overridden as needed """
|
||||
"""usually we just want to update and save"""
|
||||
# self.object may return None if the object is invalid in an expected way
|
||||
# ie, Question type
|
||||
if self.object:
|
||||
self.object.to_model()
|
||||
|
||||
|
||||
|
@ -24,8 +26,8 @@ class Verb(ActivityObject):
|
|||
class Create(Verb):
|
||||
"""Create activity"""
|
||||
|
||||
to: List
|
||||
cc: List
|
||||
to: List[str]
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
signature: Signature = None
|
||||
type: str = "Create"
|
||||
|
||||
|
@ -34,25 +36,37 @@ class Create(Verb):
|
|||
class Delete(Verb):
|
||||
"""Create activity"""
|
||||
|
||||
to: List
|
||||
cc: List
|
||||
to: List[str]
|
||||
cc: List[str] = field(default_factory=lambda: [])
|
||||
type: str = "Delete"
|
||||
|
||||
def action(self):
|
||||
"""find and delete the activity object"""
|
||||
if not self.object:
|
||||
return
|
||||
|
||||
if isinstance(self.object, str):
|
||||
# Deleted users are passed as strings. Not wild about this fix
|
||||
model = apps.get_model("bookwyrm.User")
|
||||
obj = model.find_existing_by_remote_id(self.object)
|
||||
else:
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
|
||||
if obj:
|
||||
obj.delete()
|
||||
# if we can't find it, we don't need to delete it because we don't have it
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Update(Verb):
|
||||
"""Update activity"""
|
||||
|
||||
to: List
|
||||
to: List[str]
|
||||
type: str = "Update"
|
||||
|
||||
def action(self):
|
||||
"""update a model instance from the dataclass"""
|
||||
if self.object:
|
||||
self.object.to_model(allow_create=False)
|
||||
|
||||
|
||||
|
@ -141,37 +155,28 @@ class Reject(Verb):
|
|||
class Add(Verb):
|
||||
"""Add activity"""
|
||||
|
||||
target: str
|
||||
object: Edition
|
||||
target: ActivityObject
|
||||
object: CollectionItem
|
||||
type: str = "Add"
|
||||
notes: str = None
|
||||
order: int = 0
|
||||
approved: bool = True
|
||||
|
||||
def action(self):
|
||||
""" add obj to collection """
|
||||
target = resolve_remote_id(self.target, refresh=False)
|
||||
# we want to get the related field that isn't the book, this is janky af sorry
|
||||
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][
|
||||
0
|
||||
].related_model
|
||||
self.to_model(model=model)
|
||||
"""figure out the target to assign the item to a collection"""
|
||||
target = resolve_remote_id(self.target)
|
||||
item = self.object.to_model(save=False)
|
||||
setattr(item, item.collection_field, target)
|
||||
item.save()
|
||||
|
||||
|
||||
@dataclass(init=False)
|
||||
class Remove(Verb):
|
||||
class Remove(Add):
|
||||
"""Remove activity"""
|
||||
|
||||
target: ActivityObject
|
||||
type: str = "Remove"
|
||||
|
||||
def action(self):
|
||||
"""find and remove the activity object"""
|
||||
target = resolve_remote_id(self.target, refresh=False)
|
||||
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][
|
||||
0
|
||||
].related_model
|
||||
obj = self.to_model(model=model, save=False, allow_create=False)
|
||||
obj = self.object.to_model(save=False, allow_create=False)
|
||||
if obj:
|
||||
obj.delete()
|
||||
|
||||
|
||||
|
|
|
@ -1,18 +1,13 @@
|
|||
""" access the activity streams stored in redis """
|
||||
from abc import ABC
|
||||
from django.dispatch import receiver
|
||||
from django.db.models import signals, Q
|
||||
import redis
|
||||
|
||||
from bookwyrm import models, settings
|
||||
from bookwyrm import models
|
||||
from bookwyrm.redis_store import RedisStore, r
|
||||
from bookwyrm.views.helpers import privacy_filter
|
||||
|
||||
r = redis.Redis(
|
||||
host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
|
||||
)
|
||||
|
||||
|
||||
class ActivityStream(ABC):
|
||||
class ActivityStream(RedisStore):
|
||||
"""a category of activity stream (like home, local, federated)"""
|
||||
|
||||
def stream_id(self, user):
|
||||
|
@ -23,58 +18,40 @@ class ActivityStream(ABC):
|
|||
"""the redis key for this user's unread count for this stream"""
|
||||
return "{}-unread".format(self.stream_id(user))
|
||||
|
||||
def get_value(self, status): # pylint: disable=no-self-use
|
||||
""" the status id and the rank (ie, published date) """
|
||||
return {status.id: status.published_date.timestamp()}
|
||||
def get_rank(self, obj): # pylint: disable=no-self-use
|
||||
"""statuses are sorted by date published"""
|
||||
return obj.published_date.timestamp()
|
||||
|
||||
def add_status(self, status):
|
||||
"""add a status to users' feeds"""
|
||||
value = self.get_value(status)
|
||||
# we want to do this as a bulk operation, hence "pipeline"
|
||||
pipeline = r.pipeline()
|
||||
for user in self.stream_users(status):
|
||||
# add the status to the feed
|
||||
pipeline.zadd(self.stream_id(user), value)
|
||||
pipeline.zremrangebyrank(
|
||||
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
|
||||
)
|
||||
# the pipeline contains all the add-to-stream activities
|
||||
pipeline = self.add_object_to_related_stores(status, execute=False)
|
||||
|
||||
for user in self.get_audience(status):
|
||||
# add to the unread status count
|
||||
pipeline.incr(self.unread_id(user))
|
||||
# and go!
|
||||
pipeline.execute()
|
||||
|
||||
def remove_status(self, status):
|
||||
""" remove a status from all feeds """
|
||||
pipeline = r.pipeline()
|
||||
for user in self.stream_users(status):
|
||||
pipeline.zrem(self.stream_id(user), -1, status.id)
|
||||
# and go!
|
||||
pipeline.execute()
|
||||
|
||||
def add_user_statuses(self, viewer, user):
|
||||
"""add a user's statuses to another user's feed"""
|
||||
pipeline = r.pipeline()
|
||||
statuses = user.status_set.all()[: settings.MAX_STREAM_LENGTH]
|
||||
for status in statuses:
|
||||
pipeline.zadd(self.stream_id(viewer), self.get_value(status))
|
||||
if statuses:
|
||||
pipeline.zremrangebyrank(
|
||||
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
|
||||
)
|
||||
pipeline.execute()
|
||||
# only add the statuses that the viewer should be able to see (ie, not dms)
|
||||
statuses = privacy_filter(viewer, user.status_set.all())
|
||||
self.bulk_add_objects_to_store(statuses, self.stream_id(viewer))
|
||||
|
||||
def remove_user_statuses(self, viewer, user):
|
||||
"""remove a user's status from another user's feed"""
|
||||
pipeline = r.pipeline()
|
||||
for status in user.status_set.all()[: settings.MAX_STREAM_LENGTH]:
|
||||
pipeline.lrem(self.stream_id(viewer), -1, status.id)
|
||||
pipeline.execute()
|
||||
# remove all so that followers only statuses are removed
|
||||
statuses = user.status_set.all()
|
||||
self.bulk_remove_objects_from_store(statuses, self.stream_id(viewer))
|
||||
|
||||
def get_activity_stream(self, user):
|
||||
""" load the ids for statuses to be displayed """
|
||||
"""load the statuses to be displayed"""
|
||||
# clear unreads for this feed
|
||||
r.set(self.unread_id(user), 0)
|
||||
|
||||
statuses = r.zrevrange(self.stream_id(user), 0, -1)
|
||||
statuses = self.get_store(self.stream_id(user))
|
||||
return (
|
||||
models.Status.objects.select_subclasses()
|
||||
.filter(id__in=statuses)
|
||||
|
@ -85,23 +62,11 @@ class ActivityStream(ABC):
|
|||
"""get the unread status count for this user's feed"""
|
||||
return int(r.get(self.unread_id(user)) or 0)
|
||||
|
||||
def populate_stream(self, user):
|
||||
def populate_streams(self, user):
|
||||
"""go from zero to a timeline"""
|
||||
pipeline = r.pipeline()
|
||||
statuses = self.stream_statuses(user)
|
||||
self.populate_store(self.stream_id(user))
|
||||
|
||||
stream_id = self.stream_id(user)
|
||||
for status in statuses.all()[: settings.MAX_STREAM_LENGTH]:
|
||||
pipeline.zadd(stream_id, self.get_value(status))
|
||||
|
||||
# only trim the stream if statuses were added
|
||||
if statuses.exists():
|
||||
pipeline.zremrangebyrank(
|
||||
self.stream_id(user), 0, -1 * settings.MAX_STREAM_LENGTH
|
||||
)
|
||||
pipeline.execute()
|
||||
|
||||
def stream_users(self, status): # pylint: disable=no-self-use
|
||||
def get_audience(self, status): # pylint: disable=no-self-use
|
||||
"""given a status, what users should see it"""
|
||||
# direct messages don't appeard in feeds, direct comments/reviews/etc do
|
||||
if status.privacy == "direct" and status.status_type == "Note":
|
||||
|
@ -129,7 +94,10 @@ class ActivityStream(ABC):
|
|||
)
|
||||
return audience.distinct()
|
||||
|
||||
def stream_statuses(self, user): # pylint: disable=no-self-use
|
||||
def get_stores_for_object(self, obj):
|
||||
return [self.stream_id(u) for u in self.get_audience(obj)]
|
||||
|
||||
def get_statuses_for_user(self, user): # pylint: disable=no-self-use
|
||||
"""given a user, what statuses should they see on this stream"""
|
||||
return privacy_filter(
|
||||
user,
|
||||
|
@ -137,14 +105,18 @@ class ActivityStream(ABC):
|
|||
privacy_levels=["public", "unlisted", "followers"],
|
||||
)
|
||||
|
||||
def get_objects_for_store(self, store):
|
||||
user = models.User.objects.get(id=store.split("-")[0])
|
||||
return self.get_statuses_for_user(user)
|
||||
|
||||
|
||||
class HomeStream(ActivityStream):
|
||||
"""users you follow"""
|
||||
|
||||
key = "home"
|
||||
|
||||
def stream_users(self, status):
|
||||
audience = super().stream_users(status)
|
||||
def get_audience(self, status):
|
||||
audience = super().get_audience(status)
|
||||
if not audience:
|
||||
return []
|
||||
return audience.filter(
|
||||
|
@ -152,7 +124,7 @@ class HomeStream(ActivityStream):
|
|||
| Q(following=status.user) # if the user is following the author
|
||||
).distinct()
|
||||
|
||||
def stream_statuses(self, user):
|
||||
def get_statuses_for_user(self, user):
|
||||
return privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses(),
|
||||
|
@ -166,13 +138,13 @@ class LocalStream(ActivityStream):
|
|||
|
||||
key = "local"
|
||||
|
||||
def stream_users(self, status):
|
||||
def get_audience(self, status):
|
||||
# this stream wants no part in non-public statuses
|
||||
if status.privacy != "public" or not status.user.local:
|
||||
return []
|
||||
return super().stream_users(status)
|
||||
return super().get_audience(status)
|
||||
|
||||
def stream_statuses(self, user):
|
||||
def get_statuses_for_user(self, user):
|
||||
# all public statuses by a local user
|
||||
return privacy_filter(
|
||||
user,
|
||||
|
@ -186,13 +158,13 @@ class FederatedStream(ActivityStream):
|
|||
|
||||
key = "federated"
|
||||
|
||||
def stream_users(self, status):
|
||||
def get_audience(self, status):
|
||||
# this stream wants no part in non-public statuses
|
||||
if status.privacy != "public":
|
||||
return []
|
||||
return super().stream_users(status)
|
||||
return super().get_audience(status)
|
||||
|
||||
def stream_statuses(self, user):
|
||||
def get_statuses_for_user(self, user):
|
||||
return privacy_filter(
|
||||
user,
|
||||
models.Status.objects.select_subclasses(),
|
||||
|
@ -217,7 +189,7 @@ def add_status_on_create(sender, instance, created, *args, **kwargs):
|
|||
|
||||
if instance.deleted:
|
||||
for stream in streams.values():
|
||||
stream.remove_status(instance)
|
||||
stream.remove_object_from_related_stores(instance)
|
||||
return
|
||||
|
||||
if not created:
|
||||
|
@ -234,7 +206,7 @@ def remove_boost_on_delete(sender, instance, *args, **kwargs):
|
|||
"""boosts are deleted"""
|
||||
# we're only interested in new statuses
|
||||
for stream in streams.values():
|
||||
stream.remove_status(instance)
|
||||
stream.remove_object_from_related_stores(instance)
|
||||
|
||||
|
||||
@receiver(signals.post_save, sender=models.UserFollows)
|
||||
|
@ -294,4 +266,4 @@ def populate_streams_on_account_create(sender, instance, created, *args, **kwarg
|
|||
return
|
||||
|
||||
for stream in streams.values():
|
||||
stream.populate_stream(instance)
|
||||
stream.populate_streams(instance)
|
||||
|
|
|
@ -187,7 +187,11 @@ class AbstractConnector(AbstractMinimalConnector):
|
|||
data = self.get_book_data(remote_id)
|
||||
|
||||
mapped_data = dict_from_mappings(data, self.author_mappings)
|
||||
try:
|
||||
activity = activitypub.Author(**mapped_data)
|
||||
except activitypub.ActivitySerializerError:
|
||||
return None
|
||||
|
||||
# this will dedupe
|
||||
return activity.to_model(model=models.Author)
|
||||
|
||||
|
@ -223,6 +227,12 @@ def dict_from_mappings(data, mappings):
|
|||
|
||||
def get_data(url, params=None):
|
||||
"""wrapper for request.get"""
|
||||
# check if the url is blocked
|
||||
if models.FederatedServer.is_blocked(url):
|
||||
raise ConnectorException(
|
||||
"Attempting to load data from blocked url: {:s}".format(url)
|
||||
)
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
""" interface with whatever connectors the app has """
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
@ -11,6 +12,8 @@ from requests import HTTPError
|
|||
from bookwyrm import models
|
||||
from bookwyrm.tasks import app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectorException(HTTPError):
|
||||
"""when the connector can't do what was asked"""
|
||||
|
@ -37,14 +40,17 @@ def search(query, min_confidence=0.1):
|
|||
else:
|
||||
try:
|
||||
result_set = connector.isbn_search(isbn)
|
||||
except (HTTPError, ConnectorException):
|
||||
pass
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.exception(e)
|
||||
continue
|
||||
|
||||
# if no isbn search or results, we fallback to generic search
|
||||
if result_set in (None, []):
|
||||
try:
|
||||
result_set = connector.search(query, min_confidence=min_confidence)
|
||||
except (HTTPError, ConnectorException):
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
# we don't want *any* error to crash the whole search page
|
||||
logger.exception(e)
|
||||
continue
|
||||
|
||||
# if the search results look the same, ignore them
|
||||
|
@ -61,10 +67,12 @@ def search(query, min_confidence=0.1):
|
|||
return results
|
||||
|
||||
|
||||
def local_search(query, min_confidence=0.1, raw=False):
|
||||
def local_search(query, min_confidence=0.1, raw=False, filters=None):
|
||||
"""only look at local search results"""
|
||||
connector = load_connector(models.Connector.objects.get(local=True))
|
||||
return connector.search(query, min_confidence=min_confidence, raw=raw)
|
||||
return connector.search(
|
||||
query, min_confidence=min_confidence, raw=raw, filters=filters
|
||||
)
|
||||
|
||||
|
||||
def isbn_local_search(query, raw=False):
|
||||
|
|
|
@ -93,7 +93,10 @@ class Connector(AbstractConnector):
|
|||
# this id is "/authors/OL1234567A"
|
||||
author_id = author_blob["key"]
|
||||
url = "%s%s" % (self.base_url, author_id)
|
||||
yield self.get_or_create_author(url)
|
||||
author = self.get_or_create_author(url)
|
||||
if not author:
|
||||
continue
|
||||
yield author
|
||||
|
||||
def get_cover_url(self, cover_blob, size="L"):
|
||||
"""ask openlibrary for the cover"""
|
||||
|
|
|
@ -13,15 +13,16 @@ class Connector(AbstractConnector):
|
|||
"""instantiate a connector"""
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
def search(self, query, min_confidence=0.1, raw=False):
|
||||
def search(self, query, min_confidence=0.1, raw=False, filters=None):
|
||||
"""search your local database"""
|
||||
filters = filters or []
|
||||
if not query:
|
||||
return []
|
||||
# first, try searching unqiue identifiers
|
||||
results = search_identifiers(query)
|
||||
results = search_identifiers(query, *filters)
|
||||
if not results:
|
||||
# then try searching title/author
|
||||
results = search_title_author(query, min_confidence)
|
||||
results = search_title_author(query, min_confidence, *filters)
|
||||
search_results = []
|
||||
for result in results:
|
||||
if raw:
|
||||
|
@ -98,15 +99,15 @@ class Connector(AbstractConnector):
|
|||
pass
|
||||
|
||||
|
||||
def search_identifiers(query):
|
||||
def search_identifiers(query, *filters):
|
||||
"""tries remote_id, isbn; defined as dedupe fields on the model"""
|
||||
filters = [
|
||||
or_filters = [
|
||||
{f.name: query}
|
||||
for f in models.Edition._meta.get_fields()
|
||||
if hasattr(f, "deduplication_field") and f.deduplication_field
|
||||
]
|
||||
results = models.Edition.objects.filter(
|
||||
reduce(operator.or_, (Q(**f) for f in filters))
|
||||
*filters, reduce(operator.or_, (Q(**f) for f in or_filters))
|
||||
).distinct()
|
||||
|
||||
# when there are multiple editions of the same work, pick the default.
|
||||
|
@ -114,7 +115,7 @@ def search_identifiers(query):
|
|||
return results.filter(parent_work__default_edition__id=F("id")) or results
|
||||
|
||||
|
||||
def search_title_author(query, min_confidence):
|
||||
def search_title_author(query, min_confidence, *filters):
|
||||
"""searches for title and author"""
|
||||
vector = (
|
||||
SearchVector("title", weight="A")
|
||||
|
@ -126,7 +127,7 @@ def search_title_author(query, min_confidence):
|
|||
results = (
|
||||
models.Edition.objects.annotate(search=vector)
|
||||
.annotate(rank=SearchRank(vector, query))
|
||||
.filter(rank__gt=min_confidence)
|
||||
.filter(*filters, rank__gt=min_confidence)
|
||||
.order_by("-rank")
|
||||
)
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import datetime
|
|||
from collections import defaultdict
|
||||
|
||||
from django import forms
|
||||
from django.forms import ModelForm, PasswordInput, widgets
|
||||
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 _
|
||||
|
@ -150,12 +150,10 @@ class LimitedEditUserForm(CustomForm):
|
|||
help_texts = {f: None for f in fields}
|
||||
|
||||
|
||||
class TagForm(CustomForm):
|
||||
class UserGroupForm(CustomForm):
|
||||
class Meta:
|
||||
model = models.Tag
|
||||
fields = ["name"]
|
||||
help_texts = {f: None for f in fields}
|
||||
labels = {"name": "Add a tag"}
|
||||
model = models.User
|
||||
fields = ["groups"]
|
||||
|
||||
|
||||
class CoverForm(CustomForm):
|
||||
|
@ -281,3 +279,26 @@ class ReportForm(CustomForm):
|
|||
class Meta:
|
||||
model = models.Report
|
||||
fields = ["user", "reporter", "statuses", "note"]
|
||||
|
||||
|
||||
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")),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -116,6 +116,10 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
|||
read.save()
|
||||
|
||||
if include_reviews and (item.rating or item.review):
|
||||
# we don't know the publication date of the review,
|
||||
# but "now" is a bad guess
|
||||
published_date_guess = item.date_read or item.date_added
|
||||
if item.review:
|
||||
review_title = (
|
||||
"Review of {!r} on {!r}".format(
|
||||
item.book.title,
|
||||
|
@ -124,10 +128,6 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
|||
if item.review
|
||||
else ""
|
||||
)
|
||||
|
||||
# we don't know the publication date of the review,
|
||||
# but "now" is a bad guess
|
||||
published_date_guess = item.date_read or item.date_added
|
||||
models.Review.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
|
@ -137,3 +137,12 @@ def handle_imported_book(source, user, item, include_reviews, privacy):
|
|||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
||||
else:
|
||||
# just a rating
|
||||
models.ReviewRating.objects.create(
|
||||
user=user,
|
||||
book=item.book,
|
||||
rating=item.rating,
|
||||
published_date=published_date_guess,
|
||||
privacy=privacy,
|
||||
)
|
||||
|
|
|
@ -2,7 +2,7 @@ from django.core.management.base import BaseCommand, CommandError
|
|||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
from bookwyrm.models import Connector, SiteSettings, User
|
||||
from bookwyrm.models import Connector, FederatedServer, SiteSettings, User
|
||||
from bookwyrm.settings import DOMAIN
|
||||
|
||||
|
||||
|
@ -119,6 +119,16 @@ def init_connectors():
|
|||
)
|
||||
|
||||
|
||||
def init_federated_servers():
|
||||
"""big no to nazis"""
|
||||
built_in_blocks = ["gab.ai", "gab.com"]
|
||||
for server in built_in_blocks:
|
||||
FederatedServer.objects.create(
|
||||
server_name=server,
|
||||
status="blocked",
|
||||
)
|
||||
|
||||
|
||||
def init_settings():
|
||||
SiteSettings.objects.create()
|
||||
|
||||
|
@ -130,4 +140,5 @@ class Command(BaseCommand):
|
|||
init_groups()
|
||||
init_permissions()
|
||||
init_connectors()
|
||||
init_federated_servers()
|
||||
init_settings()
|
||||
|
|
|
@ -17,7 +17,7 @@ def populate_streams():
|
|||
)
|
||||
for user in users:
|
||||
for stream in activitystreams.streams.values():
|
||||
stream.populate_stream(user)
|
||||
stream.populate_streams(user)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
|
33
bookwyrm/migrations/0062_auto_20210407_1545.py
Normal file
33
bookwyrm/migrations/0062_auto_20210407_1545.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
# Generated by Django 3.1.6 on 2021-04-07 15:45
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0061_auto_20210402_1435"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="series",
|
||||
field=bookwyrm.models.fields.TextField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="subtitle",
|
||||
field=bookwyrm.models.fields.TextField(
|
||||
blank=True, max_length=255, null=True
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="title",
|
||||
field=bookwyrm.models.fields.TextField(max_length=255),
|
||||
),
|
||||
]
|
37
bookwyrm/migrations/0063_auto_20210407_1827.py
Normal file
37
bookwyrm/migrations/0063_auto_20210407_1827.py
Normal file
|
@ -0,0 +1,37 @@
|
|||
# Generated by Django 3.1.6 on 2021-04-07 18:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0062_auto_20210407_1545"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="federatedserver",
|
||||
name="notes",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="federatedserver",
|
||||
name="application_type",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="federatedserver",
|
||||
name="application_version",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="federatedserver",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[("federated", "Federated"), ("blocked", "Blocked")],
|
||||
default="federated",
|
||||
max_length=255,
|
||||
),
|
||||
),
|
||||
]
|
27
bookwyrm/migrations/0063_auto_20210408_1556.py
Normal file
27
bookwyrm/migrations/0063_auto_20210408_1556.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 3.1.6 on 2021-04-08 15:56
|
||||
|
||||
import bookwyrm.models.fields
|
||||
import django.contrib.postgres.fields.citext
|
||||
import django.contrib.postgres.operations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0062_auto_20210407_1545"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
django.contrib.postgres.operations.CITextExtension(),
|
||||
migrations.AlterField(
|
||||
model_name="user",
|
||||
name="localname",
|
||||
field=django.contrib.postgres.fields.citext.CICharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
validators=[bookwyrm.models.fields.validate_localname],
|
||||
),
|
||||
),
|
||||
]
|
28
bookwyrm/migrations/0064_auto_20210408_2208.py
Normal file
28
bookwyrm/migrations/0064_auto_20210408_2208.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
# Generated by Django 3.1.6 on 2021-04-08 22:08
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0063_auto_20210408_1556"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="listitem",
|
||||
name="book_list",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="bookwyrm.list"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="shelfbook",
|
||||
name="shelf",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.PROTECT, to="bookwyrm.shelf"
|
||||
),
|
||||
),
|
||||
]
|
13
bookwyrm/migrations/0064_merge_20210410_1633.py
Normal file
13
bookwyrm/migrations/0064_merge_20210410_1633.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.1.8 on 2021-04-10 16:33
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0063_auto_20210408_1556"),
|
||||
("bookwyrm", "0063_auto_20210407_1827"),
|
||||
]
|
||||
|
||||
operations = []
|
13
bookwyrm/migrations/0065_merge_20210411_1702.py
Normal file
13
bookwyrm/migrations/0065_merge_20210411_1702.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
# Generated by Django 3.1.8 on 2021-04-11 17:02
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0064_auto_20210408_2208"),
|
||||
("bookwyrm", "0064_merge_20210410_1633"),
|
||||
]
|
||||
|
||||
operations = []
|
27
bookwyrm/migrations/0066_user_deactivation_reason.py
Normal file
27
bookwyrm/migrations/0066_user_deactivation_reason.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 3.1.8 on 2021-04-12 15:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0065_merge_20210411_1702"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="deactivation_reason",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("self_deletion", "Self Deletion"),
|
||||
("moderator_deletion", "Moderator Deletion"),
|
||||
("domain_block", "Domain Block"),
|
||||
],
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
30
bookwyrm/migrations/0067_denullify_list_item_order.py
Normal file
30
bookwyrm/migrations/0067_denullify_list_item_order.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def forwards_func(apps, schema_editor):
|
||||
# Set all values for ListItem.order
|
||||
BookList = apps.get_model("bookwyrm", "List")
|
||||
db_alias = schema_editor.connection.alias
|
||||
for book_list in BookList.objects.using(db_alias).all():
|
||||
for i, item in enumerate(book_list.listitem_set.order_by("id"), 1):
|
||||
item.order = i
|
||||
item.save()
|
||||
|
||||
|
||||
def reverse_func(apps, schema_editor):
|
||||
# null all values for ListItem.order
|
||||
BookList = apps.get_model("bookwyrm", "List")
|
||||
db_alias = schema_editor.connection.alias
|
||||
for book_list in BookList.objects.using(db_alias).all():
|
||||
for item in book_list.listitem_set.order_by("id"):
|
||||
item.order = None
|
||||
item.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0066_user_deactivation_reason"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(forwards_func, reverse_func)]
|
23
bookwyrm/migrations/0068_ordering_for_list_items.py
Normal file
23
bookwyrm/migrations/0068_ordering_for_list_items.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 3.1.6 on 2021-04-08 16:15
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0067_denullify_list_item_order"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="listitem",
|
||||
name="order",
|
||||
field=bookwyrm.models.fields.IntegerField(),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="listitem",
|
||||
unique_together={("order", "book_list"), ("book", "book_list")},
|
||||
),
|
||||
]
|
34
bookwyrm/migrations/0069_auto_20210422_1604.py
Normal file
34
bookwyrm/migrations/0069_auto_20210422_1604.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 3.1.8 on 2021-04-22 16:04
|
||||
|
||||
import bookwyrm.models.fields
|
||||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0068_ordering_for_list_items"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="author",
|
||||
name="last_edited_by",
|
||||
field=bookwyrm.models.fields.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="last_edited_by",
|
||||
field=bookwyrm.models.fields.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.PROTECT,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
]
|
35
bookwyrm/migrations/0070_auto_20210423_0121.py
Normal file
35
bookwyrm/migrations/0070_auto_20210423_0121.py
Normal file
|
@ -0,0 +1,35 @@
|
|||
# Generated by Django 3.1.8 on 2021-04-23 01:21
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookwyrm", "0069_auto_20210422_1604"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="usertag",
|
||||
unique_together=None,
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="usertag",
|
||||
name="book",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="usertag",
|
||||
name="tag",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="usertag",
|
||||
name="user",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="Tag",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="UserTag",
|
||||
),
|
||||
]
|
|
@ -17,8 +17,6 @@ from .favorite import Favorite
|
|||
from .notification import Notification
|
||||
from .readthrough import ReadThrough, ProgressUpdate, ProgressMode
|
||||
|
||||
from .tag import Tag, UserTag
|
||||
|
||||
from .user import User, KeyPair, AnnualGoal
|
||||
from .relationship import UserFollows, UserFollowRequest, UserBlocks
|
||||
from .report import Report, ReportComment
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
""" activitypub model functionality """
|
||||
from base64 import b64encode
|
||||
from collections import namedtuple
|
||||
from functools import reduce
|
||||
import json
|
||||
import operator
|
||||
|
@ -25,6 +26,15 @@ from bookwyrm.models.fields import ImageField, ManyToManyField
|
|||
logger = logging.getLogger(__name__)
|
||||
# I tried to separate these classes into mutliple files but I kept getting
|
||||
# circular import errors so I gave up. I'm sure it could be done though!
|
||||
|
||||
PropertyField = namedtuple("PropertyField", ("set_activity_from_field"))
|
||||
|
||||
|
||||
def set_activity_from_property_field(activity, obj, field):
|
||||
"""assign a model property value to the activity json"""
|
||||
activity[field[1]] = getattr(obj, field[0])
|
||||
|
||||
|
||||
class ActivitypubMixin:
|
||||
"""add this mixin for models that are AP serializable"""
|
||||
|
||||
|
@ -52,6 +62,12 @@ class ActivitypubMixin:
|
|||
self.activity_fields = (
|
||||
self.image_fields + self.many_to_many_fields + self.simple_fields
|
||||
)
|
||||
if hasattr(self, "property_fields"):
|
||||
self.activity_fields += [
|
||||
# pylint: disable=cell-var-from-loop
|
||||
PropertyField(lambda a, o: set_activity_from_property_field(a, o, f))
|
||||
for f in self.property_fields
|
||||
]
|
||||
|
||||
# these are separate to avoid infinite recursion issues
|
||||
self.deserialize_reverse_fields = (
|
||||
|
@ -132,14 +148,18 @@ class ActivitypubMixin:
|
|||
mentions = self.recipients if hasattr(self, "recipients") else []
|
||||
|
||||
# we always send activities to explicitly mentioned users' inboxes
|
||||
recipients = [u.inbox for u in mentions or []]
|
||||
recipients = [u.inbox for u in mentions or [] if not u.local]
|
||||
|
||||
# unless it's a dm, all the followers should receive the activity
|
||||
if privacy != "direct":
|
||||
# we will send this out to a subset of all remote users
|
||||
queryset = user_model.objects.filter(
|
||||
queryset = (
|
||||
user_model.viewer_aware_objects(user)
|
||||
.filter(
|
||||
local=False,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
# filter users first by whether they're using the desired software
|
||||
# this lets us send book updates only to other bw servers
|
||||
if software:
|
||||
|
@ -159,7 +179,7 @@ class ActivitypubMixin:
|
|||
"inbox", flat=True
|
||||
)
|
||||
recipients += list(shared_inboxes) + list(inboxes)
|
||||
return recipients
|
||||
return list(set(recipients))
|
||||
|
||||
def to_activity_dataclass(self):
|
||||
"""convert from a model to an activity"""
|
||||
|
@ -177,14 +197,16 @@ class ObjectMixin(ActivitypubMixin):
|
|||
def save(self, *args, created=None, **kwargs):
|
||||
"""broadcast created/updated/deleted objects as appropriate"""
|
||||
broadcast = kwargs.get("broadcast", True)
|
||||
# this bonus kwarg woul cause an error in the base save method
|
||||
# this bonus kwarg would cause an error in the base save method
|
||||
if "broadcast" in kwargs:
|
||||
del kwargs["broadcast"]
|
||||
|
||||
created = created or not bool(self.id)
|
||||
# first off, we want to save normally no matter what
|
||||
super().save(*args, **kwargs)
|
||||
if not broadcast:
|
||||
if not broadcast or (
|
||||
hasattr(self, "status_type") and self.status_type == "Announce"
|
||||
):
|
||||
return
|
||||
|
||||
# this will work for objects owned by a user (lists, shelves)
|
||||
|
@ -341,49 +363,66 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
|
|||
class CollectionItemMixin(ActivitypubMixin):
|
||||
"""for items that are part of an (Ordered)Collection"""
|
||||
|
||||
activity_serializer = activitypub.Add
|
||||
object_field = collection_field = None
|
||||
activity_serializer = activitypub.CollectionItem
|
||||
|
||||
def broadcast(self, activity, sender, software="bookwyrm"):
|
||||
"""only send book collection updates to other bookwyrm instances"""
|
||||
super().broadcast(activity, sender, software=software)
|
||||
|
||||
@property
|
||||
def privacy(self):
|
||||
"""inherit the privacy of the list, or direct if pending"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
if self.approved:
|
||||
return collection_field.privacy
|
||||
return "direct"
|
||||
|
||||
@property
|
||||
def recipients(self):
|
||||
"""the owner of the list is a direct recipient"""
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
if collection_field.user.local:
|
||||
# don't broadcast to yourself
|
||||
return []
|
||||
return [collection_field.user]
|
||||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
"""broadcast updated"""
|
||||
created = not bool(self.id)
|
||||
# first off, we want to save normally no matter what
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# these shouldn't be edited, only created and deleted
|
||||
if not broadcast or not created or not self.user.local:
|
||||
# list items can be updateda, normally you would only broadcast on created
|
||||
if not broadcast or not self.user.local:
|
||||
return
|
||||
|
||||
# adding an obj to the collection
|
||||
activity = self.to_add_activity()
|
||||
activity = self.to_add_activity(self.user)
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
def delete(self, *args, broadcast=True, **kwargs):
|
||||
"""broadcast a remove activity"""
|
||||
activity = self.to_remove_activity()
|
||||
activity = self.to_remove_activity(self.user)
|
||||
super().delete(*args, **kwargs)
|
||||
if self.user.local:
|
||||
if self.user.local and broadcast:
|
||||
self.broadcast(activity, self.user)
|
||||
|
||||
def to_add_activity(self):
|
||||
def to_add_activity(self, user):
|
||||
"""AP for shelving a book"""
|
||||
object_field = getattr(self, self.object_field)
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Add(
|
||||
id=self.remote_id,
|
||||
actor=self.user.remote_id,
|
||||
object=object_field,
|
||||
id="{:s}#add".format(collection_field.remote_id),
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
).serialize()
|
||||
|
||||
def to_remove_activity(self):
|
||||
def to_remove_activity(self, user):
|
||||
"""AP for un-shelving a book"""
|
||||
object_field = getattr(self, self.object_field)
|
||||
collection_field = getattr(self, self.collection_field)
|
||||
return activitypub.Remove(
|
||||
id=self.remote_id,
|
||||
actor=self.user.remote_id,
|
||||
object=object_field,
|
||||
id="{:s}#remove".format(collection_field.remote_id),
|
||||
actor=user.remote_id,
|
||||
object=self.to_activity_dataclass(),
|
||||
target=collection_field.remote_id,
|
||||
).serialize()
|
||||
|
||||
|
@ -430,7 +469,7 @@ def generate_activity(obj):
|
|||
) in obj.serialize_reverse_fields:
|
||||
related_field = getattr(obj, model_field_name)
|
||||
activity[activity_field_name] = unfurl_related_field(
|
||||
related_field, sort_field
|
||||
related_field, sort_field=sort_field
|
||||
)
|
||||
|
||||
if not activity.get("id"):
|
||||
|
@ -440,7 +479,7 @@ def generate_activity(obj):
|
|||
|
||||
def unfurl_related_field(related_field, sort_field=None):
|
||||
"""load reverse lookups (like public key owner or Status attachment"""
|
||||
if hasattr(related_field, "all"):
|
||||
if sort_field and hasattr(related_field, "all"):
|
||||
return [
|
||||
unfurl_related_field(i) for i in related_field.order_by(sort_field).all()
|
||||
]
|
||||
|
@ -498,7 +537,7 @@ def to_ordered_collection_page(
|
|||
"""serialize and pagiante a queryset"""
|
||||
paginated = Paginator(queryset, PAGE_LENGTH)
|
||||
|
||||
activity_page = paginated.page(page)
|
||||
activity_page = paginated.get_page(page)
|
||||
if id_only:
|
||||
items = [s.remote_id for s in activity_page.object_list]
|
||||
else:
|
||||
|
|
|
@ -33,4 +33,4 @@ class Image(Attachment):
|
|||
)
|
||||
caption = fields.TextField(null=True, blank=True, activitypub_field="name")
|
||||
|
||||
activity_serializer = activitypub.Image
|
||||
activity_serializer = activitypub.Document
|
||||
|
|
|
@ -31,6 +31,36 @@ class BookWyrmModel(models.Model):
|
|||
"""how to link to this object in the local app"""
|
||||
return self.get_remote_id().replace("https://%s" % DOMAIN, "")
|
||||
|
||||
def visible_to_user(self, viewer):
|
||||
"""is a user authorized to view an object?"""
|
||||
# make sure this is an object with privacy owned by a user
|
||||
if not hasattr(self, "user") or not hasattr(self, "privacy"):
|
||||
return None
|
||||
|
||||
# viewer can't see it if the object's owner blocked them
|
||||
if viewer in self.user.blocks.all():
|
||||
return False
|
||||
|
||||
# you can see your own posts and any public or unlisted posts
|
||||
if viewer == self.user or self.privacy in ["public", "unlisted"]:
|
||||
return True
|
||||
|
||||
# you can see the followers only posts of people you follow
|
||||
if (
|
||||
self.privacy == "followers"
|
||||
and self.user.followers.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
|
||||
# you can see dms you are tagged in
|
||||
if hasattr(self, "mention_users"):
|
||||
if (
|
||||
self.privacy == "direct"
|
||||
and self.mention_users.filter(id=viewer.id).first()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@receiver(models.signals.post_save)
|
||||
# pylint: disable=unused-argument
|
||||
|
|
|
@ -32,7 +32,11 @@ class BookDataModel(ObjectMixin, BookWyrmModel):
|
|||
max_length=255, blank=True, null=True, deduplication_field=True
|
||||
)
|
||||
|
||||
last_edited_by = models.ForeignKey("User", on_delete=models.PROTECT, null=True)
|
||||
last_edited_by = fields.ForeignKey(
|
||||
"User",
|
||||
on_delete=models.PROTECT,
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
"""can't initialize this model, that wouldn't make sense"""
|
||||
|
@ -59,14 +63,14 @@ class Book(BookDataModel):
|
|||
connector = models.ForeignKey("Connector", on_delete=models.PROTECT, null=True)
|
||||
|
||||
# book/work metadata
|
||||
title = fields.CharField(max_length=255)
|
||||
title = fields.TextField(max_length=255)
|
||||
sort_title = fields.CharField(max_length=255, blank=True, null=True)
|
||||
subtitle = fields.CharField(max_length=255, blank=True, null=True)
|
||||
subtitle = fields.TextField(max_length=255, blank=True, null=True)
|
||||
description = fields.HtmlField(blank=True, null=True)
|
||||
languages = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, default=list
|
||||
)
|
||||
series = fields.CharField(max_length=255, blank=True, null=True)
|
||||
series = fields.TextField(max_length=255, blank=True, null=True)
|
||||
series_number = fields.CharField(max_length=255, blank=True, null=True)
|
||||
subjects = fields.ArrayField(
|
||||
models.CharField(max_length=255), blank=True, null=True, default=list
|
||||
|
@ -169,7 +173,7 @@ class Work(OrderedCollectionPageMixin, Book):
|
|||
return self.to_ordered_collection(
|
||||
self.editions.order_by("-edition_rank").all(),
|
||||
remote_id="%s/editions" % self.remote_id,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Work
|
||||
|
|
|
@ -1,17 +1,51 @@
|
|||
""" connections to external ActivityPub servers """
|
||||
from urllib.parse import urlparse
|
||||
from django.db import models
|
||||
from .base_model import BookWyrmModel
|
||||
|
||||
FederationStatus = models.TextChoices(
|
||||
"Status",
|
||||
[
|
||||
"federated",
|
||||
"blocked",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class FederatedServer(BookWyrmModel):
|
||||
"""store which servers we federate with"""
|
||||
|
||||
server_name = models.CharField(max_length=255, unique=True)
|
||||
# federated, blocked, whatever else
|
||||
status = models.CharField(max_length=255, default="federated")
|
||||
status = models.CharField(
|
||||
max_length=255, default="federated", choices=FederationStatus.choices
|
||||
)
|
||||
# is it mastodon, bookwyrm, etc
|
||||
application_type = models.CharField(max_length=255, null=True)
|
||||
application_version = models.CharField(max_length=255, null=True)
|
||||
application_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
application_version = models.CharField(max_length=255, null=True, blank=True)
|
||||
notes = models.TextField(null=True, blank=True)
|
||||
|
||||
def block(self):
|
||||
"""block a server"""
|
||||
self.status = "blocked"
|
||||
self.save()
|
||||
|
||||
# TODO: blocked servers
|
||||
# deactivate all associated users
|
||||
self.user_set.filter(is_active=True).update(
|
||||
is_active=False, deactivation_reason="domain_block"
|
||||
)
|
||||
|
||||
def unblock(self):
|
||||
"""unblock a server"""
|
||||
self.status = "federated"
|
||||
self.save()
|
||||
|
||||
self.user_set.filter(deactivation_reason="domain_block").update(
|
||||
is_active=True, deactivation_reason=None
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def is_blocked(cls, url):
|
||||
"""look up if a domain is blocked"""
|
||||
url = urlparse(url)
|
||||
domain = url.netloc
|
||||
return cls.objects.filter(server_name=domain, status="blocked").exists()
|
||||
|
|
|
@ -275,9 +275,12 @@ class ManyToManyField(ActivitypubFieldMixin, models.ManyToManyField):
|
|||
return [i.remote_id for i in value.all()]
|
||||
|
||||
def field_from_activity(self, value):
|
||||
items = []
|
||||
if value is None or value is MISSING:
|
||||
return []
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
# If this is a link, we currently aren't doing anything with it
|
||||
return None
|
||||
items = []
|
||||
for remote_id in value:
|
||||
try:
|
||||
validate_remote_id(remote_id)
|
||||
|
@ -336,7 +339,7 @@ def image_serializer(value, alt):
|
|||
else:
|
||||
return None
|
||||
url = "https://%s%s" % (DOMAIN, url)
|
||||
return activitypub.Image(url=url, name=alt)
|
||||
return activitypub.Document(url=url, name=alt)
|
||||
|
||||
|
||||
class ImageField(ActivitypubFieldMixin, models.ImageField):
|
||||
|
|
|
@ -47,7 +47,7 @@ class List(OrderedCollectionMixin, BookWyrmModel):
|
|||
@property
|
||||
def collection_queryset(self):
|
||||
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||
return self.books.filter(listitem__approved=True).all().order_by("listitem")
|
||||
return self.books.filter(listitem__approved=True).order_by("listitem")
|
||||
|
||||
class Meta:
|
||||
"""default sorting"""
|
||||
|
@ -59,21 +59,18 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
"""ok"""
|
||||
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="object"
|
||||
)
|
||||
book_list = fields.ForeignKey(
|
||||
"List", on_delete=models.CASCADE, activitypub_field="target"
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="book"
|
||||
)
|
||||
book_list = models.ForeignKey("List", on_delete=models.CASCADE)
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||
)
|
||||
notes = fields.TextField(blank=True, null=True)
|
||||
approved = models.BooleanField(default=True)
|
||||
order = fields.IntegerField(blank=True, null=True)
|
||||
order = fields.IntegerField()
|
||||
endorsement = models.ManyToManyField("User", related_name="endorsers")
|
||||
|
||||
activity_serializer = activitypub.Add
|
||||
object_field = "book"
|
||||
activity_serializer = activitypub.ListItem
|
||||
collection_field = "book_list"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
@ -96,7 +93,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
|
|||
)
|
||||
|
||||
class Meta:
|
||||
""" an opinionated constraint! you can't put a book on a list twice """
|
||||
|
||||
unique_together = ("book", "book_list")
|
||||
# A book may only be placed into a list once, and each order in the list may be used only
|
||||
# once
|
||||
unique_together = (("book", "book_list"), ("order", "book_list"))
|
||||
ordering = ("-created_date",)
|
||||
|
|
|
@ -50,11 +50,10 @@ class UserRelationship(BookWyrmModel):
|
|||
),
|
||||
]
|
||||
|
||||
def get_remote_id(self, status=None): # pylint: disable=arguments-differ
|
||||
def get_remote_id(self):
|
||||
"""use shelf identifier in remote_id"""
|
||||
status = status or "follows"
|
||||
base_path = self.user_subject.remote_id
|
||||
return "%s#%s/%d" % (base_path, status, self.id)
|
||||
return "%s#follows/%d" % (base_path, self.id)
|
||||
|
||||
|
||||
class UserFollows(ActivityMixin, UserRelationship):
|
||||
|
@ -102,12 +101,15 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
|
||||
def save(self, *args, broadcast=True, **kwargs):
|
||||
"""make sure the follow or block relationship doesn't already exist"""
|
||||
# don't create a request if a follow already exists
|
||||
# if there's a request for a follow that already exists, accept it
|
||||
# without changing the local database state
|
||||
if UserFollows.objects.filter(
|
||||
user_subject=self.user_subject,
|
||||
user_object=self.user_object,
|
||||
).exists():
|
||||
raise IntegrityError()
|
||||
self.accept(broadcast_only=True)
|
||||
return
|
||||
|
||||
# blocking in either direction is a no-go
|
||||
if UserBlocks.objects.filter(
|
||||
Q(
|
||||
|
@ -138,16 +140,25 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
notification_type=notification_type,
|
||||
)
|
||||
|
||||
def accept(self):
|
||||
def get_accept_reject_id(self, status):
|
||||
"""get id for sending an accept or reject of a local user"""
|
||||
|
||||
base_path = self.user_object.remote_id
|
||||
return "%s#%s/%d" % (base_path, status, self.id or 0)
|
||||
|
||||
def accept(self, broadcast_only=False):
|
||||
"""turn this request into the real deal"""
|
||||
user = self.user_object
|
||||
if not self.user_subject.local:
|
||||
activity = activitypub.Accept(
|
||||
id=self.get_remote_id(status="accepts"),
|
||||
id=self.get_accept_reject_id(status="accepts"),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
self.broadcast(activity, user)
|
||||
if broadcast_only:
|
||||
return
|
||||
|
||||
with transaction.atomic():
|
||||
UserFollows.from_request(self)
|
||||
self.delete()
|
||||
|
@ -156,7 +167,7 @@ class UserFollowRequest(ActivitypubMixin, UserRelationship):
|
|||
"""generate a Reject for this follow request"""
|
||||
if self.user_object.local:
|
||||
activity = activitypub.Reject(
|
||||
id=self.get_remote_id(status="rejects"),
|
||||
id=self.get_accept_reject_id(status="rejects"),
|
||||
actor=self.user_object.remote_id,
|
||||
object=self.to_activity(),
|
||||
).serialize()
|
||||
|
|
|
@ -48,7 +48,7 @@ class Shelf(OrderedCollectionMixin, BookWyrmModel):
|
|||
@property
|
||||
def collection_queryset(self):
|
||||
"""list of books for this shelf, overrides OrderedCollectionMixin"""
|
||||
return self.books.all().order_by("shelfbook")
|
||||
return self.books.order_by("shelfbook")
|
||||
|
||||
def get_remote_id(self):
|
||||
"""shelf identifier instead of id"""
|
||||
|
@ -66,17 +66,14 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
|
|||
"""many to many join table for books and shelves"""
|
||||
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="object"
|
||||
)
|
||||
shelf = fields.ForeignKey(
|
||||
"Shelf", on_delete=models.PROTECT, activitypub_field="target"
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="book"
|
||||
)
|
||||
shelf = models.ForeignKey("Shelf", on_delete=models.PROTECT)
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||
)
|
||||
|
||||
activity_serializer = activitypub.Add
|
||||
object_field = "book"
|
||||
activity_serializer = activitypub.ShelfItem
|
||||
collection_field = "shelf"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
|
|
@ -351,6 +351,16 @@ class Boost(ActivityMixin, Status):
|
|||
|
||||
def save(self, *args, **kwargs):
|
||||
"""save and notify"""
|
||||
# This constraint can't work as it would cross tables.
|
||||
# class Meta:
|
||||
# unique_together = ('user', 'boosted_status')
|
||||
if (
|
||||
Boost.objects.filter(boosted_status=self.boosted_status, user=self.user)
|
||||
.exclude(id=self.id)
|
||||
.exists()
|
||||
):
|
||||
return
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
if not self.boosted_status.user.local or self.boosted_status.user == self.user:
|
||||
return
|
||||
|
|
|
@ -1,63 +0,0 @@
|
|||
""" models for storing different kinds of Activities """
|
||||
import urllib.parse
|
||||
|
||||
from django.apps import apps
|
||||
from django.db import models
|
||||
|
||||
from bookwyrm import activitypub
|
||||
from bookwyrm.settings import DOMAIN
|
||||
from .activitypub_mixin import CollectionItemMixin, OrderedCollectionMixin
|
||||
from .base_model import BookWyrmModel
|
||||
from . import fields
|
||||
|
||||
|
||||
class Tag(OrderedCollectionMixin, BookWyrmModel):
|
||||
""" freeform tags for books """
|
||||
|
||||
name = fields.CharField(max_length=100, unique=True)
|
||||
identifier = models.CharField(max_length=100)
|
||||
|
||||
@property
|
||||
def books(self):
|
||||
""" count of books associated with this tag """
|
||||
edition_model = apps.get_model("bookwyrm.Edition", require_ready=True)
|
||||
return (
|
||||
edition_model.objects.filter(usertag__tag__identifier=self.identifier)
|
||||
.order_by("-created_date")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
collection_queryset = books
|
||||
|
||||
def get_remote_id(self):
|
||||
""" tag should use identifier not id in remote_id """
|
||||
base_path = "https://%s" % DOMAIN
|
||||
return "%s/tag/%s" % (base_path, self.identifier)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" create a url-safe lookup key for the tag """
|
||||
if not self.id:
|
||||
# add identifiers to new tags
|
||||
self.identifier = urllib.parse.quote_plus(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class UserTag(CollectionItemMixin, BookWyrmModel):
|
||||
""" an instance of a tag on a book by a user """
|
||||
|
||||
user = fields.ForeignKey(
|
||||
"User", on_delete=models.PROTECT, activitypub_field="actor"
|
||||
)
|
||||
book = fields.ForeignKey(
|
||||
"Edition", on_delete=models.PROTECT, activitypub_field="object"
|
||||
)
|
||||
tag = fields.ForeignKey("Tag", on_delete=models.PROTECT, activitypub_field="target")
|
||||
|
||||
activity_serializer = activitypub.Add
|
||||
object_field = "book"
|
||||
collection_field = "tag"
|
||||
|
||||
class Meta:
|
||||
""" unqiueness constraint """
|
||||
|
||||
unique_together = ("user", "book", "tag")
|
|
@ -4,6 +4,7 @@ from urllib.parse import urlparse
|
|||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth.models import AbstractUser, Group
|
||||
from django.contrib.postgres.fields import CICharField
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
@ -23,6 +24,16 @@ from .federated_server import FederatedServer
|
|||
from . import fields, Review
|
||||
|
||||
|
||||
DeactivationReason = models.TextChoices(
|
||||
"DeactivationReason",
|
||||
[
|
||||
"self_deletion",
|
||||
"moderator_deletion",
|
||||
"domain_block",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class User(OrderedCollectionPageMixin, AbstractUser):
|
||||
"""a user who wants to read books"""
|
||||
|
||||
|
@ -54,7 +65,7 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
summary = fields.HtmlField(null=True, blank=True)
|
||||
local = models.BooleanField(default=False)
|
||||
bookwyrm_user = fields.BooleanField(default=True)
|
||||
localname = models.CharField(
|
||||
localname = CICharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
unique=True,
|
||||
|
@ -110,8 +121,17 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
default=str(pytz.utc),
|
||||
max_length=255,
|
||||
)
|
||||
deactivation_reason = models.CharField(
|
||||
max_length=255, choices=DeactivationReason.choices, null=True, blank=True
|
||||
)
|
||||
|
||||
name_field = "username"
|
||||
property_fields = [("following_link", "following")]
|
||||
|
||||
@property
|
||||
def following_link(self):
|
||||
"""just how to find out the following info"""
|
||||
return "{:s}/following".format(self.remote_id)
|
||||
|
||||
@property
|
||||
def alt_text(self):
|
||||
|
@ -125,13 +145,18 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
return self.name
|
||||
return self.localname or self.username
|
||||
|
||||
@property
|
||||
def deleted(self):
|
||||
"""for consistent naming"""
|
||||
return not self.is_active
|
||||
|
||||
activity_serializer = activitypub.Person
|
||||
|
||||
@classmethod
|
||||
def viewer_aware_objects(cls, viewer):
|
||||
"""the user queryset filtered for the context of the logged in user"""
|
||||
queryset = cls.objects.filter(is_active=True)
|
||||
if viewer.is_authenticated:
|
||||
if viewer and viewer.is_authenticated:
|
||||
queryset = queryset.exclude(blocks=viewer)
|
||||
return queryset
|
||||
|
||||
|
@ -185,6 +210,9 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
def to_activity(self, **kwargs):
|
||||
"""override default AP serializer to add context object
|
||||
idk if this is the best way to go about this"""
|
||||
if not self.is_active:
|
||||
return self.remote_id
|
||||
|
||||
activity_object = super().to_activity(**kwargs)
|
||||
activity_object["@context"] = [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
|
@ -263,6 +291,12 @@ class User(OrderedCollectionPageMixin, AbstractUser):
|
|||
editable=False,
|
||||
).save(broadcast=False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
"""deactivate rather than delete a user"""
|
||||
self.is_active = False
|
||||
# skip the logic in this class's save()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def local_path(self):
|
||||
"""this model doesn't inherit bookwyrm model, so here we are"""
|
||||
|
|
86
bookwyrm/redis_store.py
Normal file
86
bookwyrm/redis_store.py
Normal file
|
@ -0,0 +1,86 @@
|
|||
""" access the activity stores stored in redis """
|
||||
from abc import ABC, abstractmethod
|
||||
import redis
|
||||
|
||||
from bookwyrm import settings
|
||||
|
||||
r = redis.Redis(
|
||||
host=settings.REDIS_ACTIVITY_HOST, port=settings.REDIS_ACTIVITY_PORT, db=0
|
||||
)
|
||||
|
||||
|
||||
class RedisStore(ABC):
|
||||
"""sets of ranked, related objects, like statuses for a user's feed"""
|
||||
|
||||
max_length = settings.MAX_STREAM_LENGTH
|
||||
|
||||
def get_value(self, obj):
|
||||
"""the object and rank"""
|
||||
return {obj.id: self.get_rank(obj)}
|
||||
|
||||
def add_object_to_related_stores(self, obj, execute=True):
|
||||
"""add an object to all suitable stores"""
|
||||
value = self.get_value(obj)
|
||||
# we want to do this as a bulk operation, hence "pipeline"
|
||||
pipeline = r.pipeline()
|
||||
for store in self.get_stores_for_object(obj):
|
||||
# add the status to the feed
|
||||
pipeline.zadd(store, value)
|
||||
# trim the store
|
||||
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
|
||||
if not execute:
|
||||
return pipeline
|
||||
# and go!
|
||||
return pipeline.execute()
|
||||
|
||||
def remove_object_from_related_stores(self, obj):
|
||||
"""remove an object from all stores"""
|
||||
pipeline = r.pipeline()
|
||||
for store in self.get_stores_for_object(obj):
|
||||
pipeline.zrem(store, -1, obj.id)
|
||||
pipeline.execute()
|
||||
|
||||
def bulk_add_objects_to_store(self, objs, store):
|
||||
"""add a list of objects to a given store"""
|
||||
pipeline = r.pipeline()
|
||||
for obj in objs[: self.max_length]:
|
||||
pipeline.zadd(store, self.get_value(obj))
|
||||
if objs:
|
||||
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
|
||||
pipeline.execute()
|
||||
|
||||
def bulk_remove_objects_from_store(self, objs, store):
|
||||
"""remoev a list of objects from a given store"""
|
||||
pipeline = r.pipeline()
|
||||
for obj in objs[: self.max_length]:
|
||||
pipeline.zrem(store, -1, obj.id)
|
||||
pipeline.execute()
|
||||
|
||||
def get_store(self, store): # pylint: disable=no-self-use
|
||||
"""load the values in a store"""
|
||||
return r.zrevrange(store, 0, -1)
|
||||
|
||||
def populate_store(self, store):
|
||||
"""go from zero to a store"""
|
||||
pipeline = r.pipeline()
|
||||
queryset = self.get_objects_for_store(store)
|
||||
|
||||
for obj in queryset[: self.max_length]:
|
||||
pipeline.zadd(store, self.get_value(obj))
|
||||
|
||||
# only trim the store if objects were added
|
||||
if queryset.exists():
|
||||
pipeline.zremrangebyrank(store, 0, -1 * self.max_length)
|
||||
pipeline.execute()
|
||||
|
||||
@abstractmethod
|
||||
def get_objects_for_store(self, store):
|
||||
"""a queryset of what should go in a store, used for populating it"""
|
||||
|
||||
@abstractmethod
|
||||
def get_stores_for_object(self, obj):
|
||||
"""the stores that an object belongs in"""
|
||||
|
||||
@abstractmethod
|
||||
def get_rank(self, obj):
|
||||
"""how to rank an object"""
|
|
@ -24,7 +24,8 @@ EMAIL_HOST = env("EMAIL_HOST")
|
|||
EMAIL_PORT = env("EMAIL_PORT", 587)
|
||||
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
|
||||
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
|
||||
EMAIL_USE_TLS = env("EMAIL_USE_TLS", True)
|
||||
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", True)
|
||||
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
|
||||
DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
|
@ -33,6 +34,8 @@ LOCALE_PATHS = [
|
|||
os.path.join(BASE_DIR, "locale"),
|
||||
]
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
|
||||
|
||||
|
@ -97,6 +100,7 @@ WSGI_APPLICATION = "bookwyrm.wsgi.application"
|
|||
# redis/activity streams settings
|
||||
REDIS_ACTIVITY_HOST = env("REDIS_ACTIVITY_HOST", "localhost")
|
||||
REDIS_ACTIVITY_PORT = env("REDIS_ACTIVITY_PORT", 6379)
|
||||
REDIS_ACTIVITY_PASSWORD = env("REDIS_ACTIVITY_PASSWORD", None)
|
||||
|
||||
MAX_STREAM_LENGTH = int(env("MAX_STREAM_LENGTH", 200))
|
||||
STREAMS = ["home", "local", "federated"]
|
||||
|
@ -151,7 +155,7 @@ LANGUAGES = [
|
|||
("de-de", _("German")),
|
||||
("es", _("Spanish")),
|
||||
("fr-fr", _("French")),
|
||||
("zh-cn", _("Simplified Chinese")),
|
||||
("zh-hans", _("Simplified Chinese")),
|
||||
]
|
||||
|
||||
|
||||
|
@ -165,7 +169,7 @@ USE_TZ = True
|
|||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/2.0/howto/static-files/
|
||||
# https://docs.djangoproject.com/en/3.1/howto/static-files/
|
||||
|
||||
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
STATIC_URL = "/static/"
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 20%;
|
||||
}
|
||||
|
||||
/* --- --- */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.image {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
@ -25,17 +29,42 @@ html {
|
|||
min-width: 75% !important;
|
||||
}
|
||||
|
||||
/* --- "disabled" for non-buttons --- */
|
||||
.is-disabled {
|
||||
background-color: #dbdbdb;
|
||||
border-color: #dbdbdb;
|
||||
box-shadow: none;
|
||||
color: #7a7a7a;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
/** Utilities not covered by Bulma
|
||||
******************************************************************************/
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
/* --- SHELVING --- */
|
||||
.m-0-mobile {
|
||||
margin: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.button.is-transparent {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.card.is-stretchable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card.is-stretchable .card-content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
/** Shelving
|
||||
******************************************************************************/
|
||||
|
||||
/** @todo Replace icons with SVG symbols.
|
||||
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
||||
|
@ -45,7 +74,9 @@ html {
|
|||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
/* --- TOGGLES --- */
|
||||
/** Toggles
|
||||
******************************************************************************/
|
||||
|
||||
.toggle-button[aria-pressed=true],
|
||||
.toggle-button[aria-pressed=true]:hover {
|
||||
background-color: hsl(171, 100%, 41%);
|
||||
|
@ -57,12 +88,8 @@ html {
|
|||
display: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hidden.transition-y,
|
||||
.hidden.transition-x {
|
||||
.transition-x.is-hidden,
|
||||
.transition-y.is-hidden {
|
||||
display: block !important;
|
||||
visibility: hidden !important;
|
||||
height: 0;
|
||||
|
@ -71,16 +98,18 @@ html {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.transition-x,
|
||||
.transition-y {
|
||||
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
.transition-x {
|
||||
transition-property: width, margin-left, margin-right, padding-left, padding-right;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: ease;
|
||||
}
|
||||
|
||||
.transition-y {
|
||||
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
@ -90,6 +119,13 @@ html {
|
|||
}
|
||||
}
|
||||
|
||||
/** Stars
|
||||
******************************************************************************/
|
||||
|
||||
.stars {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/** Stars in a review form
|
||||
*
|
||||
* Specificity makes hovering taking over checked inputs.
|
||||
|
@ -121,7 +157,9 @@ html {
|
|||
content: '\e9d7';
|
||||
}
|
||||
|
||||
/* --- BOOK COVERS --- */
|
||||
/** Book covers
|
||||
******************************************************************************/
|
||||
|
||||
.cover-container {
|
||||
height: 250px;
|
||||
width: max-content;
|
||||
|
@ -186,7 +224,9 @@ html {
|
|||
padding: 0.1em;
|
||||
}
|
||||
|
||||
/* --- AVATAR --- */
|
||||
/** Avatars
|
||||
******************************************************************************/
|
||||
|
||||
.avatar {
|
||||
vertical-align: middle;
|
||||
display: inline;
|
||||
|
@ -202,25 +242,107 @@ html {
|
|||
min-height: 96px;
|
||||
}
|
||||
|
||||
/* --- QUOTES --- */
|
||||
.quote blockquote {
|
||||
/** 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 <a…class="author">Author</a></p>
|
||||
* </div>
|
||||
* ```
|
||||
******************************************************************************/
|
||||
|
||||
.quote > blockquote {
|
||||
position: relative;
|
||||
padding-left: 2em;
|
||||
}
|
||||
|
||||
.quote blockquote::before,
|
||||
.quote blockquote::after {
|
||||
.quote > blockquote::before,
|
||||
.quote > blockquote::after {
|
||||
font-family: 'icomoon';
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.quote blockquote::before {
|
||||
.quote > blockquote::before {
|
||||
content: "\e906";
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.quote blockquote::after {
|
||||
.quote > blockquote::after {
|
||||
content: "\e905";
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* States
|
||||
******************************************************************************/
|
||||
|
||||
/* "disabled" for non-buttons */
|
||||
|
||||
.is-disabled {
|
||||
background-color: #dbdbdb;
|
||||
border-color: #dbdbdb;
|
||||
box-shadow: none;
|
||||
color: #7a7a7a;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 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 #dbdbdb;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,10 +1,13 @@
|
|||
|
||||
/** @todo Replace icons with SVG symbols.
|
||||
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
|
||||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('fonts/icomoon.eot?n5x55');
|
||||
src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?n5x55') format('truetype'),
|
||||
url('fonts/icomoon.woff?n5x55') format('woff'),
|
||||
url('fonts/icomoon.svg?n5x55#icomoon') format('svg');
|
||||
src: url('../fonts/icomoon.eot?n5x55');
|
||||
src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
|
||||
url('../fonts/icomoon.ttf?n5x55') format('truetype'),
|
||||
url('../fonts/icomoon.woff?n5x55') format('woff'),
|
||||
url('../fonts/icomoon.svg?n5x55#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
285
bookwyrm/static/js/bookwyrm.js
Normal file
285
bookwyrm/static/js/bookwyrm.js
Normal file
|
@ -0,0 +1,285 @@
|
|||
/* exported BookWyrm */
|
||||
/* globals TabGroup */
|
||||
|
||||
let BookWyrm = new class {
|
||||
constructor() {
|
||||
this.initOnDOMLoaded();
|
||||
this.initReccuringTasks();
|
||||
this.initEventListeners();
|
||||
}
|
||||
|
||||
initEventListeners() {
|
||||
document.querySelectorAll('[data-controls]')
|
||||
.forEach(button => button.addEventListener(
|
||||
'click',
|
||||
this.toggleAction.bind(this))
|
||||
);
|
||||
|
||||
document.querySelectorAll('.interaction')
|
||||
.forEach(button => button.addEventListener(
|
||||
'submit',
|
||||
this.interact.bind(this))
|
||||
);
|
||||
|
||||
document.querySelectorAll('.hidden-form input')
|
||||
.forEach(button => button.addEventListener(
|
||||
'change',
|
||||
this.revealForm.bind(this))
|
||||
);
|
||||
|
||||
document.querySelectorAll('[data-back]')
|
||||
.forEach(button => button.addEventListener(
|
||||
'click',
|
||||
this.back)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute code once the DOM is loaded.
|
||||
*/
|
||||
initOnDOMLoaded() {
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.tab-group')
|
||||
.forEach(tabs => new TabGroup(tabs));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute recurring tasks.
|
||||
*/
|
||||
initReccuringTasks() {
|
||||
// Polling
|
||||
document.querySelectorAll('[data-poll]')
|
||||
.forEach(liveArea => this.polling(liveArea));
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back in browser history.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
back(event) {
|
||||
event.preventDefault();
|
||||
history.back();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a counter with recurring requests to the API
|
||||
* The delay is slightly randomized and increased on each cycle.
|
||||
*
|
||||
* @param {Object} counter - DOM node
|
||||
* @param {int} delay - frequency for polling in ms
|
||||
* @return {undefined}
|
||||
*/
|
||||
polling(counter, delay) {
|
||||
const bookwyrm = this;
|
||||
|
||||
delay = delay || 10000;
|
||||
delay += (Math.random() * 1000);
|
||||
|
||||
setTimeout(function() {
|
||||
fetch('/api/updates/' + counter.dataset.poll)
|
||||
.then(response => response.json())
|
||||
.then(data => bookwyrm.updateCountElement(counter, data));
|
||||
|
||||
bookwyrm.polling(counter, delay * 1.25);
|
||||
}, delay, counter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a counter.
|
||||
*
|
||||
* @param {object} counter - DOM node
|
||||
* @param {object} data - json formatted response from a fetch
|
||||
* @return {undefined}
|
||||
*/
|
||||
updateCountElement(counter, data) {
|
||||
const currentCount = counter.innerText;
|
||||
const count = data.count;
|
||||
|
||||
if (count != currentCount) {
|
||||
this.addRemoveClass(counter.closest('[data-poll-wrapper]'), 'is-hidden', count < 1);
|
||||
counter.innerText = count;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle form.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
revealForm(event) {
|
||||
let trigger = event.currentTarget;
|
||||
let hidden = trigger.closest('.hidden-form').querySelectorAll('.is-hidden')[0];
|
||||
|
||||
this.addRemoveClass(hidden, 'is-hidden', !hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute actions on targets based on triggers.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleAction(event) {
|
||||
let trigger = event.currentTarget;
|
||||
let pressed = trigger.getAttribute('aria-pressed') === 'false';
|
||||
let targetId = trigger.dataset.controls;
|
||||
|
||||
// Toggle pressed status on all triggers controlling the same target.
|
||||
document.querySelectorAll('[data-controls="' + targetId + '"]')
|
||||
.forEach(otherTrigger => otherTrigger.setAttribute(
|
||||
'aria-pressed',
|
||||
otherTrigger.getAttribute('aria-pressed') === 'false'
|
||||
));
|
||||
|
||||
// @todo Find a better way to handle the exception.
|
||||
if (targetId && ! trigger.classList.contains('pulldown-menu')) {
|
||||
let target = document.getElementById(targetId);
|
||||
|
||||
this.addRemoveClass(target, 'is-hidden', !pressed);
|
||||
this.addRemoveClass(target, 'is-active', pressed);
|
||||
}
|
||||
|
||||
// Show/hide pulldown-menus.
|
||||
if (trigger.classList.contains('pulldown-menu')) {
|
||||
this.toggleMenu(trigger, targetId);
|
||||
}
|
||||
|
||||
// Show/hide container.
|
||||
let container = document.getElementById('hide-' + targetId);
|
||||
|
||||
if (container) {
|
||||
this.toggleContainer(container, pressed);
|
||||
}
|
||||
|
||||
// Check checkbox, if appropriate.
|
||||
let checkbox = trigger.dataset.controlsCheckbox;
|
||||
|
||||
if (checkbox) {
|
||||
this.toggleCheckbox(checkbox, pressed);
|
||||
}
|
||||
|
||||
// Set focus, if appropriate.
|
||||
let focus = trigger.dataset.focusTarget;
|
||||
|
||||
if (focus) {
|
||||
this.toggleFocus(focus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide menus.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleMenu(trigger, targetId) {
|
||||
let expanded = trigger.getAttribute('aria-expanded') == 'false';
|
||||
|
||||
trigger.setAttribute('aria-expanded', expanded);
|
||||
|
||||
if (targetId) {
|
||||
let target = document.getElementById(targetId);
|
||||
|
||||
this.addRemoveClass(target, 'is-active', expanded);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide generic containers.
|
||||
*
|
||||
* @param {object} container - DOM node
|
||||
* @param {boolean} pressed - Is the trigger pressed?
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleContainer(container, pressed) {
|
||||
this.addRemoveClass(container, 'is-hidden', pressed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check or uncheck a checbox.
|
||||
*
|
||||
* @param {object} checkbox - DOM node
|
||||
* @param {boolean} pressed - Is the trigger pressed?
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleCheckbox(checkbox, pressed) {
|
||||
document.getElementById(checkbox).checked = !!pressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the focus to an element.
|
||||
* Only move the focus based on user interactions.
|
||||
*
|
||||
* @param {string} nodeId - ID of the DOM node to focus (button, link…)
|
||||
* @return {undefined}
|
||||
*/
|
||||
toggleFocus(nodeId) {
|
||||
let node = document.getElementById(nodeId);
|
||||
|
||||
node.focus();
|
||||
|
||||
setTimeout(function() {
|
||||
node.selectionStart = node.selectionEnd = 10000;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a request and update the UI accordingly.
|
||||
* This function is used for boosts, favourites, follows and unfollows.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
interact(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const bookwyrm = this;
|
||||
const form = event.currentTarget;
|
||||
const relatedforms = document.querySelectorAll(`.${form.dataset.id}`);
|
||||
|
||||
// Toggle class on all related forms.
|
||||
relatedforms.forEach(relatedForm => bookwyrm.addRemoveClass(
|
||||
relatedForm,
|
||||
'is-hidden',
|
||||
relatedForm.className.indexOf('is-hidden') == -1
|
||||
));
|
||||
|
||||
this.ajaxPost(form).catch(error => {
|
||||
// @todo Display a notification in the UI instead.
|
||||
console.warn('Request failed:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a form using POST.
|
||||
*
|
||||
* @param {object} form - Form to be submitted
|
||||
* @return {Promise}
|
||||
*/
|
||||
ajaxPost(form) {
|
||||
return fetch(form.action, {
|
||||
method : "POST",
|
||||
body: new FormData(form)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or remove a class based on a boolean condition.
|
||||
*
|
||||
* @param {object} node - DOM node to change class on
|
||||
* @param {string} classname - Name of the class
|
||||
* @param {boolean} add - Add?
|
||||
* @return {undefined}
|
||||
*/
|
||||
addRemoveClass(node, classname, add) {
|
||||
if (add) {
|
||||
node.classList.add(classname);
|
||||
} else {
|
||||
node.classList.remove(classname);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,11 +1,21 @@
|
|||
/* exported toggleAllCheckboxes */
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Toggle all descendant checkboxes of a target.
|
||||
*
|
||||
* Use `data-target="ID_OF_TARGET"` on the node being listened to.
|
||||
* Use `data-target="ID_OF_TARGET"` on the node on which the event is listened
|
||||
* to (checkbox, button, link…), where_ID_OF_TARGET_ should be the ID of an
|
||||
* ancestor for the checkboxes.
|
||||
*
|
||||
* @param {Event} event - change Event
|
||||
* @example
|
||||
* <input
|
||||
* type="checkbox"
|
||||
* data-action="toggle-all"
|
||||
* data-target="failed-imports"
|
||||
* >
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
function toggleAllCheckboxes(event) {
|
||||
|
@ -13,5 +23,12 @@ function toggleAllCheckboxes(event) {
|
|||
|
||||
document
|
||||
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
|
||||
.forEach(checkbox => {checkbox.checked = mainCheckbox.checked;});
|
||||
.forEach(checkbox => checkbox.checked = mainCheckbox.checked);
|
||||
}
|
||||
|
||||
document
|
||||
.querySelectorAll('[data-action="toggle-all"]')
|
||||
.forEach(input => {
|
||||
input.addEventListener('change', toggleAllCheckboxes);
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -1,20 +1,43 @@
|
|||
/* exported updateDisplay */
|
||||
/* globals addRemoveClass */
|
||||
/* exported LocalStorageTools */
|
||||
/* globals BookWyrm */
|
||||
|
||||
// set javascript listeners
|
||||
function updateDisplay(e) {
|
||||
let LocalStorageTools = new class {
|
||||
constructor() {
|
||||
document.querySelectorAll('[data-hide]')
|
||||
.forEach(t => this.setDisplay(t));
|
||||
|
||||
document.querySelectorAll('.set-display')
|
||||
.forEach(t => t.addEventListener('click', this.updateDisplay.bind(this)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update localStorage, then display content based on keys in localStorage.
|
||||
*
|
||||
* @param {Event} event
|
||||
* @return {undefined}
|
||||
*/
|
||||
updateDisplay(event) {
|
||||
// used in set reading goal
|
||||
var key = e.target.getAttribute('data-id');
|
||||
var value = e.target.getAttribute('data-value');
|
||||
let key = event.target.dataset.id;
|
||||
let value = event.target.dataset.value;
|
||||
|
||||
window.localStorage.setItem(key, value);
|
||||
|
||||
document.querySelectorAll('[data-hide="' + key + '"]')
|
||||
.forEach(t => setDisplay(t));
|
||||
.forEach(node => this.setDisplay(node));
|
||||
}
|
||||
|
||||
function setDisplay(el) {
|
||||
/**
|
||||
* Toggle display of a DOM node based on its value in the localStorage.
|
||||
*
|
||||
* @param {object} node - DOM node to toggle.
|
||||
* @return {undefined}
|
||||
*/
|
||||
setDisplay(node) {
|
||||
// used in set reading goal
|
||||
var key = el.getAttribute('data-hide');
|
||||
var value = window.localStorage.getItem(key);
|
||||
addRemoveClass(el, 'hidden', value);
|
||||
let key = node.dataset.hide;
|
||||
let value = window.localStorage.getItem(key);
|
||||
|
||||
BookWyrm.addRemoveClass(node, 'is-hidden', value);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,169 +0,0 @@
|
|||
/* globals setDisplay TabGroup toggleAllCheckboxes updateDisplay */
|
||||
|
||||
// set up javascript listeners
|
||||
window.onload = function() {
|
||||
// buttons that display or hide content
|
||||
document.querySelectorAll('[data-controls]')
|
||||
.forEach(t => t.onclick = toggleAction);
|
||||
|
||||
// javascript interactions (boost/fav)
|
||||
Array.from(document.getElementsByClassName('interaction'))
|
||||
.forEach(t => t.onsubmit = interact);
|
||||
|
||||
// handle aria settings on menus
|
||||
Array.from(document.getElementsByClassName('pulldown-menu'))
|
||||
.forEach(t => t.onclick = toggleMenu);
|
||||
|
||||
// hidden submit button in a form
|
||||
document.querySelectorAll('.hidden-form input')
|
||||
.forEach(t => t.onchange = revealForm);
|
||||
|
||||
// polling
|
||||
document.querySelectorAll('[data-poll]')
|
||||
.forEach(el => polling(el));
|
||||
|
||||
// browser back behavior
|
||||
document.querySelectorAll('[data-back]')
|
||||
.forEach(t => t.onclick = back);
|
||||
|
||||
Array.from(document.getElementsByClassName('tab-group'))
|
||||
.forEach(t => new TabGroup(t));
|
||||
|
||||
// display based on localstorage vars
|
||||
document.querySelectorAll('[data-hide]')
|
||||
.forEach(t => setDisplay(t));
|
||||
|
||||
// update localstorage
|
||||
Array.from(document.getElementsByClassName('set-display'))
|
||||
.forEach(t => t.onclick = updateDisplay);
|
||||
|
||||
// Toggle all checkboxes.
|
||||
document
|
||||
.querySelectorAll('[data-action="toggle-all"]')
|
||||
.forEach(input => {
|
||||
input.addEventListener('change', toggleAllCheckboxes);
|
||||
});
|
||||
};
|
||||
|
||||
function back(e) {
|
||||
e.preventDefault();
|
||||
history.back();
|
||||
}
|
||||
|
||||
function polling(el, delay) {
|
||||
delay = delay || 10000;
|
||||
delay += (Math.random() * 1000);
|
||||
setTimeout(function() {
|
||||
fetch('/api/updates/' + el.getAttribute('data-poll'))
|
||||
.then(response => response.json())
|
||||
.then(data => updateCountElement(el, data));
|
||||
polling(el, delay * 1.25);
|
||||
}, delay, el);
|
||||
}
|
||||
|
||||
function updateCountElement(el, data) {
|
||||
const currentCount = el.innerText;
|
||||
const count = data.count;
|
||||
if (count != currentCount) {
|
||||
addRemoveClass(el.closest('[data-poll-wrapper]'), 'hidden', count < 1);
|
||||
el.innerText = count;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function revealForm(e) {
|
||||
var hidden = e.currentTarget.closest('.hidden-form').getElementsByClassName('hidden')[0];
|
||||
if (hidden) {
|
||||
removeClass(hidden, 'hidden');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function toggleAction(e) {
|
||||
var el = e.currentTarget;
|
||||
var pressed = el.getAttribute('aria-pressed') == 'false';
|
||||
|
||||
var targetId = el.getAttribute('data-controls');
|
||||
document.querySelectorAll('[data-controls="' + targetId + '"]')
|
||||
.forEach(t => t.setAttribute('aria-pressed', (t.getAttribute('aria-pressed') == 'false')));
|
||||
|
||||
if (targetId) {
|
||||
var target = document.getElementById(targetId);
|
||||
addRemoveClass(target, 'hidden', !pressed);
|
||||
addRemoveClass(target, 'is-active', pressed);
|
||||
}
|
||||
|
||||
// show/hide container
|
||||
var container = document.getElementById('hide-' + targetId);
|
||||
if (container) {
|
||||
addRemoveClass(container, 'hidden', pressed);
|
||||
}
|
||||
|
||||
// set checkbox, if appropriate
|
||||
var checkbox = el.getAttribute('data-controls-checkbox');
|
||||
if (checkbox) {
|
||||
document.getElementById(checkbox).checked = !!pressed;
|
||||
}
|
||||
|
||||
// set focus, if appropriate
|
||||
var focus = el.getAttribute('data-focus-target');
|
||||
if (focus) {
|
||||
var focusEl = document.getElementById(focus);
|
||||
focusEl.focus();
|
||||
setTimeout(function(){ focusEl.selectionStart = focusEl.selectionEnd = 10000; }, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function interact(e) {
|
||||
e.preventDefault();
|
||||
ajaxPost(e.target);
|
||||
var identifier = e.target.getAttribute('data-id');
|
||||
Array.from(document.getElementsByClassName(identifier))
|
||||
.forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1));
|
||||
}
|
||||
|
||||
function toggleMenu(e) {
|
||||
var el = e.currentTarget;
|
||||
var expanded = el.getAttribute('aria-expanded') == 'false';
|
||||
el.setAttribute('aria-expanded', expanded);
|
||||
var targetId = el.getAttribute('data-controls');
|
||||
if (targetId) {
|
||||
var target = document.getElementById(targetId);
|
||||
addRemoveClass(target, 'is-active', expanded);
|
||||
}
|
||||
}
|
||||
|
||||
function ajaxPost(form) {
|
||||
fetch(form.action, {
|
||||
method : "POST",
|
||||
body: new FormData(form)
|
||||
});
|
||||
}
|
||||
|
||||
function addRemoveClass(el, classname, bool) {
|
||||
if (bool) {
|
||||
addClass(el, classname);
|
||||
} else {
|
||||
removeClass(el, classname);
|
||||
}
|
||||
}
|
||||
|
||||
function addClass(el, classname) {
|
||||
var classes = el.className.split(' ');
|
||||
if (classes.indexOf(classname) > -1) {
|
||||
return;
|
||||
}
|
||||
el.className = classes.concat(classname).join(' ');
|
||||
}
|
||||
|
||||
function removeClass(el, className) {
|
||||
var classes = [];
|
||||
if (el.className) {
|
||||
classes = el.className.split(' ');
|
||||
}
|
||||
const idx = classes.indexOf(className);
|
||||
if (idx > -1) {
|
||||
classes.splice(idx, 1);
|
||||
}
|
||||
el.className = classes.join(' ');
|
||||
}
|
|
@ -6,14 +6,26 @@
|
|||
{% block title %}{{ book.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
{% with user_authenticated=request.user.is_authenticated can_edit_book=perms.bookwyrm.edit_book %}
|
||||
<div class="block" itemscope itemtype="https://schema.org/Book">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">
|
||||
<span itemprop="name">
|
||||
{{ book.title }}{% if book.subtitle %}:
|
||||
<small>{{ book.subtitle }}</small>{% endif %}
|
||||
<small>{{ book.subtitle }}</small>
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
{% if book.series %}
|
||||
<small class="has-text-grey-dark">({{ book.series }}{% if book.series_number %} #{{ book.series_number }}{% endif %})</small><br>
|
||||
<meta itemprop="isPartOf" content="{{ book.series }}">
|
||||
<meta itemprop="volumeNumber" content="{{ book.series_number }}">
|
||||
|
||||
<small class="has-text-grey-dark">
|
||||
({{ book.series }}
|
||||
{% if book.series_number %} #{{ book.series_number }}{% endif %})
|
||||
</small>
|
||||
<br>
|
||||
{% endif %}
|
||||
</h1>
|
||||
{% if book.authors %}
|
||||
|
@ -23,7 +35,7 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %}
|
||||
{% if user_authenticated and can_edit_book %}
|
||||
<div class="column is-narrow">
|
||||
<a href="{{ book.id }}/edit">
|
||||
<span class="icon icon-pencil" title="{% trans "Edit Book" %}">
|
||||
|
@ -44,7 +56,7 @@
|
|||
{% include 'snippets/shelve_button/shelve_button.html' %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_authenticated and not book.cover %}
|
||||
{% if user_authenticated and not book.cover %}
|
||||
<div class="block">
|
||||
{% trans "Add cover" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="add-cover" controls_uid=book.id focus="modal-title-add-cover" class="is-small" %}
|
||||
|
@ -55,31 +67,16 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="content is-clipped">
|
||||
<dl>
|
||||
{% if book.isbn_13 %}
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||
<dt>{% trans "ISBN:" %}</dt>
|
||||
<dd>{{ book.isbn_13 }}</dd>
|
||||
<section class="is-clipped">
|
||||
{% with book=book %}
|
||||
<div class="content">
|
||||
{% include 'book/publisher_info.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.oclc_number %}
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||
<dt>{% trans "OCLC Number:" %}</dt>
|
||||
<dd>{{ book.oclc_number }}</dd>
|
||||
<div class="my-3">
|
||||
{% include 'book/book_identifiers.html' %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.asin %}
|
||||
<div class="is-flex is-justify-content-space-between is-align-items-center">
|
||||
<dt>{% trans "ASIN:" %}</dt>
|
||||
<dd>{{ book.asin }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% include 'book/publisher_info.html' with book=book %}
|
||||
{% endwith %}
|
||||
|
||||
{% if book.openlibrary_key %}
|
||||
<p><a href="https://openlibrary.org/books/{{ book.openlibrary_key }}" target="_blank" rel="noopener">{% trans "View on OpenLibrary" %}</a></p>
|
||||
|
@ -89,18 +86,35 @@
|
|||
|
||||
<div class="column is-three-fifths">
|
||||
<div class="block">
|
||||
<h3 class="field is-grouped">
|
||||
<h3
|
||||
class="field is-grouped"
|
||||
itemprop="aggregateRating"
|
||||
itemscope
|
||||
itemtype="https://schema.org/AggregateRating"
|
||||
>
|
||||
<meta itemprop="ratingValue" content="{{ rating|floatformat }}">
|
||||
{# @todo Is it possible to not hard-code the value? #}
|
||||
<meta itemprop="bestRating" content="5">
|
||||
<meta itemprop="reviewCount" content="{{ review_count }}">
|
||||
|
||||
{% include 'snippets/stars.html' with rating=rating %}
|
||||
{% blocktrans count counter=review_count %}({{ review_count }} review){% plural %}({{ review_count }} reviews){% endblocktrans %}
|
||||
|
||||
{% blocktrans count counter=review_count trimmed %}
|
||||
({{ review_count }} review)
|
||||
{% plural %}
|
||||
({{ review_count }} reviews)
|
||||
{% endblocktrans %}
|
||||
</h3>
|
||||
|
||||
{% include 'snippets/trimmed_text.html' with full=book|book_description %}
|
||||
{% with full=book|book_description itemprop='abstract' %}
|
||||
{% include 'snippets/trimmed_text.html' %}
|
||||
{% endwith %}
|
||||
|
||||
{% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %}
|
||||
{% if user_authenticated and can_edit_book and not book|book_description %}
|
||||
{% trans 'Add Description' as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %}
|
||||
|
||||
<div class="box hidden" id="add-description-{{ book.id }}">
|
||||
<div class="box is-hidden" id="add-description-{{ book.id }}">
|
||||
<form name="add-description" method="POST" action="/add-description/{{ book.id }}">
|
||||
{% csrf_token %}
|
||||
<p class="fields is-grouped">
|
||||
|
@ -138,7 +152,7 @@
|
|||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if request.user.is_authenticated %}
|
||||
{% if user_authenticated %}
|
||||
<section class="block">
|
||||
<header class="columns">
|
||||
<h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2>
|
||||
|
@ -150,7 +164,7 @@
|
|||
{% if not readthroughs.exists %}
|
||||
<p>{% trans "You don't have any reading activity for this book." %}</p>
|
||||
{% endif %}
|
||||
<section class="hidden box" id="add-readthrough">
|
||||
<section class="is-hidden box" id="add-readthrough">
|
||||
<form name="add-readthrough" action="/create-readthrough" method="post">
|
||||
{% include 'snippets/readthrough_form.html' with readthrough=None %}
|
||||
<div class="field is-grouped">
|
||||
|
@ -178,9 +192,10 @@
|
|||
{% if book.subjects %}
|
||||
<section class="content block">
|
||||
<h2 class="title is-5">{% trans "Subjects" %}</h2>
|
||||
|
||||
<ul>
|
||||
{% for subject in book.subjects %}
|
||||
<li>{{ subject }}</li>
|
||||
<li itemprop="about">{{ subject }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
|
@ -229,26 +244,66 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block" id="reviews">
|
||||
{% for review in reviews %}
|
||||
<div class="block">
|
||||
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %}
|
||||
{% if request.user.is_authenticated %}
|
||||
<nav class="tabs">
|
||||
<ul>
|
||||
{% url 'book' book.id as tab_url %}
|
||||
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
|
||||
<a href="{{ tab_url }}">{% trans "Reviews" %} ({{ review_count }})</a>
|
||||
</li>
|
||||
{% if user_statuses.review_count %}
|
||||
{% url 'book-user-statuses' book.id 'review' as tab_url %}
|
||||
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
|
||||
<a href="{{ tab_url }}">{% trans "Your reviews" %} ({{ user_statuses.review_count }})</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if user_statuses.comment_count %}
|
||||
{% url 'book-user-statuses' book.id 'comment' as tab_url %}
|
||||
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
|
||||
<a href="{{ tab_url }}">{% trans "Your comments" %} ({{ user_statuses.comment_count }})</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if user_statuses.quotation_count %}
|
||||
{% url 'book-user-statuses' book.id 'quote' as tab_url %}
|
||||
<li {% if tab_url == request.path %}class="is-active"{% endif %}>
|
||||
<a href="{{ tab_url }}">{% trans "Your quotes" %} ({{ user_statuses.quotation_count }})</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% for review in statuses %}
|
||||
<div
|
||||
class="block"
|
||||
itemprop="review"
|
||||
itemscope
|
||||
itemtype="https://schema.org/Review"
|
||||
>
|
||||
{% with status=review hide_book=True depth=1 %}
|
||||
{% include 'snippets/status/status.html' %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="block is-flex is-flex-wrap-wrap">
|
||||
{% for rating in ratings %}
|
||||
{% with user=rating.user %}
|
||||
<div class="block mr-5">
|
||||
<div class="media">
|
||||
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
|
||||
<div class="media-left">
|
||||
{% include 'snippets/avatar.html' %}
|
||||
</div>
|
||||
|
||||
<div class="media-content">
|
||||
<div>
|
||||
<a href="{{ rating.user.local_path }}">{{ rating.user.display_name }}</a>
|
||||
<a href="{{ user.local_path }}">{{ user.display_name }}</a>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<p class="mr-1">{% trans "rated it" %}</p>
|
||||
|
||||
{% include 'snippets/stars.html' with rating=rating.rating %}
|
||||
</div>
|
||||
<div>
|
||||
|
@ -257,15 +312,17 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="block">
|
||||
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
|
||||
{% include 'snippets/pagination.html' with page=statuses path=request.path anchor="#reviews" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/tabs.js"></script>
|
||||
<script src="/static/js/vendor/tabs.js"></script>
|
||||
{% endblock %}
|
||||
|
|
27
bookwyrm/templates/book/book_identifiers.html
Normal file
27
bookwyrm/templates/book/book_identifiers.html
Normal file
|
@ -0,0 +1,27 @@
|
|||
{% spaceless %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
<dl>
|
||||
{% if book.isbn_13 %}
|
||||
<div class="is-flex">
|
||||
<dt class="mr-1">{% trans "ISBN:" %}</dt>
|
||||
<dd itemprop="isbn">{{ book.isbn_13 }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.oclc_number %}
|
||||
<div class="is-flex">
|
||||
<dt class="mr-1">{% trans "OCLC Number:" %}</dt>
|
||||
<dd>{{ book.oclc_number }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if book.asin %}
|
||||
<div class="is-flex">
|
||||
<dt class="mr-1">{% trans "ASIN:" %}</dt>
|
||||
<dd>{{ book.asin }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
{% endspaceless %}
|
|
@ -88,12 +88,18 @@
|
|||
<div class="column is-half">
|
||||
<section class="block">
|
||||
<h2 class="title is-4">{% trans "Metadata" %}</h2>
|
||||
<p class="mb-2"><label class="label" for="id_title">{% trans "Title:" %}</label> {{ form.title }} </p>
|
||||
<p class="mb-2">
|
||||
<label class="label" for="id_title">{% trans "Title:" %}</label>
|
||||
<input type="text" name="title" value="{{ form.title.value|default:'' }}" maxlength="255" class="input" required="" id="id_title">
|
||||
</p>
|
||||
{% for error in form.title.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p class="mb-2"><label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label> {{ form.subtitle }} </p>
|
||||
<p class="mb-2">
|
||||
<label class="label" for="id_subtitle">{% trans "Subtitle:" %}</label>
|
||||
<input type="text" name="subtitle" value="{{ form.subtitle.value|default:'' }}" maxlength="255" class="input" id="id_subtitle">
|
||||
</p>
|
||||
{% for error in form.subtitle.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
@ -103,7 +109,10 @@
|
|||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
||||
<p class="mb-2"><label class="label" for="id_series">{% trans "Series:" %}</label> {{ form.series }} </p>
|
||||
<p class="mb-2">
|
||||
<label class="label" for="id_series">{% trans "Series:" %}</label>
|
||||
<input type="text" class="input" name="series" id="id_series" value="{{ form.series.value|default:'' }}">
|
||||
</p>
|
||||
{% for error in form.series.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
|
@ -124,7 +133,7 @@
|
|||
|
||||
<p class="mb-2">
|
||||
<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 book.first_published_date %} value="{{ book.first_published_date|date:'Y-m-d' }}"{% endif %}>
|
||||
<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 %}>
|
||||
</p>
|
||||
{% for error in form.first_published_date.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
|
@ -132,7 +141,7 @@
|
|||
|
||||
<p class="mb-2">
|
||||
<label class="label" for="id_published_date">{% trans "Published date:" %}</label>
|
||||
<input type="date" name="published_date" class="input" id="id_published_date"{% if book.published_date %} value="{{ book.published_date|date:'Y-m-d' }}"{% endif %}>
|
||||
<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 %}>
|
||||
</p>
|
||||
{% for error in form.published_date.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
|
|
|
@ -25,7 +25,18 @@
|
|||
{{ book.title }}
|
||||
</a>
|
||||
</h2>
|
||||
{% include 'book/publisher_info.html' with book=book %}
|
||||
|
||||
{% with book=book %}
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-half">
|
||||
{% include 'book/publisher_info.html' %}
|
||||
</div>
|
||||
|
||||
<div class="column is-half ">
|
||||
{% include 'book/book_identifiers.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
{% include 'snippets/shelve_button/shelve_button.html' with book=book switch_mode=True %}
|
||||
|
|
|
@ -1,24 +1,70 @@
|
|||
{% spaceless %}
|
||||
|
||||
{% load i18n %}
|
||||
{% load humanize %}
|
||||
|
||||
<p>
|
||||
{% if book.physical_format and not book.pages %}
|
||||
{{ book.physical_format | title }}
|
||||
{% elif book.physical_format and book.pages %}
|
||||
{% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %}
|
||||
{% elif book.pages %}
|
||||
{% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %}
|
||||
{% with format=book.physical_format pages=book.pages %}
|
||||
{% if format %}
|
||||
{% comment %}
|
||||
@todo The bookFormat property is limited to a list of values whereas the book edition is free text.
|
||||
@see https://schema.org/bookFormat
|
||||
{% endcomment %}
|
||||
<meta itemprop="bookFormat" content="{{ format }}">
|
||||
{% endif %}
|
||||
|
||||
{% if pages %}
|
||||
<meta itemprop="numberOfPages" content="{{ pages }}">
|
||||
{% endif %}
|
||||
|
||||
{% if format and not pages %}
|
||||
{% blocktrans %}{{ format }}{% endblocktrans %}
|
||||
{% elif format and pages %}
|
||||
{% blocktrans %}{{ format }}, {{ pages }} pages{% endblocktrans %}
|
||||
{% elif pages %}
|
||||
{% blocktrans %}{{ pages }} pages{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</p>
|
||||
|
||||
{% if book.languages %}
|
||||
{% for language in book.languages %}
|
||||
<meta itemprop="inLanguage" content="{{ language }}">
|
||||
{% endfor %}
|
||||
|
||||
<p>
|
||||
{% blocktrans with languages=book.languages|join:", " %}{{ languages }} language{% endblocktrans %}
|
||||
{% with languages=book.languages|join:", " %}
|
||||
{% blocktrans %}{{ languages }} language{% endblocktrans %}
|
||||
{% endwith %}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
{% if book.published_date and book.publishers %}
|
||||
{% blocktrans with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
|
||||
{% elif book.published_date %}
|
||||
{% blocktrans with date=book.published_date|date:'M jS Y' %}Published {{ date }}{% endblocktrans %}
|
||||
{% elif book.publishers %}
|
||||
{% blocktrans with publisher=book.publishers|join:', ' %}Published by {{ publisher }}.{% endblocktrans %}
|
||||
{% with date=book.published_date|naturalday publisher=book.publishers|join:', ' %}
|
||||
{% if date or book.first_published_date %}
|
||||
<meta
|
||||
itemprop="datePublished"
|
||||
content="{{ book.first_published_date|default:book.published_date|date:'Y-m-d' }}"
|
||||
>
|
||||
{% endif %}
|
||||
|
||||
{% comment %}
|
||||
@todo The publisher property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor.
|
||||
@see https://schema.org/Publisher
|
||||
{% endcomment %}
|
||||
{% if book.publishers %}
|
||||
{% for publisher in book.publishers %}
|
||||
<meta itemprop="publisher" content="{{ publisher }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if date and publisher %}
|
||||
{% blocktrans %}Published {{ date }} by {{ publisher }}.{% endblocktrans %}
|
||||
{% elif date %}
|
||||
{% blocktrans %}Published {{ date }}{% endblocktrans %}
|
||||
{% elif publisher %}
|
||||
{% blocktrans %}Published by {{ publisher }}.{% endblocktrans %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</p>
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -1,13 +1,34 @@
|
|||
{% spaceless %}
|
||||
{% load bookwyrm_tags %}
|
||||
|
||||
{% with 0|uuid as uuid %}
|
||||
<div class="dropdown control{% if right %} is-right{% endif %}" id="menu-{{ uuid }}">
|
||||
<button type="button" class="button dropdown-trigger pulldown-menu {{ class }}" aria-expanded="false" class="pulldown-menu" aria-haspopup="true" aria-controls="menu-options-{{ uuid }}" data-controls="menu-{{ uuid }}">
|
||||
<div
|
||||
id="menu-{{ uuid }}"
|
||||
class="
|
||||
dropdown control
|
||||
{% if right %}is-right{% endif %}
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="button dropdown-trigger pulldown-menu {{ class }}"
|
||||
type="button"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-controls="menu-options-{{ uuid }}"
|
||||
data-controls="menu-{{ uuid }}"
|
||||
>
|
||||
{% block dropdown-trigger %}{% endblock %}
|
||||
</button>
|
||||
|
||||
<div class="dropdown-menu">
|
||||
<ul class="dropdown-content" role="menu" id="menu-options-{{ uuid }}">
|
||||
<ul
|
||||
id="menu-options-{{ uuid }}"
|
||||
class="dropdown-content p-0 is-clipped"
|
||||
role="menu"
|
||||
>
|
||||
{% block dropdown-list %}{% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% load i18n %}
|
||||
<section class="card hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||
<section class="card is-hidden {{ class }}" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}">
|
||||
<header class="card-header has-background-white-ter">
|
||||
<h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}-header">
|
||||
{% block header %}{% endblock %}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{% load i18n %}
|
||||
<div
|
||||
role="dialog"
|
||||
class="modal hidden"
|
||||
class="modal is-hidden"
|
||||
id="{{ controls_text }}-{{ controls_uid }}"
|
||||
aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}"
|
||||
aria-modal="true"
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
<div class="columns is-multiline">
|
||||
{% for user in users %}
|
||||
<div class="column is-one-third">
|
||||
<div class="card block">
|
||||
<div class="card is-stretchable">
|
||||
<div class="card-content">
|
||||
<div class="media">
|
||||
<a href="{{ user.local_path }}" class="media-left">
|
||||
|
@ -56,13 +56,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div>
|
||||
{% if user.summary %}
|
||||
{{ user.summary | to_markdown | safe | truncatechars_html:40 }}
|
||||
{% else %} {% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<footer class="card-footer content">
|
||||
<footer class="card-footer">
|
||||
{% if user != request.user %}
|
||||
{% if user.mutuals %}
|
||||
<div class="card-footer-item">
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
{# announcements and system messages #}
|
||||
{% if not activities.number > 1 %}
|
||||
<a href="{{ request.path }}" class="transition-y hidden notification is-primary is-block" data-poll-wrapper>
|
||||
<a href="{{ request.path }}" class="transition-y is-hidden notification is-primary is-block" data-poll-wrapper>
|
||||
{% blocktrans %}load <span data-poll="stream/{{ tab }}">0</span> unread status(es){% endblocktrans %}
|
||||
</a>
|
||||
|
||||
|
|
|
@ -104,5 +104,5 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/js/tabs.js"></script>
|
||||
<script src="/static/js/vendor/tabs.js"></script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
{% if user == request.user %}
|
||||
<div class="block">
|
||||
{% now 'Y' as year %}
|
||||
<section class="card {% if goal %}hidden{% endif %}" id="show-edit-goal">
|
||||
<section class="card {% if goal %}is-hidden{% endif %}" id="show-edit-goal">
|
||||
<header class="card-header">
|
||||
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-header">
|
||||
<span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {% blocktrans %}{{ year }} Reading Goal{% endblocktrans %}
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="id_csv_field">{% trans "Data file:" %}</label>
|
||||
<label class="label" for="id_csv_file">{% trans "Data file:" %}</label>
|
||||
{{ import_form.csv_file }}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{% get_lang %}">
|
||||
<head>
|
||||
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link type="text/css" rel="stylesheet" href="/static/css/bulma.min.css">
|
||||
<link type="text/css" rel="stylesheet" href="/static/css/format.css">
|
||||
<link type="text/css" rel="stylesheet" href="/static/css/icons.css">
|
||||
<link rel="stylesheet" href="/static/css/vendor/bulma.min.css">
|
||||
<link rel="stylesheet" href="/static/css/vendor/icons.css">
|
||||
<link rel="stylesheet" href="/static/css/bookwyrm.css">
|
||||
|
||||
<link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}/images/{{ site.favicon }}{% else %}/static/images/favicon.ico{% endif %}">
|
||||
|
||||
|
@ -22,7 +22,8 @@
|
|||
<meta name="twitter:image:alt" content="BookWyrm Logo">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar container" aria-label="main navigation">
|
||||
<nav class="navbar" aria-label="main navigation">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<a class="navbar-item" href="/">
|
||||
<img class="image logo" src="{% if site.logo_small %}/images/{{ site.logo_small }}{% else %}/static/images/logo-small.png{% endif %}" alt="Home page">
|
||||
|
@ -70,7 +71,7 @@
|
|||
{% if request.user.is_authenticated %}
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a
|
||||
href="{{ user.local_path }}"
|
||||
href="{{ request.user.local_path }}"
|
||||
class="navbar-link pulldown-menu"
|
||||
role="button"
|
||||
aria-expanded="false"
|
||||
|
@ -134,7 +135,7 @@
|
|||
<span class="is-sr-only">{% trans "Notifications" %}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="{% if not request.user|notification_count %}hidden {% endif %}tag is-danger is-medium transition-x" data-poll-wrapper>
|
||||
<span class="{% if not request.user|notification_count %}is-hidden {% endif %}tag is-danger is-medium transition-x" data-poll-wrapper>
|
||||
<span data-poll="notifications">{{ request.user | notification_count }}</span>
|
||||
</span>
|
||||
</a>
|
||||
|
@ -175,13 +176,16 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
<div class="section container">
|
||||
<div class="section is-flex-grow-1">
|
||||
<div class="container">
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
|
@ -212,7 +216,7 @@
|
|||
<script>
|
||||
var csrf_token = '{{ csrf_token }}';
|
||||
</script>
|
||||
<script src="/static/js/shared.js"></script>
|
||||
<script src="/static/js/bookwyrm.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -13,10 +13,20 @@
|
|||
|
||||
<div class="columns mt-3">
|
||||
<section class="column is-three-quarters">
|
||||
{% if not items.exists %}
|
||||
{% if request.GET.updated %}
|
||||
<div class="notification is-primary">
|
||||
{% if list.curation != "open" and request.user != list.user %}
|
||||
{% trans "You successfully suggested a book for this list!" %}
|
||||
{% else %}
|
||||
{% trans "You successfully added a book to this list!" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not items.object_list.exists %}
|
||||
<p>{% trans "This list is currently empty" %}</p>
|
||||
{% else %}
|
||||
<ol>
|
||||
<ol start="{{ items.start_index }}">
|
||||
{% for item in items %}
|
||||
<li class="block pb-3">
|
||||
<div class="card">
|
||||
|
@ -30,11 +40,27 @@
|
|||
{% include 'snippets/shelve_button/shelve_button.html' with book=item.book %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer has-background-white-bis">
|
||||
<div class="card-footer has-background-white-bis is-align-items-baseline">
|
||||
<div class="card-footer-item">
|
||||
<div>
|
||||
<p>{% blocktrans with username=item.user.display_name user_path=user.local_path %}Added by <a href="{{ user_path }}">{{ username }}</a>{% endblocktrans %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if list.user == request.user or list.curation == 'open' and item.user == request.user %}
|
||||
<div class="card-footer-item">
|
||||
<form name="set-position" method="post" action="{% url 'list-set-book-position' item.id %}">
|
||||
<div class="field has-addons mb-0">
|
||||
{% csrf_token %}
|
||||
<div class="control">
|
||||
<input id="input-list-position" class="input is-small" type="number" min="1" name="position" value="{{ item.order }}">
|
||||
</div>
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-info is-small is-tablet">{% trans "Set" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label for="input-list-position" class="help">{% trans "List position" %}</label>
|
||||
</form>
|
||||
</div>
|
||||
<form name="add-book" method="post" action="{% url 'list-remove-book' list.id %}" class="card-footer-item">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="item" value="{{ item.id }}">
|
||||
|
@ -47,10 +73,27 @@
|
|||
{% endfor %}
|
||||
</ol>
|
||||
{% endif %}
|
||||
{% include "snippets/pagination.html" with page=items %}
|
||||
</section>
|
||||
|
||||
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
|
||||
<section class="column is-one-quarter content">
|
||||
<h2>{% trans "Sort List" %}</h2>
|
||||
<form name="sort" action="{% url 'list' list.id %}" method="GET" class="block">
|
||||
<label class="label" for="id_sort_by">{% trans "Sort By" %}</label>
|
||||
<div class="select is-fullwidth">
|
||||
{{ sort_form.sort_by }}
|
||||
</div>
|
||||
<label class="label" for="id_direction">{% trans "Direction" %}</label>
|
||||
<div class="select is-fullwidth">
|
||||
{{ sort_form.direction }}
|
||||
</div>
|
||||
<div>
|
||||
<button class="button is-primary is-fullwidth" type="submit">
|
||||
{% trans "Sort List" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% if request.user.is_authenticated and not list.curation == 'closed' or request.user == list.user %}
|
||||
<h2>{% if list.curation == 'open' or request.user == list.user %}{% trans "Add Books" %}{% else %}{% trans "Suggest Books" %}{% endif %}</h2>
|
||||
<form name="search" action="{% url 'list' list.id %}" method="GET" class="block">
|
||||
<div class="field has-addons">
|
||||
|
@ -83,7 +126,7 @@
|
|||
</div>
|
||||
<div class="column">
|
||||
<p>{% include 'snippets/book_titleby.html' with book=book %}</p>
|
||||
<form name="add-book" method="post" action="{% url 'list-add-book' %}">
|
||||
<form name="add-book" method="post" action="{% url 'list-add-book' %}{% if query %}?q={{ query }}{% endif %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="book" value="{{ book.id }}">
|
||||
<input type="hidden" name="list" value="{{ list.id }}">
|
||||
|
@ -93,7 +136,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -15,10 +15,12 @@
|
|||
{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
{% if request.user.is_authenticated %}
|
||||
<div class="column is-narrow">
|
||||
{% trans "Create List" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with controls_text="create-list" icon="plus" text=button_text focus="create-list-header" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
<div class="block">
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
{% extends 'settings/admin_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% load humanize %}
|
||||
|
||||
{% block title %}{% blocktrans with report_id=report.id username=report.user.username %}Report #{{ report_id }}: {{ username }}{% endblocktrans %}{% endblock %}
|
||||
|
@ -14,23 +15,9 @@
|
|||
{% include 'moderation/report_preview.html' with report=report %}
|
||||
</div>
|
||||
|
||||
<div class="block content">
|
||||
<h3>{% trans "Actions" %}</h3>
|
||||
<p><a href="{{ report.user.local_path }}">{% trans "View user profile" %}</a></p>
|
||||
<div class="is-flex">
|
||||
<p class="mr-1">
|
||||
<a class="button" href="{% url 'direct-messages-user' report.user.username %}">{% trans "Send direct message" %}</a>
|
||||
</p>
|
||||
<form name="deactivate" method="post" action="{% url 'settings-report-deactivate' report.id %}">
|
||||
{% csrf_token %}
|
||||
{% if report.user.is_active %}
|
||||
<button type="submit" class="button is-danger is-light">{% trans "Deactivate user" %}</button>
|
||||
{% else %}
|
||||
<button class="button">{% trans "Reactivate user" %}</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'user_admin/user_info.html' with user=report.user %}
|
||||
|
||||
{% include 'user_admin/user_moderation_actions.html' with user=report.user %}
|
||||
|
||||
<div class="block">
|
||||
<h3 class="title is-4">{% trans "Moderator Comments" %}</h3>
|
||||
|
@ -64,7 +51,7 @@
|
|||
{% for status in report.statuses.select_subclasses.all %}
|
||||
<li>
|
||||
{% if status.deleted %}
|
||||
<em>{% trans "Statuses has been deleted" %}</em>
|
||||
<em>{% trans "Status has been deleted" %}</em>
|
||||
{% else %}
|
||||
{% include 'snippets/status/status.html' with status=status moderation_mode=True %}
|
||||
{% endif %}
|
||||
|
|
|
@ -15,7 +15,9 @@
|
|||
{% csrf_token %}
|
||||
<input type="hidden" name="reporter" value="{{ reporter.id }}">
|
||||
<input type="hidden" name="user" value="{{ user.id }}">
|
||||
{% if status %}
|
||||
<input type="hidden" name="statuses" value="{{ status.id }}">
|
||||
{% endif %}
|
||||
|
||||
<section class="content">
|
||||
<p>{% blocktrans with site_name=site.name %}This report will be sent to {{ site_name }}'s moderators for review.{% endblocktrans %}</p>
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
{% trans "Reports" %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% if server %}
|
||||
{% blocktrans with server_name=server.server_name %}Reports: <small>{{ server_name }}</small>{% endblocktrans %}
|
||||
|
@ -29,6 +30,8 @@
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
{% include 'user_admin/user_admin_filters.html' %}
|
||||
|
||||
<div class="block">
|
||||
{% if not reports %}
|
||||
<em>{% trans "No reports found." %}</em>
|
||||
|
|
|
@ -123,7 +123,7 @@
|
|||
{% include 'snippets/status_preview.html' with status=related_status %}
|
||||
</div>
|
||||
<div class="column is-narrow {% if notification.notification_type == 'REPLY' or notification.notification_type == 'MENTION' %}has-text-black{% else %}has-text-grey-dark{% endif %}">
|
||||
{{ related_status.published_date | post_date }}
|
||||
{{ related_status.published_date|timesince }}
|
||||
{% include 'snippets/privacy-icons.html' with item=related_status %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="{% if local_results.results %}hidden{% endif %}" id="more-results">
|
||||
<div class="{% if local_results.results %}is-hidden{% endif %}" id="more-results">
|
||||
{% for result_set in book_results|slice:"1:" %}
|
||||
{% if result_set.results %}
|
||||
<section class="block">
|
||||
|
|
|
@ -6,7 +6,14 @@
|
|||
{% block content %}
|
||||
|
||||
<header class="block column is-offset-one-quarter pl-1">
|
||||
<div class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h1 class="title">{% block header %}{% endblock %}</h1>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{% block edit-button %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="block columns">
|
||||
|
|
71
bookwyrm/templates/settings/edit_server.html
Normal file
71
bookwyrm/templates/settings/edit_server.html
Normal file
|
@ -0,0 +1,71 @@
|
|||
{% extends 'settings/admin_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Add server" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Add server" %}
|
||||
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
{% url 'settings-import-blocklist' as url %}
|
||||
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Import block list" %}</a>
|
||||
</li>
|
||||
{% url 'settings-add-federated-server' as url %}
|
||||
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Add server" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{% url 'settings-add-federated-server' %}">
|
||||
{% csrf_token %}
|
||||
<div class="columns">
|
||||
<div class="column is-half">
|
||||
<div>
|
||||
<label class="label" for="id_server_name">{% trans "Instance:" %}</label>
|
||||
<input type="text" name="server_name" maxlength="255" class="input" required id="id_server_name" value="{{ form.server_name.value|default:'' }}" placeholder="domain.com">
|
||||
{% for error in form.server_name.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="id_status">{% trans "Status:" %}</label>
|
||||
<div class="select">
|
||||
<select name="status" class="" id="id_status">
|
||||
<option value="federated" {% if form.status.value == "federated" %}selected=""{% endif %}>{% trans "Federated" %}</option>
|
||||
<option value="blocked" {% if form.status.value == "blocked" or not form.status.value %}selected{% endif %}>{% trans "Blocked" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-half">
|
||||
<div>
|
||||
<label class="label" for="id_application_type">{% trans "Software:" %}</label>
|
||||
<input type="text" name="application_type" maxlength="255" class="input" id="id_application_type" value="{{ form.application_type.value|default:'' }}">
|
||||
{% for error in form.application_type.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="id_application_version">{% trans "Version:" %}</label>
|
||||
<input type="text" name="application_version" maxlength="255" class="input" id="id_application_version" value="{{ form.application_version.value|default:'' }}">
|
||||
{% for error in form.application_version.errors %}
|
||||
<p class="help is-danger">{{ error | escape }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<label class="label" for="id_notes">{% trans "Notes:" %}</label>
|
||||
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ form.notes.value|default:'' }}</textarea>
|
||||
</p>
|
||||
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
|
@ -1,14 +1,20 @@
|
|||
{% extends 'settings/admin_layout.html' %}
|
||||
{% block title %}{{ server.server_name }}{% endblock %}
|
||||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
|
||||
{% block header %}
|
||||
{{ server.server_name }}
|
||||
|
||||
{% if server.status == "blocked" %}<span class="icon icon-x has-text-danger is-size-5" title="{% trans 'Blocked' %}"><span class="is-sr-only">{% trans "Blocked" %}</span></span>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<section class="block content">
|
||||
<div class="columns">
|
||||
<section class="column is-half content">
|
||||
<h2 class="title is-4">{% trans "Details" %}</h2>
|
||||
<dl>
|
||||
<div class="is-flex">
|
||||
|
@ -21,26 +27,26 @@
|
|||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Status:" %}</dt>
|
||||
<dd>Federated</dd>
|
||||
<dd>{{ server.status }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="block content">
|
||||
<section class="column is-half content">
|
||||
<h2 class="title is-4">{% trans "Activity" %}</h2>
|
||||
<dl>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Users:" %}</dt>
|
||||
<dd>
|
||||
{{ users.count }}
|
||||
{% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
|
||||
{% if server.user_set.count %}(<a href="{% url 'settings-users' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
<dt>{% trans "Reports:" %}</dt>
|
||||
<dd>
|
||||
{{ reports.count }}
|
||||
{% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.id }}">{% trans "View all" %}</a>){% endif %}
|
||||
{% if reports.count %}(<a href="{% url 'settings-reports' %}?server={{ server.server_name }}">{% trans "View all" %}</a>){% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="is-flex">
|
||||
|
@ -63,6 +69,49 @@
|
|||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="block content">
|
||||
<header class="columns is-mobile">
|
||||
<div class="column">
|
||||
<h2 class="title is-4 mb-0">{% trans "Notes" %}</h2>
|
||||
</div>
|
||||
<div class="column is-narrow">
|
||||
{% trans "Edit" as button_text %}
|
||||
{% include 'snippets/toggle/open_button.html' with text=button_text icon="pencil" controls_text="edit-notes" %}
|
||||
</div>
|
||||
</header>
|
||||
{% if server.notes %}
|
||||
<p id="hide-edit-notes">{{ server.notes|to_markdown|safe }}</p>
|
||||
{% endif %}
|
||||
<form class="box is-hidden" method="POST" action="{% url 'settings-federated-server' server.id %}" id="edit-notes">
|
||||
{% csrf_token %}
|
||||
<p>
|
||||
<label class="is-sr-only" for="id_notes">Notes:</label>
|
||||
<textarea name="notes" cols="None" rows="None" class="textarea" id="id_notes">{{ server.notes|default:"" }}</textarea>
|
||||
</p>
|
||||
<button type="submit" class="button is-primary">{% trans "Save" %}</button>
|
||||
{% trans "Cancel" as button_text %}
|
||||
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="edit-notes" %}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="block content">
|
||||
<h2 class="title is-4">{% trans "Actions" %}</h2>
|
||||
{% if server.status != 'blocked' %}
|
||||
<form class="block" method="post" action="{% url 'settings-federated-server-block' server.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-danger">{% trans "Block" %}</button>
|
||||
<p class="help">{% trans "All users from this instance will be deactivated." %}</p>
|
||||
</form>
|
||||
{% else %}
|
||||
<form class="block" method="post" action="{% url 'settings-federated-server-unblock' server.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="button">{% trans "Un-block" %}</button>
|
||||
<p class="help">{% trans "All users from this instance will be re-activated." %}</p>
|
||||
</form>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
@ -4,8 +4,15 @@
|
|||
|
||||
{% block header %}{% trans "Federated Servers" %}{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
{% block edit-button %}
|
||||
<a href="{% url 'settings-import-blocklist' %}">
|
||||
<span class="icon icon-plus" title="{% trans 'Add server' %}">
|
||||
<span class="is-sr-only">{% trans "Add server" %}</span>
|
||||
</span>
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
<table class="table is-striped">
|
||||
<tr>
|
||||
{% url 'settings-federation' as url %}
|
||||
|
|
67
bookwyrm/templates/settings/server_blocklist.html
Normal file
67
bookwyrm/templates/settings/server_blocklist.html
Normal file
|
@ -0,0 +1,67 @@
|
|||
{% extends 'settings/admin_layout.html' %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Add server" %}{% endblock %}
|
||||
|
||||
{% block header %}
|
||||
{% trans "Import Blocklist" %}
|
||||
<a href="{% url 'settings-federation' %}" class="has-text-weight-normal help">{% trans "Back to server list" %}</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block panel %}
|
||||
|
||||
<div class="tabs">
|
||||
<ul>
|
||||
{% url 'settings-import-blocklist' as url %}
|
||||
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Import block list" %}</a>
|
||||
</li>
|
||||
{% url 'settings-add-federated-server' as url %}
|
||||
<li {% if url in request.path %}class="is-active" aria-current="page"{% endif %}>
|
||||
<a href="{{ url }}">{% trans "Add server" %}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if succeeded and not failed %}
|
||||
<p class="notification is-primary">{% trans "Success!" %}</p>
|
||||
{% elif succeeded or failed %}
|
||||
<div class="block content">
|
||||
{% if succeeded %}
|
||||
<p>{% trans "Successfully blocked:" %} {{ succeeded }}</p>
|
||||
{% endif %}
|
||||
<p>{% trans "Failed:" %}</p>
|
||||
<ul>
|
||||
{% for item in failed %}
|
||||
<li>
|
||||
<pre>
|
||||
{{ item }}
|
||||
</pre>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{% url 'settings-import-blocklist' %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="label" for="id_file">JSON data:</label>
|
||||
<aside class="help">
|
||||
Expects a json file in the format provided by <a href="https://fediblock.org/" target="_blank" rel=”noopener”>FediBlock</a>, with a list of entries that have <code>instance</code> and <code>url</code> fields. For example:
|
||||
<pre>
|
||||
[
|
||||
{
|
||||
"instance": "example.server.com",
|
||||
"url": "https://link.to.more/info"
|
||||
},
|
||||
...
|
||||
]
|
||||
</pre>
|
||||
</aside>
|
||||
<input type="file" name="json_file" required="" id="id_file">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button is-primary">{% trans "Import" %}</button>
|
||||
</form>
|
||||
|
||||
{% endblock %}
|
|
@ -1 +1,17 @@
|
|||
{% for author in book.authors.all %}<a href="/author/{{ author.id }}" class="author">{{ author.name }}</a>{% if not forloop.last %}, {% endif %}{% endfor %}
|
||||
{% spaceless %}
|
||||
{% comment %}
|
||||
@todo The author property needs to be an Organization or a Person. We’ll be using Thing which is the more generic ancestor.
|
||||
@see https://schema.org/Author
|
||||
{% endcomment %}
|
||||
{% for author in book.authors.all %}
|
||||
<a
|
||||
href="/author/{{ author.id }}"
|
||||
class="author"
|
||||
itemprop="author"
|
||||
itemscope
|
||||
itemtype="https://schema.org/Thing"
|
||||
><span
|
||||
itemprop="name"
|
||||
>{{ author.name }}<span></a>{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -1,13 +1,29 @@
|
|||
{% spaceless %}
|
||||
|
||||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
|
||||
<div class="cover-container is-{{ size }}">
|
||||
{% if book.cover %}
|
||||
<img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}" title="{{ book.alt_text }}">
|
||||
<img
|
||||
class="book-cover"
|
||||
src="/images/{{ book.cover }}"
|
||||
alt="{{ book.alt_text }}"
|
||||
title="{{ book.alt_text }}"
|
||||
itemprop="thumbnailUrl"
|
||||
>
|
||||
{% else %}
|
||||
<div class="no-cover book-cover">
|
||||
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover">
|
||||
<img
|
||||
class="book-cover"
|
||||
src="/static/images/no_cover.jpg"
|
||||
alt="{% trans "No cover" %}"
|
||||
>
|
||||
|
||||
<div>
|
||||
<p>{{ book.alt_text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endspaceless %}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
{% load i18n %}
|
||||
{% load bookwyrm_tags %}
|
||||
{% if book.authors %}
|
||||
{% blocktrans with path=book.local_path title=book.title %}<a href="{{ path }}">{{ title }}</a> by {% endblocktrans %}{% include 'snippets/authors.html' with book=book %}
|
||||
{% blocktrans with path=book.local_path title=book|title %}<a href="{{ path }}">{{ title }}</a> by {% endblocktrans %}{% include 'snippets/authors.html' with book=book %}
|
||||
{% else %}
|
||||
<a href="{{ book.local_path }}">{{ book.title }}</a>
|
||||
<a href="{{ book.local_path }}">{{ book|title }}</a>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -2,20 +2,18 @@
|
|||
{% load i18n %}
|
||||
|
||||
{% with status.id|uuid as uuid %}
|
||||
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||
<form name="boost" action="/boost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} {% if request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
|
||||
<span class="icon icon-boost" title="{% trans 'Boost status' %}">
|
||||
<span class="is-sr-only">{% trans "Boost status" %}</span>
|
||||
</span>
|
||||
<button class="button is-small is-light is-transparent" type="submit" {% if not status.boostable %}disabled{% endif %}>
|
||||
<span class="icon icon-boost m-0-mobile" title="{% trans 'Boost' %}"></span>
|
||||
<span class="is-sr-only-mobile">{% trans "Boost" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||
<form name="unboost" action="/unboost/{{ status.id }}" method="post" class="interaction boost-{{ status.id }}-{{ uuid }} active {% if not request.user|boosted:status %}is-hidden{% endif %}" data-id="boost-{{ status.id }}-{{ uuid }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small is-primary" type="submit">
|
||||
<span class="icon icon-boost" title="{% trans 'Un-boost status' %}">
|
||||
<span class="is-sr-only">{% trans "Un-boost status" %}</span>
|
||||
</span>
|
||||
<button class="button is-small is-light is-transparent" type="submit">
|
||||
<span class="icon icon-boost has-text-primary m-0-mobile" title="{% trans 'Un-boost' %}"></span>
|
||||
<span class="is-sr-only-mobile">{% trans "Un-boost" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endwith %}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{% load i18n %}
|
||||
<div class="control{% if not parent_status.content_warning and not draft.content_warning %} hidden{% endif %}" id="spoilers-{{ uuid }}">
|
||||
<div class="control{% if not parent_status.content_warning and not draft.content_warning %} is-hidden{% endif %}" id="spoilers-{{ uuid }}">
|
||||
<label class="is-sr-only" for="id_content_warning-{{ uuid }}">{% trans "Spoiler alert:" %}</label>
|
||||
<input
|
||||
type="text"
|
||||
|
|
|
@ -6,14 +6,16 @@
|
|||
<input type="hidden" name="user" value="{{ request.user.id }}">
|
||||
<input type="hidden" name="reply_parent" value="{% firstof draft.reply_parent.id reply_parent.id %}">
|
||||
{% if type == 'review' %}
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
<label class="label" for="id_name_{{ book.id }}_{{ type }}">{% trans "Title" %}:</label>
|
||||
<div class="control">
|
||||
<input type="text" name="name" maxlength="255" class="input" required="" id="id_name_{{ book.id }}_{{ type }}" placeholder="My {{ type }} of '{{ book.title }}'" value="{% firstof draft.name ''%}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
{% if type != 'reply' and type != 'direct' %}
|
||||
<label class="label" for="id_{% if type == 'quotation' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}">
|
||||
<label class="label{% if type == 'review' %} mb-0{% endif %}" for="id_{% if type == 'quotation' %}quote{% else %}content{% endif %}_{{ book.id }}_{{ type }}">
|
||||
{% if type == 'comment' %}
|
||||
{% trans "Comment:" %}
|
||||
{% elif type == 'quotation' %}
|
||||
|
@ -25,28 +27,37 @@
|
|||
{% endif %}
|
||||
|
||||
{% if type == 'review' %}
|
||||
<fieldset>
|
||||
<fieldset class="mb-1">
|
||||
<legend class="is-sr-only">{% trans "Rating" %}</legend>
|
||||
|
||||
{% include 'snippets/form_rate_stars.html' with book=book type=type|default:'summary' default_rating=draft.rating %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
|
||||
<div class="control">
|
||||
{% if type == 'quotation' %}
|
||||
<textarea name="quote" class="textarea" id="id_quote_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.quote|default:'' }}</textarea>
|
||||
{% elif type == 'reply' %}
|
||||
{% include 'snippets/content_warning_field.html' with parent_status=status %}
|
||||
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" aria-label="{% trans 'Reply' %}" required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
|
||||
{% else %}
|
||||
{% include 'snippets/content_warning_field.html' with parent_status=status %}
|
||||
<textarea name="content" class="textarea" id="id_content_{{ type }}-{{ book.id }}{{reply_parent.id}}" placeholder="{{ placeholder }}" {% if type == 'reply' %} aria-label="Reply"{% endif %} required>{% if reply_parent %}{{ reply_parent|mentions:request.user }}{% endif %}{% if mentions %}@{{ mentions|username }} {% endif %}{{ draft.content|default:'' }}</textarea>
|
||||
<textarea name="content" class="textarea" id="id_content_{{ book.id }}_{{ type }}" placeholder="{{ placeholder }}" required>{{ draft.content|default:'' }}</textarea>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Supplemental fields #}
|
||||
{% if type == 'quotation' %}
|
||||
<div class="control">
|
||||
<div class="field">
|
||||
<label class="label" for="id_content_quote-{{ book.id }}">{% trans "Comment" %}:</label>
|
||||
{% include 'snippets/content_warning_field.html' with parent_status=status %}
|
||||
<textarea name="content" class="textarea is-small" id="id_content_quote-{{ book.id }}">{{ draft.content|default:'' }}</textarea>
|
||||
<div class="control">
|
||||
<textarea name="content" class="textarea" rows="3" id="id_content_quote-{{ book.id }}">{{ draft.content|default:'' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
{% elif type == 'comment' %}
|
||||
<div class="control">
|
||||
<div>
|
||||
{% active_shelf book as active_shelf %}
|
||||
{% if active_shelf.shelf.identifier == 'reading' and book.latest_readthrough %}
|
||||
|
||||
|
@ -58,13 +69,15 @@
|
|||
<div class="control">
|
||||
<input aria-label="{% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}Current page{% else %}Percent read{% endif %}" class="input" type="number" min="0" name="progress" size="3" value="{% firstof draft.progress readthrough.progress '' %}" id="progress-{{ uuid }}">
|
||||
</div>
|
||||
<div class="control select">
|
||||
<div class="control">
|
||||
<div class="select">
|
||||
<select name="progress_mode" aria-label="Progress mode">
|
||||
<option value="PG" {% if draft.progress_mode == 'PG' or readthrough.progress_mode == 'PG' %}selected{% endif %}>{% trans "pages" %}</option>
|
||||
<option value="PCT" {% if draft.progress_mode == 'PCT' or readthrough.progress_mode == 'PCT' %}selected{% endif %}>{% trans "percent" %}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if readthrough.progress_mode == 'PG' and book.pages %}
|
||||
<p class="help">{% blocktrans with pages=book.pages %}of {{ pages }} pages{% endblocktrans %}</p>
|
||||
{% endif %}
|
||||
|
@ -73,9 +86,12 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<input type="checkbox" class="hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
|
||||
|
||||
|
||||
{# bottom bar #}
|
||||
<div class="columns pt-1">
|
||||
<input type="checkbox" class="is-hidden" name="sensitive" id="id_show_spoilers-{{ uuid }}" {% if draft.content_warning or status.content_warning %}checked{% endif %} aria-hidden="true">
|
||||
|
||||
<div class="columns mt-1">
|
||||
<div class="field has-addons column">
|
||||
<div class="control">
|
||||
{% trans "Include spoiler alert" as button_text %}
|
||||
|
|
|
@ -1,20 +1,19 @@
|
|||
{% load bookwyrm_tags %}
|
||||
{% load i18n %}
|
||||
{% with status.id|uuid as uuid %}
|
||||
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
|
||||
<form name="favorite" action="/favorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} {% if request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-small" type="submit">
|
||||
<span class="icon icon-heart" title="{% trans 'Like status' %}">
|
||||
<span class="is-sr-only">{% trans "Like status" %}</span>
|
||||
<button class="button is-small is-light is-transparent" type="submit">
|
||||
<span class="icon icon-heart m-0-mobile" title="{% trans 'Like' %}">
|
||||
</span>
|
||||
<span class="is-sr-only-mobile">{% trans "Like" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
|
||||
<form name="unfavorite" action="/unfavorite/{{ status.id }}" method="POST" class="interaction fav-{{ status.id }}-{{ uuid }} active {% if not request.user|liked:status %}is-hidden{% endif %}" data-id="fav-{{ status.id }}-{{ uuid }}">
|
||||
{% csrf_token %}
|
||||
<button class="button is-primary is-small" type="submit">
|
||||
<span class="icon icon-heart" title="{% trans 'Un-like status' %}">
|
||||
<span class="is-sr-only">{% trans "Un-like status" %}</span>
|
||||
</span>
|
||||
<button class="button is-light is-transparent is-small" type="submit">
|
||||
<span class="icon icon-heart has-text-primary m-0-mobile" title="{% trans 'Un-like' %}"></span>
|
||||
<span class="is-sr-only-mobile">{% trans "Un-like" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endwith %}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
</span>
|
||||
</h2>
|
||||
|
||||
<form class="hidden mt-3" id="filters" method="get" action="{{ request.path }}" tabindex="0">
|
||||
<form class="is-hidden mt-3" id="filters" method="get" action="{{ request.path }}" tabindex="0">
|
||||
{% if sort %}
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
{% endif %}
|
||||
|
|
|
@ -6,12 +6,12 @@
|
|||
|
||||
<div class="field{% if not minimal %} has-addons{% else %} mb-0{% endif %}">
|
||||
<div class="control">
|
||||
<form action="{% url 'follow' %}" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all or request.user in user.follower_requests.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
|
||||
<form action="{% url 'follow' %}" method="POST" class="interaction follow-{{ user.id }} {% if request.user in user.followers.all or request.user in user.follower_requests.all %}is-hidden{%endif %}" data-id="follow-{{ user.id }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ user.username }}">
|
||||
<button class="button is-small{% if not minimal %} is-link{% endif %}" type="submit">{% trans "Follow" %}</button>
|
||||
</form>
|
||||
<form action="{% url 'unfollow' %}" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all and not request.user in user.follower_requests.all %}hidden{%endif %}" data-id="follow-{{ user.id }}">
|
||||
<form action="{% url 'unfollow' %}" method="POST" class="interaction follow-{{ user.id }} {% if not request.user in user.followers.all and not request.user in user.follower_requests.all %}is-hidden{%endif %}" data-id="follow-{{ user.id }}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="user" value="{{ user.username }}">
|
||||
{% if user.manually_approves_followers and request.user not in user.followers.all %}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
{% load i18n %}
|
||||
{% if rating %}
|
||||
|
||||
{% blocktrans with book_title=book.title display_rating=rating|floatformat:"0" review_title=name count counter=rating %}Review of "{{ book_title }}" ({{ display_rating }} star): {{ review_title }}{% plural %}Review of "{{ book_title }}" ({{ display_rating }} stars): {{ review_title }}{% endblocktrans %}
|
||||
{% blocktrans with book_title=book.title|safe display_rating=rating|floatformat:"0" review_title=name|safe count counter=rating %}Review of "{{ book_title }}" ({{ display_rating }} star): {{ review_title }}{% plural %}Review of "{{ book_title }}" ({{ display_rating }} stars): {{ review_title }}{% endblocktrans %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{% blocktrans with book_title=book.title review_title=name %}Review of "{{ book_title }}": {{ review_title }}{% endblocktrans %}
|
||||
{% blocktrans with book_title=book.title|safe review_title=name|safe %}Review of "{{ book_title }}": {{ review_title }}{% endblocktrans %}
|
||||
|
||||
{% endif %}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
{% include 'snippets/form_rate_stars.html' with book=book classes='mb-1 has-text-warning-dark' default_rating=book|user_rating:request.user %}
|
||||
|
||||
<div class="field has-addons hidden">
|
||||
<div class="field has-addons is-hidden">
|
||||
<div class="control">
|
||||
{% include 'snippets/privacy_select.html' with class="is-small" %}
|
||||
</div>
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
{% if readthrough.progress %}
|
||||
{% trans "Show all updates" as button_text %}
|
||||
{% include 'snippets/toggle/toggle_button.html' with text=button_text controls_text="updates" controls_uid=readthrough.id class="is-small" %}
|
||||
<ul id="updates-{{ readthrough.id }}" class="hidden">
|
||||
<ul id="updates-{{ readthrough.id }}" class="is-hidden">
|
||||
{% for progress_update in readthrough.progress_updates %}
|
||||
<li>
|
||||
<form name="delete-update" action="/delete-progressupdate" method="POST">
|
||||
|
@ -67,7 +67,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="box hidden" id="edit-readthrough-{{ readthrough.id }}" tabindex="0">
|
||||
<div class="box is-hidden" id="edit-readthrough-{{ readthrough.id }}" tabindex="0">
|
||||
<h3 class="title is-5">{% trans "Edit read dates" %}</h3>
|
||||
<form name="edit-readthrough" action="/edit-readthrough" method="post">
|
||||
{% include 'snippets/readthrough_form.html' with readthrough=readthrough %}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue