Merge branch 'master' into appdev
authorRadek Czajka <rczajka@rczajka.pl>
Wed, 12 Nov 2025 10:02:07 +0000 (11:02 +0100)
committerRadek Czajka <rczajka@rczajka.pl>
Wed, 12 Nov 2025 10:02:07 +0000 (11:02 +0100)
50 files changed:
src/api/serializers.py
src/api/urls.py
src/api/utils.py
src/api/views.py
src/bookmarks/api/urls.py [new file with mode: 0644]
src/bookmarks/api/views.py [new file with mode: 0644]
src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py [new file with mode: 0644]
src/bookmarks/migrations/0004_bookmark_audio_timestamp_bookmark_mode_and_more.py [new file with mode: 0644]
src/bookmarks/models.py
src/catalogue/api/serializers.py
src/catalogue/api/tojson.py [new file with mode: 0644]
src/catalogue/api/urls2.py
src/catalogue/api/views.py
src/catalogue/models/book.py
src/catalogue/models/bookmedia.py
src/catalogue/models/tag.py
src/catalogue/templates/catalogue/book_short.html [deleted file]
src/catalogue/templatetags/catalogue_tags.py
src/catalogue/views.py
src/lesmianator/models.py
src/lesmianator/views.py
src/opds/views.py
src/push/api/urls.py [new file with mode: 0644]
src/push/api/views.py [new file with mode: 0644]
src/push/migrations/0005_devicetoken.py [new file with mode: 0644]
src/push/migrations/0006_alter_devicetoken_options_alter_devicetoken_token.py [new file with mode: 0644]
src/push/models.py
src/reporting/views.py
src/search/api/urls.py [new file with mode: 0644]
src/search/api/views.py [new file with mode: 0644]
src/search/views.py
src/social/admin.py
src/social/api/urls2.py
src/social/api/views.py
src/social/forms.py
src/social/migrations/0018_progress.py [new file with mode: 0644]
src/social/migrations/0019_progress_deleted.py [new file with mode: 0644]
src/social/migrations/0020_userlist_userlistitem.py [new file with mode: 0644]
src/social/migrations/0021_move_sets.py [new file with mode: 0644]
src/social/migrations/0022_userlist_reported_timestamp_and_more.py [new file with mode: 0644]
src/social/migrations/0023_auto_20250722_1513.py [new file with mode: 0644]
src/social/migrations/0024_auto_20250722_1513.py [new file with mode: 0644]
src/social/migrations/0025_progress_reported_timestamp_alter_userlistitem_uuid.py [new file with mode: 0644]
src/social/migrations/0026_userprofile.py [new file with mode: 0644]
src/social/models.py
src/social/syncable.py [new file with mode: 0644]
src/social/templates/social/shelf_tags.html [deleted file]
src/social/templatetags/social_tags.py
src/social/utils.py
src/social/views.py

index 8c00892..6806e91 100644 (file)
@@ -46,3 +46,30 @@ class RefreshTokenSerializer(serializers.Serializer):
 
 class RequestConfirmSerializer(serializers.Serializer):
     email = serializers.CharField()
+
+
+class DeleteAccountSerializer(serializers.Serializer):
+    password =serializers.CharField(
+        style={'input_type': 'password'}
+    )
+
+    def validate_password(self, value):
+        u = self.context['user']
+        if not u.check_password(value):
+            raise serializers.ValidationError("Password incorrect.")
+        return value
+
+
+class PasswordSerializer(serializers.Serializer):
+    old_password = serializers.CharField(
+        style={'input_type': 'password'}
+    )
+    new_password = serializers.CharField(
+        style={'input_type': 'password'}
+    )
+
+    def validate_old_password(self, value):
+        u = self.context['user']
+        if not u.check_password(value):
+            raise serializers.ValidationError("Password incorrect.")
+        return value
index 62d4fa7..d4dc0ee 100644 (file)
@@ -15,8 +15,14 @@ urlpatterns1 = [
     path('requestConfirm/', csrf_exempt(views.RequestConfirmView.as_view())),
     path('login/', csrf_exempt(views.Login2View.as_view())),
     path('me/', views.UserView.as_view()),
+    path('deleteAccount/', views.DeleteAccountView.as_view()),
+    path('password/', views.PasswordView.as_view()),
+
     path('', include('catalogue.api.urls2')),
     path('', include('social.api.urls2')),
+    path('', include('bookmarks.api.urls')),
+    path('', include('search.api.urls')),
+    path('', include('push.api.urls')),
 ]
 
 
index 3b23246..26e0778 100644 (file)
@@ -5,6 +5,7 @@ from django.http import HttpResponse, HttpResponseRedirect
 from django.utils.decorators import method_decorator
 from django.utils.encoding import iri_to_uri
 from django.views.decorators.vary import vary_on_headers
+import django.views.decorators.cache
 
 
 def oauthlib_request(request):
@@ -36,6 +37,7 @@ def oauthlib_response(response_tuple):
 
 
 vary_on_auth = method_decorator(vary_on_headers('Authorization'), 'dispatch')
+never_cache = method_decorator(django.views.decorators.cache.never_cache, 'dispatch')
 
 
 class HttpResponseAppRedirect(HttpResponseRedirect):
index 011161e..5a77bd8 100644 (file)
@@ -334,3 +334,36 @@ class RequestConfirmView(APIView):
         UserConfirmation.request(user)
         return Response({})
 
+
+class DeleteAccountView(GenericAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.DeleteAccountSerializer
+
+    def post(self, request):
+        u = request.user
+        serializer = self.get_serializer(
+            data=request.data,
+            context={'user': u}
+        )
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+        u.is_active = False
+        u.save()
+        return Response({})
+
+
+class PasswordView(GenericAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.PasswordSerializer
+
+    def post(self, request):
+        u = request.user
+        serializer = self.get_serializer(
+            data=request.data,
+            context={'user': u}
+        )
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+        u.set_password(d['new_password'])
+        u.save()
+        return Response({})
diff --git a/src/bookmarks/api/urls.py b/src/bookmarks/api/urls.py
new file mode 100644 (file)
index 0000000..f61b326
--- /dev/null
@@ -0,0 +1,9 @@
+from django.urls import path
+from . import views
+
+
+urlpatterns = [
+    path('bookmarks/', views.BookmarksView.as_view()),
+    path('bookmarks/book/<slug:book>/', views.BookBookmarksView.as_view()),
+    path('bookmarks/<uuid:uuid>/', views.BookmarkView.as_view(), name='api_bookmark'),
+]
diff --git a/src/bookmarks/api/views.py b/src/bookmarks/api/views.py
new file mode 100644 (file)
index 0000000..32449da
--- /dev/null
@@ -0,0 +1,62 @@
+from api.utils import never_cache
+
+from django.http import Http404, JsonResponse
+from django.shortcuts import render, get_object_or_404
+from django.views.decorators import cache
+import catalogue.models
+from wolnelektury.utils import is_ajax
+from bookmarks import models
+from lxml import html
+import re
+from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView
+from rest_framework import serializers
+from rest_framework.permissions import IsAuthenticated
+from api.fields import AbsoluteURLField
+
+
+class BookmarkSerializer(serializers.ModelSerializer):
+    book = serializers.SlugRelatedField(
+        queryset=catalogue.models.Book.objects.all(), slug_field='slug',
+        required=False
+    )
+    href = AbsoluteURLField(view_name='api_bookmark', view_args=['uuid'])
+    timestamp = serializers.IntegerField(required=False)
+    location = serializers.CharField(required=False)
+    
+    class Meta:
+        model = models.Bookmark
+        fields = ['book', 'anchor', 'audio_timestamp', 'mode', 'note', 'href', 'uuid', 'location', 'timestamp', 'deleted']
+        read_only_fields = ['uuid', 'mode']
+
+
+
+@never_cache
+class BookmarksView(ListCreateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = BookmarkSerializer
+
+    def get_queryset(self):
+        return self.request.user.bookmark_set.all()
+
+    def perform_create(self, serializer):
+        serializer.save(user=self.request.user)
+
+
+@never_cache
+class BookBookmarksView(ListAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = BookmarkSerializer
+    pagination_class = None
+
+    def get_queryset(self):
+        return self.request.user.bookmark_set.filter(book__slug=self.kwargs['book'])
+
+
+@never_cache
+class BookmarkView(RetrieveUpdateDestroyAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = BookmarkSerializer
+    lookup_field = 'uuid'
+
+    def get_queryset(self):
+        return self.request.user.bookmark_set.all()
diff --git a/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py b/src/bookmarks/migrations/0003_bookmark_deleted_bookmark_reported_timestamp_and_more.py
new file mode 100644 (file)
index 0000000..8e9f32c
--- /dev/null
@@ -0,0 +1,30 @@
+# Generated by Django 4.0.8 on 2025-08-01 14:35
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('bookmarks', '0002_quote'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='bookmark',
+            name='deleted',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='bookmark',
+            name='reported_timestamp',
+            field=models.DateTimeField(default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='bookmark',
+            name='updated_at',
+            field=models.DateTimeField(auto_now=True),
+        ),
+    ]
diff --git a/src/bookmarks/migrations/0004_bookmark_audio_timestamp_bookmark_mode_and_more.py b/src/bookmarks/migrations/0004_bookmark_audio_timestamp_bookmark_mode_and_more.py
new file mode 100644 (file)
index 0000000..87dc168
--- /dev/null
@@ -0,0 +1,29 @@
+# Generated by Django 4.0.8 on 2025-08-22 14:52
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('bookmarks', '0003_bookmark_deleted_bookmark_reported_timestamp_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='bookmark',
+            name='audio_timestamp',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='bookmark',
+            name='mode',
+            field=models.CharField(choices=[('text', 'text'), ('audio', 'audio')], default='text', max_length=64),
+        ),
+        migrations.AlterField(
+            model_name='bookmark',
+            name='reported_timestamp',
+            field=models.DateTimeField(default=django.utils.timezone.now),
+        ),
+    ]
index 67a4fa5..8747ffb 100644 (file)
 import uuid
+from django.apps import apps
 from django.db import models
+from django.utils.timezone import now
+from social.syncable import Syncable
 
 
-class Bookmark(models.Model):
+class Bookmark(Syncable, models.Model):
     uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False)
     user = models.ForeignKey('auth.User', models.CASCADE)
     book = models.ForeignKey('catalogue.Book', models.CASCADE)
     anchor = models.CharField(max_length=100, blank=True)
+    audio_timestamp = models.IntegerField(null=True, blank=True)
+    mode = models.CharField(max_length=64, choices=[
+        ('text', 'text'),
+        ('audio', 'audio'),
+    ], default='text')
     created_at = models.DateTimeField(auto_now_add=True)
     note = models.TextField(blank=True)
+    updated_at = models.DateTimeField(auto_now=True)
+    reported_timestamp = models.DateTimeField(default=now)
+    deleted = models.BooleanField(default=False)
+
+    syncable_fields = [
+        'deleted', 'note',
+    ]
 
     def __str__(self):
         return str(self.uuid)
+
+    def save(self, *args, **kwargs):
+        # TODO: placeholder.
+        try:
+            audio_l = self.book.get_audio_length()
+        except:
+            audio_l = 60
+        if self.anchor:
+            self.mode = 'text'
+            if audio_l:
+                self.audio_timestamp = audio_l * .4
+        if self.audio_timestamp:
+            self.mode = 'audio'
+            if self.audio_timestamp > audio_l:
+                self.audio_timestamp = audio_l
+            if audio_l:
+                self.anchor = 'f20'
+        return super().save(*args, **kwargs)
+
+    @classmethod
+    def create_from_data(cls, user, data):
+        if data.get('location'):
+            return cls.get_by_location(user, data['location'], create=True)
+        elif data.get('book') and data.get('anchor'):
+            return cls.objects.create(user=user, book=data['book'], anchor=data['anchor'])
+        elif data.get('book') and data.get('audio_timestamp'):
+            return cls.objects.create(user=user, book=data['book'], audio_timestamp=data['audio_timestamp'])
+    
+    @property
+    def timestamp(self):
+        return self.updated_at.timestamp()
+    
+    def location(self):
+        if self.mode == 'text':
+            return f'{self.book.slug}/{self.anchor}'
+        else:
+            return f'{self.book.slug}/audio/{self.audio_timestamp}'
+
+    @classmethod
+    def get_by_location(cls, user, location, create=False):
+        Book = apps.get_model('catalogue', 'Book')
+        try:
+            slug, anchor = location.split('/', 1)
+        except:
+            return None
+        if '/' in anchor:
+            try:
+                mode, audio_timestamp = anchor.split('/', 1)
+                assert mode == 'audio'
+                audio_timestamp = int(audio_timestamp)
+            except:
+                return None
+            anchor = ''
+            instance = cls.objects.filter(
+                user=user,
+                book__slug=slug,
+                mode=mode,
+                audio_timestamp=audio_timestamp,
+            ).first()
+        else:
+            mode = 'text'
+            audio_timestamp = None
+            instance = cls.objects.filter(
+                user=user,
+                book__slug=slug,
+                mode='text',
+                anchor=anchor,
+            ).first()
+        if instance is None and create:
+            try:
+                book = Book.objects.get(slug=slug)
+            except Book.DoesNotExist:
+                return None
+            instance = cls.objects.create(
+                user=user,
+                book=book,
+                mode=mode,
+                anchor=anchor,
+                audio_timestamp=audio_timestamp,
+            )
+        return instance
     
     def get_for_json(self):
         return {
index 406cd39..609fa74 100644 (file)
@@ -50,7 +50,7 @@ class AuthorItemSerializer(serializers.ModelSerializer):
     class Meta:
         model = Tag
         fields = [
-            'url', 'href', 'name'
+            'url', 'href', 'name', 'slug'
         ]
 
 class AuthorSerializer(AuthorItemSerializer):
@@ -59,7 +59,7 @@ class AuthorSerializer(AuthorItemSerializer):
     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 @@ class EpochItemSerializer(serializers.ModelSerializer):
     )
     class Meta:
         model = Tag
-        fields = ['url', 'href', 'name']
+        fields = ['url', 'href', 'name', 'slug']
 
 class EpochSerializer(EpochItemSerializer):
     class Meta:
@@ -89,7 +89,7 @@ class GenreItemSerializer(serializers.ModelSerializer):
     )
     class Meta:
         model = Tag
-        fields = ['url', 'href', 'name']
+        fields = ['url', 'href', 'name', 'slug']
 
 class GenreSerializer(GenreItemSerializer):
     class Meta:
@@ -107,7 +107,7 @@ class KindItemSerializer(serializers.ModelSerializer):
     )
     class Meta:
         model = Tag
-        fields = ['url', 'href', 'name']
+        fields = ['url', 'href', 'name', 'slug']
 
 class KindSerializer(KindItemSerializer):
     class Meta:
@@ -117,6 +117,18 @@ class KindSerializer(KindItemSerializer):
             '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='*')
@@ -377,4 +389,4 @@ class FragmentSerializer2(serializers.ModelSerializer):
 class FilterTagSerializer(serializers.ModelSerializer):
     class Meta:
         model = Tag
-        fields = ['id', 'category', 'name']
+        fields = ['id', 'category', 'name', 'slug']
diff --git a/src/catalogue/api/tojson.py b/src/catalogue/api/tojson.py
new file mode 100644 (file)
index 0000000..1fe055c
--- /dev/null
@@ -0,0 +1,211 @@
+from collections import defaultdict
+import json
+import re
+from sys import argv
+from lxml import etree
+
+tags = {
+    'utwor': ('_pass', False, None, None, None),
+    '{http://www.w3.org/1999/02/22-rdf-syntax-ns#}RDF': ('_ignore', False, None, None, None),
+    'abstrakt': ('_ignore', False, None, None, None),
+    'uwaga': ('_ignore', False, None, None, None),
+    'extra': ('_ignore', False, None, None, None),
+    'nota_red': ('_ignore', False, None, None, None),
+    'numeracja': ('_ignore', False, None, None, None),
+
+    'powiesc': ('master', False, None, None, None),
+    'opowiadanie': ('master', False, None, None, None),
+    'liryka_lp': ('master', False, None, None, None),
+    'liryka_l': ('master', False, None, None, None),
+    'dramat_wspolczesny': ('master', False, None, None, None),
+    'dramat_wierszowany_lp': ('master', False, None, None, None),
+    'dramat_wierszowany_l': ('master', False, None, None, None),
+
+    'dlugi_cytat': ('blockquote', False, None, None, None),
+    'poezja_cyt': ('blockquote', False, None, None, None),
+    'dlugi_cyt': ('blockquote', False, None, None, None),
+    'ramka': ('blockquote', False, {'class': 'ramka'}, None, None),
+    
+    'blok': ('div', False, None, None, None),
+
+    'strofa': ('div', True, {'class': 'stanza'}, None, None),
+    'wers': ('div', True, {'class': 'verse'}, None, None),
+    'wers_wciety': ('div', True, {'class': 'wers_wciety'}, None, None),
+    'wers_cd': ('div', True, {'class': 'wers_cd'}, None, None),
+    'wers_akap': ('div', True, {'class': 'wers_akap'}, None, None),
+    'zastepnik_wersu': ('div', True, {'class': 'zastepnik_wersu'}, None, None),
+    'wers_do_prawej': ('div', True, {'class': 'wers_do_prawej'}, None, None),
+    'wers_srodek': ('div', True, {'class': 'wers_srodek'}, None, None),
+    
+    'autor_utworu': ('div', True, {'class': 'author'}, None, None),
+    'dzielo_nadrzedne': ('div', True, {'class': 'dzielo_nadrzedne'}, None, None),
+    'nazwa_utworu': ('div', True, {'class': 'title'}, None, None),
+    'podtytul': ('div', True, {'class': 'podtytul'}, None, None),
+
+    'motto': ('div', False, {'class': 'motto'}, None, None),
+    'motto_podpis': ('div', True, {'class': 'motto_podpis'}, None, None),
+    'dedykacja': ('div', True, {'class': 'dedykacja'}, None, None),
+    'miejsce_czas': ('div', True, {'class': 'miejsce_czas'}, None, None),
+    
+    'lista_osob': ('div', False, {'class': 'lista_osob'}, None, None),
+    'naglowek_listy': ('div', True, {'class': 'naglowek_listy'}, None, None),
+    'lista_osoba': ('div', True, {'class': 'lista_osoba'}, None, None),
+    'naglowek_osoba': ('div', True, {'class': 'naglowek_osoba'}, None, None),
+    'osoba': ('em', True, {'class': 'osoba'}, None, None),
+    'didaskalia': ('div', True, {'class': 'didaskalia'}, None, None),
+    'kwestia': ('div', False, {'class': 'kwestia'}, None, None),
+    'didask_tekst': ('em', False, {'class': 'didask_tekst'}, None, None),
+    
+    'naglowek_czesc': ('h2', True, None, None, None),
+    'naglowek_akt': ('h2', True, None, None, None),
+    'naglowek_scena': ('h3', True, None, None, None),
+    'naglowek_rozdzial': ('h3', True, None, None, None),
+    'naglowek_podrozdzial': ('h4', True, None, None, None),
+    'srodtytul': ('h5', True, None, None, None),
+
+    'nota': ('div', True, {'class': 'note'}, None, False),
+
+    'akap': ('p', True, {'class': 'paragraph'}, None, True),
+    'akap_dialog': ('p', True, {'class': 'paragraph'}, None, True),
+    'akap_cd': ('p', True, {'class': 'paragraph'}, None, True),
+
+    'sekcja_asterysk': ('p', True, {'class': 'spacer-asterisk'}, None, True),
+    'sekcja_swiatlo': ('p', True, {'class': 'sekcja_swiatlo'}, None, True),
+    'separator_linia': ('p', True, {'class': 'separator_linia'}, None, True),
+
+    'tytul_dziela': ('em', True, {'class': 'book-title'}, None, False),
+    'slowo_obce': ('em', True, {'class': 'foreign-word'}, None, False),
+    'wyroznienie': ('em', True, {'class': 'author-emphasis'}, None, False),
+    'wieksze_odstepy': ('em', True, {'class': 'wieksze_odstepy'}, None, False),
+
+    'ref': ('a', True, {'class': 'reference'}, {'data-uri': 'href'}, False),
+
+    'begin': ('_ignore', True, {'class': 'reference'}, {'data-uri': 'href'}, False),
+    'end': ('_ignore', True, {'class': 'reference'}, {'data-uri': 'href'}, False),
+    'motyw': ('a', True, {'class': 'theme'}, None, False),
+
+    'pa': ('a', True, {'class': 'footnote footnote-pa'}, None, False),
+    'pe': ('a', True, {'class': 'footnote footnote-pe'}, None, False),
+    'pr': ('a', True, {'class': 'footnote footnote-pr'}, None, False),
+    'pt': ('a', True, {'class': 'footnote footnote-pt'}, None, False),
+    'ptrad': ('a', True, {'class': 'footnote footnote-ptrad'}, None, False),
+}
+
+id_prefixes = {
+    'pa': 'fn',
+    'pe': 'fn',
+    'pr': 'fn',
+    'pt': 'fn',
+    'ptrad': 'fn',
+    }
+
+
+#tree = etree.parse(argv[1])
+
+front1 = set([
+    'dzielo_nadrzedne',
+    'nazwa_utworu',
+    'podtytul',
+    ])
+front2 = set(['autor_utworu'])
+
+
+def norm(text):
+    text = text.replace('---', '—').replace('--', '–').replace('...', '…').replace(',,', '„').replace('"', '”')
+    return text
+
+
+def toj(elem, S):
+    if elem.tag is etree.Comment: return []
+    tag, hastext, attrs, attr_map, num = tags[elem.tag]
+    contents = []
+    if tag == '_pass':
+        output = contents
+    elif tag == '_ignore':
+        return []
+    else:
+        output = {
+            'tag': tag,
+        }
+        if num:
+            S['index'] += 1
+            output['paragraphIndex'] = S['index']
+            if 'dlugi_cytat' not in S['stack'] and 'poezja_cyt' not in S['stack']:
+                S['vindex'] += 1
+                output['visibleNumber'] = S['vindex']
+        id_prefix = id_prefixes.get(tag, 'i')
+        S['id'][id_prefix] += 1
+        output['id'] = id_prefix + str(S['id'][id_prefix])
+        if attrs:
+            output['attr'] = attrs.copy()
+        if attr_map:
+            output.setdefault('attr', {})
+            for k, v in attr_map.items():
+                output['attr'][k] = elem.attrib[v]
+        output['contents'] = contents
+        output = [output]
+    if elem.tag == 'strofa':
+        verses = [etree.Element('wers')]
+        if elem.text:
+            vparts = re.split(r'/\s+', elem.text)
+            for i, v in enumerate(vparts):
+                if i:
+                    verses.append(etree.Element('wers'))
+                verses[-1].text = (verses[-1].text or '') + v
+        for child in elem:
+            vparts = re.split(r'/\s+', child.tail or '')
+            child.tail = vparts[0]
+            verses[-1].append(child)
+            for v in vparts[1:]:
+                verses.append(etree.Element('wers'))
+                verses[-1].text = v
+
+        if not(len(verses[-1]) or (verses[-1].text or '').strip()):
+            verses.pop()
+
+        elem.clear(keep_tail=True)
+        for verse in verses:
+            if len(verse) == 1 and (verse[0].tag.startswith('wers') or verse[0].tag == 'zastepnik_wersu') and not (verse[0].tail or '').strip():
+                elem.append(verse[0])
+            else:
+                elem.append(verse)
+
+        #if not len(elem):
+        #    for v in re.split(r'/\s+', elem.text):
+        #        etree.SubElement(elem, 'wers').text = v
+        #    elem.text = None
+        
+    if hastext and elem.text:
+        contents.append(norm(elem.text))
+    for c in elem:
+        S['stack'].append(elem.tag)
+        contents += toj(c, S)
+        if hastext and c.tail:
+            contents.append(norm(c.tail))
+        S['stack'].pop()
+
+    if elem.tag in front1:
+        S['front1'] += output
+        return []
+    if elem.tag in front2:
+        S['front2'] += output
+        return []
+    return output
+
+def conv(tree):
+    S = {
+        'index': 0,
+        'vindex': 0,
+        'id': defaultdict(lambda: 0),
+        'stack': [],
+        'front1': [],
+        'front2': [],
+    }
+    output = toj(tree.getroot(), S)
+    if not len(output): return {}
+    jt = output[0]
+    jt['front1'] = S['front1']
+    jt['front2'] = S['front2']
+    return jt
+
+#print(json.dumps(jt, indent=2, ensure_ascii=False))
index e90e13e..ad96d7a 100644 (file)
@@ -26,6 +26,8 @@ urlpatterns = [
     path('books/<slug:slug>/media/<slug:type>/',
          views.BookMediaView.as_view()
          ),
+    path('books/<slug:slug>.json',
+        views.BookJsonView.as_view()),
 
     path('suggested-tags/',
          piwik_track_view(views.SuggestedTags.as_view()),
@@ -56,4 +58,10 @@ urlpatterns = [
     path('genres/<slug:slug>/',
          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/<slug:slug>/',
+         piwik_track_view(views.ThemeView.as_view()),
+         name='catalogue_api_theme'),
 ]
index 821b281..ed5d10f 100644 (file)
@@ -6,7 +6,7 @@ import os.path
 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,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):
@@ -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]
@@ -527,3 +541,13 @@ class BookMediaView(ListAPIView):
             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})
index 0400656..b2148e6 100644 (file)
@@ -94,7 +94,7 @@ 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)
@@ -1094,7 +1094,7 @@ class Book(models.Model):
             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
@@ -1105,17 +1105,6 @@ class Book(models.Model):
     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)))
 
index 87d7f78..a5df657 100644 (file)
@@ -89,7 +89,7 @@ class BookMedia(models.Model):
         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:
index 13d8a64..fb90a7b 100644 (file)
@@ -161,6 +161,8 @@ class Tag(models.Model):
 
     @staticmethod
     def get_tag_list(tag_str):
+        from social.models import UserList
+
         if not tag_str:
             return []
         tags = []
@@ -170,7 +172,10 @@ class Tag(models.Model):
         tags_splitted = tag_str.split('/')
         for name in tags_splitted:
             if category:
-                tags.append(Tag.objects.get(slug=name, category=category))
+                if category == 'set':
+                    tags.append(UserList.objects.get(slug=name, deleted=False))
+                else:
+                    tags.append(Tag.objects.get(slug=name, category=category))
                 category = None
             elif name in Tag.categories_rev:
                 category = Tag.categories_rev[name]
@@ -242,6 +247,11 @@ class Tag(models.Model):
                     meta_tags.append((tag, relationship))
         return meta_tags
 
+#    def get_books(self):
+#        """ Only useful for sets. """
+#        return 
+
+
 
 TagRelation.tag_model = Tag
 
diff --git a/src/catalogue/templates/catalogue/book_short.html b/src/catalogue/templates/catalogue/book_short.html
deleted file mode 100644 (file)
index 190bdc8..0000000
+++ /dev/null
@@ -1,172 +0,0 @@
-{% spaceless %}
-  {% load i18n %}
-  {% load thumbnail %}
-  {% load cache %}
-  {% load catalogue_tags %}
-  {% load book_shelf_tags from social_tags %}
-  {% load static %}
-
-  {% with ga=book.get_audiobooks %}
-  {% with audiobooks=ga.0 %}
-  <div class="{% block box-class %}book-box{% if audiobooks %} audiobook-box{% endif %}{% endblock %}">
-    <div class="book-box-inner">
-
-    {% with book.tags_by_category as tags %}
-    <div class="book-left-column">
-      <div class="book-box-body">
-        {% block book-box-body-pre %}
-        {% endblock %}
-
-        <div class="cover-area">
-          {% if book.cover_clean %}
-            <a href="{% block cover-link %}{{ book.get_absolute_url }}{% endblock %}">
-              <img src="{% thumbnail book.cover_clean '139x193' as th %}{{ th.url }}{% endthumbnail %}" alt="Cover" class="cover" />
-            </a>
-          {% endif %}
-          {% block cover-area-extra %}{% endblock %}
-        </div>
-
-        {% get_current_language as LANGUAGE_CODE %}
-        {% cache 86400 book_box_head_tags book.pk LANGUAGE_CODE %}
-        <div class="book-box-head">
-          <div class="author">
-            {% for tag in tags.author %}
-              <a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>{% if not forloop.last %},
-            {% endif %}{% endfor %}{% for parent in book.parents %},
-              <a href="{{ parent.get_absolute_url }}">{{ parent.title }}</a>{% endfor %}
-          </div>
-          <div class="title">
-            <a href="{{ book.get_absolute_url }}">{{ book.title }}</a>
-          </div>
-          {% if book.translator %}
-              <div class="author">
-                  tłum. {{ book.translator }}
-              </div>
-          {% endif %}
-        </div>
-
-        <div class="tags">
-          <span class="category">
-          <span class="mono"> {% trans "Epoka" %}:</span>&nbsp;<span class="book-box-tag">
-            {% for tag in tags.epoch %}
-              <a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>
-              {% if not forloop.last %}<span>, </span>{% endif %}
-            {% endfor %}
-          </span></span>
-
-          <span class="category">
-          <span class="mono"> {% trans "Rodzaj" %}:</span>&nbsp;<span class="book-box-tag">
-            {% for tag in tags.kind %}
-              <a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>
-              {% if not forloop.last %}<span>, </span>{% endif %}
-            {% endfor %}
-          </span></span>
-
-          <span class="category">
-          <span class="mono"> {% trans "Gatunek" %}:</span>&nbsp;<span class="book-box-tag">
-            {% for tag in tags.genre %}
-              <a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a>
-              {% if not forloop.last %}<span>, </span>{% endif %}
-            {% endfor %}
-          </span></span>
-
-         {% with extra_info=book.get_extra_info_json %}
-            {% if extra_info.location %}
-              <span class="category">
-              <span class="mono"> {% trans "Region" %}:</span>&nbsp;<span class="book-box-tag">
-                  {{ extra_info.location }}
-              </span></span>
-            {% endif %}
-         {% endwith %}
-
-          {% if book.is_foreign %}
-            <span class="category">
-              <span class="mono"> {% trans "Język" %}:</span>&nbsp;<span class="book-box-tag">
-                <a>{{ book.language_name }}</a>
-              </span>
-            </span>
-          {% endif %}
-
-          {% with stage_note=book.stage_note %}
-          {% if stage_note.0 %}
-            <br>
-            <span class="category">
-              <a{% if stage_note.1 %} href="{{ stage_note.1 }}"{% endif %}>{{ stage_note.0 }}</a>
-            </span>
-          {% endif %}
-          {% endwith %}
-        </div>
-        {% endcache %}
-      </div>
-      {% book_shelf_tags book.pk %}
-
-      {% cache 86400 book_box_tools book.pk book|status:request.user LANGUAGE_CODE %}
-      {% if book|status:request.user != 'closed' %}
-        <ul class="book-box-tools">
-          <li class="book-box-read">
-            {% if book.html_file %}
-           <div>{% content_warning book %}</div>
-              <a href="{% url 'book_text' book.slug %}" class="downarrow">{% trans "Czytaj online" %}</a>
-            {% endif %}
-            {% if book.print_on_demand %}
-              <a href="{{ book.ridero_link }}" class="downarrow print tlite-tooltip" title="{% trans "Cena książki w druku cyfrowym jest zależna od liczby stron.<br>Przed zakupem upewnij się, że cena druku na żądanie jest dla Ciebie odpowiednia.<br>Wszystkie nasze zasoby w wersji elektronicznej są zawsze dostępne bezpłatnie." %}">{% trans "Druk na żądanie z" %}
-                  <img src="{% static 'img/ridero.png' %}" style="height: 0.8em;"/></a>
-            {% endif %}
-          </li>
-          <li class="book-box-download">
-            <div class="book-box-formats">
-              {% trans "Pobierz ebook" %}:<br>
-              {% if book.pdf_file %}
-                <a href="{{ book.pdf_url}}">PDF</a>
-              {% endif %}
-              {% if book.epub_file %}
-                <a href="{{ book.epub_url}}">EPUB</a>
-              {% endif %}
-              {% if book.mobi_file %}
-                <a href="{{ book.mobi_url}}">MOBI</a>
-              {% endif %}
-              {% if  book.fb2_file %}
-                <a href="{{ book.fb2_url}}">FB2</a>
-              {% endif %}
-              {% if  book.txt_file %}
-                <a href="{{ book.txt_url}}">TXT</a>
-              {% endif %}
-            </div>
-            {% if book.has_mp3_file %}
-              <div class="book-box-formats">
-                {% trans "Pobierz audiobook" %}:<br>
-                {% download_audio book %}
-              </div>
-            {% endif %}
-            <div class="book-box-formats">
-              {% custom_pdf_link_li book %}
-            </div>
-          </li>
-        </ul>
-      {% else %}
-        {% block preview-info %}
-          <p class="book-box-tools book-box-tools-warn">
-            Ten utwór jest na razie dostępny wyłącznie dla naszych Darczyńców.
-           <a href="{% url 'club_join' %}">Wspieraj Wolne Lektury</a>
-         </p>
-          <div>{% content_warning book %}</div>
-        {% endblock %}
-      {% endif %}
-      {% endcache %}
-      {% block book-box-extra-info %}{% endblock %}
-      {% block box-append %}{% endblock %}
-    </div>
-    {% endwith %}
-
-    {% if book.abstract %}
-      <div class="abstract more-expand">
-        {{ book.abstract|safe }}
-      </div>
-    {% endif %}
-
-    <div class="clearboth"></div>
-    </div>
-  </div>
-  {% endwith %}
-  {% endwith %}
-{% endspaceless %}
index d2298d9..ddf1a8f 100644 (file)
@@ -17,6 +17,7 @@ from catalogue.helpers import get_audiobook_tags
 from catalogue.models import Book, BookMedia, Fragment, Tag, Source
 from catalogue.constants import LICENSES
 from club.models import Membership
+from social.models import UserList
 
 register = template.Library()
 
@@ -73,7 +74,10 @@ def nice_title_from_tags(tags, related_tags):
     def split_tags(tags):
         result = {}
         for tag in tags:
-            result.setdefault(tag.category, []).append(tag)
+            if isinstance(tag, UserList):
+                result.setdefault('userlist', []).append(tag)
+            else:
+                result.setdefault(tag.category, []).append(tag)
         return result
 
     self = split_tags(tags)
index d5b83ab..1ac6c08 100644 (file)
@@ -23,6 +23,7 @@ from club.forms import DonationStep1Form
 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
@@ -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'])
@@ -242,6 +260,9 @@ class TaggedObjectList(BookList):
         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 = []
@@ -253,6 +274,7 @@ class TaggedObjectList(BookList):
                     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(
@@ -293,6 +315,7 @@ def object_list(request, objects, list_type='books'):
             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(
@@ -310,7 +333,7 @@ def object_list(request, objects, list_type='books'):
             .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']:
@@ -325,7 +348,7 @@ def object_list(request, objects, list_type='books'):
     }
 
     template = 'catalogue/author_detail.html'
-        
+
     return render(
         request, template, result,
     )
index fbcee04..bcf271c 100644 (file)
@@ -16,7 +16,8 @@ from django.contrib.contenttypes.fields import GenericForeignKey
 from django.conf import settings
 from django.urls import reverse
 
-from catalogue.models import Book, Tag
+from catalogue.models import Book
+from social.models import UserList
 
 
 class Poem(models.Model):
@@ -138,17 +139,16 @@ class Continuations(models.Model):
                       conts)
 
     @classmethod
-    def for_set(cls, tag):
-        books = Book.tagged_top_level([tag])
-        cont_tabs = (cls.get(b) for b in books.iterator())
+    def for_userlist(cls, ul):
+        cont_tabs = (cls.get(b) for b in ul.get_books())
         return reduce(cls.join_conts, cont_tabs)
 
     @classmethod
     def get(cls, sth):
         object_type = ContentType.objects.get_for_model(sth)
         should_keys = {sth.id}
-        if isinstance(sth, Tag):
-            should_keys = set(b.pk for b in Book.tagged.with_any((sth,)).iterator())
+        if isinstance(sth, UserList):
+            should_keys = set(b.pk for b in sth.get_books())
         try:
             obj = cls.objects.get(content_type=object_type, object_id=sth.id)
             if not obj.pickle:
@@ -162,8 +162,8 @@ class Continuations(models.Model):
         except cls.DoesNotExist:
             if isinstance(sth, Book):
                 conts = cls.for_book(sth)
-            elif isinstance(sth, Tag):
-                conts = cls.for_set(sth)
+            elif isinstance(sth, UserList):
+                conts = cls.for_userlist(sth)
             else:
                 raise NotImplementedError('Lesmianator continuations: only Book and Tag supported')
 
index 938ba4c..0efeba3 100644 (file)
@@ -6,13 +6,14 @@ from django.shortcuts import render, get_object_or_404
 from django.views.decorators import cache
 
 from catalogue.utils import get_random_hash
-from catalogue.models import Book, Tag
+from catalogue.models import Book
+from social.models import UserList
 from lesmianator.models import Poem, Continuations
 
 
 def main_page(request):
     last = Poem.objects.all().order_by('-created_at')[:10]
-    shelves = Tag.objects.filter(user__username='lesmianator')
+    shelves = UserList.objects.filter(user__username='lesmianator')
 
     return render(
         request,
@@ -50,10 +51,10 @@ def poem_from_book(request, slug):
 @cache.never_cache
 def poem_from_set(request, shelf):
     user = request.user if request.user.is_authenticated else None
-    tag = get_object_or_404(Tag, category='set', slug=shelf)
+    tag = get_object_or_404(UserList, slug=shelf)
     text = Poem.write(Continuations.get(tag))
     p = Poem(slug=get_random_hash(text), text=text, created_by=user)
-    books = Book.tagged.with_any((tag,))
+    books = tag.get_books()
     p.created_from = json.dumps([b.id for b in books])
     p.save()
 
index 38f34ef..561eb99 100644 (file)
@@ -17,6 +17,7 @@ from django.utils.functional import lazy
 from basicauth import logged_in_or_basicauth, factory_decorator
 from catalogue.models import Book, Tag
 from search.utils import UnaccentSearchQuery, UnaccentSearchVector
+from social.models import UserList
 
 import operator
 import logging
@@ -318,7 +319,7 @@ class UserFeed(Feed):
         return "Półki użytkownika %s" % user.username
 
     def items(self, user):
-        return Tag.objects.filter(category='set', user=user).exclude(items=None)
+        return UserList.objects.filter(user=user, deleted=False)
 
     def item_title(self, item):
         return item.name
@@ -343,10 +344,10 @@ class UserSetFeed(AcquisitionFeed):
         return "Spis utworów na stronie http://WolneLektury.pl"
 
     def get_object(self, request, slug):
-        return get_object_or_404(Tag, category='set', slug=slug, user=request.user)
+        return get_object_or_404(UserList, deleted=False, slug=slug, user=request.user)
 
     def items(self, tag):
-        return Book.tagged.with_any([tag])
+        return tag.get_books()
 
 
 @piwik_track
diff --git a/src/push/api/urls.py b/src/push/api/urls.py
new file mode 100644 (file)
index 0000000..2920966
--- /dev/null
@@ -0,0 +1,7 @@
+from django.urls import path
+from . import views
+
+
+urlpatterns = [
+    path('deviceTokens/', views.DeviceTokensView.as_view()),
+]
diff --git a/src/push/api/views.py b/src/push/api/views.py
new file mode 100644 (file)
index 0000000..0c575b9
--- /dev/null
@@ -0,0 +1,44 @@
+from rest_framework import serializers
+from rest_framework.generics import ListCreateAPIView
+from rest_framework.permissions import IsAuthenticated
+from api.utils import never_cache
+from api.fields import AbsoluteURLField
+from push import models
+
+
+class DeviceTokenSerializer(serializers.ModelSerializer):
+    deleted = serializers.BooleanField(default=False, write_only=True)
+    # Explicit definition to disable unique validator.
+    token = serializers.CharField()
+
+    class Meta:
+        model = models.DeviceToken
+        fields = ['token', 'created_at', 'updated_at', 'deleted']
+        read_only_fields = ['created_at', 'updated_at']
+
+    def save(self):
+        if self.validated_data['deleted']:
+            self.destroy(self.validated_data)
+        else:
+            return self.create(self.validated_data)
+
+    def create(self, validated_data):
+        obj, created = models.DeviceToken.objects.get_or_create(
+            user=self.context['request'].user,
+            token=validated_data['token'],
+        )
+        return obj
+
+    def destroy(self, validated_data):
+        models.DeviceToken.objects.filter(
+            user=self.context['request'].user,
+            token=validated_data['token']
+        ).delete()
+
+@never_cache
+class DeviceTokensView(ListCreateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = DeviceTokenSerializer
+
+    def get_queryset(self):
+        return models.DeviceToken.objects.filter(user=self.request.user)
diff --git a/src/push/migrations/0005_devicetoken.py b/src/push/migrations/0005_devicetoken.py
new file mode 100644 (file)
index 0000000..029051c
--- /dev/null
@@ -0,0 +1,26 @@
+# Generated by Django 4.0.8 on 2025-08-26 07:47
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('push', '0004_alter_notification_body_alter_notification_image_and_more'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='DeviceToken',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('token', models.CharField(max_length=1024)),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('updated_at', models.DateTimeField(auto_now=True)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
diff --git a/src/push/migrations/0006_alter_devicetoken_options_alter_devicetoken_token.py b/src/push/migrations/0006_alter_devicetoken_options_alter_devicetoken_token.py
new file mode 100644 (file)
index 0000000..520404a
--- /dev/null
@@ -0,0 +1,22 @@
+# Generated by Django 4.0.8 on 2025-09-03 12:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('push', '0005_devicetoken'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='devicetoken',
+            options={'ordering': ('-updated_at',)},
+        ),
+        migrations.AlterField(
+            model_name='devicetoken',
+            name='token',
+            field=models.CharField(max_length=1024, unique=True),
+        ),
+    ]
index 461845e..3d86b96 100644 (file)
@@ -16,3 +16,13 @@ class Notification(models.Model):
 
     def __str__(self):
         return '%s: %s' % (self.timestamp, self.title)
+
+
+class DeviceToken(models.Model):
+    user = models.ForeignKey('auth.User', models.CASCADE)
+    token = models.CharField(max_length=1024, unique=True)
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+
+    class Meta:
+        ordering = ('-updated_at',)
index 553371e..eb64746 100644 (file)
@@ -60,7 +60,7 @@ def stats_page(request):
         ]
         etags.append(d)
 
-    unused_tags = Tag.objects.exclude(category='set').filter(items=None, book=None)
+    unused_tags = Tag.objects.filter(items=None, book=None)
         
     return render(request, 'reporting/main.html', {
         'media_types': media_types,
diff --git a/src/search/api/urls.py b/src/search/api/urls.py
new file mode 100644 (file)
index 0000000..93ddf6d
--- /dev/null
@@ -0,0 +1,13 @@
+# 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 . import views
+
+
+urlpatterns = [
+    path('search/hint/', views.HintView.as_view()),
+    path('search/', views.SearchView.as_view()),
+    path('search/books/', views.BookSearchView.as_view()),
+    path('search/text/', views.TextSearchView.as_view()),
+]
diff --git a/src/search/api/views.py b/src/search/api/views.py
new file mode 100644 (file)
index 0000000..17642d1
--- /dev/null
@@ -0,0 +1,92 @@
+# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
+#
+from rest_framework.generics import ListAPIView
+from rest_framework.response import Response
+from rest_framework import serializers
+from rest_framework.views import APIView
+import catalogue.models
+import catalogue.api.serializers
+from search.views import get_hints
+from search.forms import SearchFilters
+
+
+class HintView(APIView):
+    def get(self, request):
+        term = request.query_params.get('q')
+        hints = get_hints(term, request.user)
+        for h in hints:
+            if h.get('img'):
+                h['img'] = request.build_absolute_uri(h['img'])
+        return Response(hints)
+
+
+class SearchView(APIView):
+    def get(self, request):
+        term = self.request.query_params.get('q')
+        f = SearchFilters({'q': term})
+        if f.is_valid():
+            r = f.results()
+            res = {}
+            rl = res['author'] = []
+            c = {'request': request}
+            for item in r['author']:
+                rl.append(
+                    catalogue.api.serializers.AuthorSerializer(item, context=c).data
+                )
+            rl = res['genre'] = []
+            for item in r['genre']:
+                rl.append(
+                    catalogue.api.serializers.GenreSerializer(item, context=c).data
+                )
+            rl = res['theme'] = []
+            for item in r['theme']:
+                rl.append(
+                    catalogue.api.serializers.ThemeSerializer(item, context=c).data
+                )
+
+        return Response(res)
+
+
+class BookSearchView(ListAPIView):
+    serializer_class = catalogue.api.serializers.BookSerializer2
+
+    def get_queryset(self):
+        term = self.request.query_params.get('q')
+        f = SearchFilters({'q': term})
+        if f.is_valid():
+            r = f.results()
+            return r['book']
+        return []
+
+
+
+class SnippetSerializer(serializers.ModelSerializer):
+    anchor = serializers.CharField(source='sec')
+    headline = serializers.CharField()
+
+    class Meta:
+        model = catalogue.models.Snippet
+        fields = ['anchor', 'headline']
+
+
+class BookSnippetsSerializer(serializers.Serializer):
+    book = catalogue.api.serializers.BookSerializer2()
+    snippets = SnippetSerializer(many=True)
+
+
+class TextSearchView(ListAPIView):
+    serializer_class = BookSnippetsSerializer
+
+    def get_queryset(self):
+        term = self.request.query_params.get('q')
+        f = SearchFilters({'q': term})
+        if f.is_valid():
+            r = f.results()
+            r = list({
+                'book': book,
+                'snippets': snippets
+            } for (book, snippets) in r['snippet'].items())
+            return r
+        return []
+
index e439c41..6066cd9 100644 (file)
@@ -9,6 +9,7 @@ from sorl.thumbnail import get_thumbnail
 
 import catalogue.models
 import infopages.models
+import social.models
 from .forms import SearchFilters
 import re
 import json
@@ -23,22 +24,8 @@ def remove_query_syntax_chars(query, replace=' '):
     return query_syntax_chars.sub(replace, query)
 
 
-@cache.never_cache
-def hint(request, mozhint=False, param='term'):
-    prefix = request.GET.get(param, '')
-    if len(prefix) < 2:
-        return JsonResponse([], safe=False)
-
-    prefix = re_escape(' '.join(remove_query_syntax_chars(prefix).split()))
-
-    try:
-        limit = int(request.GET.get('max', ''))
-    except ValueError:
-        limit = 20
-    else:
-        if limit < 1:
-            limit = 20
-
+def get_hints(prefix, user=None, limit=10):
+    if not prefix: return []
     data = []
     if len(data) < limit:
         authors = catalogue.models.Tag.objects.filter(
@@ -49,17 +36,21 @@ def hint(request, mozhint=False, param='term'):
                 'label': author.name,
                 'url': author.get_absolute_url(),
                 'img': get_thumbnail(author.photo, '72x72', crop='top').url if author.photo else '',
+                'slug': author.slug,
+                'id': author.id,
             }
             for author in authors[:limit - len(data)]
         ])
-    if request.user.is_authenticated and len(data) < limit:
-        tags = catalogue.models.Tag.objects.filter(
-            category='set', user=request.user, name_pl__iregex='\m' + prefix).only('name', 'id', 'slug', 'category')
+    
+    if user is not None and user.is_authenticated and len(data) < limit:
+        tags = social.models.UserList.objects.filter(
+            user=user, name__iregex='\m' + prefix).only('name', 'id', 'slug')
         data.extend([
             {
-                'type': 'set',
+                'type': 'userlist',
                 'label': tag.name,
                 'url': tag.get_absolute_url(),
+                'slug': tag.slug,
             }
             for tag in tags[:limit - len(data)]
         ])
@@ -71,6 +62,8 @@ def hint(request, mozhint=False, param='term'):
                 'type': tag.category,
                 'label': tag.name,
                 'url': tag.get_absolute_url(),
+                'slug': tag.slug,
+                'id': tag.id,
             }
             for tag in tags[:limit - len(data)]
         ])
@@ -82,6 +75,7 @@ def hint(request, mozhint=False, param='term'):
                 'type': 'collection',
                 'label': collection.title,
                 'url': collection.get_absolute_url(),
+                'slug': collection.slug,
             }
             for collection in collections[:limit - len(data)]
         ])
@@ -98,6 +92,7 @@ def hint(request, mozhint=False, param='term'):
                     'author': author_str,
                     'url': b.get_absolute_url(),
                     'img': get_thumbnail(b.cover_clean, '72x72').url if b.cover_clean else '',
+                    'slug': b.slug,
                 }
             )
     if len(data) < limit:
@@ -110,9 +105,34 @@ def hint(request, mozhint=False, param='term'):
                 'type': 'info',
                 'label': info.title,
                 'url': info.get_absolute_url(),
+                'slug': info.slug,
             }
             for info in infos[:limit - len(data)]
         ])
+    return data
+
+
+@cache.never_cache
+def hint(request, mozhint=False, param='term'):
+    prefix = request.GET.get(param, '')
+    if len(prefix) < 2:
+        return JsonResponse([], safe=False)
+
+    prefix = re_escape(' '.join(remove_query_syntax_chars(prefix).split()))
+
+    try:
+        limit = int(request.GET.get('max', ''))
+    except ValueError:
+        limit = 20
+    else:
+        if limit < 1:
+            limit = 20
+
+    data = get_hints(
+        prefix,
+        user=request.user if request.user.is_authenticated else None,
+        limit=limit
+    )
 
     if mozhint:
         data = [
index ac5fbd2..9d1ee45 100644 (file)
@@ -6,7 +6,7 @@ from django.forms import ModelForm
 from django.forms.widgets import TextInput
 from admin_ordering.admin import OrderableAdmin
 from social.models import Cite, BannerGroup, Carousel, CarouselItem
-
+from social import models
 
 class CiteForm(ModelForm):
     class Meta:
@@ -79,3 +79,5 @@ class CarouselAdmin(admin.ModelAdmin):
 
 admin.site.register(Carousel, CarouselAdmin)
 
+
+admin.site.register(models.UserList)
index b150e61..3863ec6 100644 (file)
@@ -7,11 +7,27 @@ from . import views
 
 
 urlpatterns = [
+    path('settings/', views.SettingsView.as_view()),
+    
     path('like/<slug:slug>/',
         piwik_track_view(views.LikeView2.as_view()),
         name='social_api_like'),
     path('likes/', views.LikesView.as_view()),
     path('my-likes/', views.MyLikesView.as_view()),
+
+    path('lists/', views.ListsView.as_view()),
+    path('lists/<slug:slug>/', views.ListView.as_view()),
+    path('lists/<slug:slug>/<slug:book>/', views.ListItemView.as_view()),
+
+    path('progress/', views.ProgressListView.as_view()),
+    path('progress/<slug:slug>/', views.ProgressView.as_view()),
+    path('progress/<slug:slug>/text/', views.TextProgressView.as_view()),
+    path('progress/<slug:slug>/audio/', views.AudioProgressView.as_view()),
+
+    path('sync/progress/', views.ProgressSyncView.as_view()),
+    path('sync/userlist/', views.UserListSyncView.as_view()),
+    path('sync/userlistitem/', views.UserListItemSyncView.as_view()),
+    path('sync/bookmark/', views.BookmarkSyncView.as_view()),
 ]
 
 
index 22a0e9c..402245d 100644 (file)
@@ -1,22 +1,42 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
+from datetime import datetime
 from django.http import Http404
-from rest_framework.generics import ListAPIView, get_object_or_404
-from rest_framework.permissions import IsAuthenticated
+from django.utils.timezone import now, utc
+from rest_framework.generics import ListAPIView, ListCreateAPIView, RetrieveAPIView, RetrieveUpdateAPIView, RetrieveUpdateDestroyAPIView, get_object_or_404
+from rest_framework.permissions import IsAuthenticated, IsAuthenticatedOrReadOnly
 from rest_framework.response import Response
+from rest_framework import serializers
 from rest_framework.views import APIView
 from api.models import BookUserData
-from api.utils import vary_on_auth
+from api.utils import vary_on_auth, never_cache
 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
+from social.utils import likes
+from social import models
+import bookmarks.models
+from bookmarks.api.views import BookmarkSerializer
 
 
-@vary_on_auth
+class SettingsSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.UserProfile
+        fields = ['notifications']
+
+
+class SettingsView(RetrieveUpdateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = SettingsSerializer
+
+    def get_object(self):
+        return models.UserProfile.get_for(self.request.user)
+
+
+@never_cache
 class LikeView(APIView):
     permission_classes = [IsAuthenticated]
 
@@ -28,13 +48,13 @@ class LikeView(APIView):
         book = get_object_or_404(Book, slug=slug)
         action = request.query_params.get('action', 'like')
         if action == 'like':
-            book.like(request.user)
+            models.UserList.like(request.user, book)
         elif action == 'unlike':
-            book.unlike(request.user)
+            models.UserList.unlike(request.user, book)
         return Response({})
 
 
-@vary_on_auth
+@never_cache
 class LikeView2(APIView):
     permission_classes = [IsAuthenticated]
 
@@ -44,16 +64,16 @@ class LikeView2(APIView):
 
     def put(self, request, slug):
         book = get_object_or_404(Book, slug=slug)
-        book.like(request.user)
+        models.UserList.like(request.user, book)
         return Response({"likes": likes(request.user, book)})
 
     def delete(self, request, slug):
         book = get_object_or_404(Book, slug=slug)
-        book.unlike(request.user)
+        models.UserList.unlike(request.user, book)
         return Response({"likes": likes(request.user, book)})
 
 
-@vary_on_auth
+@never_cache
 class LikesView(APIView):
     permission_classes = [IsAuthenticated]
 
@@ -64,21 +84,175 @@ class LikesView(APIView):
         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
+@never_cache
 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)
+        ul = models.UserList.get_favorites_list(request.user)
+        if ul is None:
+            return Response([])
+        return Response(
+            ul.userlistitem_set.exclude(deleted=True).exclude(book=None).values_list('book__slug', flat=True)
+        )
+
+
+class UserListItemsField(serializers.Field):
+    def to_representation(self, value):
+        return value.userlistitem_set.exclude(deleted=True).exclude(book=None).values_list('book__slug', flat=True)
+
+    def to_internal_value(self, value):
+        return {'books': catalogue.models.Book.objects.filter(slug__in=value)}
+
+
+class UserListSerializer(serializers.ModelSerializer):
+    client_id = serializers.CharField(write_only=True, required=False)
+    books = UserListItemsField(source='*', required=False)
+    timestamp = serializers.IntegerField(required=False)
+
+    class Meta:
+        model = models.UserList
+        fields = [
+            'timestamp',
+            'client_id',
+            'name',
+            'slug',
+            'favorites',
+            'deleted',
+            'books',
+        ]
+        read_only_fields = ['favorites']
+        extra_kwargs = {
+            'slug': {
+                'required': False
+            }
+        }
+
+    def create(self, validated_data):
+        instance = models.UserList.get_by_name(
+            validated_data['user'],
+            validated_data['name'],
+            create=True
+        )
+        instance.userlistitem_set.all().delete()
+        for book in validated_data['books']:
+            instance.append(book)
+        return instance
+
+    def update(self, instance, validated_data):
+        instance.userlistitem_set.all().delete()
+        for book in validated_data['books']:
+            instance.append(instance)
+        return instance
+
+class UserListBooksSerializer(UserListSerializer):
+    class Meta:
+        model = models.UserList
+        fields = ['books']
+
+
+class UserListItemSerializer(serializers.ModelSerializer):
+    client_id = serializers.CharField(write_only=True, required=False)
+    favorites = serializers.BooleanField(required=False)
+    list_slug = serializers.SlugRelatedField(
+        queryset=models.UserList.objects.all(),
+        source='list',
+        slug_field='slug',
+        required=False,
+    )
+    timestamp = serializers.IntegerField(required=False)
+    book_slug = serializers.SlugRelatedField(
+        queryset=Book.objects.all(),
+        source='book',
+        slug_field='slug',
+        required=False
+    )
+
+    class Meta:
+        model = models.UserListItem
+        fields = [
+            'client_id',
+            'uuid',
+            'order',
+            'list_slug',
+            'timestamp',
+            'favorites',
+            'deleted',
+
+            'book_slug',
+            'fragment',
+            'quote',
+            'bookmark',
+            'note',
+        ]
+        extra_kwargs = {
+            'order': {
+                'required': False
+            }
+        }
+
+
+@never_cache
+class ListsView(ListCreateAPIView):
+    permission_classes = [IsAuthenticated]
+    #pagination_class = None
+    serializer_class = UserListSerializer
+
+    def get_queryset(self):
+        return models.UserList.objects.filter(
+            user=self.request.user,
+            favorites=False,
+            deleted=False
+        )
+
+    def perform_create(self, serializer):
+        serializer.save(user=self.request.user)
+
+
+@never_cache
+class ListView(RetrieveUpdateDestroyAPIView):
+    # TODO: check if can modify
+    permission_classes = [IsAuthenticated]
+    serializer_class = UserListSerializer
+
+    def get_object(self):
+        return get_object_or_404(
+            models.UserList,
+            slug=self.kwargs['slug'],
+            user=self.request.user)
+
+    def perform_update(self, serializer):
+        serializer.save(user=self.request.user)
+
+    def post(self, request, slug):
+        serializer = UserListBooksSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        instance = self.get_object()
+        for book in serializer.validated_data['books']:
+            instance.append(book)
+        return Response(self.get_serializer(instance).data)
+
+    def perform_destroy(self, instance):
+        instance.update(
+            deleted=True,
+            updated_at=now()
+        )
+
+
+@never_cache
+class ListItemView(APIView):
+    permission_classes = [IsAuthenticated]
 
+    def delete(self, request, slug, book):
+        instance = get_object_or_404(
+            models.UserList, slug=slug, user=self.request.user)
+        book = get_object_or_404(catalogue.models.Book, slug=book)
+        instance.remove(book=book)
+        return Response(UserListSerializer(instance).data)
 
 
 @vary_on_auth
@@ -95,7 +269,7 @@ class ShelfView(ListAPIView):
         after = self.request.query_params.get('after')
         count = int(self.request.query_params.get('count', 50))
         if state == 'likes':
-            books = Book.tagged.with_any(self.request.user.tag_set.all())
+            books = Book.objects.filter(userlistitem__list__user=self.request.user)
         else:
             ids = BookUserData.objects.filter(user=self.request.user, complete=state == 'complete')\
                 .values_list('book_id', flat=True)
@@ -108,3 +282,206 @@ class ShelfView(ListAPIView):
 
         return books
 
+
+
+class ProgressSerializer(serializers.ModelSerializer):
+    book = serializers.HyperlinkedRelatedField(
+        read_only=True,
+        view_name='catalogue_api_book',
+        lookup_field='slug'
+    )
+    book_slug = serializers.SlugRelatedField(
+        queryset=Book.objects.all(),
+        source='book',
+        slug_field='slug')
+    timestamp = serializers.IntegerField(required=False)
+
+    class Meta:
+        model = models.Progress
+        fields = [
+            'timestamp',
+            'book', 'book_slug', 'last_mode', 'text_percent',
+            'text_anchor',
+            'audio_percent',
+            'audio_timestamp',
+            'implicit_text_percent',
+            'implicit_text_anchor',
+            'implicit_audio_percent',
+            'implicit_audio_timestamp',
+        ]
+        extra_kwargs = {
+            'last_mode': {
+                'required': False,
+                'default': 'text',
+            }
+        }
+
+
+class TextProgressSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.Progress
+        fields = [
+                'text_percent',
+                'text_anchor',
+                ]
+        read_only_fields = ['text_percent']
+
+class AudioProgressSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.Progress
+        fields = ['audio_percent', 'audio_timestamp']
+        read_only_fields = ['audio_percent']
+
+
+@never_cache
+class ProgressListView(ListAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = ProgressSerializer
+
+    def get_queryset(self):
+        return models.Progress.objects.filter(user=self.request.user).order_by('-updated_at')
+
+
+class ProgressMixin:
+    def get_object(self):
+        try:
+            return models.Progress.objects.get(user=self.request.user, book__slug=self.kwargs['slug'])
+        except models.Progress.DoesNotExist:
+            book = get_object_or_404(Book, slug=self.kwargs['slug'])
+            return models.Progress(user=self.request.user, book=book)
+
+
+
+@never_cache
+class ProgressView(ProgressMixin, RetrieveAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = ProgressSerializer
+
+
+@never_cache
+class TextProgressView(ProgressMixin, RetrieveUpdateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = TextProgressSerializer
+
+    def perform_update(self, serializer):
+        serializer.instance.last_mode = 'text'
+        serializer.save()
+
+
+@never_cache
+class AudioProgressView(ProgressMixin, RetrieveUpdateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = AudioProgressSerializer
+
+    def perform_update(self, serializer):
+        serializer.instance.last_mode = 'audio'
+        serializer.save()
+
+
+
+@never_cache
+class SyncView(ListAPIView):
+    permission_classes = [IsAuthenticated]
+    sync_id_field = 'slug'
+    sync_id_serializer_field = 'slug'
+    sync_user_field = 'user'
+
+    def get_queryset(self):
+        try:
+            timestamp = int(self.request.GET.get('ts'))
+        except:
+            timestamp = 0
+
+        timestamp = datetime.fromtimestamp(timestamp, tz=utc)
+        
+        data = []
+        return self.get_queryset_for_ts(timestamp)
+
+    def get_queryset_for_ts(self, timestamp):
+        return self.model.objects.filter(
+            updated_at__gt=timestamp,
+            **{
+                self.sync_user_field: self.request.user
+            }
+        ).order_by('updated_at')
+
+    def get_instance(self, user, data):
+        sync_id = data.get(self.sync_id_serializer_field)
+        if not sync_id:
+            return None
+        return self.model.objects.filter(**{
+            self.sync_user_field: user,
+            self.sync_id_field: sync_id
+        }).first()
+
+    def post(self, request):
+        new_ids = []
+        data = request.data
+        if not isinstance(data, list):
+            raise serializers.ValidationError('Payload should be a list')
+        for item in data:
+            instance = self.get_instance(request.user, item)
+            ser = self.get_serializer(
+                instance=instance,
+                data=item
+            )
+            ser.is_valid(raise_exception=True)
+            synced_instance = self.model.sync(
+                request.user,
+                instance,
+                ser.validated_data
+            )
+            if instance is None and 'client_id' in ser.validated_data and synced_instance is not None:
+                new_ids.append({
+                    'client_id': ser.validated_data['client_id'],
+                    self.sync_id_serializer_field: getattr(synced_instance, self.sync_id_field),
+                })
+        return Response(new_ids)
+
+
+class ProgressSyncView(SyncView):
+    model = models.Progress
+    serializer_class = ProgressSerializer
+    
+    sync_id_field = 'book__slug'
+    sync_id_serializer_field = 'book_slug'
+
+
+class UserListSyncView(SyncView):
+    model = models.UserList
+    serializer_class = UserListSerializer
+
+
+class UserListItemSyncView(SyncView):
+    model = models.UserListItem
+    serializer_class = UserListItemSerializer
+
+    sync_id_field = 'uuid'
+    sync_id_serializer_field = 'uuid'
+    sync_user_field = 'list__user'
+
+    def get_queryset_for_ts(self, timestamp):
+        qs = self.model.objects.filter(
+            updated_at__gt=timestamp,
+            **{
+                self.sync_user_field: self.request.user
+            }
+        )
+        if self.request.query_params.get('favorites'):
+            qs = qs.filter(list__favorites=True)
+        return qs.order_by('updated_at')
+
+
+class BookmarkSyncView(SyncView):
+    model = bookmarks.models.Bookmark
+    serializer_class = BookmarkSerializer
+
+    sync_id_field = 'uuid'
+    sync_id_serializer_field = 'uuid'
+
+    def get_instance(self, user, data):
+        ret = super().get_instance(user, data)
+        if ret is None:
+            if data.get('location'):
+                ret = self.model.get_by_location(user, data['location'])
+        return ret
index f82e27c..4d39f08 100644 (file)
@@ -3,16 +3,8 @@
 #
 from django import forms
 
-from catalogue.models import Book, Tag
-from social.utils import get_set
-
-
-class UserSetsForm(forms.Form):
-    def __init__(self, book, user, *args, **kwargs):
-        super(UserSetsForm, self).__init__(*args, **kwargs)
-        self.fields['set_ids'] = forms.ChoiceField(
-            choices=[(tag.id, tag.name) for tag in Tag.objects.filter(category='set', user=user).iterator()],
-        )
+from catalogue.models import Book
+from . import models
 
 
 class AddSetForm(forms.Form):
@@ -22,18 +14,18 @@ class AddSetForm(forms.Form):
     def save(self, user):
         name = self.cleaned_data['name'].strip()
         if not name: return
-        tag = get_set(user, name)
+        ul = models.UserList.get_by_name(user, name, create=True)
         try:
             book = Book.objects.get(id=self.cleaned_data['book'])
         except Book.DoesNotExist:
             return
 
         try:
-            book.tag_relations.create(tag=tag)
+            ul.append(book=book)
         except:
             pass
 
-        return book, tag
+        return book, ul
 
 
 class RemoveSetForm(forms.Form):
@@ -43,8 +35,8 @@ class RemoveSetForm(forms.Form):
     def save(self, user):
         slug = self.cleaned_data['slug']
         try:
-            tag = Tag.objects.get(user=user, slug=slug)
-        except Tag.DoesNotExist:
+            ul = models.UserList.objects.get(user=user, slug=slug)
+        except models.UserList.DoesNotExist:
             return
         try:
             book = Book.objects.get(id=self.cleaned_data['book'])
@@ -52,8 +44,8 @@ class RemoveSetForm(forms.Form):
             return
 
         try:
-            book.tag_relations.filter(tag=tag).delete()
+            ul.userlistitem_set.filter(book=book).delete()
         except:
             pass
 
-        return book, tag
+        return book, ul
diff --git a/src/social/migrations/0018_progress.py b/src/social/migrations/0018_progress.py
new file mode 100644 (file)
index 0000000..ae2628c
--- /dev/null
@@ -0,0 +1,39 @@
+# Generated by Django 4.0.8 on 2025-05-07 13:21
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('social', '0017_userconfirmation'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Progress',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('updated_at', models.DateTimeField(auto_now=True)),
+                ('last_mode', models.CharField(choices=[('text', 'text'), ('audio', 'audio')], max_length=64)),
+                ('text_percent', models.FloatField(blank=True, null=True)),
+                ('text_anchor', models.CharField(blank=True, max_length=64)),
+                ('audio_percent', models.FloatField(blank=True, null=True)),
+                ('audio_timestamp', models.FloatField(blank=True, null=True)),
+                ('implicit_text_percent', models.FloatField(blank=True, null=True)),
+                ('implicit_text_anchor', models.CharField(blank=True, max_length=64)),
+                ('implicit_audio_percent', models.FloatField(blank=True, null=True)),
+                ('implicit_audio_timestamp', models.FloatField(blank=True, null=True)),
+                ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='catalogue.book')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+            options={
+                'unique_together': {('user', 'book')},
+            },
+        ),
+    ]
diff --git a/src/social/migrations/0019_progress_deleted.py b/src/social/migrations/0019_progress_deleted.py
new file mode 100644 (file)
index 0000000..71b1fab
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-07-08 14:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('social', '0018_progress'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='progress',
+            name='deleted',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/src/social/migrations/0020_userlist_userlistitem.py b/src/social/migrations/0020_userlist_userlistitem.py
new file mode 100644 (file)
index 0000000..f97f833
--- /dev/null
@@ -0,0 +1,48 @@
+# Generated by Django 4.0.8 on 2025-07-14 13:39
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('bookmarks', '0002_quote'),
+        ('social', '0019_progress_deleted'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UserList',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('slug', models.SlugField(unique=True)),
+                ('name', models.CharField(max_length=1024)),
+                ('favorites', models.BooleanField(default=False)),
+                ('public', models.BooleanField(default=False)),
+                ('deleted', models.BooleanField(default=False)),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('updated_at', models.DateTimeField()),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='UserListItem',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('order', models.IntegerField()),
+                ('deleted', models.BooleanField(default=False)),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('updated_at', models.DateTimeField()),
+                ('note', models.TextField(blank=True)),
+                ('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='catalogue.book')),
+                ('bookmark', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='bookmarks.bookmark')),
+                ('fragment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='catalogue.fragment')),
+                ('list', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='social.userlist')),
+                ('quote', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='bookmarks.quote')),
+            ],
+        ),
+    ]
diff --git a/src/social/migrations/0021_move_sets.py b/src/social/migrations/0021_move_sets.py
new file mode 100644 (file)
index 0000000..64e9ccc
--- /dev/null
@@ -0,0 +1,56 @@
+# Generated by Django 4.0.8 on 2025-07-14 13:40
+
+from django.db import migrations
+from django.utils.timezone import now
+
+
+def move_sets_to_userlists(apps, schema_editor):
+    UserList = apps.get_model('social', 'UserList')
+    UserListItem = apps.get_model('social', 'UserListItem')
+    Tag = apps.get_model('catalogue', 'Tag')
+
+    for tag in Tag.objects.filter(category='set'):
+        print()
+        print(tag)
+        ul = UserList.objects.create(
+            slug=tag.slug,
+            user=tag.user,
+            name=tag.name,
+            favorites=not tag.name,
+            public=not tag.name,
+            created_at=tag.created_at,
+            updated_at=tag.changed_at,
+        )
+
+        for i, item in enumerate(tag.items.all()):
+            #assert item.content_type_id == 12, item.content_type_id
+            print(item)
+            ul.userlistitem_set.create(
+                order=i + 1,
+                created_at=ul.updated_at,
+                updated_at=ul.updated_at,
+                book_id=item.object_id
+            )
+
+        tag.delete()
+
+
+def rollback_userlists_to_sets(apps, schema_editor):
+    UserList = apps.get_model('social', 'UserList')
+    UserListItem = apps.get_model('social', 'UserListItem')
+    Tag = apps.get_model('catalogue', 'Tag')
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('social', '0020_userlist_userlistitem'),
+        ('catalogue', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            move_sets_to_userlists,
+            rollback_userlists_to_sets
+        )
+    ]
diff --git a/src/social/migrations/0022_userlist_reported_timestamp_and_more.py b/src/social/migrations/0022_userlist_reported_timestamp_and_more.py
new file mode 100644 (file)
index 0000000..2e21005
--- /dev/null
@@ -0,0 +1,42 @@
+# Generated by Django 4.0.8 on 2025-07-22 13:09
+
+from django.db import migrations, models
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('social', '0021_move_sets'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='userlist',
+            name='reported_timestamp',
+            field=models.DateTimeField(default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='userlistitem',
+            name='reported_timestamp',
+            field=models.DateTimeField(default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='userlistitem',
+            name='uuid',
+            field=models.UUIDField(editable=False, null=True),
+        ),
+        migrations.AlterField(
+            model_name='userlist',
+            name='updated_at',
+            field=models.DateTimeField(auto_now=True),
+        ),
+        migrations.AlterField(
+            model_name='userlistitem',
+            name='updated_at',
+            field=models.DateTimeField(auto_now=True),
+        ),
+    ]
diff --git a/src/social/migrations/0023_auto_20250722_1513.py b/src/social/migrations/0023_auto_20250722_1513.py
new file mode 100644 (file)
index 0000000..a7120e8
--- /dev/null
@@ -0,0 +1,26 @@
+# Generated by Django 4.0.8 on 2025-07-22 13:13
+
+from django.db import migrations, transaction
+import uuid
+
+
+def gen_uuid(apps, schema_editor):
+    UserListItem = apps.get_model("social", "UserListItem")
+    while UserListItem.objects.filter(uuid__isnull=True).exists():
+        print(UserListItem.objects.filter(uuid__isnull=True).count(), 'rows left')
+        with transaction.atomic():
+            for row in UserListItem.objects.filter(uuid__isnull=True)[:1000]:
+                row.uuid = uuid.uuid4()
+                row.save(update_fields=["uuid"])
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('social', '0022_userlist_reported_timestamp_and_more'),
+    ]
+
+    operations = [
+        migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
+    ]
diff --git a/src/social/migrations/0024_auto_20250722_1513.py b/src/social/migrations/0024_auto_20250722_1513.py
new file mode 100644 (file)
index 0000000..c0a8b0c
--- /dev/null
@@ -0,0 +1,19 @@
+# Generated by Django 4.0.8 on 2025-07-22 13:13
+
+from django.db import migrations, models
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('social', '0023_auto_20250722_1513'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='userlistitem',
+            name='uuid',
+            field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
+        ),
+    ]
diff --git a/src/social/migrations/0025_progress_reported_timestamp_alter_userlistitem_uuid.py b/src/social/migrations/0025_progress_reported_timestamp_alter_userlistitem_uuid.py
new file mode 100644 (file)
index 0000000..56923c0
--- /dev/null
@@ -0,0 +1,26 @@
+# Generated by Django 4.0.8 on 2025-07-29 12:44
+
+from django.db import migrations, models
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('social', '0024_auto_20250722_1513'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='progress',
+            name='reported_timestamp',
+            field=models.DateTimeField(default=django.utils.timezone.now),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='userlistitem',
+            name='uuid',
+            field=models.UUIDField(blank=True, default=uuid.uuid4, editable=False, unique=True),
+        ),
+    ]
diff --git a/src/social/migrations/0026_userprofile.py b/src/social/migrations/0026_userprofile.py
new file mode 100644 (file)
index 0000000..d5e8c5a
--- /dev/null
@@ -0,0 +1,24 @@
+# Generated by Django 4.0.8 on 2025-08-22 13:18
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('social', '0025_progress_reported_timestamp_alter_userlistitem_uuid'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UserProfile',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('notifications', models.BooleanField(default=False)),
+                ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
index 17fe7d0..c41a78f 100644 (file)
@@ -1,6 +1,8 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
+from datetime import datetime
+import uuid
 from oauthlib.common import urlencode, generate_token
 from random import randint
 from django.db import models
@@ -9,8 +11,11 @@ from django.contrib.auth.models import User
 from django.core.exceptions import ValidationError
 from django.core.mail import send_mail
 from django.urls import reverse
+from django.utils.timezone import now, utc
 from catalogue.models import Book
+from catalogue.utils import get_random_hash
 from wolnelektury.utils import cached_render, clear_cached_renders
+from .syncable import Syncable
 
 
 class BannerGroup(models.Model):
@@ -175,6 +180,16 @@ class CarouselItem(models.Model):
         return self.banner or self.banner_group.get_banner()
 
 
+class UserProfile(models.Model):
+    user = models.OneToOneField(User, models.CASCADE)
+    notifications = models.BooleanField(default=False)
+
+    @classmethod
+    def get_for(cls, user):
+        obj, created = cls.objects.get_or_create(user=user)
+        return obj
+
+
 class UserConfirmation(models.Model):
     user = models.ForeignKey(User, models.CASCADE)
     created_at = models.DateTimeField(auto_now_add=True)
@@ -200,3 +215,219 @@ class UserConfirmation(models.Model):
             user=user,
             key=generate_token()
         ).send()
+
+
+class Progress(Syncable, models.Model):
+    user = models.ForeignKey(User, models.CASCADE)
+    book = models.ForeignKey('catalogue.Book', models.CASCADE)
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+    reported_timestamp = models.DateTimeField()
+    deleted = models.BooleanField(default=False)
+    last_mode = models.CharField(max_length=64, choices=[
+        ('text', 'text'),
+        ('audio', 'audio'),
+    ])
+    text_percent = models.FloatField(null=True, blank=True)
+    text_anchor = models.CharField(max_length=64, blank=True)
+    audio_percent = models.FloatField(null=True, blank=True)
+    audio_timestamp = models.FloatField(null=True, blank=True)
+    implicit_text_percent = models.FloatField(null=True, blank=True)
+    implicit_text_anchor = models.CharField(max_length=64, blank=True)
+    implicit_audio_percent = models.FloatField(null=True, blank=True)
+    implicit_audio_timestamp = models.FloatField(null=True, blank=True)
+
+    syncable_fields = [
+        'deleted',
+        'last_mode', 'text_anchor', 'audio_timestamp'
+    ]
+    
+    class Meta:
+        unique_together = [('user', 'book')]
+
+    @property
+    def timestamp(self):
+        return self.updated_at.timestamp()
+
+    @classmethod
+    def create_from_data(cls, user, data):
+        return cls.objects.create(
+            user=user,
+            book=data['book'],
+            reported_timestamp=now(),
+        )
+        
+    def save(self, *args, **kwargs):
+        try:
+            audio_l = self.book.get_audio_length()
+        except:
+            audio_l = 60
+        if self.text_anchor:
+            self.text_percent = 33
+            if audio_l:
+                self.implicit_audio_percent = 40
+                self.implicit_audio_timestamp = audio_l * .4
+        if self.audio_timestamp:
+            if self.audio_timestamp > audio_l:
+                self.audio_timestamp = audio_l
+            if audio_l:
+                self.audio_percent = 100 * self.audio_timestamp / audio_l
+                self.implicit_text_percent = 60
+                self.implicit_text_anchor = 'f20'
+        return super().save(*args, **kwargs)
+
+
+class UserList(Syncable, models.Model):
+    slug = models.SlugField(unique=True)
+    user = models.ForeignKey(User, models.CASCADE)
+    name = models.CharField(max_length=1024)
+    favorites = models.BooleanField(default=False)
+    public = models.BooleanField(default=False)
+    deleted = models.BooleanField(default=False)
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+    reported_timestamp = models.DateTimeField()
+
+    syncable_fields = ['name', 'public', 'deleted']
+    
+    def get_absolute_url(self):
+        return reverse(
+            'tagged_object_list',
+            args=[f'polka/{self.slug}']
+        )
+
+    def __str__(self):
+        return self.name
+
+    @property
+    def url_chunk(self):
+        return f'polka/{self.slug}'
+    
+    @classmethod
+    def create_from_data(cls, user, data):
+        return cls.create(user, data['name'])
+
+    @classmethod
+    def create(cls, user, name):
+        n = now()
+        return cls.objects.create(
+            user=user,
+            name=name,
+            slug=get_random_hash(name),
+            updated_at=n,
+            reported_timestamp=n,
+        )
+
+    @classmethod
+    def get_by_name(cls, user, name, create=False):
+        l = cls.objects.filter(
+            user=user,
+            name=name
+        ).first()
+        if l is None and create:
+            l = cls.create(user, name)
+        return l
+        
+    @classmethod
+    def get_favorites_list(cls, user, create=False):
+        try:
+            return cls.objects.get(
+                user=user,
+                favorites=True
+            )
+        except cls.DoesNotExist:
+            n = now()
+            if create:
+                return cls.objects.create(
+                    user=user,
+                    favorites=True,
+                    slug=get_random_hash('favorites'),
+                    updated_at=n,
+                    reported_timestamp=n,
+                )
+            else:
+                return None
+        except cls.MultipleObjectsReturned:
+            # merge?
+            lists = list(cls.objects.filter(user=user, favorites=True))
+            for l in lists[1:]:
+                t.userlistitem_set.all().update(
+                    list=lists[0]
+                )
+                l.delete()
+            return lists[0]
+
+    @classmethod
+    def likes(cls, user, book):
+        ls = cls.get_favorites_list(user)
+        if ls is None:
+            return False
+        return ls.userlistitem_set.filter(deleted=False, book=book).exists()
+
+    def append(self, book):
+        # TODO: check for duplicates?
+        n = now()
+        item = self.userlistitem_set.create(
+            book=book,
+            order=(self.userlistitem_set.aggregate(m=models.Max('order'))['m'] or 0) + 1,
+            updated_at=n,
+            reported_timestamp=n,
+        )
+        book.update_popularity()
+        return item
+
+    def remove(self, book):
+        self.userlistitem_set.filter(book=book).update(
+            deleted=True,
+            updated_at=now()
+        )
+        book.update_popularity()
+
+    @classmethod
+    def like(cls, user, book):
+        ul = cls.get_favorites_list(user, create=True)
+        ul.append(book)
+
+    @classmethod
+    def unlike(cls, user, book):
+        ul = cls.get_favorites_list(user)
+        if ul is not None:
+            ul.remove(book)
+
+    def get_books(self):
+        return [item.book for item in self.userlistitem_set.exclude(deleted=True).exclude(book=None)]
+            
+
+class UserListItem(Syncable, models.Model):
+    list = models.ForeignKey(UserList, models.CASCADE)
+    uuid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, blank=True)
+    order = models.IntegerField()
+    deleted = models.BooleanField(default=False)
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+    reported_timestamp = models.DateTimeField()
+
+    book = models.ForeignKey('catalogue.Book', models.SET_NULL, null=True, blank=True)
+    fragment = models.ForeignKey('catalogue.Fragment', models.SET_NULL, null=True, blank=True)
+    quote = models.ForeignKey('bookmarks.Quote', models.SET_NULL, null=True, blank=True)
+    bookmark = models.ForeignKey('bookmarks.Bookmark', models.SET_NULL, null=True, blank=True)
+
+    note = models.TextField(blank=True)
+    
+    syncable_fields = ['order', 'deleted', 'book', 'fragment', 'quote', 'bookmark', 'note']
+
+    @classmethod
+    def create_from_data(cls, user, data):
+        if data.get('favorites'):
+            l = UserList.get_favorites_list(user, create=True)
+        else:
+            l = data['list']
+            try:
+                assert l.user == user
+            except AssertionError:
+                return
+        return l.append(book=data['book'])
+
+    @property
+    def favorites(self):
+        return self.list.favorites
diff --git a/src/social/syncable.py b/src/social/syncable.py
new file mode 100644 (file)
index 0000000..6447f34
--- /dev/null
@@ -0,0 +1,39 @@
+from datetime import datetime
+from django.utils.timezone import now, utc
+
+
+class Syncable:
+    @classmethod
+    def sync(cls, user, instance, data):
+        ts = data.get('timestamp')
+        if ts is None:
+            ts = now()
+        else:
+            ts = datetime.fromtimestamp(ts, tz=utc)
+
+        if instance is not None:
+            if ts and ts < instance.reported_timestamp:
+                return
+
+        if instance is None:
+            if data.get('deleted'):
+                return
+            instance = cls.create_from_data(user, data)
+            if instance is None:
+                return
+
+        instance.reported_timestamp = ts
+        for f in cls.syncable_fields:
+            if f in data:
+                setattr(instance, f, data[f])
+
+        instance.save()
+        return instance
+
+    @property
+    def timestamp(self):
+        return self.updated_at.timestamp()
+    
+    @classmethod
+    def create_from_data(cls, user, data):
+        raise NotImplementedError
diff --git a/src/social/templates/social/shelf_tags.html b/src/social/templates/social/shelf_tags.html
deleted file mode 100644 (file)
index 47e0b0a..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-{% spaceless %}
-  <ul class='social-shelf-tags'>
-    {% for tag in tags %}
-      <li><a href="{{ tag.get_absolute_url }}">{{ tag.name }}</a></li>
-    {% endfor %}
-  </ul>
-{% endspaceless %}
\ No newline at end of file
index a4b0f3e..ae7c5a0 100644 (file)
@@ -3,7 +3,6 @@
 #
 import re
 from django import template
-from django.utils.functional import lazy
 from django.utils.cache import add_never_cache_headers
 from catalogue.models import Book, Fragment
 from social.utils import likes, get_or_choose_cite, choose_cite as cs
@@ -32,25 +31,6 @@ def choose_cites(number, book=None, author=None):
         return Fragment.tagged.with_all([author]).order_by('?')[:number]
 
 
-@register.simple_tag(takes_context=True)
-def book_shelf_tags(context, book_id):
-    request = context['request']
-    if not request.user.is_authenticated:
-        return ''
-    book = Book.objects.get(pk=book_id)
-    lks = likes(request.user, book, request)
-
-    def get_value():
-        if not lks:
-            return ''
-        tags = book.tags.filter(category='set', user=request.user).exclude(name='')
-        if not tags:
-            return ''
-        ctx = {'tags': tags}
-        return template.loader.render_to_string('social/shelf_tags.html', ctx)
-    return lazy(get_value, str)()
-
-
 @register.inclusion_tag('social/carousel.html', takes_context=True)
 def carousel(context, placement):
     banners = Carousel.get(placement).carouselitem_set.all()#first().get_banner()
index 6ab303a..eb16507 100644 (file)
@@ -7,10 +7,9 @@ from random import randint
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.utils.functional import lazy
-from catalogue.models import Book, Tag
-from catalogue import utils
-from catalogue.tasks import touch_tag
+from catalogue.models import Book
 from social.models import Cite
+from social import models
 
 
 def likes(user, work, request=None):
@@ -18,7 +17,7 @@ def likes(user, work, request=None):
         return False
 
     if request is None:
-        return work.tags.filter(category='set', user=user).exists()
+        return models.UserList.likes(user, work)
 
     if not hasattr(request, 'social_likes'):
         # tuple: unchecked, checked, liked
@@ -35,54 +34,17 @@ def likes(user, work, request=None):
             if likes_t[0]:
                 ids = tuple(likes_t[0])
                 likes_t[0].clear()
-                likes_t[2].update(Tag.intermediary_table_model.objects.filter(
-                    content_type_id=ct.pk, tag__user_id=user.pk,
-                    object_id__in=ids
-                ).distinct().values_list('object_id', flat=True))
+                ls = models.UserList.get_favorites_list(user)
+                if ls is None:
+                    return False
+                likes_t[2].update(
+                    ls.userlistitem_set.filter(deleted=False).filter(
+                        book_id__in=ids).values_list('book_id', flat=True))
                 likes_t[1].update(ids)
             return work.pk in likes_t[2]
         return lazy(_likes, bool)()
 
 
-def get_set(user, name):
-    """Returns a tag for use by the user. Creates it, if necessary."""
-    try:
-        tag = Tag.objects.get(category='set', user=user, name=name)
-    except Tag.DoesNotExist:
-        tag = Tag.objects.create(
-            category='set', user=user, name=name, slug=utils.get_random_hash(name), sort_key=name.lower())
-    except Tag.MultipleObjectsReturned:
-        # fix duplicated noname shelf
-        tags = list(Tag.objects.filter(category='set', user=user, name=name))
-        tag = tags[0]
-        for other_tag in tags[1:]:
-            for item in other_tag.items.all():
-                Tag.objects.remove_tag(item, other_tag)
-                Tag.objects.add_tag(item, tag)
-            other_tag.delete()
-    return tag
-
-
-def set_sets(user, work, sets):
-    """Set tags used for given work by a given user."""
-
-    old_sets = list(work.tags.filter(category='set', user=user))
-
-    work.tags = sets + list(
-            work.tags.filter(~Q(category='set') | ~Q(user=user)))
-
-    for shelf in [shelf for shelf in old_sets if shelf not in sets]:
-        touch_tag(shelf)
-    for shelf in [shelf for shelf in sets if shelf not in old_sets]:
-        touch_tag(shelf)
-
-    # delete empty tags
-    Tag.objects.filter(category='set', user=user, items=None).delete()
-
-    if isinstance(work, Book):
-        work.update_popularity()
-
-
 def cites_for_tags(tags):
     """Returns a QuerySet with all Cites for books with given tags."""
     return Cite.objects.filter(book__in=Book.tagged.with_all(tags))
index 8f27b87..0ff0771 100644 (file)
@@ -8,8 +8,7 @@ from django.views.decorators.cache import never_cache
 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
 
@@ -26,7 +25,7 @@ def like_book(request, 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})
@@ -58,7 +57,7 @@ def unlike_book(request, 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})
@@ -69,32 +68,29 @@ def unlike_book(request, slug):
 @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:
@@ -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
     )