diff --git a/Dockerfile b/Dockerfile index 99d2671c..0f10015c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,15 +2,12 @@ FROM python:3.9 ENV PYTHONUNBUFFERED 1 -RUN mkdir /app -RUN mkdir /app/static -RUN mkdir /app/images +RUN mkdir /app /app/static /app/images WORKDIR /app COPY requirements.txt /app/ -RUN pip install -r requirements.txt -RUN apt-get update && apt-get install -y gettext libgettextpo-dev +RUN pip install -r requirements.txt --no-cache-dir +RUN apt-get update && apt-get install -y gettext libgettextpo-dev && apt-get clean -COPY ./bookwyrm /app -COPY ./celerywyrm /app +COPY ./bookwyrm ./celerywyrm /app/ diff --git a/README.md b/README.md index 2564d7af..41bd7065 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ 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. +Do you speak a language besides English? BookWyrm needs localization! If you're comfortable using git and want to get into the code, there are [instructions](#working-with-translations-and-locale-files) on how to create and edit localization files. If you feel more comfortable working in a regular text editor and would prefer not to run the application, get in touch directly and we can figure out a system, like emailing a text file, that works best. ### Financial Support BookWyrm is an ad-free passion project with no intentions of seeking out venture funding or corporate financial relationships. If you want to help keep the project going, you can donate to the [Patreon](https://www.patreon.com/bookwyrm), or make a one time gift via [PayPal](https://paypal.me/oulipo). @@ -118,7 +118,7 @@ If you edit the CSS or JavaScript, you will need to run Django's `collectstatic` ./bw-dev collectstatic ``` -### Workin 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. 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. @@ -132,7 +132,10 @@ To start translation into a language which is currently supported, run the djang #### 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: +Add your translations to the `msgstr` strings. As the messages in the application are updated, `gettext` will sometimes add best-guess fuzzy matched options for those translations. When a message is marked as fuzzy, it will not be used in the application, so be sure to remove it when you translate that line. + +When you're done, compile the locale by running: + ``` bash ./bw-dev compilemessages ``` diff --git a/bookwyrm/activitypub/base_activity.py b/bookwyrm/activitypub/base_activity.py index 57f1a713..c732fe1d 100644 --- a/bookwyrm/activitypub/base_activity.py +++ b/bookwyrm/activitypub/base_activity.py @@ -102,7 +102,7 @@ class ActivityObject: if allow_create and \ hasattr(model, 'ignore_activity') and \ model.ignore_activity(self): - return None + raise ActivitySerializerError() # check for an existing instance instance = instance or model.find_existing(self.serialize()) diff --git a/bookwyrm/activitypub/book.py b/bookwyrm/activitypub/book.py index 87c40c90..8c32be96 100644 --- a/bookwyrm/activitypub/book.py +++ b/bookwyrm/activitypub/book.py @@ -26,7 +26,7 @@ class Book(ActivityObject): librarythingKey: str = '' goodreadsKey: str = '' - cover: Image = field(default_factory=lambda: {}) + cover: Image = None type: str = 'Book' diff --git a/bookwyrm/connectors/abstract_connector.py b/bookwyrm/connectors/abstract_connector.py index 68ff2a48..e6372438 100644 --- a/bookwyrm/connectors/abstract_connector.py +++ b/bookwyrm/connectors/abstract_connector.py @@ -26,6 +26,7 @@ class AbstractMinimalConnector(ABC): 'books_url', 'covers_url', 'search_url', + 'isbn_search_url', 'max_query_count', 'name', 'identifier', @@ -61,6 +62,30 @@ class AbstractMinimalConnector(ABC): results.append(self.format_search_result(doc)) return results + def isbn_search(self, query): + ''' isbn search ''' + params = {} + resp = requests.get( + '%s%s' % (self.isbn_search_url, query), + params=params, + headers={ + 'Accept': 'application/json; charset=utf-8', + 'User-Agent': settings.USER_AGENT, + }, + ) + if not resp.ok: + resp.raise_for_status() + try: + data = resp.json() + except ValueError as e: + logger.exception(e) + raise ConnectorException('Unable to parse json response', e) + results = [] + + for doc in self.parse_isbn_search_data(data): + results.append(self.format_isbn_search_result(doc)) + return results + @abstractmethod def get_or_create_book(self, remote_id): ''' pull up a book record by whatever means possible ''' @@ -73,6 +98,14 @@ class AbstractMinimalConnector(ABC): def format_search_result(self, search_result): ''' create a SearchResult obj from json ''' + @abstractmethod + def parse_isbn_search_data(self, data): + ''' turn the result json from a search into a list ''' + + @abstractmethod + def format_isbn_search_result(self, search_result): + ''' create a SearchResult obj from json ''' + class AbstractConnector(AbstractMinimalConnector): ''' generic book data connector ''' diff --git a/bookwyrm/connectors/bookwyrm_connector.py b/bookwyrm/connectors/bookwyrm_connector.py index 00e6c62f..96b72f26 100644 --- a/bookwyrm/connectors/bookwyrm_connector.py +++ b/bookwyrm/connectors/bookwyrm_connector.py @@ -19,3 +19,11 @@ class Connector(AbstractMinimalConnector): def format_search_result(self, search_result): search_result['connector'] = self return SearchResult(**search_result) + + def parse_isbn_search_data(self, data): + return data + + def format_isbn_search_result(self, search_result): + search_result['connector'] = self + return SearchResult(**search_result) + diff --git a/bookwyrm/connectors/connector_manager.py b/bookwyrm/connectors/connector_manager.py index a63a788e..053e1f9e 100644 --- a/bookwyrm/connectors/connector_manager.py +++ b/bookwyrm/connectors/connector_manager.py @@ -1,5 +1,6 @@ ''' interface with whatever connectors the app has ''' import importlib +import re from urllib.parse import urlparse from requests import HTTPError @@ -15,13 +16,31 @@ class ConnectorException(HTTPError): def search(query, min_confidence=0.1): ''' find books based on arbitary keywords ''' results = [] + + # Have we got a ISBN ? + isbn = re.sub('[\W_]', '', query) + maybe_isbn = len(isbn) in [10, 13] # ISBN10 or ISBN13 + dedup_slug = lambda r: '%s/%s/%s' % (r.title, r.author, r.year) result_index = set() for connector in get_connectors(): - try: - result_set = connector.search(query, min_confidence=min_confidence) - except (HTTPError, ConnectorException): - continue + result_set = None + if maybe_isbn: + # Search on ISBN + if not connector.isbn_search_url or connector.isbn_search_url == '': + result_set = [] + else: + try: + result_set = connector.isbn_search(isbn) + except (HTTPError, ConnectorException): + pass + + # if no isbn search or results, we fallback to generic search + if result_set == None or result_set == []: + try: + result_set = connector.search(query, min_confidence=min_confidence) + except (HTTPError, ConnectorException): + continue result_set = [r for r in result_set \ if dedup_slug(r) not in result_index] @@ -41,6 +60,12 @@ def local_search(query, min_confidence=0.1, raw=False): return connector.search(query, min_confidence=min_confidence, raw=raw) +def isbn_local_search(query, raw=False): + ''' only look at local search results ''' + connector = load_connector(models.Connector.objects.get(local=True)) + return connector.isbn_search(query, raw=raw) + + def first_search_result(query, min_confidence=0.1): ''' search until you find a result that fits ''' for connector in get_connectors(): diff --git a/bookwyrm/connectors/openlibrary.py b/bookwyrm/connectors/openlibrary.py index a767a45a..8d227eef 100644 --- a/bookwyrm/connectors/openlibrary.py +++ b/bookwyrm/connectors/openlibrary.py @@ -129,6 +129,22 @@ class Connector(AbstractConnector): ) + def parse_isbn_search_data(self, data): + return list(data.values()) + + def format_isbn_search_result(self, search_result): + # build the remote id from the openlibrary key + key = self.books_url + search_result['key'] + authors = search_result.get('authors') or [{'name': 'Unknown'}] + author_names = [ author.get('name') for author in authors] + return SearchResult( + title=search_result.get('title'), + key=key, + author=', '.join(author_names), + connector=self, + year=search_result.get('publish_date'), + ) + def load_edition_data(self, olkey): ''' query openlibrary for editions of a work ''' url = '%s/works/%s/editions' % (self.books_url, olkey) diff --git a/bookwyrm/connectors/self_connector.py b/bookwyrm/connectors/self_connector.py index f57fbc1c..b3a4d6f9 100644 --- a/bookwyrm/connectors/self_connector.py +++ b/bookwyrm/connectors/self_connector.py @@ -33,6 +33,31 @@ class Connector(AbstractConnector): search_results.sort(key=lambda r: r.confidence, reverse=True) return search_results + def isbn_search(self, query, raw=False): + ''' search your local database ''' + if not query: + return [] + + filters = [{f: query} for f in ['isbn_10', 'isbn_13']] + results = models.Edition.objects.filter( + reduce(operator.or_, (Q(**f) for f in filters)) + ).distinct() + + # when there are multiple editions of the same work, pick the default. + # it would be odd for this to happen. + results = results.filter(parent_work__default_edition__id=F('id')) \ + or results + + search_results = [] + for result in results: + if raw: + search_results.append(result) + else: + search_results.append(self.format_search_result(result)) + if len(search_results) >= 10: + break + return search_results + def format_search_result(self, search_result): return SearchResult( @@ -47,6 +72,19 @@ class Connector(AbstractConnector): ) + def format_isbn_search_result(self, search_result): + return SearchResult( + title=search_result.title, + key=search_result.remote_id, + author=search_result.author_text, + year=search_result.published_date.year if \ + search_result.published_date else None, + connector=self, + confidence=search_result.rank if \ + hasattr(search_result, 'rank') else 1, + ) + + def is_work_data(self, data): pass @@ -59,6 +97,10 @@ class Connector(AbstractConnector): def get_authors_from_data(self, data): return None + def parse_isbn_search_data(self, data): + ''' it's already in the right format, don't even worry about it ''' + return data + def parse_search_data(self, data): ''' it's already in the right format, don't even worry about it ''' return data diff --git a/bookwyrm/management/commands/initdb.py b/bookwyrm/management/commands/initdb.py index 9fd11787..5759abfc 100644 --- a/bookwyrm/management/commands/initdb.py +++ b/bookwyrm/management/commands/initdb.py @@ -66,6 +66,7 @@ def init_connectors(): books_url='https://%s/book' % DOMAIN, covers_url='https://%s/images/covers' % DOMAIN, search_url='https://%s/search?q=' % DOMAIN, + isbn_search_url='https://%s/isbn/' % DOMAIN, priority=1, ) @@ -77,6 +78,7 @@ def init_connectors(): books_url='https://bookwyrm.social/book', covers_url='https://bookwyrm.social/images/covers', search_url='https://bookwyrm.social/search?q=', + isbn_search_url='https://bookwyrm.social/isbn/', priority=2, ) @@ -88,6 +90,7 @@ def init_connectors(): books_url='https://openlibrary.org', covers_url='https://covers.openlibrary.org', search_url='https://openlibrary.org/search?q=', + isbn_search_url='https://openlibrary.org/api/books?jscmd=data&format=json&bibkeys=ISBN:', priority=3, ) diff --git a/bookwyrm/migrations/0047_connector_isbn_search_url.py b/bookwyrm/migrations/0047_connector_isbn_search_url.py new file mode 100644 index 00000000..617a89d9 --- /dev/null +++ b/bookwyrm/migrations/0047_connector_isbn_search_url.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.7 on 2021-02-28 16:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bookwyrm', '0046_sitesettings_privacy_policy'), + ] + + operations = [ + migrations.AddField( + model_name='connector', + name='isbn_search_url', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/bookwyrm/models/activitypub_mixin.py b/bookwyrm/models/activitypub_mixin.py index bebe00d0..10015bf1 100644 --- a/bookwyrm/models/activitypub_mixin.py +++ b/bookwyrm/models/activitypub_mixin.py @@ -449,7 +449,7 @@ def broadcast_task(sender_id, activity, recipients): for recipient in recipients: try: sign_and_send(sender, activity, recipient) - except (HTTPError, SSLError) as e: + except (HTTPError, SSLError, ConnectionError) as e: logger.exception(e) diff --git a/bookwyrm/models/book.py b/bookwyrm/models/book.py index f1f20830..84bfbc6b 100644 --- a/bookwyrm/models/book.py +++ b/bookwyrm/models/book.py @@ -37,6 +37,10 @@ class BookDataModel(ObjectMixin, BookWyrmModel): self.remote_id = None return super().save(*args, **kwargs) + def broadcast(self, activity, sender, software='bookwyrm'): + ''' only send book data updates to other bookwyrm instances ''' + super().broadcast(activity, sender, software=software) + class Book(BookDataModel): ''' a generic book, which can mean either an edition or a work ''' @@ -91,7 +95,7 @@ class Book(BookDataModel): @property def alt_text(self): ''' image alt test ''' - text = '%s cover' % self.title + text = '%s' % self.title if self.edition_info: text += ' (%s)' % self.edition_info return text diff --git a/bookwyrm/models/connector.py b/bookwyrm/models/connector.py index 6f64cdf3..c1fbf58b 100644 --- a/bookwyrm/models/connector.py +++ b/bookwyrm/models/connector.py @@ -22,6 +22,7 @@ class Connector(BookWyrmModel): books_url = models.CharField(max_length=255) covers_url = models.CharField(max_length=255) search_url = models.CharField(max_length=255, null=True, blank=True) + isbn_search_url = models.CharField(max_length=255, null=True, blank=True) politeness_delay = models.IntegerField(null=True, blank=True) #seconds max_query_count = models.IntegerField(null=True, blank=True) diff --git a/bookwyrm/models/favorite.py b/bookwyrm/models/favorite.py index f9019501..d34cbcba 100644 --- a/bookwyrm/models/favorite.py +++ b/bookwyrm/models/favorite.py @@ -7,6 +7,7 @@ from bookwyrm import activitypub from .activitypub_mixin import ActivityMixin from .base_model import BookWyrmModel from . import fields +from .status import Status class Favorite(ActivityMixin, BookWyrmModel): ''' fav'ing a post ''' @@ -17,6 +18,11 @@ class Favorite(ActivityMixin, BookWyrmModel): activity_serializer = activitypub.Like + @classmethod + def ignore_activity(cls, activity): + ''' don't bother with incoming favs of unknown statuses ''' + return not Status.objects.filter(remote_id=activity.object).exists() + def save(self, *args, **kwargs): ''' update user active time ''' self.user.last_active_date = timezone.now() diff --git a/bookwyrm/models/user.py b/bookwyrm/models/user.py index f137236c..bbeb10cc 100644 --- a/bookwyrm/models/user.py +++ b/bookwyrm/models/user.py @@ -3,7 +3,7 @@ import re from urllib.parse import urlparse from django.apps import apps -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractUser, Group from django.core.validators import MinValueValidator from django.db import models from django.utils import timezone @@ -208,6 +208,13 @@ class User(OrderedCollectionPageMixin, AbstractUser): # an id needs to be set before we can proceed with related models super().save(*args, **kwargs) + # make users editors by default + try: + self.groups.add(Group.objects.get(name='editor')) + except Group.DoesNotExist: + # this should only happen in tests + pass + # create keys and shelves for new local users self.key_pair = KeyPair.objects.create( remote_id='%s/#main-key' % self.remote_id) diff --git a/bookwyrm/settings.py b/bookwyrm/settings.py index 499e72a7..45fdbd9d 100644 --- a/bookwyrm/settings.py +++ b/bookwyrm/settings.py @@ -141,6 +141,7 @@ LANGUAGE_CODE = 'en-us' LANGUAGES = [ ('en-us', _('English')), ('de-de', _('German')), + ('es', _('Spanish')), ('fr-fr', _('French')), ('zh-cn', _('Simplified Chinese')), ] diff --git a/bookwyrm/static/css/format.css b/bookwyrm/static/css/format.css index 9d4b3105..435d8eb9 100644 --- a/bookwyrm/static/css/format.css +++ b/bookwyrm/static/css/format.css @@ -1,3 +1,8 @@ +html { + scroll-behavior: smooth; + scroll-padding-top: 20%; +} + /* --- --- */ .image { overflow: hidden; diff --git a/bookwyrm/static/js/shared.js b/bookwyrm/static/js/shared.js index 7d65ee03..65f0dd65 100644 --- a/bookwyrm/static/js/shared.js +++ b/bookwyrm/static/js/shared.js @@ -8,9 +8,12 @@ window.onload = function() { Array.from(document.getElementsByClassName('interaction')) .forEach(t => t.onsubmit = interact); - // select all - Array.from(document.getElementsByClassName('select-all')) - .forEach(t => t.onclick = selectAll); + // Toggle all checkboxes. + document + .querySelectorAll('[data-action="toggle-all"]') + .forEach(input => { + input.addEventListener('change', toggleAllCheckboxes); + }); // tab groups Array.from(document.getElementsByClassName('tab-group')) @@ -136,9 +139,20 @@ function interact(e) { .forEach(t => addRemoveClass(t, 'hidden', t.className.indexOf('hidden') == -1)); } -function selectAll(e) { - e.target.parentElement.parentElement.querySelectorAll('[type="checkbox"]') - .forEach(t => t.checked=true); +/** + * Toggle all descendant checkboxes of a target. + * + * Use `data-target="ID_OF_TARGET"` on the node being listened to. + * + * @param {Event} event - change Event + * @return {undefined} + */ +function toggleAllCheckboxes(event) { + const mainCheckbox = event.target; + + document + .querySelectorAll(`#${mainCheckbox.dataset.target} [type="checkbox"]`) + .forEach(checkbox => {checkbox.checked = mainCheckbox.checked;}); } function toggleMenu(e) { diff --git a/bookwyrm/templates/book.html b/bookwyrm/templates/book.html index d80daca2..16bf1197 100644 --- a/bookwyrm/templates/book.html +++ b/bookwyrm/templates/book.html @@ -35,7 +35,7 @@
-
+
{% include 'snippets/book_cover.html' with book=book size=large %} {% include 'snippets/rate_action.html' with user=request.user book=book %} {% include 'snippets/shelve_button/shelve_button.html' %} @@ -93,7 +93,7 @@
-
+

{% include 'snippets/stars.html' with rating=rating %} @@ -201,7 +201,7 @@

-
+
{% if book.subjects %}

{% trans "Subjects" %}

@@ -217,7 +217,7 @@

{% trans "Places" %}

    - {% for place in book.subject_placess %} + {% for place in book.subject_places %}
  • {{ place }}
  • {% endfor %}
@@ -252,10 +252,10 @@
{% include 'snippets/avatar.html' with user=rating.user %}
- {% include 'snippets/username.html' with user=rating.user %} + {{ rating.user.display_name }}
-
-
{% trans "rated it" %}
+
+

{% trans "rated it" %}

{% include 'snippets/stars.html' with rating=rating.rating %}
diff --git a/bookwyrm/templates/components/dropdown.html b/bookwyrm/templates/components/dropdown.html index 1e45fe51..72582ddc 100644 --- a/bookwyrm/templates/components/dropdown.html +++ b/bookwyrm/templates/components/dropdown.html @@ -5,7 +5,7 @@ {% block dropdown-trigger %}{% endblock %} diff --git a/bookwyrm/templates/components/modal.html b/bookwyrm/templates/components/modal.html index 554f9ccd..9ffbc2b3 100644 --- a/bookwyrm/templates/components/modal.html +++ b/bookwyrm/templates/components/modal.html @@ -1,8 +1,15 @@ - diff --git a/bookwyrm/templates/discover/large-book.html b/bookwyrm/templates/discover/large-book.html index 7881a33a..44b91b94 100644 --- a/bookwyrm/templates/discover/large-book.html +++ b/bookwyrm/templates/discover/large-book.html @@ -1,4 +1,5 @@ {% load bookwyrm_tags %} +{% load i18n %} {% if book %}
@@ -8,7 +9,7 @@

{{ book.title }}

{% if book.authors %} -

by {% include 'snippets/authors.html' with book=book %}

+

{% trans "by" %} {% include 'snippets/authors.html' with book=book %}

{% endif %} {% if book|book_description %}
{{ book|book_description|to_markdown|safe|truncatewords_html:50 }}
diff --git a/bookwyrm/templates/discover/small-book.html b/bookwyrm/templates/discover/small-book.html index 72108c30..6df27746 100644 --- a/bookwyrm/templates/discover/small-book.html +++ b/bookwyrm/templates/discover/small-book.html @@ -1,11 +1,12 @@ {% load bookwyrm_tags %} +{% load i18n %} {% if book %} {% include 'snippets/book_cover.html' with book=book %} {% include 'snippets/stars.html' with rating=book|rating:request.user %}

{{ book.title }}

{% if book.authors %} -

by {% include 'snippets/authors.html' with book=book %}

+

{% trans "by" %} {% include 'snippets/authors.html' with book=book %}

{% endif %} {% endif %} diff --git a/bookwyrm/templates/feed/feed.html b/bookwyrm/templates/feed/feed.html index 1eae24d4..4eb363e4 100644 --- a/bookwyrm/templates/feed/feed.html +++ b/bookwyrm/templates/feed/feed.html @@ -6,13 +6,13 @@

{% blocktrans %}{{ tab_title }} Timeline{% endblocktrans %}

diff --git a/bookwyrm/templates/feed/status.html b/bookwyrm/templates/feed/status.html index fc92f855..9f90f355 100644 --- a/bookwyrm/templates/feed/status.html +++ b/bookwyrm/templates/feed/status.html @@ -4,7 +4,7 @@ {% block panel %}
- + {% trans "Back" %}
diff --git a/bookwyrm/templates/import_status.html b/bookwyrm/templates/import_status.html index b28187b0..f9ba36bf 100644 --- a/bookwyrm/templates/import_status.html +++ b/bookwyrm/templates/import_status.html @@ -5,7 +5,7 @@ {% block title %}{% trans "Import Status" %}{% endblock %} -{% block content %} +{% block content %}{% spaceless %}

{% trans "Import Status" %}

@@ -36,8 +36,19 @@ {% if not job.retry %}
{% csrf_token %} - -
-
- + + + + +
+ {% else %}
    {% for item in failed_items %} @@ -123,4 +147,4 @@
-{% endblock %} +{% endspaceless %}{% endblock %} diff --git a/bookwyrm/templates/isbn_search_results.html b/bookwyrm/templates/isbn_search_results.html new file mode 100644 index 00000000..a3861a68 --- /dev/null +++ b/bookwyrm/templates/isbn_search_results.html @@ -0,0 +1,33 @@ +{% extends 'layout.html' %} +{% load i18n %} + +{% block title %}{% trans "Search Results" %}{% endblock %} + +{% block content %} +{% with book_results|first as local_results %} +
+

{% blocktrans %}Search Results for "{{ query }}"{% endblocktrans %}

+
+ +
+
+

{% trans "Matching Books" %}

+
+ {% if not results %} +

{% blocktrans %}No books found for "{{ query }}"{% endblocktrans %}

+ {% else %} + + {% endif %} +
+ +
+
+
+{% endwith %} +{% endblock %} diff --git a/bookwyrm/templates/layout.html b/bookwyrm/templates/layout.html index bc2e7e09..901a12ff 100644 --- a/bookwyrm/templates/layout.html +++ b/bookwyrm/templates/layout.html @@ -22,7 +22,7 @@ -