From: Radek Czajka Date: Tue, 19 Aug 2025 10:14:04 +0000 (+0200) Subject: Audiobooks: narrators as authors, and top-level players. X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/refs/heads/master?ds=sidebyside;hp=1501fe79a5abe6fc214309ade4ceb85f6bfd0328 Audiobooks: narrators as authors, and top-level players. --- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..032024ba7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +FROM python:3.8 AS base + +ARG UID=1000 +ARG GID=1000 + +RUN apt-get update && apt-get install -y \ + git \ + calibre \ + texlive-xetex texlive-lang-polish \ + libespeak-dev + +COPY requirements/requirements.txt requirements.txt + +# numpy -> aeneas +RUN pip install numpy +RUN pip install aeneas + +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir \ + psycopg2-binary \ + django-debug-toolbar==3.2.2 \ + python-bidi + +RUN addgroup --gid $GID app +RUN adduser --gid $GID --home /app --uid $UID app + +RUN apt-get install -y \ + texlive-extra-utils \ + texlive-lang-greek \ + texlive-lang-other \ + texlive-luatex \ + texlive-fonts-extra \ + texlive-fonts-extra-links \ + fonts-noto-core fonts-noto-extra + + +USER app + +# fonts +RUN cp -a /usr/local/lib/python*/site-packages/librarian/fonts /app/.fonts +RUN fc-cache + +WORKDIR /app/src + + +FROM base AS dev + +#RUN pip install --no-cache-dir coverage +USER app + + +FROM base AS prod + +RUN pip install --no-cache-dir gunicorn + +USER app +COPY src /app/src diff --git a/Makefile b/Makefile index d43ff1e6d..77d345df2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,8 @@ -.PHONY: deploy test +.PHONY: deploy test shell + + +UID != id -u +GID != id -g deploy: src/wolnelektury/localsettings.py @@ -17,3 +21,11 @@ test: mv ../htmlcov.new ../htmlcov coverage report rm .coverage + + +shell: + UID=$(UID) GID=$(GID) docker-compose run --rm dev bash + + +build: + UID=$(UID) GID=$(GID) docker-compose build dev diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..89b2216d7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + dev: + build: + context: . + target: dev + args: + - "UID=${UID}" + - "GID=${GID}" + volumes: + - ./src:/app/src + - ./var/media:/app/var/media + - ./var/static:/app/var/static + - ./var/counters/:/app/var/counters + depends_on: + - db + db: + image: postgres + container_name: db + env_file: + - .env + volumes: + - ./var/postgresql-data/:/var/lib/postgresql/data/ diff --git a/manage b/manage new file mode 100755 index 000000000..1077a0396 --- /dev/null +++ b/manage @@ -0,0 +1,14 @@ +#!/bin/sh +export UID=`id -u` +export GID=`id -g` + +if [ "$1" = "runserver" ] +then + PORT="$2" + [ -z "$PORT" ] && PORT=8000 + EXPOSED=127.0.0.1:"$PORT" + echo "expose as: $EXPOSED" + exec docker-compose run --rm -p "$EXPOSED":"$PORT" dev python $PYARGS manage.py runserver 0.0.0.0:"$PORT" +else + exec docker-compose run --rm dev python $PYARGS manage.py "$@" +fi diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 10498cb2b..0559a74ab 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -40,7 +40,7 @@ mutagen==1.47 sorl-thumbnail==12.10.0 # home-brewed & dependencies -librarian==24.5.7 +librarian==24.5.10 # celery tasks celery[redis]==5.4.0 diff --git a/src/api/urls.py b/src/api/urls.py index de3dba77f..62d4fa7f7 100644 --- a/src/api/urls.py +++ b/src/api/urls.py @@ -16,6 +16,7 @@ urlpatterns1 = [ path('login/', csrf_exempt(views.Login2View.as_view())), path('me/', views.UserView.as_view()), path('', include('catalogue.api.urls2')), + path('', include('social.api.urls2')), ] diff --git a/src/catalogue/api/serializers.py b/src/catalogue/api/serializers.py index 60e52a052..406cd39aa 100644 --- a/src/catalogue/api/serializers.py +++ b/src/catalogue/api/serializers.py @@ -156,6 +156,8 @@ class BookSerializer2(serializers.ModelSerializer): 'epub', 'mobi', 'pdf', 'html', 'txt', 'fb2', 'xml', 'cover_thumb', 'cover', 'isbn_pdf', 'isbn_epub', 'isbn_mobi', + 'abstract', + 'has_mp3_file', ] class BookSerializer11Labs(serializers.ModelSerializer): @@ -237,6 +239,17 @@ class MediaSerializer(LegacyMixin, serializers.ModelSerializer): legacy_non_null_fields = ['director', 'artist'] +class MediaSerializer2(MediaSerializer): + size = serializers.SerializerMethodField() + + class Meta: + model = BookMedia + fields = ['url', 'director', 'type', 'name', 'part_name', 'artist', 'duration', 'size'] + + def get_size(self, obj): + return obj.file.size + + class BookDetailSerializer(LegacyMixin, serializers.ModelSerializer): url = AbsoluteURLField() @@ -352,6 +365,15 @@ class FragmentDetailSerializer(serializers.ModelSerializer): fields = ['book', 'anchor', 'text', 'url', 'themes'] +class FragmentSerializer2(serializers.ModelSerializer): + url = AbsoluteURLField() + html = serializers.CharField(source='text') + + class Meta: + model = Fragment + fields = ['anchor', 'html', 'url'] + + class FilterTagSerializer(serializers.ModelSerializer): class Meta: model = Tag diff --git a/src/catalogue/api/urls2.py b/src/catalogue/api/urls2.py index b16af6651..e90e13eea 100644 --- a/src/catalogue/api/urls2.py +++ b/src/catalogue/api/urls2.py @@ -19,6 +19,13 @@ urlpatterns = [ piwik_track_view(views.BookDetail2.as_view()), name='catalogue_api_book' ), + path('books//fragment/', + piwik_track_view(views.BookFragmentView.as_view()), + name='catalogue_api_book_fragment' + ), + path('books//media//', + views.BookMediaView.as_view() + ), path('suggested-tags/', piwik_track_view(views.SuggestedTags.as_view()), diff --git a/src/catalogue/api/views.py b/src/catalogue/api/views.py index 0e758b15e..821b281c4 100644 --- a/src/catalogue/api/views.py +++ b/src/catalogue/api/views.py @@ -501,6 +501,29 @@ class SuggestedTags(ListAPIView): def get_queryset(self): tag_ids = self.request.GET.getlist('tag', []) + search = self.request.GET.get('search') tags = [get_object_or_404(Tag, id=tid) for tid in tag_ids] related_tags = list(t.id for t in get_top_level_related_tags(tags)) - return Tag.objects.filter(id__in=related_tags) + tags = Tag.objects.filter(id__in=related_tags) + if search: + tags = tags.filter(name__icontains=search) + return tags + + +class BookFragmentView(RetrieveAPIView): + serializer_class = serializers.FragmentSerializer2 + + def get_object(self): + book = get_object_or_404(Book, slug=self.kwargs['slug']) + return book.choose_fragment() + + +class BookMediaView(ListAPIView): + serializer_class = serializers.MediaSerializer2 + pagination_class = None + + def get_queryset(self): + return BookMedia.objects.filter( + book__slug=self.kwargs['slug'], + type=self.kwargs['type'] + ).order_by('index') diff --git a/src/catalogue/migrations/0050_book_narrators.py b/src/catalogue/migrations/0050_book_narrators.py new file mode 100644 index 000000000..388f150e9 --- /dev/null +++ b/src/catalogue/migrations/0050_book_narrators.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.8 on 2025-08-12 09:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogue', '0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='narrators', + field=models.ManyToManyField(blank=True, related_name='narrated', to='catalogue.tag'), + ), + ] diff --git a/src/catalogue/migrations/0051_book_has_audio.py b/src/catalogue/migrations/0051_book_has_audio.py new file mode 100644 index 000000000..e333bff6c --- /dev/null +++ b/src/catalogue/migrations/0051_book_has_audio.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.8 on 2025-08-12 10:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('catalogue', '0050_book_narrators'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='has_audio', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/catalogue/models/book.py b/src/catalogue/models/book.py index c2b9eeddf..0400656e2 100644 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@ -7,6 +7,8 @@ from datetime import date, timedelta from random import randint import os.path import re +from slugify import slugify +from sortify import sortify from urllib.request import urlretrieve from django.apps import apps from django.conf import settings @@ -94,6 +96,8 @@ class Book(models.Model): tags = managers.TagDescriptor(Tag) tag_relations = GenericRelation(Tag.intermediary_table_model) translators = models.ManyToManyField(Tag, blank=True) + narrators = models.ManyToManyField(Tag, blank=True, related_name='narrated') + has_audio = models.BooleanField(default=False) html_built = django.dispatch.Signal() published = django.dispatch.Signal() @@ -266,17 +270,6 @@ class Book(models.Model): return sibling.get_first_text() return self.parent.get_next_text(inside=False) - def get_child_audiobook(self): - BookMedia = apps.get_model('catalogue', 'BookMedia') - if not BookMedia.objects.filter(book__ancestor=self).exists(): - return None - for child in self.children.order_by('parent_number').all(): - if child.has_mp3_file(): - return child - child_sub = child.get_child_audiobook() - if child_sub is not None: - return child_sub - def get_siblings(self): if not self.parent: return [] @@ -337,9 +330,6 @@ class Book(models.Model): else: return self.media.filter(type=type_).exists() - def has_audio(self): - return self.has_media('mp3') - def get_media(self, type_): if self.has_media(type_): if type_ in Book.formats: @@ -421,6 +411,67 @@ class Book(models.Model): def has_sync_file(self): return settings.FEATURE_SYNCHRO and self.has_media("sync") + def build_sync_file(self): + from lxml import html + from django.core.files.base import ContentFile + with self.html_file.open('rb') as f: + h = html.fragment_fromstring(f.read().decode('utf-8')) + + durations = [ + m['mp3'].duration + for m in self.get_audiobooks()[0] + ] + if settings.MOCK_DURATIONS: + durations = settings.MOCK_DURATIONS + + sync = [] + ts = None + sid = 1 + dirty = False + for elem in h.iter(): + if elem.get('data-audio-ts'): + part, ts = int(elem.get('data-audio-part')), float(elem.get('data-audio-ts')) + ts = str(round(sum(durations[:part - 1]) + ts, 3)) + # check if inside verse + p = elem.getparent() + while p is not None: + # Workaround for missing ids. + if 'verse' in p.get('class', ''): + if not p.get('id'): + p.set('id', f'syn{sid}') + dirty = True + sid += 1 + sync.append((ts, p.get('id'))) + ts = None + break + p = p.getparent() + elif ts: + cls = elem.get('class', '') + # Workaround for missing ids. + if 'paragraph' in cls or 'verse' in cls or elem.tag in ('h1', 'h2', 'h3', 'h4'): + if not elem.get('id'): + elem.set('id', f'syn{sid}') + dirty = True + sid += 1 + sync.append((ts, elem.get('id'))) + ts = None + if dirty: + htext = html.tostring(h, encoding='utf-8') + with open(self.html_file.path, 'wb') as f: + f.write(htext) + try: + bm = self.media.get(type='sync') + except: + bm = BookMedia(book=self, type='sync') + sync = ( + '27\n' + '\n'.join( + f'{s[0]}\t{sync[i+1][0]}\t{s[1]}' for i, s in enumerate(sync[:-1]) + )).encode('latin1') + bm.file.save( + None, ContentFile(sync) + ) + + def get_sync(self): with self.get_media('sync').first().file.open('r') as f: sync = f.read().split('\n') @@ -444,7 +495,7 @@ class Book(models.Model): def media_audio_epub(self): return self.get_media('audio.epub') - def get_audiobooks(self): + def get_audiobooks(self, with_children=False, processing=False): ogg_files = {} for m in self.media.filter(type='ogg').order_by().iterator(): ogg_files[m.name] = m @@ -470,13 +521,27 @@ class Book(models.Model): media['ogg'] = ogg audiobooks.append(media) - projects = sorted(projects) - total_duration = '%d:%02d' % ( - total_duration // 60, - total_duration % 60 - ) + if with_children: + for child in self.get_children(): + ch_audiobooks, ch_projects, ch_duration = child.get_audiobooks( + with_children=True, processing=True) + audiobooks.append({'part': child}) + audiobooks += ch_audiobooks + projects.update(ch_projects) + total_duration += ch_duration + + if not processing: + projects = sorted(projects) + total_duration = '%d:%02d' % ( + total_duration // 60, + total_duration % 60 + ) + return audiobooks, projects, total_duration + def get_audiobooks_with_children(self): + return self.get_audiobooks(with_children=True) + def wldocument(self, parse_dublincore=True, inherit=True): from catalogue.import_utils import ORMDocProvider from librarian.parser import WLDocument @@ -769,6 +834,42 @@ class Book(models.Model): def references(self): return self.reference_set.all().select_related('entity') + def update_has_audio(self): + self.has_audio = False + if self.media.filter(type='mp3').exists(): + self.has_audio = True + if self.descendant.filter(has_audio=True).exists(): + self.has_audio = True + self.save(update_fields=['has_audio']) + if self.parent is not None: + self.parent.update_has_audio() + + def update_narrators(self): + narrator_names = set() + for bm in self.media.filter(type='mp3'): + narrator_names.update(set( + a.strip() for a in re.split(r',|\si\s', bm.artist) + )) + narrators = [] + + for name in narrator_names: + if not name: continue + slug = slugify(name) + try: + t = Tag.objects.get(category='author', slug=slug) + except Tag.DoesNotExist: + sort_key = sortify( + ' '.join(name.rsplit(' ', 1)[::-1]).lower() + ) + t = Tag.objects.create( + category='author', + name_pl=name, + slug=slug, + sort_key=sort_key, + ) + narrators.append(t) + self.narrators.set(narrators) + @classmethod @transaction.atomic def repopulate_ancestors(cls): diff --git a/src/catalogue/models/bookmedia.py b/src/catalogue/models/bookmedia.py index acb1881e5..87d7f7803 100644 --- a/src/catalogue/models/bookmedia.py +++ b/src/catalogue/models/bookmedia.py @@ -70,6 +70,8 @@ class BookMedia(models.Model): return f'{name}.{ext}' def save(self, parts_count=None, *args, **kwargs): + if self.type in ('daisy', 'audio.epub'): + return super().save(*args, **kwargs) from catalogue.utils import ExistingFile, remove_zip if not parts_count: @@ -99,7 +101,9 @@ class BookMedia(models.Model): self.extra_info = json.dumps(extra_info) self.source_sha1 = self.read_source_sha1(self.file.path, self.type) self.duration = self.read_duration() - return super(BookMedia, self).save(*args, **kwargs) + super(BookMedia, self).save(*args, **kwargs) + self.book.update_narrators() + self.book.update_has_audio() def read_duration(self): try: diff --git a/src/catalogue/static/book/filter.js b/src/catalogue/static/book/filter.js index 2ff426234..37613bfc1 100644 --- a/src/catalogue/static/book/filter.js +++ b/src/catalogue/static/book/filter.js @@ -41,7 +41,11 @@ } $("#paginator").on('click', 'a', function() { - get_page_by_url(url=$(this).attr('href')); + get_page_by_url(url=$(this).attr('href'), () => { + $("html").animate({ + scrollTop: $("#book-list").offset().top - 50 + }); + }); return false; }); diff --git a/src/catalogue/static/player/player.js b/src/catalogue/static/player/player.js index cbf90261b..f94dc872b 100644 --- a/src/catalogue/static/player/player.js +++ b/src/catalogue/static/player/player.js @@ -114,31 +114,28 @@ $(".c-media__caption .license", $root).html($(".license", elem).html()); $(".c-media__caption .project-logo", $root).html($(".project-icon", elem).html()); - console.log('sm 1'); doesUpdateSynchro = false; if (!$currentMedia || $currentMedia[0] != elem[0]) { - console.log('set', player.jPlayer("setMedia", media)) player.jPlayer("option", "playbackRate", speed); } doesUpdateSynchro = true; player.jPlayer(cmd, time); $currentMedia = elem; - $(".play-next", $root).prop("disabled", !elem.next().length); + $(".play-next", $root).prop("disabled", !elem.nextAll('li').length); let du = parseFloat(elem.data('duration')); currentDuration = du; - elem.nextAll().each(function() { + elem.nextAll('li').each(function() { du += parseFloat($(this).data('duration')); }); totalDurationLeft = du; let pdu = 0; - elem.prevAll().each(function() { + elem.prevAll('li').each(function() { pdu += parseFloat($(this).data('duration')); }); totalDurationBefore = pdu; - console.log('sm 3', du, pdu); return player; }; @@ -166,7 +163,6 @@ // TODO: if snap then roll locator.removeClass('up').removeClass('down'); if (locator.hasClass('snap')) { - console.log('SCROLL!'); scrollTo(); } else { if (y < miny) { @@ -212,14 +208,14 @@ }); $('.play-next', $root).click(function() { - let p = $currentMedia.next(); + let p = $currentMedia.nextAll('li').first(); if (p.length) { setMedia(p).jPlayer("play"); _paq.push(['trackEvent', 'audiobook', 'next']); } }); $('.play-prev', $root).click(function() { - let p = $currentMedia.prev(); + let p = $currentMedia.prevAll('li').first(); if (p.length) { setMedia(p).jPlayer("play"); _paq.push(['trackEvent', 'audiobook', 'prev']); @@ -236,10 +232,8 @@ _paq.push(['trackEvent', 'audiobook', 'chapter']); }); - console.log('READY 3!'); var initialElem = $('.jp-playlist li', $root).first(); var initialTime = 0; - console.log('READY 4!'); if (true || Modernizr.localstorage) { try { let speedStr = localStorage['audiobook-speed']; @@ -266,9 +260,7 @@ initialTime = last[2]; } } - console.log('READY 5!', initialElem, initialTime); setMedia($(initialElem), initialTime); - console.log('READY 6!'); }, timeupdate: function(event) { @@ -310,7 +302,7 @@ ended: function(event) { - let p = $currentMedia.next(); + let p = $currentMedia.nextAll('li'); if (p.length) { setMedia(p).jPlayer("play"); } diff --git a/src/catalogue/templates/catalogue/book_box.html b/src/catalogue/templates/catalogue/book_box.html index 4458b5a22..b82d926e8 100644 --- a/src/catalogue/templates/catalogue/book_box.html +++ b/src/catalogue/templates/catalogue/book_box.html @@ -34,7 +34,7 @@ {% if book.is_book %} {% endif %} - {% if book.has_mp3_file %} + {% if book.has_audio %} {% endif %} {% if book.is_picture %} diff --git a/src/catalogue/templates/catalogue/book_detail.html b/src/catalogue/templates/catalogue/book_detail.html index eb1ab8a7d..016b2f2db 100644 --- a/src/catalogue/templates/catalogue/book_detail.html +++ b/src/catalogue/templates/catalogue/book_detail.html @@ -162,13 +162,18 @@
- {% if book.has_mp3_file %} + {% if book.has_audio %} {% endif %}
+
+ {% if first_text %} + {% trans + {% endif %} +
{% if first_text %} {% trans "czytaj online" %} @@ -179,21 +184,11 @@
- - {% if book.has_mp3_file %} + {% if book.has_audio %} {% include 'catalogue/snippets/jplayer.html' %} - {% else %} - {% with ch=book.get_child_audiobook %} - {% if ch %} - {% include 'catalogue/snippets/jplayer_link.html' with book=ch %} - {% endif %} - {% endwith %} - {% endif %} - -
diff --git a/src/catalogue/templates/catalogue/book_list.html b/src/catalogue/templates/catalogue/book_list.html index c57bdd3f9..2a3db148c 100644 --- a/src/catalogue/templates/catalogue/book_list.html +++ b/src/catalogue/templates/catalogue/book_list.html @@ -129,6 +129,17 @@
{% endif %} + {% if narrated %} +
+

Audiobooki

+
+ {% for book in narrated %} + {% include "catalogue/book_box.html" %} + {% endfor %} +
+
+ {% endif %} + {% if main_tag %}
diff --git a/src/catalogue/templates/catalogue/snippets/jplayer.html b/src/catalogue/templates/catalogue/snippets/jplayer.html index e0893facb..2399a09f0 100644 --- a/src/catalogue/templates/catalogue/snippets/jplayer.html +++ b/src/catalogue/templates/catalogue/snippets/jplayer.html @@ -1,6 +1,6 @@ {% load i18n l10n catalogue_tags %} -{% with audiobooks=book.get_audiobooks %} +{% with audiobooks=book.get_audiobooks_with_children %}
@@ -38,6 +38,9 @@ diff --git a/src/catalogue/templates/catalogue/snippets/jplayer_link.html b/src/catalogue/templates/catalogue/snippets/jplayer_link.html deleted file mode 100644 index 7bbddcdda..000000000 --- a/src/catalogue/templates/catalogue/snippets/jplayer_link.html +++ /dev/null @@ -1,24 +0,0 @@ -{% load i18n catalogue_tags %} - -
-
-   -
- -
-
- - -
- -
-
- -
-
{{ book.pretty_title }}
-
- -
-
-
-
diff --git a/src/catalogue/views.py b/src/catalogue/views.py index 3c4a55fe9..d5b83ab08 100644 --- a/src/catalogue/views.py +++ b/src/catalogue/views.py @@ -227,6 +227,7 @@ class TaggedObjectList(BookList): ] if len(self.ctx['tags']) == 1 and self.ctx['main_tag'].category == 'author': self.ctx['translation_list'] = self.ctx['main_tag'].book_set.all() + self.ctx['narrated'] = self.ctx['main_tag'].narrated.all() def get_queryset(self): qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True) diff --git a/src/club/civicrm.py b/src/club/civicrm.py index 20b287a28..dffeaa766 100644 --- a/src/club/civicrm.py +++ b/src/club/civicrm.py @@ -27,12 +27,12 @@ class CiviCRM: d = response.json() return d - def create_or_update_contact(self, email, key=None): + def create_or_update_contact(self, email, fields=None): contact_id = self.get_contact_id(email) if contact_id is None: - contact_id = self.create_contact(email, key) - elif key: - self.update_contact(contact_id, key) + contact_id = self.create_contact(email, fields) + elif fields: + self.update_contact(contact_id, fields) return contact_id def get_contact_id(self, email): @@ -49,7 +49,7 @@ class CiviCRM: if result: return result[0]['id'] - def create_contact(self, email, key=None): + def create_contact(self, email, fields): data = { 'values': {}, 'chain': { @@ -65,19 +65,22 @@ class CiviCRM: ] } } - if key: - data['values']['WL.TPWL_key'] = key + if fields: + data['values'].update(fields) result = self.request('Contact', 'create', data) return result['values'][0]['id'] - - def update_contact(self, contact_id, key): + + def update_phone(self, contact_id, phone): + if self.request('Phone', 'get', {'where': [['phone', "=", phone], ['contact_id', "=", contact_id]]})['count']: + return + self.request('Phone', 'create', {'values': {'phone': phone, 'contact_id': contact_id}}) + + def update_contact(self, contact_id, fields): return self.request( 'Contact', 'update', { - 'values': { - 'WL.TPWL_key': key, - }, + 'values': fields, 'where': [ ['id', '=', contact_id] ] @@ -89,7 +92,8 @@ class CiviCRM: if not self.enabled: return - contact_id = self.create_or_update_contact(email, tpwl_key) + fields = {'WL.TPWL_key': tpwl_key} + contact_id = self.create_or_update_contact(email, fields) activity_id = self.get_activity_id(key) if activity_id is None: diff --git a/src/social/api/urls2.py b/src/social/api/urls2.py new file mode 100644 index 000000000..b150e6183 --- /dev/null +++ b/src/social/api/urls2.py @@ -0,0 +1,17 @@ +# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later. +# Copyright © Fundacja Wolne Lektury. See NOTICE for more information. +# +from django.urls import path +from stats.utils import piwik_track_view +from . import views + + +urlpatterns = [ + path('like//', + piwik_track_view(views.LikeView2.as_view()), + name='social_api_like'), + path('likes/', views.LikesView.as_view()), + path('my-likes/', views.MyLikesView.as_view()), +] + + diff --git a/src/social/api/views.py b/src/social/api/views.py index a29930423..22a0e9c52 100644 --- a/src/social/api/views.py +++ b/src/social/api/views.py @@ -11,7 +11,9 @@ from api.utils import vary_on_auth from catalogue.api.helpers import order_books, books_after from catalogue.api.serializers import BookSerializer from catalogue.models import Book +import catalogue.models from social.utils import likes +from social.views import get_sets_for_book_ids @vary_on_auth @@ -32,6 +34,53 @@ class LikeView(APIView): return Response({}) +@vary_on_auth +class LikeView2(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request, slug): + book = get_object_or_404(Book, slug=slug) + return Response({"likes": likes(request.user, book)}) + + def put(self, request, slug): + book = get_object_or_404(Book, slug=slug) + book.like(request.user) + return Response({"likes": likes(request.user, book)}) + + def delete(self, request, slug): + book = get_object_or_404(Book, slug=slug) + book.unlike(request.user) + return Response({"likes": likes(request.user, book)}) + + +@vary_on_auth +class LikesView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + slugs = request.GET.getlist('slug') + books = Book.objects.filter(slug__in=slugs) + books = {b.id: b.slug for b in books} + ids = books.keys() + res = get_sets_for_book_ids(ids, request.user) + res = {books[bid]: v for bid, v in res.items()} + return Response(res) + + +@vary_on_auth +class MyLikesView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + ids = catalogue.models.tag.TagRelation.objects.filter(tag__user=request.user).values_list('object_id', flat=True).distinct() + books = Book.objects.filter(id__in=ids) + books = {b.id: b.slug for b in books} + res = get_sets_for_book_ids(ids, request.user) + res = {books[bid]: v for bid, v in res.items()} + return Response(res) + + + @vary_on_auth class ShelfView(ListAPIView): permission_classes = [IsAuthenticated] diff --git a/src/social/views.py b/src/social/views.py index 3dfcd9e2b..8f27b8746 100644 --- a/src/social/views.py +++ b/src/social/views.py @@ -19,12 +19,13 @@ from wolnelektury.utils import is_ajax # ==================== -@require_POST +@login_required def like_book(request, slug): - if not request.user.is_authenticated: - return HttpResponseForbidden('Login required.') book = get_object_or_404(Book, slug=slug) + if request.method != 'POST': + return redirect(book) + book.like(request.user) if is_ajax(request): @@ -50,12 +51,13 @@ class RemoveSetView(AddSetView): form_class = forms.RemoveSetForm -@require_POST +@login_required def unlike_book(request, slug): - if not request.user.is_authenticated: - return HttpResponseForbidden('Login required.') book = get_object_or_404(Book, slug=slug) + if request.method != 'POST': + return redirect(book) + book.unlike(request.user) if is_ajax(request): diff --git a/src/wolnelektury/static/2022/styles/components/_media.scss b/src/wolnelektury/static/2022/styles/components/_media.scss index c4af4ebd1..9c5f34f94 100644 --- a/src/wolnelektury/static/2022/styles/components/_media.scss +++ b/src/wolnelektury/static/2022/styles/components/_media.scss @@ -10,7 +10,7 @@ .c-media__actions { display: flex; - column-gap: 38px; + column-gap: 20px; row-gap: 10px; } diff --git a/src/wolnelektury/static/2022/styles/components/_player.scss b/src/wolnelektury/static/2022/styles/components/_player.scss index 52729f9f6..d1fb096f1 100644 --- a/src/wolnelektury/static/2022/styles/components/_player.scss +++ b/src/wolnelektury/static/2022/styles/components/_player.scss @@ -296,6 +296,15 @@ display: inline; } } + div.title { + font-size: 12px; + line-height: 140%; + letter-spacing: 0.05em; + list-style-type: decimal; + margin: 5px 0; + color: white; + cursor: pointer; + } } } diff --git a/src/wolnelektury/static/2022/styles/layout/_button.scss b/src/wolnelektury/static/2022/styles/layout/_button.scss index 78b33bbae..b1844d896 100644 --- a/src/wolnelektury/static/2022/styles/layout/_button.scss +++ b/src/wolnelektury/static/2022/styles/layout/_button.scss @@ -21,12 +21,16 @@ @include rwd($break-flow) { @include font-size(16px); line-height: 20px; - padding: 19px 20px; + padding: 19px 0; } .icon { font-size: 21px; - margin-right: 15px; + margin-right: 10px; + } + + img { + height: 21px; } &:hover { diff --git a/src/wolnelektury/static/2022/styles/layout/_text.scss b/src/wolnelektury/static/2022/styles/layout/_text.scss index 1fde2b98a..c4ff0f355 100644 --- a/src/wolnelektury/static/2022/styles/layout/_text.scss +++ b/src/wolnelektury/static/2022/styles/layout/_text.scss @@ -1064,6 +1064,10 @@ background: #fff; } #other { + display: none; + @include rwd($break-wide) { + display: block; + } ul { list-style: none; margin: 0; diff --git a/src/wolnelektury/static/2022/styles/reader_player.scss b/src/wolnelektury/static/2022/styles/reader_player.scss index bf0fb2b29..4f85a03ec 100644 --- a/src/wolnelektury/static/2022/styles/reader_player.scss +++ b/src/wolnelektury/static/2022/styles/reader_player.scss @@ -448,7 +448,6 @@ #menu { - padding-bottom: 50px; * { box-sizing: content-box; } diff --git a/src/wolnelektury/static/img/elevenreader-21.png b/src/wolnelektury/static/img/elevenreader-21.png new file mode 100644 index 000000000..86e433d74 Binary files /dev/null and b/src/wolnelektury/static/img/elevenreader-21.png differ diff --git a/src/wolnelektury/static/img/elevenreader.png b/src/wolnelektury/static/img/elevenreader.png new file mode 100644 index 000000000..16dcadead Binary files /dev/null and b/src/wolnelektury/static/img/elevenreader.png differ diff --git a/src/wolnelektury/static/js/book_text/progress.js b/src/wolnelektury/static/js/book_text/progress.js index ae5b14ac4..723e03ec9 100644 --- a/src/wolnelektury/static/js/book_text/progress.js +++ b/src/wolnelektury/static/js/book_text/progress.js @@ -4,6 +4,8 @@ function upd_t() { $text = $('#main-text #book-text'); + if (!$text.length) return; + texttop = $text.offset().top; $footnotes = $('#footnotes', $text); diff --git a/src/wolnelektury/static/js/book_text/references.js b/src/wolnelektury/static/js/book_text/references.js index fa4a35a69..3f5b7e37f 100644 --- a/src/wolnelektury/static/js/book_text/references.js +++ b/src/wolnelektury/static/js/book_text/references.js @@ -10,10 +10,12 @@ } var map_enabled = false; - var marker = L.circleMarker([0,0]); + var marker = null; var map = null; function enable_map() { + if (!$("#reference-map").length) return; + $("#reference-map").show('slow'); if (map_enabled) return; @@ -22,6 +24,7 @@ L.tileLayer('https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=a8a97f0ae5134403ac38c1a075b03e15', { attribution: 'Maps © Thunderforest, Data © OpenStreetMap contributors' }).addTo(map); + marker = L.circleMarker([0,0]); map_enabled = true; } diff --git a/src/wolnelektury/static/js/main.js b/src/wolnelektury/static/js/main.js index 2feb7b8eb..45a41970c 100644 --- a/src/wolnelektury/static/js/main.js +++ b/src/wolnelektury/static/js/main.js @@ -381,24 +381,27 @@ $.post({ url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/nie_lubie/', data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()}, + dataType: 'json', success: function() { delete state.liked[$btn.attr('data-book')]; updateLiked($btn); + }, + error: function() { + window.location.href = $('#login-link').attr('href'); } }) } else { $.post({ url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/lubie/', data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()}, + dataType: 'json', success: function() { state.liked[$btn.attr('data-book')] = []; updateLiked($btn); }, - error: function(e) { - if (e.status == 403) { - $('#login-link').click(); - } - }, + error: function() { + window.location.href = $('#login-link').attr('href') + } }); } }) diff --git a/src/wolnelektury/templates/admin/catalogue/book/change_list.html b/src/wolnelektury/templates/admin/catalogue/book/change_list.html index b9574d9c9..db3308460 100644 --- a/src/wolnelektury/templates/admin/catalogue/book/change_list.html +++ b/src/wolnelektury/templates/admin/catalogue/book/change_list.html @@ -4,7 +4,7 @@
{% csrf_token %}

- +