From: Radek Czajka Date: Wed, 8 Oct 2025 12:43:07 +0000 (+0200) Subject: Merge branch 'master' into appdev X-Git-Url: https://git.mdrn.pl/wolnelektury.git/commitdiff_plain/102acc0b6eb715826f26b5082611604cf6ebe240?ds=sidebyside;hp=-c Merge branch 'master' into appdev --- 102acc0b6eb715826f26b5082611604cf6ebe240 diff --combined src/catalogue/api/serializers.py index 9c70a9f0e,406cd39aa..609fa7467 --- a/src/catalogue/api/serializers.py +++ b/src/catalogue/api/serializers.py @@@ -50,7 -50,7 +50,7 @@@ class AuthorItemSerializer(serializers. class Meta: model = Tag fields = [ - 'url', 'href', 'name' + 'url', 'href', 'name', 'slug' ] class AuthorSerializer(AuthorItemSerializer): @@@ -59,7 -59,7 +59,7 @@@ class Meta: model = Tag fields = [ - 'url', 'href', 'name', 'slug', 'sort_key', 'description', + 'id', 'url', 'href', 'name', 'slug', 'sort_key', 'description', 'genitive', 'photo', 'photo_thumb', 'photo_attribution', ] @@@ -71,7 -71,7 +71,7 @@@ class EpochItemSerializer(serializers.M ) class Meta: model = Tag - fields = ['url', 'href', 'name'] + fields = ['url', 'href', 'name', 'slug'] class EpochSerializer(EpochItemSerializer): class Meta: @@@ -89,7 -89,7 +89,7 @@@ class GenreItemSerializer(serializers.M ) class Meta: model = Tag - fields = ['url', 'href', 'name'] + fields = ['url', 'href', 'name', 'slug'] class GenreSerializer(GenreItemSerializer): class Meta: @@@ -107,7 -107,7 +107,7 @@@ class KindItemSerializer(serializers.Mo ) class Meta: model = Tag - fields = ['url', 'href', 'name'] + fields = ['url', 'href', 'name', 'slug'] class KindSerializer(KindItemSerializer): class Meta: @@@ -117,18 -117,6 +117,18 @@@ 'collective_noun', ] +class ThemeSerializer(serializers.ModelSerializer): + url = AbsoluteURLField() + href = AbsoluteURLField( + view_name='catalogue_api_theme', + view_args=('slug',) + ) + class Meta: + model = Tag + fields = [ + 'url', 'href', 'name', 'slug', 'sort_key', 'description', + ] + class TranslatorSerializer(serializers.Serializer): name = serializers.CharField(source='*') @@@ -261,6 -249,7 +261,7 @@@ class MediaSerializer2(MediaSerializer) def get_size(self, obj): return obj.file.size + class BookDetailSerializer(LegacyMixin, serializers.ModelSerializer): url = AbsoluteURLField() @@@ -388,4 -377,4 +389,4 @@@ class FragmentSerializer2(serializers.M class FilterTagSerializer(serializers.ModelSerializer): class Meta: model = Tag - fields = ['id', 'category', 'name'] + fields = ['id', 'category', 'name', 'slug'] diff --combined src/catalogue/api/urls2.py index 29d4a3a0e,e90e13eea..ad96d7a51 --- a/src/catalogue/api/urls2.py +++ b/src/catalogue/api/urls2.py @@@ -23,9 -23,9 +23,11 @@@ urlpatterns = piwik_track_view(views.BookFragmentView.as_view()), name='catalogue_api_book_fragment' ), - path('books//media//', views.BookMediaView.as_view()), + path('books//media//', + views.BookMediaView.as_view() + ), + path('books/.json', + views.BookJsonView.as_view()), path('suggested-tags/', piwik_track_view(views.SuggestedTags.as_view()), @@@ -56,10 -56,4 +58,10 @@@ path('genres//', piwik_track_view(views.GenreView.as_view()), name='catalogue_api_genre'), + path('themes/', + piwik_track_view(views.ThemeList.as_view()), + name="catalogue_api_theme_list"), + path('themes//', + piwik_track_view(views.ThemeView.as_view()), + name='catalogue_api_theme'), ] diff --combined src/catalogue/api/views.py index 198f87fdd,821b281c4..ed5d10f1d --- a/src/catalogue/api/views.py +++ b/src/catalogue/api/views.py @@@ -6,7 -6,7 +6,7 @@@ import os.pat from urllib.request import urlopen from django.conf import settings from django.core.files.base import ContentFile -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, JsonResponse from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache from django_filters import rest_framework as dfilters @@@ -198,11 -198,6 +198,11 @@@ class BookFilter(dfilters.FilterSet) queryset=Tag.objects.filter(category__in=('author', 'epoch', 'genre', 'kind')), conjoined=True, ) + translator = dfilters.ModelMultipleChoiceFilter( + field_name='translators', + queryset=Tag.objects.filter(category='author'), + conjoined=True, + ) class BookList2(ListAPIView): @@@ -406,15 -401,6 +406,15 @@@ class KindView(RetrieveAPIView) queryset = Tag.objects.filter(category='kind') lookup_field = 'slug' +class ThemeList(ListAPIView): + serializer_class = serializers.ThemeSerializer + queryset = Tag.objects.filter(category='theme') + +class ThemeView(RetrieveAPIView): + serializer_class = serializers.ThemeSerializer + queryset = Tag.objects.filter(category='theme') + lookup_field = 'slug' + class TagView(RetrieveAPIView): permission_classes = [DjangoModelPermissionsOrAnonReadOnly] @@@ -537,15 -523,7 +537,17 @@@ class BookMediaView(ListAPIView) pagination_class = None def get_queryset(self): - return BookMedia.objects.filter(book__slug=self.kwargs['slug'], type=self.kwargs['type']).order_by('index') + return BookMedia.objects.filter( + book__slug=self.kwargs['slug'], + type=self.kwargs['type'] + ).order_by('index') + + +from .tojson import conv +from lxml import etree +from rest_framework.views import APIView +class BookJsonView(APIView): + def get(self, request, slug): + book = get_object_or_404(Book, slug=slug) + js = conv(etree.parse(book.xml_file.path)) + return JsonResponse(js, json_dumps_params={'ensure_ascii': False}) - diff --combined src/catalogue/models/book.py index 0f53baf0c,0400656e2..b2148e6b4 --- a/src/catalogue/models/book.py +++ b/src/catalogue/models/book.py @@@ -7,6 -7,8 +7,8 @@@ from datetime import date, timedelt 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 @@@ -92,8 -94,10 +94,10 @@@ class Book(models.Model) objects = models.Manager() tagged = managers.ModelTaggedItemManager(Tag) tags = managers.TagDescriptor(Tag) - tag_relations = GenericRelation(Tag.intermediary_table_model) + tag_relations = GenericRelation(Tag.intermediary_table_model, related_query_name='tagged_book') 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 +270,6 @@@ 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 +330,6 @@@ 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 +411,67 @@@ 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 +495,7 @@@ 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 +521,27 @@@ 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 +834,42 @@@ 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): @@@ -993,7 -1094,7 +1094,7 @@@ return None def update_popularity(self): - count = self.tags.filter(category='set').values('user').order_by('user').distinct().count() + count = self.userlistitem_set.values('list__user').order_by('list__user').distinct().count() try: pop = self.popularity pop.count = count @@@ -1004,6 -1105,17 +1105,6 @@@ def ridero_link(self): return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_')) - def like(self, user): - from social.utils import likes, get_set, set_sets - if not likes(user, self): - tag = get_set(user, '') - set_sets(user, self, [tag]) - - def unlike(self, user): - from social.utils import likes, set_sets - if likes(user, self): - set_sets(user, self, []) - def full_sort_key(self): return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id))) diff --combined src/catalogue/models/bookmedia.py index fd0dd56a7,87d7f7803..a5df65740 --- a/src/catalogue/models/bookmedia.py +++ b/src/catalogue/models/bookmedia.py @@@ -70,6 -70,8 +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: @@@ -87,7 -89,7 +89,7 @@@ except BookMedia.DoesNotExist: old = None - super(BookMedia, self).save(*args, **kwargs) + #super(BookMedia, self).save(*args, **kwargs) # remove the zip package for book with modified media if old: @@@ -99,7 -101,9 +101,9 @@@ 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 --combined src/catalogue/views.py index a56286de7,d5b83ab08..1ac6c089d --- a/src/catalogue/views.py +++ b/src/catalogue/views.py @@@ -23,7 -23,6 +23,7 @@@ from club.forms import DonationStep1For from club.models import Club from annoy.models import DynamicTextInsert from pdcounter import views as pdcounter_views +from social.models import UserList from wolnelektury.utils import is_ajax from catalogue import constants from catalogue import forms @@@ -212,42 -211,26 +212,43 @@@ class AudiobooksView(LiteratureView) class TaggedObjectList(BookList): def analyse(self): super().analyse() + self.ctx['tags'] = analyse_tags(self.request, self.kwargs['tags']) - self.ctx['fragment_tags'] = [t for t in self.ctx['tags'] if t.category in ('theme', 'object')] - self.ctx['work_tags'] = [t for t in self.ctx['tags'] if t not in self.ctx['fragment_tags']] + self.ctx.update({ + 'fragment_tags': [], + 'work_tags': [], + 'user_lists': [], + }) + for tag in self.ctx['tags']: + if isinstance(tag, UserList): + self.ctx['user_lists'].append(tag) + elif tag.category == 'theme': + self.ctx['fragment_tags'].append(tag) + else: + self.ctx['work_tags'].append(tag) + self.is_themed = self.ctx['has_theme'] = bool(self.ctx['fragment_tags']) if self.is_themed: self.ctx['main_tag'] = self.ctx['fragment_tags'][0] - elif self.ctx['tags']: - self.ctx['main_tag'] = self.ctx['tags'][0] + elif self.ctx['work_tags']: + self.ctx['main_tag'] = self.ctx['work_tags'][0] else: self.ctx['main_tag'] = None self.ctx['filtering_tags'] = [ t for t in self.ctx['tags'] if t is not self.ctx['main_tag'] ] - if len(self.ctx['tags']) == 1 and self.ctx['main_tag'].category == 'author': + if len(self.ctx['tags']) == 1 and self.ctx['main_tag'] is not None 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) + if self.ctx['work_tags']: + qs = Book.tagged.with_all(self.ctx['work_tags']).filter(findable=True) + else: + qs = Book.objects.filter(findable=True) + for ul in self.ctx['user_lists']: + qs = qs.filter(id__in=[i.id for i in ul.get_books()]) qs = qs.exclude(ancestor__in=qs) if self.is_themed: fqs = Fragment.tagged.with_all(self.ctx['fragment_tags']) @@@ -259,9 -242,6 +260,9 @@@ return qs def get_suggested_tags(self, queryset): + if self.ctx['user_lists']: + # TODO + return [] tag_ids = [t.id for t in self.ctx['tags']] if self.is_themed: related_tags = [] @@@ -273,7 -253,6 +274,7 @@@ containing_books, ).exclude(category='set').exclude(pk__in=tag_ids) )) + ### FIXME: These won't be tags if self.request.user.is_authenticated: related_tags.extend(list( Tag.objects.usage_for_queryset( @@@ -314,7 -293,6 +315,7 @@@ def object_list(request, objects, list_ Tag.objects.usage_for_queryset( objects, counts=True ).exclude(category='set')) + ### FIXME: these won't be tags if request.user.is_authenticated: related_tag_lists.append( Tag.objects.usage_for_queryset( @@@ -332,7 -310,7 +333,7 @@@ .only('name', 'sort_key', 'category', 'slug')) if isinstance(objects, QuerySet): objects = prefetch_relations(objects, 'author') - + categories = split_tags(*related_tag_lists) suggest = [] for c in ['set', 'author', 'epoch', 'kind', 'genre']: @@@ -347,7 -325,7 +348,7 @@@ } template = 'catalogue/author_detail.html' - + return render( request, template, result, ) diff --combined src/social/views.py index e3d60ca93,8f27b8746..0ff077104 --- a/src/social/views.py +++ b/src/social/views.py @@@ -8,7 -8,8 +8,7 @@@ from django.views.decorators.cache impo from django.views.decorators.http import require_POST from django.views.generic.edit import FormView -from catalogue.models import Book, Tag -import catalogue.models.tag +from catalogue.models import Book from social import forms, models from wolnelektury.utils import is_ajax @@@ -18,13 -19,14 +18,14 @@@ # ==================== - @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) + models.UserList.like(request.user, book) if is_ajax(request): return JsonResponse({"success": True, "msg": "ok", "like": True}) @@@ -49,13 -51,14 +50,14 @@@ 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) + models.UserList.unlike(request.user, book) if is_ajax(request): return JsonResponse({"success": True, "msg": "ok", "like": False}) @@@ -66,29 -69,32 +68,29 @@@ @login_required def my_shelf(request): template_name = 'social/my_shelf.html' - tags = list(request.user.tag_set.all()) - suggest = [t for t in tags if t.name] - print(suggest) + ulists = list(request.user.userlist_set.all()) + suggest = [t for t in ulists if t.name] return render(request, template_name, { - 'tags': tags, - 'books': Book.tagged.with_any(tags), + 'tags': ulists, + 'books': Book.objects_filter(userlistitem__list__user=request.user), 'suggest': suggest, }) def get_sets_for_book_ids(book_ids, user): data = {} - tagged = catalogue.models.tag.TagRelation.objects.filter( - tag__user=user, - #content_type= # for books, - object_id__in=book_ids - ).order_by('tag__sort_key') + tagged = models.UserListItem.objects.filter( + list__user=user, + book_id__in=book_ids + ).order_by('list__name') for t in tagged: - # related? - item = data.setdefault(t.object_id, []) - if t.tag.name: + item = data.setdefault(t.book_id, []) + if t.list.name: item.append({ - "slug": t.tag.slug, - "url": t.tag.get_absolute_url(), - "name": t.tag.name, + "slug": t.list.slug, + "url": t.list.get_absolute_url(), + "name": t.list.name, }) for b in book_ids: if b not in data: @@@ -113,12 -119,12 +115,12 @@@ def my_liked(request) @login_required def my_tags(request): term = request.GET.get('term', '') - tags = Tag.objects.filter(user=request.user).order_by('sort_key') + tags = models.UserList.objects.filter(user=request.user).order_by('name') if term: - tags = tags.filter(name__icontains=term) + ulists = tags.filter(name__icontains=term) return JsonResponse( [ - t.name for t in tags + ul.name for ul in ulists ], safe=False )