diff --git a/Dockerfile b/Dockerfile index 7456996e..99d2671c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ WORKDIR /app COPY requirements.txt /app/ RUN pip install -r requirements.txt +RUN apt-get update && apt-get install -y gettext libgettextpo-dev COPY ./bookwyrm /app COPY ./celerywyrm /app diff --git a/README.md b/README.md index 3db6dd92..2564d7af 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ Code contributions are gladly welcomed! If you're not sure where to start, take If you have questions about the project or contributing, you can set up a video call during BookWyrm ["office hours"](https://calendly.com/mouse-reeve/30min). +### Translation +Do you speak a language besides English? BookWyrm needs localization! If you're comfortable using git and want to get into the code, there are [instructions](#workin-with-translations-and-locale-files) on how to create and edit localization files. If you feel more comfortable working in a regular text editor and would prefer not to run the application, get in touch directly and we can figure out a system, like emailing a text file, that works best. + ### Financial Support BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo). @@ -68,7 +71,7 @@ Since the project is still in its early stages, the features are growing every d - Private, followers-only, and public privacy levels for posting, shelves, and lists - Option for users to manually approve followers - Allow blocking and flagging for moderation - + ### The Tech Stack Web backend - [Django](https://www.djangoproject.com/) web server @@ -76,12 +79,12 @@ Web backend - [ActivityPub](http://activitypub.rocks/) federation - [Celery](http://celeryproject.org/) task queuing - [Redis](https://redis.io/) task backend - + Front end - Django templates - [Bulma.io](https://bulma.io/) css framework - Vanilla JavaScript, in moderation - + Deployment - [Docker](https://www.docker.com/) and docker-compose - [Gunicorn](https://gunicorn.org/) web runner @@ -109,6 +112,33 @@ docker-compose up Once the build is complete, you can access the instance at `localhost:1333` +### Editing static files +If you edit the CSS or JavaScript, you will need to run Django's `collectstatic` command in order for your changes to have effect. You can do this by running: +``` bash +./bw-dev collectstatic +``` + +### Workin with translations and locale files +Text in the html files are wrapped in translation tags (`{% trans %}` and `{% blocktrans %}`), and Django generates locale files for all the strings in which you can add translations for the text. You can find existing translations in the `locale/` directory. + +The application's language is set by a request header sent by your browser to the application, so to change the language of the application, you can change the default language requested by your browser. + +#### Adding a locale +To start translation into a language which is currently supported, run the django-admin `makemessages` command with the language code for the language you want to add (like `de` for German, or `en-gb` for British English): +``` bash +./bw-dev makemessages -l +``` + +#### Editing a locale +When you have a locale file, open the `django.po` in the directory for the language (for example, if you were adding German, `locale/de/LC_MESSAGES/django.po`. All the the text in the application will be shown in paired strings, with `msgid` as the original text, and `msgstr` as the translation (by default, this is set to an empty string, and will display the original text). + +Add you translations to the `msgstr` strings, and when you're ready, compile the locale by running: +``` bash +./bw-dev compilemessages +``` + +You can add the `-l ` to only compile one language. When you refresh the application, you should see your translations at work. + ## Installing in Production This project is still young and isn't, at the moment, very stable, so please proceed with caution when running in production. diff --git a/bookwyrm/goodreads_import.py b/bookwyrm/goodreads_import.py index 1b2b971c..f5b84e17 100644 --- a/bookwyrm/goodreads_import.py +++ b/bookwyrm/goodreads_import.py @@ -1,121 +1,13 @@ ''' handle reading a csv from goodreads ''' -import csv -import logging +from bookwyrm.importer import Importer -from bookwyrm import models -from bookwyrm.models import ImportJob, ImportItem -from bookwyrm.tasks import app +# GoodReads is the default importer, thus Importer follows its structure. For a more complete example of overriding see librarything_import.py -logger = logging.getLogger(__name__) +class GoodreadsImporter(Importer): + service = 'GoodReads' - -def create_job(user, csv_file, include_reviews, privacy): - ''' check over a csv and creates a database entry for the job''' - job = ImportJob.objects.create( - user=user, - include_reviews=include_reviews, - privacy=privacy - ) - for index, entry in enumerate(list(csv.DictReader(csv_file))): - if not all(x in entry for x in ('ISBN13', 'Title', 'Author')): - raise ValueError('Author, title, and isbn must be in data.') - ImportItem(job=job, index=index, data=entry).save() - return job - - -def create_retry_job(user, original_job, items): - ''' retry items that didn't import ''' - job = ImportJob.objects.create( - user=user, - include_reviews=original_job.include_reviews, - privacy=original_job.privacy, - retry=True - ) - for item in items: - ImportItem(job=job, index=item.index, data=item.data).save() - return job - - -def start_import(job): - ''' initalizes a csv import job ''' - result = import_data.delay(job.id) - job.task_id = result.id - job.save() - - -@app.task -def import_data(job_id): - ''' does the actual lookup work in a celery task ''' - job = ImportJob.objects.get(id=job_id) - try: - for item in job.items.all(): - try: - item.resolve() - except Exception as e:# pylint: disable=broad-except - logger.exception(e) - item.fail_reason = 'Error loading book' - item.save() - continue - - if item.book: - item.save() - - # shelves book and handles reviews - handle_imported_book( - job.user, item, job.include_reviews, job.privacy) - else: - item.fail_reason = 'Could not find a match for book' - item.save() - finally: - job.complete = True - job.save() - - -def handle_imported_book(user, item, include_reviews, privacy): - ''' process a goodreads csv and then post about it ''' - if isinstance(item.book, models.Work): - item.book = item.book.default_edition - if not item.book: - return - - existing_shelf = models.ShelfBook.objects.filter( - book=item.book, user=user).exists() - - # shelve the book if it hasn't been shelved already - if item.shelf and not existing_shelf: - desired_shelf = models.Shelf.objects.get( - identifier=item.shelf, - user=user - ) - models.ShelfBook.objects.create( - book=item.book, shelf=desired_shelf, user=user) - - for read in item.reads: - # check for an existing readthrough with the same dates - if models.ReadThrough.objects.filter( - user=user, book=item.book, - start_date=read.start_date, - finish_date=read.finish_date - ).exists(): - continue - read.book = item.book - read.user = user - read.save() - - if include_reviews and (item.rating or item.review): - review_title = 'Review of {!r} on Goodreads'.format( - item.book.title, - ) if item.review else '' - - # we don't know the publication date of the review, - # but "now" is a bad guess - published_date_guess = item.date_read or item.date_added - models.Review.objects.create( - user=user, - book=item.book, - name=review_title, - content=item.review, - rating=item.rating, - published_date=published_date_guess, - privacy=privacy, - ) + def parse_fields(self, data): + data.update({'import_source': self.service }) + # add missing 'Date Started' field + data.update({'Date Started': None }) + return data diff --git a/bookwyrm/importer.py b/bookwyrm/importer.py new file mode 100644 index 00000000..a1288400 --- /dev/null +++ b/bookwyrm/importer.py @@ -0,0 +1,135 @@ +''' handle reading a csv from an external service, defaults are from GoodReads ''' +import csv +import logging + +from bookwyrm import models +from bookwyrm.models import ImportJob, ImportItem +from bookwyrm.tasks import app + +logger = logging.getLogger(__name__) + +class Importer: + service = 'Unknown' + delimiter = ',' + encoding = 'UTF-8' + mandatory_fields = ['Title', 'Author'] + + def create_job(self, user, csv_file, include_reviews, privacy): + ''' check over a csv and creates a database entry for the job''' + job = ImportJob.objects.create( + user=user, + include_reviews=include_reviews, + privacy=privacy + ) + for index, entry in enumerate(list(csv.DictReader(csv_file, delimiter=self.delimiter ))): + if not all(x in entry for x in self.mandatory_fields): + raise ValueError('Author and title must be in data.') + entry = self.parse_fields(entry) + self.save_item(job, index, entry) + return job + + + def save_item(self, job, index, data): + ImportItem(job=job, index=index, data=data).save() + + def parse_fields(self, entry): + entry.update({'import_source': self.service }) + return entry + + def create_retry_job(self, user, original_job, items): + ''' retry items that didn't import ''' + job = ImportJob.objects.create( + user=user, + include_reviews=original_job.include_reviews, + privacy=original_job.privacy, + retry=True + ) + for item in items: + self.save_item(job, item.index, item.data) + return job + + + def start_import(self, job): + ''' initalizes a csv import job ''' + result = import_data.delay(self.service, job.id) + job.task_id = result.id + job.save() + + +@app.task +def import_data(source, job_id): + ''' does the actual lookup work in a celery task ''' + job = ImportJob.objects.get(id=job_id) + try: + for item in job.items.all(): + try: + item.resolve() + except Exception as e:# pylint: disable=broad-except + logger.exception(e) + item.fail_reason = 'Error loading book' + item.save() + continue + + if item.book: + item.save() + + # shelves book and handles reviews + handle_imported_book(source, + job.user, item, job.include_reviews, job.privacy) + else: + item.fail_reason = 'Could not find a match for book' + item.save() + finally: + job.complete = True + job.save() + + +def handle_imported_book(source, user, item, include_reviews, privacy): + ''' process a csv and then post about it ''' + if isinstance(item.book, models.Work): + item.book = item.book.default_edition + if not item.book: + return + + existing_shelf = models.ShelfBook.objects.filter( + book=item.book, user=user).exists() + + # shelve the book if it hasn't been shelved already + if item.shelf and not existing_shelf: + desired_shelf = models.Shelf.objects.get( + identifier=item.shelf, + user=user + ) + models.ShelfBook.objects.create( + book=item.book, shelf=desired_shelf, user=user) + + for read in item.reads: + # check for an existing readthrough with the same dates + if models.ReadThrough.objects.filter( + user=user, book=item.book, + start_date=read.start_date, + finish_date=read.finish_date + ).exists(): + continue + read.book = item.book + read.user = user + read.save() + + if include_reviews and (item.rating or item.review): + review_title = 'Review of {!r} on {!r}'.format( + item.book.title, + source, + ) if item.review else '' + + # we don't know the publication date of the review, + # but "now" is a bad guess + published_date_guess = item.date_read or item.date_added + models.Review.objects.create( + user=user, + book=item.book, + name=review_title, + content=item.review, + rating=item.rating, + published_date=published_date_guess, + privacy=privacy, + ) diff --git a/bookwyrm/librarything_import.py b/bookwyrm/librarything_import.py new file mode 100644 index 00000000..0584daad --- /dev/null +++ b/bookwyrm/librarything_import.py @@ -0,0 +1,42 @@ +''' handle reading a csv from librarything ''' +import csv +import re +import math + +from bookwyrm import models +from bookwyrm.models import ImportItem +from bookwyrm.importer import Importer + + +class LibrarythingImporter(Importer): + service = 'LibraryThing' + delimiter = '\t' + encoding = 'ISO-8859-1' + # mandatory_fields : fields matching the book title and author + mandatory_fields = ['Title', 'Primary Author'] + + def parse_fields(self, initial): + data = {} + data['import_source'] = self.service + data['Book Id'] = initial['Book Id'] + data['Title'] = initial['Title'] + data['Author'] = initial['Primary Author'] + data['ISBN13'] = initial['ISBN'] + data['My Review'] = initial['Review'] + if initial['Rating']: + data['My Rating'] = math.ceil(float(initial['Rating'])) + else: + data['My Rating'] = '' + data['Date Added'] = re.sub('\[|\]', '', initial['Entry Date']) + data['Date Started'] = re.sub('\[|\]', '', initial['Date Started']) + data['Date Read'] = re.sub('\[|\]', '', initial['Date Read']) + + data['Exclusive Shelf'] = None + if data['Date Read']: + data['Exclusive Shelf'] = "read" + elif data['Date Started']: + data['Exclusive Shelf'] = "reading" + else: + data['Exclusive Shelf'] = "to-read" + + return data diff --git a/bookwyrm/models/import_job.py b/bookwyrm/models/import_job.py index 407d820b..ca05ddb0 100644 --- a/bookwyrm/models/import_job.py +++ b/bookwyrm/models/import_job.py @@ -97,8 +97,8 @@ class ImportItem(models.Model): def get_book_from_title_author(self): ''' search by title and author ''' search_term = construct_search_term( - self.data['Title'], - self.data['Author'] + self.title, + self.author ) search_result = connector_manager.first_search_result( search_term, min_confidence=0.999 @@ -149,6 +149,14 @@ class ImportItem(models.Model): dateutil.parser.parse(self.data['Date Added'])) return None + @property + def date_started(self): + ''' when the book was started ''' + if "Date Started" in self.data and self.data['Date Started']: + return timezone.make_aware( + dateutil.parser.parse(self.data['Date Started'])) + return None + @property def date_read(self): ''' the date a book was completed ''' @@ -160,18 +168,24 @@ class ImportItem(models.Model): @property def reads(self): ''' formats a read through dataset for the book in this line ''' - if (self.shelf == 'reading' - and self.date_added and not self.date_read): - return [ReadThrough(start_date=self.date_added)] + start_date = self.date_started + + # Goodreads special case (no 'date started' field) + if ((self.shelf == 'reading' or (self.shelf == 'read' and self.date_read)) + and self.date_added and not start_date): + start_date = self.date_added + + if (start_date and start_date is not None and not self.date_read): + return [ReadThrough(start_date=start_date)] if self.date_read: return [ReadThrough( - start_date=self.date_added, + start_date=start_date, finish_date=self.date_read, )] return [] def __repr__(self): - return "".format(self.data['Title']) + return "<{!r}Item {!r}>".format(self.data['import_source'], self.data['Title']) def __str__(self): return "{} by {}".format(self.data['Title'], self.data['Author']) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 46c38b5a..8cdf87ff 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -1,9 +1,10 @@ ''' bookwyrm settings and configuration ''' import os - from environs import Env import requests +from django.utils.translation import gettext_lazy as _ + env = Env() DOMAIN = env('DOMAIN') @@ -27,6 +28,7 @@ EMAIL_USE_TLS = env('EMAIL_USE_TLS', True) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +LOCALE_PATHS = [os.path.join(BASE_DIR, 'locale'),] # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/ @@ -58,6 +60,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', @@ -135,6 +138,11 @@ AUTH_PASSWORD_VALIDATORS = [ # https://docs.djangoproject.com/en/2.0/topics/i18n/ LANGUAGE_CODE = 'en-us' +LANGUAGES = [ + ('en-us', _('English')), + ('fr-fr', _('French')), +] + TIME_ZONE = 'UTC' diff --git a/bookwyrm/static/js/shared.js b/bookwyrm/static/js/shared.js index 758b76dc..7d65ee03 100644 --- a/bookwyrm/static/js/shared.js +++ b/bookwyrm/static/js/shared.js @@ -12,9 +12,9 @@ window.onload = function() { Array.from(document.getElementsByClassName('select-all')) .forEach(t => t.onclick = selectAll); - // toggle between tabs - Array.from(document.getElementsByClassName('tab-change')) - .forEach(t => t.onclick = tabChange); + // tab groups + Array.from(document.getElementsByClassName('tab-group')) + .forEach(t => new TabGroup(t)); // handle aria settings on menus Array.from(document.getElementsByClassName('pulldown-menu')) @@ -141,23 +141,6 @@ function selectAll(e) { .forEach(t => t.checked=true); } -function tabChange(e) { - var el = e.currentTarget; - var parentElement = el.closest('[role="tablist"]'); - - parentElement.querySelectorAll('[aria-selected="true"]') - .forEach(t => t.setAttribute("aria-selected", false)); - el.setAttribute("aria-selected", true); - - parentElement.querySelectorAll('li') - .forEach(t => removeClass(t, 'is-active')); - addClass(el, 'is-active'); - - var tabId = el.getAttribute('data-tab'); - Array.from(document.getElementsByClassName(el.getAttribute('data-category'))) - .forEach(t => addRemoveClass(t, 'hidden', t.id != tabId)); -} - function toggleMenu(e) { var el = e.currentTarget; var expanded = el.getAttribute('aria-expanded') == 'false'; @@ -203,3 +186,258 @@ function removeClass(el, className) { } el.className = classes.join(' '); } + +/* +* The content below is licensed according to the W3C Software License at +* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document +* Heavily modified to web component by Zach Leatherman +* Modified back to vanilla JavaScript with support for Bulma markup and nested tabs by Ned Zimmerman +*/ +class TabGroup { + constructor(container) { + this.container = container; + + this.tablist = this.container.querySelector('[role="tablist"]'); + this.buttons = this.tablist.querySelectorAll('[role="tab"]'); + this.panels = this.container.querySelectorAll(':scope > [role="tabpanel"]'); + this.delay = this.determineDelay(); + + if(!this.tablist || !this.buttons.length || !this.panels.length) { + return; + } + + this.keys = this.keys(); + this.direction = this.direction(); + this.initButtons(); + this.initPanels(); + } + + keys() { + return { + end: 35, + home: 36, + left: 37, + up: 38, + right: 39, + down: 40 + }; + } + + // Add or substract depending on key pressed + direction() { + return { + 37: -1, + 38: -1, + 39: 1, + 40: 1 + }; + } + + initButtons() { + let count = 0; + for(let button of this.buttons) { + let isSelected = button.getAttribute("aria-selected") === "true"; + button.setAttribute("tabindex", isSelected ? "0" : "-1"); + + button.addEventListener('click', this.clickEventListener.bind(this)); + button.addEventListener('keydown', this.keydownEventListener.bind(this)); + button.addEventListener('keyup', this.keyupEventListener.bind(this)); + + button.index = count++; + } + } + + initPanels() { + let selectedPanelId = this.tablist.querySelector('[role="tab"][aria-selected="true"]').getAttribute("aria-controls"); + for(let panel of this.panels) { + if(panel.getAttribute("id") !== selectedPanelId) { + panel.setAttribute("hidden", ""); + } + panel.setAttribute("tabindex", "0"); + } + } + + clickEventListener(event) { + let button = event.target.closest('a'); + + event.preventDefault(); + + this.activateTab(button, false); + } + + // Handle keydown on tabs + keydownEventListener(event) { + var key = event.keyCode; + + switch (key) { + case this.keys.end: + event.preventDefault(); + // Activate last tab + this.activateTab(this.buttons[this.buttons.length - 1]); + break; + case this.keys.home: + event.preventDefault(); + // Activate first tab + this.activateTab(this.buttons[0]); + break; + + // Up and down are in keydown + // because we need to prevent page scroll >:) + case this.keys.up: + case this.keys.down: + this.determineOrientation(event); + break; + }; + } + + // Handle keyup on tabs + keyupEventListener(event) { + var key = event.keyCode; + + switch (key) { + case this.keys.left: + case this.keys.right: + this.determineOrientation(event); + break; + }; + } + + // When a tablist’s aria-orientation is set to vertical, + // only up and down arrow should function. + // In all other cases only left and right arrow function. + determineOrientation(event) { + var key = event.keyCode; + var vertical = this.tablist.getAttribute('aria-orientation') == 'vertical'; + var proceed = false; + + if (vertical) { + if (key === this.keys.up || key === this.keys.down) { + event.preventDefault(); + proceed = true; + }; + } + else { + if (key === this.keys.left || key === this.keys.right) { + proceed = true; + }; + }; + + if (proceed) { + this.switchTabOnArrowPress(event); + }; + } + + // Either focus the next, previous, first, or last tab + // depending on key pressed + switchTabOnArrowPress(event) { + var pressed = event.keyCode; + + for (let button of this.buttons) { + button.addEventListener('focus', this.focusEventHandler.bind(this)); + }; + + if (this.direction[pressed]) { + var target = event.target; + if (target.index !== undefined) { + if (this.buttons[target.index + this.direction[pressed]]) { + this.buttons[target.index + this.direction[pressed]].focus(); + } + else if (pressed === this.keys.left || pressed === this.keys.up) { + this.focusLastTab(); + } + else if (pressed === this.keys.right || pressed == this.keys.down) { + this.focusFirstTab(); + } + } + } + } + + // Activates any given tab panel + activateTab (tab, setFocus) { + if(tab.getAttribute("role") !== "tab") { + tab = tab.closest('[role="tab"]'); + } + + setFocus = setFocus || true; + + // Deactivate all other tabs + this.deactivateTabs(); + + // Remove tabindex attribute + tab.removeAttribute('tabindex'); + + // Set the tab as selected + tab.setAttribute('aria-selected', 'true'); + + // Give the tab parent an is-active class + tab.parentNode.classList.add('is-active'); + + // Get the value of aria-controls (which is an ID) + var controls = tab.getAttribute('aria-controls'); + + // Remove hidden attribute from tab panel to make it visible + document.getElementById(controls).removeAttribute('hidden'); + + // Set focus when required + if (setFocus) { + tab.focus(); + } + } + + // Deactivate all tabs and tab panels + deactivateTabs() { + for (let button of this.buttons) { + button.parentNode.classList.remove('is-active'); + button.setAttribute('tabindex', '-1'); + button.setAttribute('aria-selected', 'false'); + button.removeEventListener('focus', this.focusEventHandler.bind(this)); + } + + for (let panel of this.panels) { + panel.setAttribute('hidden', 'hidden'); + } + } + + focusFirstTab() { + this.buttons[0].focus(); + } + + focusLastTab() { + this.buttons[this.buttons.length - 1].focus(); + } + + // Determine whether there should be a delay + // when user navigates with the arrow keys + determineDelay() { + var hasDelay = this.tablist.hasAttribute('data-delay'); + var delay = 0; + + if (hasDelay) { + var delayValue = this.tablist.getAttribute('data-delay'); + if (delayValue) { + delay = delayValue; + } + else { + // If no value is specified, default to 300ms + delay = 300; + }; + }; + + return delay; + } + + focusEventHandler(event) { + var target = event.target; + + setTimeout(this.checkTabFocus.bind(this), this.delay, target); + }; + + // Only activate tab on focus if it still has focus after the delay + checkTabFocus(target) { + let focused = document.activeElement; + + if (target === focused) { + this.activateTab(target, false); + } + } + } diff --git a/bookwyrm/templates/author.html b/bookwyrm/templates/author.html index 9f2054d0..bc1034a8 100644 --- a/bookwyrm/templates/author.html +++ b/bookwyrm/templates/author.html @@ -1,5 +1,9 @@ {% extends 'layout.html' %} +{% load i18n %} {% load bookwyrm_tags %} + +{% block title %}{{ author.name }}{% endblock %} + {% block content %}
@@ -9,8 +13,8 @@ {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} @@ -25,12 +29,12 @@

{% endif %} {% if author.wikipedia_link %} -

Wikipedia

+

{% trans "Wikipedia" %}

{% endif %}
-

Books by {{ author.name }}

+

{% blocktrans with name=author.name %}Books by {{ name }}{% endblocktrans %}

{% include 'snippets/book_tiles.html' with books=books %}
{% endblock %} diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index dc7d4ce8..10682347 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -1,8 +1,11 @@ {% extends 'layout.html' %} +{% load i18n %} {% load bookwyrm_tags %} {% load humanize %} -{% block content %} +{% block title %}{{ book.title }}{% endblock %} + +{% block content %}
@@ -23,8 +26,8 @@ {% if request.user.is_authenticated and perms.bookwyrm.edit_book %} @@ -39,13 +42,13 @@ {% if request.user.is_authenticated and not book.cover %}
-

Add cover

+

{% trans "Add cover" %}

{% csrf_token %} - +
{% endif %} @@ -54,21 +57,21 @@
{% if book.isbn_13 %}
-
ISBN:
+
{% trans "ISBN:" %}
{{ book.isbn_13 }}
{% endif %} {% if book.oclc_number %}
-
OCLC Number:
+
{% trans "OCLC Number:" %}
{{ book.oclc_number }}
{% endif %} {% if book.asin %}
-
ASIN:
+
{% trans "ASIN:" %}
{{ book.asin }}
{% endif %} @@ -80,7 +83,7 @@

{% if book.openlibrary_key %} -

View on OpenLibrary

+

{% trans "View on OpenLibrary" %}

{% endif %}
@@ -92,18 +95,20 @@ {% include 'snippets/trimmed_text.html' with full=book|book_description %} {% if request.user.is_authenticated and perms.bookwyrm.edit_book and not book|book_description %} - {% include 'snippets/toggle/open_button.html' with text="Add description" controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %} + {% trans 'Add Description' as button_text %} + {% include 'snippets/toggle/open_button.html' with text=button_text controls_text="add-description" controls_uid=book.id focus="id_description" hide_active=True id="hide-description" %} @@ -134,23 +139,25 @@ {% if request.user.is_authenticated %}
-

Your reading activity

+

{% trans "Your reading activity" %}

- {% include 'snippets/toggle/open_button.html' with text="Add read dates" icon="plus" class="is-small" controls_text="add-readthrough" %} + {% trans "Add read dates" as button_text %} + {% include 'snippets/toggle/open_button.html' with text=button_text icon="plus" class="is-small" controls_text="add-readthrough" %}
{% if not readthroughs.exists %} -

You don't have any reading activity for this book.

+

{% trans "You don't have any reading activity for this book." %}

{% endif %}