Merge pull request #8 from mouse-reeve/main

Merge
This commit is contained in:
tofuwabohu 2021-04-11 18:53:22 +02:00 committed by GitHub
commit 9a6aed4efa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 1408 additions and 1014 deletions

View file

@ -36,3 +36,4 @@ EMAIL_PORT=587
EMAIL_HOST_USER=mail@your.domain.here EMAIL_HOST_USER=mail@your.domain.here
EMAIL_HOST_PASSWORD=emailpassword123 EMAIL_HOST_PASSWORD=emailpassword123
EMAIL_USE_TLS=true EMAIL_USE_TLS=true
EMAIL_USE_SSL=false

1
.eslintignore Normal file
View file

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

View file

@ -6,5 +6,85 @@ module.exports = {
"es6": true "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",
}
}; };

View file

@ -3,12 +3,14 @@ name: Lint Frontend
on: on:
push: push:
branches: [ main, ci ] branches: [ main, ci, frontend ]
paths: paths:
- '.github/workflows/**' - '.github/workflows/**'
- 'static/**' - 'static/**'
- '.eslintrc'
- '.stylelintrc'
pull_request: pull_request:
branches: [ main, ci ] branches: [ main, ci, frontend ]
jobs: jobs:
lint: lint:
@ -22,8 +24,10 @@ jobs:
- name: Install modules - name: Install modules
run: yarn run: yarn
# See .stylelintignore for files that are not linted.
- name: Run stylelint - 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 - name: Run ESLint
run: yarn eslint . --ext .js,.jsx,.ts,.tsx run: yarn eslint bookwyrm/static --ext .js,.jsx,.ts,.tsx

View file

@ -1,2 +1 @@
bookwyrm/static/css/bulma.*.css* **/vendor/**
bookwyrm/static/css/icons.css

View file

@ -116,6 +116,8 @@ If you edit the CSS or JavaScript, you will need to run Django's `collectstatic`
./bw-dev collectstatic ./bw-dev collectstatic
``` ```
If you have [installed yarn](https://yarnpkg.com/getting-started/install), you can run `yarn watch:static` to automatically run the previous script every time a change occurs in _bookwyrm/static_ directory.
### Working with translations and locale files ### 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. 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.
@ -156,24 +158,6 @@ The `production` branch of BookWyrm contains a number of tools not on the `main`
Instructions for running BookWyrm in production: 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: - Get the application code:
`git clone git@github.com:mouse-reeve/bookwyrm.git` `git clone git@github.com:mouse-reeve/bookwyrm.git`
- Switch to the `production` branch - Switch to the `production` branch

View file

@ -10,6 +10,7 @@ from .note import Note, GeneratedNote, Article, Comment, Quotation
from .note import Review, Rating from .note import Review, Rating
from .note import Tombstone from .note import Tombstone
from .ordered_collection import OrderedCollection, OrderedCollectionPage from .ordered_collection import OrderedCollection, OrderedCollectionPage
from .ordered_collection import CollectionItem, ListItem, ShelfItem
from .ordered_collection import BookList, Shelf from .ordered_collection import BookList, Shelf
from .person import Person, PublicKey from .person import Person, PublicKey
from .response import ActivitypubResponse from .response import ActivitypubResponse

View file

@ -111,7 +111,7 @@ class ActivityObject:
and hasattr(model, "ignore_activity") and hasattr(model, "ignore_activity")
and model.ignore_activity(self) and model.ignore_activity(self)
): ):
raise ActivitySerializerError() return None
# check for an existing instance # check for an existing instance
instance = instance or model.find_existing(self.serialize()) instance = instance or model.find_existing(self.serialize())

View file

@ -50,3 +50,30 @@ class OrderedCollectionPage(ActivityObject):
next: str = None next: str = None
prev: str = None prev: str = None
type: str = "OrderedCollectionPage" 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"

View file

@ -4,7 +4,7 @@ from typing import List
from django.apps import apps from django.apps import apps
from .base_activity import ActivityObject, Signature, resolve_remote_id from .base_activity import ActivityObject, Signature, resolve_remote_id
from .book import Edition from .ordered_collection import CollectionItem
@dataclass(init=False) @dataclass(init=False)
@ -141,37 +141,27 @@ class Reject(Verb):
class Add(Verb): class Add(Verb):
"""Add activity """ """Add activity """
target: str target: ActivityObject
object: Edition object: CollectionItem
type: str = "Add" type: str = "Add"
notes: str = None
order: int = 0
approved: bool = True
def action(self): def action(self):
""" add obj to collection """ """ figure out the target to assign the item to a collection """
target = resolve_remote_id(self.target, refresh=False) target = resolve_remote_id(self.target)
# we want to get the related field that isn't the book, this is janky af sorry item = self.object.to_model(save=False)
model = [t for t in type(target)._meta.related_objects if t.name != "edition"][ setattr(item, item.collection_field, target)
0 item.save()
].related_model
self.to_model(model=model)
@dataclass(init=False) @dataclass(init=False)
class Remove(Verb): class Remove(Add):
"""Remove activity """ """Remove activity """
target: ActivityObject
type: str = "Remove" type: str = "Remove"
def action(self): def action(self):
""" find and remove the activity object """ """ find and remove the activity object """
target = resolve_remote_id(self.target, refresh=False) obj = self.object.to_model(save=False, allow_create=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.delete() obj.delete()

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

View file

@ -64,6 +64,7 @@ class ActivitypubMixin:
) )
if hasattr(self, "property_fields"): if hasattr(self, "property_fields"):
self.activity_fields += [ self.activity_fields += [
# pylint: disable=cell-var-from-loop
PropertyField(lambda a, o: set_activity_from_property_field(a, o, f)) PropertyField(lambda a, o: set_activity_from_property_field(a, o, f))
for f in self.property_fields for f in self.property_fields
] ]
@ -356,49 +357,59 @@ class OrderedCollectionMixin(OrderedCollectionPageMixin):
class CollectionItemMixin(ActivitypubMixin): class CollectionItemMixin(ActivitypubMixin):
""" for items that are part of an (Ordered)Collection """ """ for items that are part of an (Ordered)Collection """
activity_serializer = activitypub.Add activity_serializer = activitypub.CollectionItem
object_field = collection_field = None
@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)
return [collection_field.user]
def save(self, *args, broadcast=True, **kwargs): def save(self, *args, broadcast=True, **kwargs):
""" broadcast updated """ """ broadcast updated """
created = not bool(self.id)
# first off, we want to save normally no matter what # first off, we want to save normally no matter what
super().save(*args, **kwargs) super().save(*args, **kwargs)
# these shouldn't be edited, only created and deleted # list items can be updateda, normally you would only broadcast on created
if not broadcast or not created or not self.user.local: if not broadcast or not self.user.local:
return return
# adding an obj to the collection # adding an obj to the collection
activity = self.to_add_activity() activity = self.to_add_activity(self.user)
self.broadcast(activity, self.user) self.broadcast(activity, self.user)
def delete(self, *args, **kwargs): def delete(self, *args, broadcast=True, **kwargs):
""" broadcast a remove activity """ """ broadcast a remove activity """
activity = self.to_remove_activity() activity = self.to_remove_activity(self.user)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.user.local: if self.user.local and broadcast:
self.broadcast(activity, self.user) self.broadcast(activity, self.user)
def to_add_activity(self): def to_add_activity(self, user):
""" AP for shelving a book""" """ AP for shelving a book"""
object_field = getattr(self, self.object_field)
collection_field = getattr(self, self.collection_field) collection_field = getattr(self, self.collection_field)
return activitypub.Add( return activitypub.Add(
id=self.get_remote_id(), id="{:s}#add".format(collection_field.remote_id),
actor=self.user.remote_id, actor=user.remote_id,
object=object_field, object=self.to_activity_dataclass(),
target=collection_field.remote_id, target=collection_field.remote_id,
).serialize() ).serialize()
def to_remove_activity(self): def to_remove_activity(self, user):
""" AP for un-shelving a book""" """ AP for un-shelving a book"""
object_field = getattr(self, self.object_field)
collection_field = getattr(self, self.collection_field) collection_field = getattr(self, self.collection_field)
return activitypub.Remove( return activitypub.Remove(
id=self.get_remote_id(), id="{:s}#remove".format(collection_field.remote_id),
actor=self.user.remote_id, actor=user.remote_id,
object=object_field, object=self.to_activity_dataclass(),
target=collection_field.remote_id, target=collection_field.remote_id,
).serialize() ).serialize()

View file

@ -59,11 +59,9 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
""" ok """ """ ok """
book = fields.ForeignKey( book = fields.ForeignKey(
"Edition", on_delete=models.PROTECT, activitypub_field="object" "Edition", on_delete=models.PROTECT, activitypub_field="book"
)
book_list = fields.ForeignKey(
"List", on_delete=models.CASCADE, activitypub_field="target"
) )
book_list = models.ForeignKey("List", on_delete=models.CASCADE)
user = fields.ForeignKey( user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="actor" "User", on_delete=models.PROTECT, activitypub_field="actor"
) )
@ -72,8 +70,7 @@ class ListItem(CollectionItemMixin, BookWyrmModel):
order = fields.IntegerField(blank=True, null=True) order = fields.IntegerField(blank=True, null=True)
endorsement = models.ManyToManyField("User", related_name="endorsers") endorsement = models.ManyToManyField("User", related_name="endorsers")
activity_serializer = activitypub.Add activity_serializer = activitypub.ListItem
object_field = "book"
collection_field = "book_list" collection_field = "book_list"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View file

@ -66,17 +66,14 @@ class ShelfBook(CollectionItemMixin, BookWyrmModel):
""" many to many join table for books and shelves """ """ many to many join table for books and shelves """
book = fields.ForeignKey( book = fields.ForeignKey(
"Edition", on_delete=models.PROTECT, activitypub_field="object" "Edition", on_delete=models.PROTECT, activitypub_field="book"
)
shelf = fields.ForeignKey(
"Shelf", on_delete=models.PROTECT, activitypub_field="target"
) )
shelf = models.ForeignKey("Shelf", on_delete=models.PROTECT)
user = fields.ForeignKey( user = fields.ForeignKey(
"User", on_delete=models.PROTECT, activitypub_field="actor" "User", on_delete=models.PROTECT, activitypub_field="actor"
) )
activity_serializer = activitypub.Add activity_serializer = activitypub.ShelfItem
object_field = "book"
collection_field = "shelf" collection_field = "shelf"
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

View file

@ -25,6 +25,7 @@ EMAIL_PORT = env("EMAIL_PORT", 587)
EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env.bool("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")) DEFAULT_FROM_EMAIL = "admin@{:s}".format(env("DOMAIN"))
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)

View file

@ -3,7 +3,6 @@ html {
scroll-padding-top: 20%; scroll-padding-top: 20%;
} }
/* --- --- */
.image { .image {
overflow: hidden; overflow: hidden;
} }
@ -25,17 +24,8 @@ html {
min-width: 75% !important; min-width: 75% !important;
} }
/* --- "disabled" for non-buttons --- */ /** Shelving
.is-disabled { ******************************************************************************/
background-color: #dbdbdb;
border-color: #dbdbdb;
box-shadow: none;
color: #7a7a7a;
opacity: 0.5;
cursor: not-allowed;
}
/* --- SHELVING --- */
/** @todo Replace icons with SVG symbols. /** @todo Replace icons with SVG symbols.
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */ @see https://www.youtube.com/watch?v=9xXBYcWgCHA */
@ -45,7 +35,9 @@ html {
margin-left: 0.5em; margin-left: 0.5em;
} }
/* --- TOGGLES --- */ /** Toggles
******************************************************************************/
.toggle-button[aria-pressed=true], .toggle-button[aria-pressed=true],
.toggle-button[aria-pressed=true]:hover { .toggle-button[aria-pressed=true]:hover {
background-color: hsl(171, 100%, 41%); background-color: hsl(171, 100%, 41%);
@ -57,12 +49,8 @@ html {
display: none; display: none;
} }
.hidden { .transition-x.is-hidden,
display: none !important; .transition-y.is-hidden {
}
.hidden.transition-y,
.hidden.transition-x {
display: block !important; display: block !important;
visibility: hidden !important; visibility: hidden !important;
height: 0; height: 0;
@ -71,16 +59,18 @@ html {
padding: 0; padding: 0;
} }
.transition-x,
.transition-y { .transition-y {
transition-property: height, margin-top, margin-bottom, padding-top, padding-bottom;
transition-duration: 0.5s; transition-duration: 0.5s;
transition-timing-function: ease; transition-timing-function: ease;
} }
.transition-x { .transition-x {
transition-property: width, margin-left, margin-right, padding-left, padding-right; 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) { @media (prefers-reduced-motion: reduce) {
@ -121,7 +111,9 @@ html {
content: '\e9d7'; content: '\e9d7';
} }
/* --- BOOK COVERS --- */ /** Book covers
******************************************************************************/
.cover-container { .cover-container {
height: 250px; height: 250px;
width: max-content; width: max-content;
@ -186,7 +178,9 @@ html {
padding: 0.1em; padding: 0.1em;
} }
/* --- AVATAR --- */ /** Avatars
******************************************************************************/
.avatar { .avatar {
vertical-align: middle; vertical-align: middle;
display: inline; display: inline;
@ -202,25 +196,57 @@ html {
min-height: 96px; min-height: 96px;
} }
/* --- QUOTES --- */ /** Statuses: Quotes
.quote blockquote { *
* \e906: icon-quote-open
* \e905: icon-quote-close
*
* The `content` class on the blockquote allows to apply styles to markdown
* generated HTML in the quote: https://bulma.io/documentation/elements/content/
*
* ```html
* <div class="quote block">
* <blockquote dir="auto" class="content mb-2">
* User generated quote in markdown
* </blockquote>
*
* <p> <a>Book Title</a> by <aclass="author">Author</a></p>
* </div>
* ```
******************************************************************************/
.quote > blockquote {
position: relative; position: relative;
padding-left: 2em; padding-left: 2em;
} }
.quote blockquote::before, .quote > blockquote::before,
.quote blockquote::after { .quote > blockquote::after {
font-family: 'icomoon'; font-family: 'icomoon';
position: absolute; position: absolute;
} }
.quote blockquote::before { .quote > blockquote::before {
content: "\e906"; content: "\e906";
top: 0; top: 0;
left: 0; left: 0;
} }
.quote blockquote::after { .quote > blockquote::after {
content: "\e905"; content: "\e905";
right: 0; 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;
}

View file

@ -1,10 +1,13 @@
/** @todo Replace icons with SVG symbols.
@see https://www.youtube.com/watch?v=9xXBYcWgCHA */
@font-face { @font-face {
font-family: 'icomoon'; font-family: 'icomoon';
src: url('fonts/icomoon.eot?n5x55'); src: url('../fonts/icomoon.eot?n5x55');
src: url('fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'), src: url('../fonts/icomoon.eot?n5x55#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?n5x55') format('truetype'), url('../fonts/icomoon.ttf?n5x55') format('truetype'),
url('fonts/icomoon.woff?n5x55') format('woff'), url('../fonts/icomoon.woff?n5x55') format('woff'),
url('fonts/icomoon.svg?n5x55#icomoon') format('svg'); url('../fonts/icomoon.svg?n5x55#icomoon') format('svg');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-display: block; font-display: block;

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

View file

@ -1,17 +1,34 @@
/* exported toggleAllCheckboxes */
/** (function() {
* Toggle all descendant checkboxes of a target. 'use strict';
*
* Use `data-target="ID_OF_TARGET"` on the node being listened to. /**
* * Toggle all descendant checkboxes of a target.
* @param {Event} event - change Event *
* @return {undefined} * 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
function toggleAllCheckboxes(event) { * ancestor for the checkboxes.
const mainCheckbox = event.target; *
* @example
* <input
* type="checkbox"
* data-action="toggle-all"
* data-target="failed-imports"
* >
* @param {Event} event
* @return {undefined}
*/
function toggleAllCheckboxes(event) {
const mainCheckbox = event.target;
document
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`)
.forEach(checkbox => checkbox.checked = mainCheckbox.checked);
}
document document
.querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`) .querySelectorAll('[data-action="toggle-all"]')
.forEach(checkbox => {checkbox.checked = mainCheckbox.checked;}); .forEach(input => {
} input.addEventListener('change', toggleAllCheckboxes);
});
})();

View file

@ -1,20 +1,43 @@
/* exported updateDisplay */ /* exported LocalStorageTools */
/* globals addRemoveClass */ /* globals BookWyrm */
// set javascript listeners let LocalStorageTools = new class {
function updateDisplay(e) { constructor() {
// used in set reading goal document.querySelectorAll('[data-hide]')
var key = e.target.getAttribute('data-id'); .forEach(t => this.setDisplay(t));
var value = e.target.getAttribute('data-value');
window.localStorage.setItem(key, value);
document.querySelectorAll('[data-hide="' + key + '"]') document.querySelectorAll('.set-display')
.forEach(t => setDisplay(t)); .forEach(t => t.addEventListener('click', this.updateDisplay.bind(this)));
} }
function setDisplay(el) { /**
// used in set reading goal * Update localStorage, then display content based on keys in localStorage.
var key = el.getAttribute('data-hide'); *
var value = window.localStorage.getItem(key); * @param {Event} event
addRemoveClass(el, 'hidden', value); * @return {undefined}
*/
updateDisplay(event) {
// used in set reading goal
let key = event.target.dataset.id;
let value = event.target.dataset.value;
window.localStorage.setItem(key, value);
document.querySelectorAll('[data-hide="' + key + '"]')
.forEach(node => this.setDisplay(node));
}
/**
* 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
let key = node.dataset.hide;
let value = window.localStorage.getItem(key);
BookWyrm.addRemoveClass(node, 'is-hidden', value);
}
} }

View file

@ -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(' ');
}

View file

@ -6,24 +6,36 @@
{% block title %}{{ book.title }}{% endblock %} {% block title %}{{ book.title }}{% endblock %}
{% block content %} {% 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="columns is-mobile">
<div class="column"> <div class="column">
<h1 class="title"> <h1 class="title">
{{ book.title }}{% if book.subtitle %}: <span itemprop="name">
<small>{{ book.subtitle }}</small>{% endif %} {{ book.title }}{% if book.subtitle %}:
<small>{{ book.subtitle }}</small>
{% endif %}
</span>
{% if book.series %} {% 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 %} {% endif %}
</h1> </h1>
{% if book.authors %} {% if book.authors %}
<h2 class="subtitle"> <h2 class="subtitle">
{% trans "by" %} {% include 'snippets/authors.html' with book=book %} {% trans "by" %} {% include 'snippets/authors.html' with book=book %}
</h2> </h2>
{% endif %} {% endif %}
</div> </div>
{% if request.user.is_authenticated and perms.bookwyrm.edit_book %} {% if user_authenticated and can_edit_book %}
<div class="column is-narrow"> <div class="column is-narrow">
<a href="{{ book.id }}/edit"> <a href="{{ book.id }}/edit">
<span class="icon icon-pencil" title="{% trans "Edit Book" %}"> <span class="icon icon-pencil" title="{% trans "Edit Book" %}">
@ -44,7 +56,7 @@
{% include 'snippets/shelve_button/shelve_button.html' %} {% include 'snippets/shelve_button/shelve_button.html' %}
</div> </div>
{% if request.user.is_authenticated and not book.cover %} {% if user_authenticated and not book.cover %}
<div class="block"> <div class="block">
{% trans "Add cover" as button_text %} {% 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" %} {% 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" %}
@ -60,7 +72,7 @@
{% if book.isbn_13 %} {% if book.isbn_13 %}
<div class="is-flex is-justify-content-space-between is-align-items-center"> <div class="is-flex is-justify-content-space-between is-align-items-center">
<dt>{% trans "ISBN:" %}</dt> <dt>{% trans "ISBN:" %}</dt>
<dd>{{ book.isbn_13 }}</dd> <dd itemprop="isbn">{{ book.isbn_13 }}</dd>
</div> </div>
{% endif %} {% endif %}
@ -89,18 +101,35 @@
<div class="column is-three-fifths"> <div class="column is-three-fifths">
<div class="block"> <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 %} {% 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> </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 %} {% 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" %} {% 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 }}"> <form name="add-description" method="POST" action="/add-description/{{ book.id }}">
{% csrf_token %} {% csrf_token %}
<p class="fields is-grouped"> <p class="fields is-grouped">
@ -138,7 +167,7 @@
{% endfor %} {% endfor %}
</div> </div>
{% if request.user.is_authenticated %} {% if user_authenticated %}
<section class="block"> <section class="block">
<header class="columns"> <header class="columns">
<h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2> <h2 class="column title is-5 mb-1">{% trans "Your reading activity" %}</h2>
@ -150,7 +179,7 @@
{% if not readthroughs.exists %} {% if not readthroughs.exists %}
<p>{% trans "You don't have any reading activity for this book." %}</p> <p>{% trans "You don't have any reading activity for this book." %}</p>
{% endif %} {% 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"> <form name="add-readthrough" action="/create-readthrough" method="post">
{% include 'snippets/readthrough_form.html' with readthrough=None %} {% include 'snippets/readthrough_form.html' with readthrough=None %}
<div class="field is-grouped"> <div class="field is-grouped">
@ -176,14 +205,15 @@
</div> </div>
<div class="column is-one-fifth"> <div class="column is-one-fifth">
{% if book.subjects %} {% if book.subjects %}
<section class="content block"> <section class="content block">
<h2 class="title is-5">{% trans "Subjects" %}</h2> <h2 class="title is-5">{% trans "Subjects" %}</h2>
<ul>
{% for subject in book.subjects %} <ul>
<li>{{ subject }}</li> {% for subject in book.subjects %}
{% endfor %} <li itemprop="about">{{ subject }}</li>
</ul> {% endfor %}
</section> </ul>
</section>
{% endif %} {% endif %}
{% if book.subject_places %} {% if book.subject_places %}
@ -229,43 +259,56 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<div class="block" id="reviews"> <div class="block" id="reviews">
{% for review in reviews %} {% for review in reviews %}
<div class="block"> <div
{% include 'snippets/status/status.html' with status=review hide_book=True depth=1 %} class="block"
</div> itemprop="review"
{% endfor %} itemscope
itemtype="https://schema.org/Review"
<div class="block is-flex is-flex-wrap-wrap"> >
{% for rating in ratings %} {% with status=review hide_book=True depth=1 %}
<div class="block mr-5"> {% include 'snippets/status/status.html' %}
<div class="media"> {% endwith %}
<div class="media-left">{% include 'snippets/avatar.html' with user=rating.user %}</div>
<div class="media-content">
<div>
<a href="{{ rating.user.local_path }}">{{ rating.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>
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
</div>
</div>
</div> </div>
</div>
{% endfor %} {% endfor %}
</div>
<div class="block"> <div class="block is-flex is-flex-wrap-wrap">
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %} {% for rating in ratings %}
{% with user=rating.user %}
<div class="block mr-5">
<div class="media">
<div class="media-left">
{% include 'snippets/avatar.html' %}
</div>
<div class="media-content">
<div>
<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>
<a href="{{ rating.remote_id }}">{{ rating.published_date | naturaltime }}</a>
</div>
</div>
</div>
</div>
{% endwith %}
{% endfor %}
</div>
<div class="block">
{% include 'snippets/pagination.html' with page=reviews path=book.local_path anchor="#reviews" %}
</div>
</div> </div>
</div> </div>
{% endwith %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/tabs.js"></script> <script src="/static/js/vendor/tabs.js"></script>
{% endblock %} {% endblock %}

View file

@ -1,24 +1,69 @@
{% spaceless %}
{% load i18n %} {% load i18n %}
<p> <p>
{% if book.physical_format and not book.pages %} {% with format=book.physical_format pages=book.pages %}
{{ book.physical_format | title }} {% if format %}
{% elif book.physical_format and book.pages %} {% comment %}
{% blocktrans with format=book.physical_format|title pages=book.pages %}{{ format }}, {{ pages }} pages{% endblocktrans %} @todo The bookFormat property is limited to a list of values whereas the book edition is free text.
{% elif book.pages %} @see https://schema.org/bookFormat
{% blocktrans with pages=book.pages %}{{ pages }} pages{% endblocktrans %} {% endcomment %}
{% endif %} <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> </p>
{% if book.languages %} {% if book.languages %}
<p> {% for language in book.languages %}
{% blocktrans with languages=book.languages|join:", " %}{{ languages }} language{% endblocktrans %} <meta itemprop="inLanguage" content="{{ language }}">
</p> {% endfor %}
<p>
{% with languages=book.languages|join:", " %}
{% blocktrans %}{{ languages }} language{% endblocktrans %}
{% endwith %}
</p>
{% endif %} {% endif %}
<p> <p>
{% if book.published_date and book.publishers %} {% with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}
{% blocktrans with date=book.published_date|date:'M jS Y' publisher=book.publishers|join:', ' %}Published {{ date }} by {{ publisher }}.{% endblocktrans %} {% if date or book.first_published_date %}
{% elif book.published_date %} <meta
{% blocktrans with date=book.published_date|date:'M jS Y' %}Published {{ date }}{% endblocktrans %} itemprop="datePublished"
{% elif book.publishers %} content="{{ book.first_published_date|default:book.published_date|date:'Y-m-d' }}"
{% blocktrans with publisher=book.publishers|join:', ' %}Published by {{ publisher }}.{% endblocktrans %} >
{% endif %} {% endif %}
{% comment %}
@todo The publisher property needs to be an Organization or a Person. Well 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> </p>
{% endspaceless %}

View file

@ -1,13 +1,34 @@
{% spaceless %}
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% with 0|uuid as uuid %} {% with 0|uuid as uuid %}
<div class="dropdown control{% if right %} is-right{% endif %}" id="menu-{{ uuid }}"> <div
<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 }}"> 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 %} {% block dropdown-trigger %}{% endblock %}
</button> </button>
<div class="dropdown-menu"> <div class="dropdown-menu">
<ul class="dropdown-content" role="menu" id="menu-options-{{ uuid }}"> <ul
id="menu-options-{{ uuid }}"
class="dropdown-content"
role="menu"
>
{% block dropdown-list %}{% endblock %} {% block dropdown-list %}{% endblock %}
</ul> </ul>
</div> </div>
</div> </div>
{% endwith %} {% endwith %}
{% endspaceless %}

View file

@ -1,5 +1,5 @@
{% load i18n %} {% 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"> <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"> <h2 class="card-header-title" tabindex="0" id="{{ controls_text }}{% if controls_uid %}-{{ controls_uid }}{% endif %}-header">
{% block header %}{% endblock %} {% block header %}{% endblock %}

View file

@ -1,7 +1,7 @@
{% load i18n %} {% load i18n %}
<div <div
role="dialog" role="dialog"
class="modal hidden" class="modal is-hidden"
id="{{ controls_text }}-{{ controls_uid }}" id="{{ controls_text }}-{{ controls_uid }}"
aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}" aria-labelledby="modal-card-title-{{ controls_text }}-{{ controls_uid }}"
aria-modal="true" aria-modal="true"

View file

@ -29,7 +29,7 @@
{# announcements and system messages #} {# announcements and system messages #}
{% if not activities.number > 1 %} {% 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 %} {% blocktrans %}load <span data-poll="stream/{{ tab }}">0</span> unread status(es){% endblocktrans %}
</a> </a>

View file

@ -104,5 +104,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="/static/js/tabs.js"></script> <script src="/static/js/vendor/tabs.js"></script>
{% endblock %} {% endblock %}

View file

@ -20,7 +20,7 @@
{% if user == request.user %} {% if user == request.user %}
<div class="block"> <div class="block">
{% now 'Y' as year %} {% 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"> <header class="card-header">
<h2 class="card-header-title has-background-primary has-text-white" tabindex="0" id="edit-form-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 %} <span class="icon icon-book is-size-3 mr-2" aria-hidden="true"></span> {% blocktrans %}{{ year }} Reading Goal{% endblocktrans %}

View file

@ -5,9 +5,9 @@
<head> <head>
<title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title> <title>{% block title %}BookWyrm{% endblock %} | {{ site.name }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link type="text/css" rel="stylesheet" href="/static/css/bulma.min.css"> <link rel="stylesheet" href="/static/css/vendor/bulma.min.css">
<link type="text/css" rel="stylesheet" href="/static/css/format.css"> <link rel="stylesheet" href="/static/css/vendor/icons.css">
<link type="text/css" rel="stylesheet" href="/static/css/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 %}"> <link rel="shortcut icon" type="image/x-icon" href="{% if site.favicon %}/images/{{ site.favicon }}{% else %}/static/images/favicon.ico{% endif %}">
@ -134,7 +134,7 @@
<span class="is-sr-only">{% trans "Notifications" %}</span> <span class="is-sr-only">{% trans "Notifications" %}</span>
</span> </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 data-poll="notifications">{{ request.user | notification_count }}</span>
</span> </span>
</a> </a>
@ -212,7 +212,7 @@
<script> <script>
var csrf_token = '{{ csrf_token }}'; var csrf_token = '{{ csrf_token }}';
</script> </script>
<script src="/static/js/shared.js"></script> <script src="/static/js/bookwyrm.js"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>

View file

@ -37,7 +37,7 @@
</div> </div>
{% endif %} {% 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:" %} {% for result_set in book_results|slice:"1:" %}
{% if result_set.results %} {% if result_set.results %}
<section class="block"> <section class="block">

View file

@ -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. Well 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 %}

View file

@ -1,13 +1,29 @@
{% spaceless %}
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %}
<div class="cover-container is-{{ size }}"> <div class="cover-container is-{{ size }}">
{% if book.cover %} {% if book.cover %}
<img class="book-cover" src="/images/{{ book.cover }}" alt="{{ book.alt_text }}" title="{{ book.alt_text }}"> <img
{% else %} class="book-cover"
<div class="no-cover book-cover"> src="/images/{{ book.cover }}"
<img class="book-cover" src="/static/images/no_cover.jpg" alt="No cover"> alt="{{ book.alt_text }}"
<div> title="{{ book.alt_text }}"
<p>{{ book.alt_text }}</p> itemprop="thumbnailUrl"
>
{% else %}
<div class="no-cover book-cover">
<img
class="book-cover"
src="/static/images/no_cover.jpg"
alt="{% trans "No cover" %}"
>
<div>
<p>{{ book.alt_text }}</p>
</div>
</div> </div>
</div> {% endif %}
{% endif %}
</div> </div>
{% endspaceless %}

View file

@ -2,7 +2,7 @@
{% load i18n %} {% load i18n %}
{% with status.id|uuid as uuid %} {% 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 %} {% csrf_token %}
<button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}> <button class="button is-small" type="submit" {% if not status.boostable %}disabled{% endif %}>
<span class="icon icon-boost" title="{% trans 'Boost status' %}"> <span class="icon icon-boost" title="{% trans 'Boost status' %}">
@ -10,7 +10,7 @@
</span> </span>
</button> </button>
</form> </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 %} {% csrf_token %}
<button class="button is-small is-primary" type="submit"> <button class="button is-small is-primary" type="submit">
<span class="icon icon-boost" title="{% trans 'Un-boost status' %}"> <span class="icon icon-boost" title="{% trans 'Un-boost status' %}">

View file

@ -1,5 +1,5 @@
{% load i18n %} {% 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> <label class="is-sr-only" for="id_content_warning-{{ uuid }}">{% trans "Spoiler alert:" %}</label>
<input <input
type="text" type="text"

View file

@ -73,7 +73,7 @@
{% endif %} {% endif %}
</div> </div>
{% endif %} {% 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"> <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">
{# bottom bar #} {# bottom bar #}
<div class="columns pt-1"> <div class="columns pt-1">
<div class="field has-addons column"> <div class="field has-addons column">

View file

@ -1,7 +1,7 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
{% with status.id|uuid as uuid %} {% 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 %} {% csrf_token %}
<button class="button is-small" type="submit"> <button class="button is-small" type="submit">
<span class="icon icon-heart" title="{% trans 'Like status' %}"> <span class="icon icon-heart" title="{% trans 'Like status' %}">
@ -9,7 +9,7 @@
</span> </span>
</button> </button>
</form> </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 %} {% csrf_token %}
<button class="button is-primary is-small" type="submit"> <button class="button is-primary is-small" type="submit">
<span class="icon icon-heart" title="{% trans 'Un-like status' %}"> <span class="icon icon-heart" title="{% trans 'Un-like status' %}">

View file

@ -11,7 +11,7 @@
</span> </span>
</h2> </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 %} {% if sort %}
<input type="hidden" name="sort" value="{{ sort }}"> <input type="hidden" name="sort" value="{{ sort }}">
{% endif %} {% endif %}

View file

@ -6,12 +6,12 @@
<div class="field{% if not minimal %} has-addons{% else %} mb-0{% endif %}"> <div class="field{% if not minimal %} has-addons{% else %} mb-0{% endif %}">
<div class="control"> <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 %} {% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}"> <input type="hidden" name="user" value="{{ user.username }}">
<button class="button is-small{% if not minimal %} is-link{% endif %}" type="submit">{% trans "Follow" %}</button> <button class="button is-small{% if not minimal %} is-link{% endif %}" type="submit">{% trans "Follow" %}</button>
</form> </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 %} {% csrf_token %}
<input type="hidden" name="user" value="{{ user.username }}"> <input type="hidden" name="user" value="{{ user.username }}">
{% if user.manually_approves_followers and request.user not in user.followers.all %} {% if user.manually_approves_followers and request.user not in user.followers.all %}

View file

@ -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 %} {% 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"> <div class="control">
{% include 'snippets/privacy_select.html' with class="is-small" %} {% include 'snippets/privacy_select.html' with class="is-small" %}
</div> </div>

View file

@ -24,7 +24,7 @@
{% if readthrough.progress %} {% if readthrough.progress %}
{% trans "Show all updates" as button_text %} {% 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" %} {% 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 %} {% for progress_update in readthrough.progress_updates %}
<li> <li>
<form name="delete-update" action="/delete-progressupdate" method="POST"> <form name="delete-update" action="/delete-progressupdate" method="POST">
@ -67,7 +67,7 @@
</div> </div>
</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> <h3 class="title is-5">{% trans "Edit read dates" %}</h3>
<form name="edit-readthrough" action="/edit-readthrough" method="post"> <form name="edit-readthrough" action="/edit-readthrough" method="post">
{% include 'snippets/readthrough_form.html' with readthrough=readthrough %} {% include 'snippets/readthrough_form.html' with readthrough=readthrough %}

View file

@ -3,7 +3,7 @@
{% for shelf in shelves %} {% for shelf in shelves %}
{% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %} {% comparison_bool shelf.identifier active_shelf.shelf.identifier as is_current %}
{% if dropdown %}<li role="menuitem">{% endif %} {% if dropdown %}<li role="menuitem">{% endif %}
<div class="{% if dropdown %}dropdown-item pt-0 pb-0{% elif active_shelf.shelf.identifier|next_shelf != shelf.identifier %}hidden{% endif %}"> <div class="{% if dropdown %}dropdown-item pt-0 pb-0{% elif active_shelf.shelf.identifier|next_shelf != shelf.identifier %}is-hidden{% endif %}">
{% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %} {% if shelf.identifier == 'reading' %}{% if not dropdown or active_shelf.shelf.identifier|next_shelf != shelf.identifier %}
{% trans "Start reading" as button_text %} {% trans "Start reading" as button_text %}
{% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %} {% include 'snippets/toggle/toggle_button.html' with class=class text=button_text controls_text="start-reading" controls_uid=button_uuid focus="modal-title-start-reading" disabled=is_current %}

View file

@ -78,7 +78,7 @@
{% block card-bonus %} {% block card-bonus %}
{% if request.user.is_authenticated and not moderation_mode %} {% if request.user.is_authenticated and not moderation_mode %}
{% with status.id|uuid as uuid %} {% with status.id|uuid as uuid %}
<section class="hidden" id="show-comment-{{ status.id }}"> <section class="is-hidden" id="show-comment-{{ status.id }}">
<div class="card-footer"> <div class="card-footer">
<div class="card-footer-item"> <div class="card-footer-item">
{% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %} {% include 'snippets/create_status_form.html' with reply_parent=status type="reply" %}

View file

@ -1,68 +1,137 @@
{% spaceless %}
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
<div class="block">
{% if status.status_type == 'Review' or status.status_type == 'Rating' %} {% with status_type=status.status_type %}
<div> <div
{% if status.name %} class="block"
<h3 class="title is-5 has-subtitle" dir="auto">
{{ status.name|escape }} {% if status_type == 'Review' %}
</h3> {% firstof "reviewBody" as body_prop %}
{% endif %} {% firstof 'itemprop="reviewRating" itemscope itemtype="https://schema.org/Rating"' as rating_type %}
{% include 'snippets/stars.html' with rating=status.rating %} {% endif %}
</div>
{% if status_type == 'Rating' %}
itemprop="rating"
itemtype="https://schema.org/Rating"
{% endif %}
>
{% if status_type == 'Review' or status_type == 'Rating' %}
<div>
{% if status.name %}
<h3
class="title is-5 has-subtitle"
dir="auto"
itemprop="name"
>
{{ status.name|escape }}
</h3>
{% endif %}
<span
class="is-sr-only"
{{ rating_type }}
>
<meta itemprop="ratingValue" content="{{ status.rating|floatformat }}">
{% if status_type == 'Rating' %}
{# @todo Is it possible to not hard-code the value? #}
<meta itemprop="bestRating" content="5">
{% endif %}
</span>
{% include 'snippets/stars.html' with rating=status.rating %}
</div>
{% endif %} {% endif %}
{% if status.content_warning %} {% if status.content_warning %}
<div> <div>
<p>{{ status.content_warning }}</p> <p>{{ status.content_warning }}</p>
{% trans "Show more" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %} {% trans "Show more" as button_text %}
</div>
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/open_button.html' %}
{% endwith %}
</div>
{% endif %} {% endif %}
<div{% if status.content_warning %} class="hidden" id="show-status-cw-{{ status.id }}"{% endif %}> <div
{% if status.content_warning %} {% if status.content_warning %}
{% trans "Show less" as button_text %} id="show-status-cw-{{ status.id }}"
{% include 'snippets/toggle/close_button.html' with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %} class="is-hidden"
{% endif %}
>
{% if status.content_warning %}
{% trans "Show less" as button_text %}
{% with text=button_text class="is-small" controls_text="show-status-cw" controls_uid=status.id %}
{% include 'snippets/toggle/close_button.html' %}
{% endwith %}
{% endif %} {% endif %}
{% if status.quote %} {% if status.quote %}
<div class="quote block"> <div class="quote block">
<blockquote dir="auto" class="mb-2">{{ status.quote | safe }}</blockquote> <blockquote dir="auto" class="content mb-2">{{ status.quote | safe }}</blockquote>
<p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p> <p> &mdash; {% include 'snippets/book_titleby.html' with book=status.book %}</p>
</div> </div>
{% endif %} {% endif %}
{% if status.content and status.status_type != 'GeneratedNote' and status.status_type != 'Announce' %} {% if status.content and status_type != 'GeneratedNote' and status_type != 'Announce' %}
{% include 'snippets/trimmed_text.html' with full=status.content|safe no_trim=status.content_warning %} {% with full=status.content|safe no_trim=status.content_warning itemprop=body_prop %}
{% endif %} {% include 'snippets/trimmed_text.html' %}
{% if status.attachments.exists %} {% endwith %}
<div class="block"> {% endif %}
<div class="columns">
{% for attachment in status.attachments.all %} {% if status.attachments.exists %}
<div class="column is-narrow"> <div class="block">
<figure class="image is-128x128"> <div class="columns">
<a href="/images/{{ attachment.image }}" target="_blank" aria-label="{% trans 'Open image in new window' %}"> {% for attachment in status.attachments.all %}
<img src="/images/{{ attachment.image }}"{% if attachment.caption %} alt="{{ attachment.caption }}" title="{{ attachment.caption }}"{% endif %}> <div class="column is-narrow">
</a> <figure class="image is-128x128">
</figure> <a
</div> href="/images/{{ attachment.image }}"
{% endfor %} target="_blank"
aria-label="{% trans 'Open image in new window' %}"
>
<img
src="/images/{{ attachment.image }}"
{% if attachment.caption %}
alt="{{ attachment.caption }}"
title="{{ attachment.caption }}"
{% endif %}
>
</a>
</figure>
</div>
{% endfor %}
</div>
</div> </div>
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if not hide_book %} {% if not hide_book %}
{% if status.book or status.mention_books.count %} {% if status.book or status.mention_books.count %}
<div class="{% if status.status_type != 'GeneratedNote' %}box has-background-white-bis{% endif %}"> <div
{% if status.book %} {% if status_type != 'GeneratedNote' %}
{% include 'snippets/status/book_preview.html' with book=status.book %} class="box has-background-white-bis"
{% elif status.mention_books.count %} {% endif %}
{% include 'snippets/status/book_preview.html' with book=status.mention_books.first %} >
{% if status.book %}
{% with book=status.book %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% elif status.mention_books.count %}
{% with book=status.mention_books.first %}
{% include 'snippets/status/book_preview.html' %}
{% endwith %}
{% endif %}
</div>
{% endif %} {% endif %}
</div>
{% endif %}
{% endif %} {% endif %}
{% endwith %}
{% endspaceless %}

View file

@ -1,9 +1,19 @@
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
<a href="{{ status.user.local_path }}"> <span
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %} itemprop="author"
{{ status.user.display_name }} itemscope
</a> itemtype="https://schema.org/Person"
>
<a
href="{{ status.user.local_path }}"
itemprop="url"
>
{% include 'snippets/avatar.html' with user=status.user ariaHide="true" %}
<span itemprop="name">{{ status.user.display_name }}</span>
</a>
</span>
{% if status.status_type == 'GeneratedNote' %} {% if status.status_type == 'GeneratedNote' %}
{{ status.content | safe }} {{ status.content | safe }}

View file

@ -1,40 +1,49 @@
{% spaceless %}
{% load bookwyrm_tags %} {% load bookwyrm_tags %}
{% load i18n %} {% load i18n %}
{% with 0|uuid as uuid %} {% with 0|uuid as uuid %}
{% if full %} {% if full %}
{% with full|to_markdown|safe as full %} {% with full|to_markdown|safe as full %}
{% with full|to_markdown|safe|truncatewords_html:60 as trimmed %}
{% if not no_trim and trimmed != full %}
<div id="hide-full-{{ uuid }}">
<div class="content" id="trimmed-{{ uuid }}">
<div dir="auto">{{ trimmed }}</div>
{% with full|to_markdown|safe|truncatewords_html:60 as trimmed %} <div>
{% if not no_trim and trimmed != full %} {% trans "Show more" as button_text %}
<div id="hide-full-{{ uuid }}"> {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
<div class="content" id="trimmed-{{ uuid }}"> </div>
<div dir="auto">{{ trimmed }}</div> </div>
</div>
<div id="full-{{ uuid }}" class="is-hidden">
<div class="content">
<div
dir="auto"
{% if itemprop %}itemprop="{{ itemprop }}{% endif %}"
>
{{ full }}
</div>
<div> <div>
{% trans "Show more" as button_text %} {% trans "Show less" as button_text %}
{% include 'snippets/toggle/open_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %} {% include 'snippets/toggle/close_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %}
</div> </div>
</div> </div>
</div> </div>
<div id="full-{{ uuid }}" class="hidden"> {% else %}
<div class="content"> <div class="content">
<div dir="auto">{{ full }}</div> <div
dir="auto"
<div> {% if itemprop %}itemprop="{{ itemprop }}{% endif %}"
{% trans "Show less" as button_text %} >
{% include 'snippets/toggle/close_button.html' with text=button_text controls_text="full" controls_uid=uuid class="is-small" %} {{ full }}
</div> </div>
</div> </div>
</div> {% endif %}
{% else %} {% endwith %}
<div class="content"> {% endwith %}
<div dir="auto">{{ full }}</div> {% endif %}
</div>
{% endif %}
{% endwith %} {% endwith %}
{% endspaceless %}
{% endwith %}
{% endif %}
{% endwith %}

View file

@ -24,7 +24,7 @@
{% block panel %} {% block panel %}
<section class="block content"> <section class="block content">
<form name="create-list" method="post" action="{% url 'lists' %}" class="box hidden" id="create-list"> <form name="create-list" method="post" action="{% url 'lists' %}" class="box is-hidden" id="create-list">
<header class="columns"> <header class="columns">
<h3 class="title column">{% trans "Create list" %}</h3> <h3 class="title column">{% trans "Create list" %}</h3>
<div class="column is-narrow"> <div class="column is-narrow">

View file

@ -11,45 +11,64 @@ class List(TestCase):
def setUp(self): def setUp(self):
""" look, a list """ """ look, a list """
self.user = models.User.objects.create_user( self.local_user = models.User.objects.create_user(
"mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse" "mouse", "mouse@mouse.mouse", "mouseword", local=True, localname="mouse"
) )
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): work = models.Work.objects.create(title="hello")
self.list = models.List.objects.create(name="Test List", user=self.user) self.book = models.Edition.objects.create(title="hi", parent_work=work)
def test_remote_id(self, _): def test_remote_id(self, _):
""" shelves use custom remote ids """ """ shelves use custom remote ids """
expected_id = "https://%s/list/%d" % (settings.DOMAIN, self.list.id) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
self.assertEqual(self.list.get_remote_id(), expected_id) book_list = models.List.objects.create(
name="Test List", user=self.local_user
)
expected_id = "https://%s/list/%d" % (settings.DOMAIN, book_list.id)
self.assertEqual(book_list.get_remote_id(), expected_id)
def test_to_activity(self, _): def test_to_activity(self, _):
""" jsonify it """ """ jsonify it """
activity_json = self.list.to_activity() with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
book_list = models.List.objects.create(
name="Test List", user=self.local_user
)
activity_json = book_list.to_activity()
self.assertIsInstance(activity_json, dict) self.assertIsInstance(activity_json, dict)
self.assertEqual(activity_json["id"], self.list.remote_id) self.assertEqual(activity_json["id"], book_list.remote_id)
self.assertEqual(activity_json["totalItems"], 0) self.assertEqual(activity_json["totalItems"], 0)
self.assertEqual(activity_json["type"], "BookList") self.assertEqual(activity_json["type"], "BookList")
self.assertEqual(activity_json["name"], "Test List") self.assertEqual(activity_json["name"], "Test List")
self.assertEqual(activity_json["owner"], self.user.remote_id) self.assertEqual(activity_json["owner"], self.local_user.remote_id)
def test_list_item(self, _): def test_list_item(self, _):
""" a list entry """ """ a list entry """
work = models.Work.objects.create(title="hello") with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
book = models.Edition.objects.create(title="hi", parent_work=work) book_list = models.List.objects.create(
name="Test List", user=self.local_user, privacy="unlisted"
)
item = models.ListItem.objects.create( item = models.ListItem.objects.create(
book_list=self.list, book_list=book_list,
book=book, book=self.book,
user=self.user, user=self.local_user,
) )
self.assertTrue(item.approved) self.assertTrue(item.approved)
self.assertEqual(item.privacy, "unlisted")
self.assertEqual(item.recipients, [self.local_user])
add_activity = item.to_add_activity() def test_list_item_pending(self, _):
self.assertEqual(add_activity["actor"], self.user.remote_id) """ a list entry """
self.assertEqual(add_activity["object"]["id"], book.remote_id) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
self.assertEqual(add_activity["target"], self.list.remote_id) book_list = models.List.objects.create(
name="Test List", user=self.local_user
)
remove_activity = item.to_remove_activity() item = models.ListItem.objects.create(
self.assertEqual(remove_activity["actor"], self.user.remote_id) book_list=book_list, book=self.book, user=self.local_user, approved=False
self.assertEqual(remove_activity["object"]["id"], book.remote_id) )
self.assertEqual(remove_activity["target"], self.list.remote_id)
self.assertFalse(item.approved)
self.assertEqual(item.book_list.privacy, "public")
self.assertEqual(item.privacy, "direct")
self.assertEqual(item.recipients, [self.local_user])

View file

@ -1,4 +1,6 @@
""" testing models """ """ testing models """
import json
from unittest.mock import patch
from django.test import TestCase from django.test import TestCase
from bookwyrm import models, settings from bookwyrm import models, settings
@ -18,30 +20,19 @@ class Shelf(TestCase):
def test_remote_id(self): def test_remote_id(self):
""" shelves use custom remote ids """ """ shelves use custom remote ids """
real_broadcast = models.Shelf.broadcast with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
shelf = models.Shelf.objects.create(
def broadcast_mock(_, activity, user, **kwargs): name="Test Shelf", identifier="test-shelf", user=self.local_user
""" nah """ )
models.Shelf.broadcast = broadcast_mock
shelf = models.Shelf.objects.create(
name="Test Shelf", identifier="test-shelf", user=self.local_user
)
expected_id = "https://%s/user/mouse/books/test-shelf" % settings.DOMAIN expected_id = "https://%s/user/mouse/books/test-shelf" % settings.DOMAIN
self.assertEqual(shelf.get_remote_id(), expected_id) self.assertEqual(shelf.get_remote_id(), expected_id)
models.Shelf.broadcast = real_broadcast
def test_to_activity(self): def test_to_activity(self):
""" jsonify it """ """ jsonify it """
real_broadcast = models.Shelf.broadcast with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
shelf = models.Shelf.objects.create(
def empty_mock(_, activity, user, **kwargs): name="Test Shelf", identifier="test-shelf", user=self.local_user
""" nah """ )
models.Shelf.broadcast = empty_mock
shelf = models.Shelf.objects.create(
name="Test Shelf", identifier="test-shelf", user=self.local_user
)
activity_json = shelf.to_activity() activity_json = shelf.to_activity()
self.assertIsInstance(activity_json, dict) self.assertIsInstance(activity_json, dict)
self.assertEqual(activity_json["id"], shelf.remote_id) self.assertEqual(activity_json["id"], shelf.remote_id)
@ -49,77 +40,53 @@ class Shelf(TestCase):
self.assertEqual(activity_json["type"], "Shelf") self.assertEqual(activity_json["type"], "Shelf")
self.assertEqual(activity_json["name"], "Test Shelf") self.assertEqual(activity_json["name"], "Test Shelf")
self.assertEqual(activity_json["owner"], self.local_user.remote_id) self.assertEqual(activity_json["owner"], self.local_user.remote_id)
models.Shelf.broadcast = real_broadcast
def test_create_update_shelf(self): def test_create_update_shelf(self):
""" create and broadcast shelf creation """ """ create and broadcast shelf creation """
real_broadcast = models.Shelf.broadcast
def create_mock(_, activity, user, **kwargs): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
""" ok """ shelf = models.Shelf.objects.create(
self.assertEqual(user.remote_id, self.local_user.remote_id) name="Test Shelf", identifier="test-shelf", user=self.local_user
self.assertEqual(activity["type"], "Create") )
self.assertEqual(activity["actor"], self.local_user.remote_id) activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["object"]["name"], "Test Shelf") self.assertEqual(activity["type"], "Create")
self.assertEqual(activity["actor"], self.local_user.remote_id)
models.Shelf.broadcast = create_mock self.assertEqual(activity["object"]["name"], "Test Shelf")
shelf = models.Shelf.objects.create(
name="Test Shelf", identifier="test-shelf", user=self.local_user
)
def update_mock(_, activity, user, **kwargs):
""" ok """
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Update")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"]["name"], "arthur russel")
models.Shelf.broadcast = update_mock
shelf.name = "arthur russel" shelf.name = "arthur russel"
shelf.save() with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
shelf.save()
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Update")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"]["name"], "arthur russel")
self.assertEqual(shelf.name, "arthur russel") self.assertEqual(shelf.name, "arthur russel")
models.Shelf.broadcast = real_broadcast
def test_shelve(self): def test_shelve(self):
""" create and broadcast shelf creation """ """ create and broadcast shelf creation """
real_broadcast = models.Shelf.broadcast with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
real_shelfbook_broadcast = models.ShelfBook.broadcast shelf = models.Shelf.objects.create(
name="Test Shelf", identifier="test-shelf", user=self.local_user
)
def add_mock(_, activity, user, **kwargs): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
""" ok """ shelf_book = models.ShelfBook.objects.create(
self.assertEqual(user.remote_id, self.local_user.remote_id) shelf=shelf, user=self.local_user, book=self.book
self.assertEqual(activity["type"], "Add") )
self.assertEqual(activity["actor"], self.local_user.remote_id) self.assertEqual(mock.call_count, 1)
self.assertEqual(activity["object"]["id"], self.book.remote_id) activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["target"], shelf.remote_id) self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.local_user.remote_id)
def remove_mock(_, activity, user, **kwargs): self.assertEqual(activity["object"]["id"], shelf_book.remote_id)
""" ok """ self.assertEqual(activity["target"], shelf.remote_id)
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Remove")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"]["id"], self.book.remote_id)
self.assertEqual(activity["target"], shelf.remote_id)
def empty_mock(_, activity, user, **kwargs):
""" nah """
models.Shelf.broadcast = empty_mock
shelf = models.Shelf.objects.create(
name="Test Shelf", identifier="test-shelf", user=self.local_user
)
models.ShelfBook.broadcast = add_mock
shelf_book = models.ShelfBook.objects.create(
shelf=shelf, user=self.local_user, book=self.book
)
self.assertEqual(shelf.books.first(), self.book) self.assertEqual(shelf.books.first(), self.book)
models.ShelfBook.broadcast = remove_mock with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
shelf_book.delete() shelf_book.delete()
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Remove")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"]["id"], shelf_book.remote_id)
self.assertEqual(activity["target"], shelf.remote_id)
self.assertFalse(shelf.books.exists()) self.assertFalse(shelf.books.exists())
models.ShelfBook.broadcast = real_shelfbook_broadcast
models.Shelf.broadcast = real_broadcast

View file

@ -8,20 +8,20 @@ from bookwyrm import models, views
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
class InboxActivities(TestCase): class InboxAdd(TestCase):
""" inbox tests """ """ inbox tests """
def setUp(self): def setUp(self):
""" basic user and book data """ """ basic user and book data """
self.local_user = models.User.objects.create_user( local_user = models.User.objects.create_user(
"mouse@example.com", "mouse@example.com",
"mouse@mouse.com", "mouse@mouse.com",
"mouseword", "mouseword",
local=True, local=True,
localname="mouse", localname="mouse",
) )
self.local_user.remote_id = "https://example.com/user/mouse" local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False) local_user.save(broadcast=False)
with patch("bookwyrm.models.user.set_remote_server.delay"): with patch("bookwyrm.models.user.set_remote_server.delay"):
self.remote_user = models.User.objects.create_user( self.remote_user = models.User.objects.create_user(
"rat", "rat",
@ -32,17 +32,17 @@ class InboxActivities(TestCase):
inbox="https://example.com/users/rat/inbox", inbox="https://example.com/users/rat/inbox",
outbox="https://example.com/users/rat/outbox", outbox="https://example.com/users/rat/outbox",
) )
work = models.Work.objects.create(title="work title")
self.book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
def test_handle_add_book_to_shelf(self): def test_handle_add_book_to_shelf(self):
""" shelving a book """ """ shelving a book """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf") shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read" shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
shelf.save() shelf.save()
@ -52,27 +52,20 @@ class InboxActivities(TestCase):
"type": "Add", "type": "Add",
"actor": "https://example.com/users/rat", "actor": "https://example.com/users/rat",
"object": { "object": {
"type": "Edition", "actor": self.remote_user.remote_id,
"title": "Test Title", "type": "ShelfItem",
"work": work.remote_id, "book": self.book.remote_id,
"id": "https://bookwyrm.social/book/37292", "id": "https://bookwyrm.social/shelfbook/6189",
}, },
"target": "https://bookwyrm.social/user/mouse/shelf/to-read", "target": "https://bookwyrm.social/user/mouse/shelf/to-read",
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
} }
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
self.assertEqual(shelf.books.first(), book) self.assertEqual(shelf.books.first(), self.book)
@responses.activate @responses.activate
def test_handle_add_book_to_list(self): def test_handle_add_book_to_list(self):
""" listing a book """ """ listing a book """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
responses.add( responses.add(
responses.GET, responses.GET,
"https://bookwyrm.social/user/mouse/list/to-read", "https://bookwyrm.social/user/mouse/list/to-read",
@ -97,10 +90,10 @@ class InboxActivities(TestCase):
"type": "Add", "type": "Add",
"actor": "https://example.com/users/rat", "actor": "https://example.com/users/rat",
"object": { "object": {
"type": "Edition", "actor": self.remote_user.remote_id,
"title": "Test Title", "type": "ListItem",
"work": work.remote_id, "book": self.book.remote_id,
"id": "https://bookwyrm.social/book/37292", "id": "https://bookwyrm.social/listbook/6189",
}, },
"target": "https://bookwyrm.social/user/mouse/list/to-read", "target": "https://bookwyrm.social/user/mouse/list/to-read",
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
@ -108,49 +101,7 @@ class InboxActivities(TestCase):
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
booklist = models.List.objects.get() booklist = models.List.objects.get()
listitem = models.ListItem.objects.get()
self.assertEqual(booklist.name, "Test List") self.assertEqual(booklist.name, "Test List")
self.assertEqual(booklist.books.first(), book) self.assertEqual(booklist.books.first(), self.book)
self.assertEqual(listitem.remote_id, "https://bookwyrm.social/listbook/6189")
@responses.activate
def test_handle_tag_book(self):
""" listing a book """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
responses.add(
responses.GET,
"https://www.example.com/tag/cool-tag",
json={
"id": "https://1b1a78582461.ngrok.io/tag/tag",
"type": "OrderedCollection",
"totalItems": 0,
"first": "https://1b1a78582461.ngrok.io/tag/tag?page=1",
"last": "https://1b1a78582461.ngrok.io/tag/tag?page=1",
"name": "cool tag",
"@context": "https://www.w3.org/ns/activitystreams",
},
)
activity = {
"id": "https://bookwyrm.social/listbook/6189#add",
"type": "Add",
"actor": "https://example.com/users/rat",
"object": {
"type": "Edition",
"title": "Test Title",
"work": work.remote_id,
"id": "https://bookwyrm.social/book/37292",
},
"target": "https://www.example.com/tag/cool-tag",
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
tag = models.Tag.objects.get()
self.assertFalse(models.List.objects.exists())
self.assertEqual(tag.name, "cool tag")
self.assertEqual(tag.books.first(), book)

View file

@ -136,6 +136,9 @@ class InboxActivities(TestCase):
"id": "http://www.faraway.com/boost/12", "id": "http://www.faraway.com/boost/12",
"actor": self.remote_user.remote_id, "actor": self.remote_user.remote_id,
"object": status.remote_id, "object": status.remote_id,
"to": ["https://www.w3.org/ns/activitystreams#public"],
"cc": ["https://example.com/user/mouse/followers"],
"published": "Mon, 25 May 2020 19:31:20 GMT",
} }
responses.add( responses.add(
responses.GET, status.remote_id, json=status.to_activity(), status=200 responses.GET, status.remote_id, json=status.to_activity(), status=200
@ -185,6 +188,7 @@ class InboxActivities(TestCase):
"id": "http://fake.com/unknown/boost", "id": "http://fake.com/unknown/boost",
"actor": self.remote_user.remote_id, "actor": self.remote_user.remote_id,
"object": self.status.remote_id, "object": self.status.remote_id,
"published": "Mon, 25 May 2020 19:31:20 GMT",
}, },
} }
views.inbox.activity_task(activity) views.inbox.activity_task(activity)

View file

@ -9,7 +9,7 @@ from bookwyrm import models, views
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
class InboxActivities(TestCase): class InboxCreate(TestCase):
""" readthrough tests """ """ readthrough tests """
def setUp(self): def setUp(self):

View file

@ -7,11 +7,20 @@ from bookwyrm import models, views
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
class InboxActivities(TestCase): class InboxRemove(TestCase):
""" inbox tests """ """ inbox tests """
def setUp(self): def setUp(self):
""" basic user and book data """ """ basic user and book data """
self.local_user = models.User.objects.create_user(
"mouse@example.com",
"mouse@mouse.com",
"mouseword",
local=True,
localname="mouse",
)
self.local_user.remote_id = "https://example.com/user/mouse"
self.local_user.save(broadcast=False)
with patch("bookwyrm.models.user.set_remote_server.delay"): with patch("bookwyrm.models.user.set_remote_server.delay"):
self.remote_user = models.User.objects.create_user( self.remote_user = models.User.objects.create_user(
"rat", "rat",
@ -22,26 +31,26 @@ class InboxActivities(TestCase):
inbox="https://example.com/users/rat/inbox", inbox="https://example.com/users/rat/inbox",
outbox="https://example.com/users/rat/outbox", outbox="https://example.com/users/rat/outbox",
) )
self.work = models.Work.objects.create(title="work title")
self.book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=self.work,
)
models.SiteSettings.objects.create() models.SiteSettings.objects.create()
def test_handle_unshelve_book(self): def test_handle_unshelve_book(self):
""" remove a book from a shelf """ """ remove a book from a shelf """
work = models.Work.objects.create(title="work title")
book = models.Edition.objects.create(
title="Test",
remote_id="https://bookwyrm.social/book/37292",
parent_work=work,
)
shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf") shelf = models.Shelf.objects.create(user=self.remote_user, name="Test Shelf")
shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read" shelf.remote_id = "https://bookwyrm.social/user/mouse/shelf/to-read"
shelf.save() shelf.save()
shelfbook = models.ShelfBook.objects.create( shelfbook = models.ShelfBook.objects.create(
user=self.remote_user, shelf=shelf, book=book user=self.remote_user, shelf=shelf, book=self.book
) )
self.assertEqual(shelf.books.first(), book) self.assertEqual(shelf.books.first(), self.book)
self.assertEqual(shelf.books.count(), 1) self.assertEqual(shelf.books.count(), 1)
activity = { activity = {
@ -49,13 +58,44 @@ class InboxActivities(TestCase):
"type": "Remove", "type": "Remove",
"actor": "https://example.com/users/rat", "actor": "https://example.com/users/rat",
"object": { "object": {
"type": "Edition", "actor": self.remote_user.remote_id,
"title": "Test Title", "type": "ShelfItem",
"work": work.remote_id, "book": self.book.remote_id,
"id": "https://bookwyrm.social/book/37292", "id": shelfbook.remote_id,
}, },
"target": "https://bookwyrm.social/user/mouse/shelf/to-read", "target": "https://bookwyrm.social/user/mouse/shelf/to-read",
"@context": "https://www.w3.org/ns/activitystreams", "@context": "https://www.w3.org/ns/activitystreams",
} }
views.inbox.activity_task(activity) views.inbox.activity_task(activity)
self.assertFalse(shelf.books.exists()) self.assertFalse(shelf.books.exists())
def test_handle_remove_book_from_list(self):
""" listing a book """
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
booklist = models.List.objects.create(
name="test list",
user=self.local_user,
)
listitem = models.ListItem.objects.create(
user=self.local_user,
book=self.book,
book_list=booklist,
)
self.assertEqual(booklist.books.count(), 1)
activity = {
"id": listitem.remote_id,
"type": "Remove",
"actor": "https://example.com/users/rat",
"object": {
"actor": self.remote_user.remote_id,
"type": "ListItem",
"book": self.book.remote_id,
"id": listitem.remote_id,
},
"target": booklist.remote_id,
"@context": "https://www.w3.org/ns/activitystreams",
}
views.inbox.activity_task(activity)
self.assertEqual(booklist.books.count(), 0)

View file

@ -1,4 +1,5 @@
""" test for app action functionality """ """ test for app action functionality """
import json
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
@ -71,16 +72,6 @@ class ListViews(TestCase):
def test_lists_create(self): def test_lists_create(self):
""" create list view """ """ create list view """
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user, **kwargs):
""" ok """
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Create")
self.assertEqual(activity["actor"], self.local_user.remote_id)
models.List.broadcast = mock_broadcast
view = views.Lists.as_view() view = views.Lists.as_view()
request = self.factory.post( request = self.factory.post(
"", "",
@ -93,13 +84,19 @@ class ListViews(TestCase):
}, },
) )
request.user = self.local_user request.user = self.local_user
result = view(request) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
result = view(request)
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Create")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
new_list = models.List.objects.filter(name="A list").get() new_list = models.List.objects.filter(name="A list").get()
self.assertEqual(new_list.description, "wow") self.assertEqual(new_list.description, "wow")
self.assertEqual(new_list.privacy, "unlisted") self.assertEqual(new_list.privacy, "unlisted")
self.assertEqual(new_list.curation, "open") self.assertEqual(new_list.curation, "open")
models.List.broadcast = real_broadcast
def test_list_page(self): def test_list_page(self):
""" there are so many views, this just makes sure it LOADS """ """ there are so many views, this just makes sure it LOADS """
@ -138,17 +135,6 @@ class ListViews(TestCase):
def test_list_edit(self): def test_list_edit(self):
""" edit a list """ """ edit a list """
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user, **kwargs):
""" ok """
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Update")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"]["id"], self.list.remote_id)
models.List.broadcast = mock_broadcast
view = views.List.as_view() view = views.List.as_view()
request = self.factory.post( request = self.factory.post(
"", "",
@ -162,7 +148,15 @@ class ListViews(TestCase):
) )
request.user = self.local_user request.user = self.local_user
result = view(request, self.list.id) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
result = view(request, self.list.id)
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Update")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["object"]["id"], self.list.remote_id)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.list.refresh_from_db() self.list.refresh_from_db()
@ -170,7 +164,6 @@ class ListViews(TestCase):
self.assertEqual(self.list.description, "wow") self.assertEqual(self.list.description, "wow")
self.assertEqual(self.list.privacy, "direct") self.assertEqual(self.list.privacy, "direct")
self.assertEqual(self.list.curation, "curated") self.assertEqual(self.list.curation, "curated")
models.List.broadcast = real_broadcast
def test_curate_page(self): def test_curate_page(self):
""" there are so many views, this just makes sure it LOADS """ """ there are so many views, this just makes sure it LOADS """
@ -194,17 +187,6 @@ class ListViews(TestCase):
def test_curate_approve(self): def test_curate_approve(self):
""" approve a pending item """ """ approve a pending item """
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user, **kwargs):
""" ok """
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
view = views.Curate.as_view() view = views.Curate.as_view()
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
pending = models.ListItem.objects.create( pending = models.ListItem.objects.create(
@ -223,12 +205,19 @@ class ListViews(TestCase):
) )
request.user = self.local_user request.user = self.local_user
view(request, self.list.id) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
view(request, self.list.id)
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
pending.refresh_from_db() pending.refresh_from_db()
self.assertEqual(self.list.books.count(), 1) self.assertEqual(self.list.books.count(), 1)
self.assertEqual(self.list.listitem_set.first(), pending) self.assertEqual(self.list.listitem_set.first(), pending)
self.assertTrue(pending.approved) self.assertTrue(pending.approved)
models.ListItem.broadcast = real_broadcast
def test_curate_reject(self): def test_curate_reject(self):
""" approve a pending item """ """ approve a pending item """
@ -250,23 +239,13 @@ class ListViews(TestCase):
) )
request.user = self.local_user request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): view(request, self.list.id)
view(request, self.list.id)
self.assertFalse(self.list.books.exists()) self.assertFalse(self.list.books.exists())
self.assertFalse(models.ListItem.objects.exists()) self.assertFalse(models.ListItem.objects.exists())
def test_add_book(self): def test_add_book(self):
""" put a book on a list """ """ put a book on a list """
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user):
""" ok """
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
request = self.factory.post( request = self.factory.post(
"", "",
{ {
@ -276,25 +255,21 @@ class ListViews(TestCase):
) )
request.user = self.local_user request.user = self.local_user
views.list.add_book(request) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
views.list.add_book(request)
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
item = self.list.listitem_set.get() item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book) self.assertEqual(item.book, self.book)
self.assertEqual(item.user, self.local_user) self.assertEqual(item.user, self.local_user)
self.assertTrue(item.approved) self.assertTrue(item.approved)
models.ListItem.broadcast = real_broadcast
def test_add_book_outsider(self): def test_add_book_outsider(self):
""" put a book on a list """ """ put a book on a list """
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user):
""" ok """
self.assertEqual(user.remote_id, self.rat.remote_id)
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.rat.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
self.list.curation = "open" self.list.curation = "open"
self.list.save(broadcast=False) self.list.save(broadcast=False)
request = self.factory.post( request = self.factory.post(
@ -306,26 +281,21 @@ class ListViews(TestCase):
) )
request.user = self.rat request.user = self.rat
views.list.add_book(request) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
views.list.add_book(request)
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.rat.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
item = self.list.listitem_set.get() item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book) self.assertEqual(item.book, self.book)
self.assertEqual(item.user, self.rat) self.assertEqual(item.user, self.rat)
self.assertTrue(item.approved) self.assertTrue(item.approved)
models.ListItem.broadcast = real_broadcast
def test_add_book_pending(self): def test_add_book_pending(self):
""" put a book on a list awaiting approval """ """ put a book on a list awaiting approval """
real_broadcast = models.List.broadcast
def mock_broadcast(_, activity, user):
""" ok """
self.assertEqual(user.remote_id, self.rat.remote_id)
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.rat.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
self.assertEqual(activity["object"]["id"], self.book.remote_id)
models.ListItem.broadcast = mock_broadcast
self.list.curation = "curated" self.list.curation = "curated"
self.list.save(broadcast=False) self.list.save(broadcast=False)
request = self.factory.post( request = self.factory.post(
@ -337,26 +307,25 @@ class ListViews(TestCase):
) )
request.user = self.rat request.user = self.rat
views.list.add_book(request) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
views.list.add_book(request)
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.rat.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
item = self.list.listitem_set.get() item = self.list.listitem_set.get()
self.assertEqual(activity["object"]["id"], item.remote_id)
self.assertEqual(item.book, self.book) self.assertEqual(item.book, self.book)
self.assertEqual(item.user, self.rat) self.assertEqual(item.user, self.rat)
self.assertFalse(item.approved) self.assertFalse(item.approved)
models.ListItem.broadcast = real_broadcast
def test_add_book_self_curated(self): def test_add_book_self_curated(self):
""" put a book on a list automatically approved """ """ put a book on a list automatically approved """
real_broadcast = models.ListItem.broadcast
def mock_broadcast(_, activity, user):
""" ok """
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
self.list.curation = "curated" self.list.curation = "curated"
self.list.save(broadcast=False) self.list.save(broadcast=False)
request = self.factory.post( request = self.factory.post(
@ -368,16 +337,21 @@ class ListViews(TestCase):
) )
request.user = self.local_user request.user = self.local_user
views.list.add_book(request) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
views.list.add_book(request)
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Add")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
item = self.list.listitem_set.get() item = self.list.listitem_set.get()
self.assertEqual(item.book, self.book) self.assertEqual(item.book, self.book)
self.assertEqual(item.user, self.local_user) self.assertEqual(item.user, self.local_user)
self.assertTrue(item.approved) self.assertTrue(item.approved)
models.ListItem.broadcast = real_broadcast
def test_remove_book(self): def test_remove_book(self):
""" take an item off a list """ """ take an item off a list """
real_broadcast = models.ListItem.broadcast
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
item = models.ListItem.objects.create( item = models.ListItem.objects.create(
@ -387,14 +361,6 @@ class ListViews(TestCase):
) )
self.assertTrue(self.list.listitem_set.exists()) self.assertTrue(self.list.listitem_set.exists())
def mock_broadcast(_, activity, user):
""" ok """
self.assertEqual(user.remote_id, self.local_user.remote_id)
self.assertEqual(activity["type"], "Remove")
self.assertEqual(activity["actor"], self.local_user.remote_id)
self.assertEqual(activity["target"], self.list.remote_id)
models.ListItem.broadcast = mock_broadcast
request = self.factory.post( request = self.factory.post(
"", "",
{ {
@ -403,10 +369,9 @@ class ListViews(TestCase):
) )
request.user = self.local_user request.user = self.local_user
views.list.remove_book(request, self.list.id) with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"):
views.list.remove_book(request, self.list.id)
self.assertFalse(self.list.listitem_set.exists()) self.assertFalse(self.list.listitem_set.exists())
models.ListItem.broadcast = real_broadcast
def test_remove_book_unauthorized(self): def test_remove_book_unauthorized(self):
""" take an item off a list """ """ take an item off a list """
@ -426,5 +391,4 @@ class ListViews(TestCase):
request.user = self.rat request.user = self.rat
views.list.remove_book(request, self.list.id) views.list.remove_book(request, self.list.id)
self.assertTrue(self.list.listitem_set.exists()) self.assertTrue(self.list.listitem_set.exists())

View file

@ -1,4 +1,5 @@
""" test for app action functionality """ """ test for app action functionality """
import json
from unittest.mock import patch from unittest.mock import patch
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
from django.test import TestCase from django.test import TestCase
@ -120,8 +121,15 @@ class ShelfViews(TestCase):
"", {"book": self.book.id, "shelf": self.shelf.identifier} "", {"book": self.book.id, "shelf": self.shelf.identifier}
) )
request.user = self.local_user request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
views.shelve(request) views.shelve(request)
self.assertEqual(mock.call_count, 1)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Add")
item = models.ShelfBook.objects.get()
self.assertEqual(activity["object"]["id"], item.remote_id)
# make sure the book is on the shelf # make sure the book is on the shelf
self.assertEqual(self.shelf.books.get(), self.book) self.assertEqual(self.shelf.books.get(), self.book)
@ -170,10 +178,15 @@ class ShelfViews(TestCase):
models.ShelfBook.objects.create( models.ShelfBook.objects.create(
book=self.book, user=self.local_user, shelf=self.shelf book=self.book, user=self.local_user, shelf=self.shelf
) )
item = models.ShelfBook.objects.get()
self.shelf.save() self.shelf.save()
self.assertEqual(self.shelf.books.count(), 1) self.assertEqual(self.shelf.books.count(), 1)
request = self.factory.post("", {"book": self.book.id, "shelf": self.shelf.id}) request = self.factory.post("", {"book": self.book.id, "shelf": self.shelf.id})
request.user = self.local_user request.user = self.local_user
with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay"): with patch("bookwyrm.models.activitypub_mixin.broadcast_task.delay") as mock:
views.unshelve(request) views.unshelve(request)
activity = json.loads(mock.call_args[0][1])
self.assertEqual(activity["type"], "Remove")
self.assertEqual(activity["object"]["id"], item.remote_id)
self.assertEqual(self.shelf.books.count(), 0) self.assertEqual(self.shelf.books.count(), 0)

View file

@ -58,18 +58,11 @@ class Inbox(View):
def activity_task(activity_json): def activity_task(activity_json):
""" do something with this json we think is legit """ """ do something with this json we think is legit """
# lets see if the activitypub module can make sense of this json # lets see if the activitypub module can make sense of this json
try: activity = activitypub.parse(activity_json)
activity = activitypub.parse(activity_json)
except activitypub.ActivitySerializerError:
return
# cool that worked, now we should do the action described by the type # cool that worked, now we should do the action described by the type
# (create, update, delete, etc) # (create, update, delete, etc)
try: activity.action()
activity.action()
except activitypub.ActivitySerializerError:
# this is raised if the activity is discarded
return
def has_valid_signature(request, activity): def has_valid_signature(request, activity):

View file

@ -168,7 +168,7 @@ class Curate(View):
suggestion.approved = True suggestion.approved = True
suggestion.save() suggestion.save()
else: else:
suggestion.delete() suggestion.delete(broadcast=False)
return redirect("list-curate", book_list.id) return redirect("list-curate", book_list.id)

View file

@ -21,6 +21,7 @@ EMAIL_PORT = env("EMAIL_PORT")
EMAIL_HOST_USER = env("EMAIL_HOST_USER") EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS") EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS")
EMAIL_USE_SSL = env.bool("EMAIL_USE_SSL", False)
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)

View file

@ -1,5 +1,5 @@
| name | url | admin contact | open registration | | name | url | admin contact | open registration |
| :--- | :-- | :------------ | :---------------- | | :--- | :-- | :------------ | :---------------- |
| bookwyrm.social | http://bookwyrm.social/ | mousereeve@riseup.net / @tripofmice@friend.camp | ❌ | | bookwyrm.social | http://bookwyrm.social/ | mousereeve@riseup.net / [@tripofmice@friend.camp](https://friend.camp/@tripofmice) | ❌ |
| wyrms.de | https://wyrms.de/ | wyrms@tofuwabo.hu / @tofuwabohu@subversive.zone | ❌ | | wyrms.de | https://wyrms.de/ | wyrms@tofuwabo.hu / [@tofuwabohu@subversive.zone](https://subversive.zone/@tofuwabohu) | ❌ |

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,12 @@
{ {
"scripts": {
"watch:static": "yarn watch \"./bw-dev collectstatic\" bookwyrm/static/**"
},
"devDependencies": { "devDependencies": {
"eslint": "^7.23.0", "eslint": "^7.23.0",
"stylelint": "^13.12.0", "stylelint": "^13.12.0",
"stylelint-config-standard": "^21.0.0", "stylelint-config-standard": "^21.0.0",
"stylelint-order": "^4.1.0" "stylelint-order": "^4.1.0",
"watch": "^1.0.2"
} }
} }

View file

@ -768,6 +768,13 @@ esutils@^2.0.2:
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
exec-sh@^0.2.0:
version "0.2.2"
resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.2.2.tgz#2a5e7ffcbd7d0ba2755bdecb16e5a427dfbdec36"
integrity sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==
dependencies:
merge "^1.2.0"
execall@^2.0.0: execall@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/execall/-/execall-2.0.0.tgz#16a06b5fe5099df7d00be5d9c06eecded1663b45" resolved "https://registry.yarnpkg.com/execall/-/execall-2.0.0.tgz#16a06b5fe5099df7d00be5d9c06eecded1663b45"
@ -1368,6 +1375,11 @@ merge2@^1.3.0:
resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae"
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
merge@^1.2.0:
version "1.2.1"
resolved "https://registry.yarnpkg.com/merge/-/merge-1.2.1.tgz#38bebf80c3220a8a487b6fcfb3941bb11720c145"
integrity sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==
micromark@~2.11.0: micromark@~2.11.0:
version "2.11.4" version "2.11.4"
resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.11.4.tgz#d13436138eea826383e822449c9a5c50ee44665a" resolved "https://registry.yarnpkg.com/micromark/-/micromark-2.11.4.tgz#d13436138eea826383e822449c9a5c50ee44665a"
@ -1405,7 +1417,7 @@ minimist-options@4.1.0:
is-plain-obj "^1.1.0" is-plain-obj "^1.1.0"
kind-of "^6.0.3" kind-of "^6.0.3"
minimist@^1.2.5: minimist@^1.2.0, minimist@^1.2.5:
version "1.2.5" version "1.2.5"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
@ -2183,6 +2195,14 @@ vfile@^4.0.0:
unist-util-stringify-position "^2.0.0" unist-util-stringify-position "^2.0.0"
vfile-message "^2.0.0" vfile-message "^2.0.0"
watch@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/watch/-/watch-1.0.2.tgz#340a717bde765726fa0aa07d721e0147a551df0c"
integrity sha1-NApxe952Vyb6CqB9ch4BR6VR3ww=
dependencies:
exec-sh "^0.2.0"
minimist "^1.2.0"
which@^1.3.1: which@^1.3.1:
version "1.3.1" version "1.3.1"
resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"