Fix for player timer. master
authorRadek Czajka <rczajka@rczajka.pl>
Tue, 3 Mar 2026 16:47:50 +0000 (17:47 +0100)
committerRadek Czajka <rczajka@rczajka.pl>
Tue, 3 Mar 2026 16:47:50 +0000 (17:47 +0100)
133 files changed:
Dockerfile [new file with mode: 0644]
Makefile
docker-compose.yml [new file with mode: 0644]
requirements/requirements.txt
src/annoy/admin.py
src/annoy/migrations/0019_campaign_banner_books_alter_banner_place_and_more.py [new file with mode: 0644]
src/annoy/models.py
src/annoy/places.py
src/annoy/templates/annoy/banner.html
src/annoy/templates/annoy/banner_top.html [new file with mode: 0644]
src/annoy/templatetags/annoy.py
src/api/migrations/0008_alter_token_token_type.py [new file with mode: 0644]
src/api/migrations/0009_sessiontransfertoken.py [new file with mode: 0644]
src/api/models.py
src/api/pagination.py
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/bookmarks/views.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/fields.py
src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py [new file with mode: 0644]
src/catalogue/migrations/0050_book_narrators.py [new file with mode: 0644]
src/catalogue/migrations/0051_book_has_audio.py [new file with mode: 0644]
src/catalogue/migrations/0052_book_pages_book_read_time.py [new file with mode: 0644]
src/catalogue/migrations/0053_alter_book_pages_alter_book_read_time.py [new file with mode: 0644]
src/catalogue/migrations/0054_alter_book_sort_key_alter_book_sort_key_author_and_more.py [new file with mode: 0644]
src/catalogue/models/book.py
src/catalogue/models/bookmedia.py
src/catalogue/models/tag.py
src/catalogue/static/book/filter.js
src/catalogue/static/player/player.js
src/catalogue/templates/catalogue/book_box.html
src/catalogue/templates/catalogue/book_detail.html
src/catalogue/templates/catalogue/book_list.html
src/catalogue/templates/catalogue/book_short.html [deleted file]
src/catalogue/templates/catalogue/book_text.html
src/catalogue/templates/catalogue/snippets/jplayer.html
src/catalogue/templates/catalogue/snippets/jplayer_link.html [deleted file]
src/catalogue/templatetags/catalogue_tags.py
src/catalogue/views.py
src/club/admin.py
src/club/civicrm.py
src/club/forms.py
src/club/models.py
src/club/templates/admin/club/schedule/change_list.html
src/club/templates/club/donation_infobox.html [new file with mode: 0644]
src/club/templates/club/donation_step1_form.html
src/club/templates/club/donation_step2.html
src/club/templates/club/donation_step3.html
src/club/templates/club/donation_step_base.html
src/club/templates/club/payment/paypal_invite.html
src/club/templates/club/receipt_email.txt
src/club/templates/payu/rec_widget.html
src/club/urls.py
src/club/views.py
src/dictionary/migrations/0005_alter_note_sort_key.py [new file with mode: 0644]
src/dictionary/models.py
src/lesmianator/models.py
src/lesmianator/views.py
src/opds/views.py
src/pdcounter/migrations/0004_alter_author_sort_key.py [new file with mode: 0644]
src/pdcounter/models.py
src/pdcounter/templates/pdcounter/author_detail.html
src/pdcounter/templates/pdcounter/book_detail.html
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/references/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 [new file with mode: 0644]
src/social/api/views.py
src/social/forms.py
src/social/migrations/0017_userconfirmation.py [new file with mode: 0644]
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/my_shelf.html
src/social/templates/social/shelf_tags.html [deleted file]
src/social/templates/social/user_confirmation.html [new file with mode: 0644]
src/social/templatetags/social_tags.py
src/social/urls.py
src/social/utils.py
src/social/views.py
src/sponsors/models.py
src/wolnelektury/settings/apps.py
src/wolnelektury/settings/auth.py
src/wolnelektury/settings/basic.py
src/wolnelektury/settings/celery.py
src/wolnelektury/settings/contrib.py
src/wolnelektury/settings/custom.py
src/wolnelektury/static/2022/styles/components/_media.scss
src/wolnelektury/static/2022/styles/components/_player.scss
src/wolnelektury/static/2022/styles/layout/_annoy.scss
src/wolnelektury/static/2022/styles/layout/_button.scss
src/wolnelektury/static/2022/styles/layout/_checkout.scss
src/wolnelektury/static/2022/styles/layout/_navigation.scss
src/wolnelektury/static/2022/styles/layout/_text.scss
src/wolnelektury/static/2022/styles/reader_player.scss
src/wolnelektury/static/img/elevenreader-21.png [new file with mode: 0644]
src/wolnelektury/static/img/elevenreader.png [new file with mode: 0644]
src/wolnelektury/static/js/book_text/menu.js
src/wolnelektury/static/js/book_text/progress.js
src/wolnelektury/static/js/book_text/references.js
src/wolnelektury/static/js/contrib/jplayer/jquery.jplayer.js
src/wolnelektury/static/js/contrib/jplayer/jquery.jplayer.min.js
src/wolnelektury/static/js/main.js
src/wolnelektury/templates/admin/catalogue/book/change_list.html
src/wolnelektury/templates/header.html
src/wolnelektury/templates/user_actions.html

diff --git a/Dockerfile b/Dockerfile
new file mode 100644 (file)
index 0000000..ed113fe
--- /dev/null
@@ -0,0 +1,49 @@
+FROM python:3.9-trixie AS base
+
+ARG UID=1000
+ARG GID=1000
+
+RUN     apt-get update && apt-get install -y \
+       git \
+       calibre \
+       texlive-xetex texlive-lang-polish \
+       texlive-extra-utils \
+       texlive-lang-greek \
+       texlive-lang-other \
+       texlive-luatex \
+       texlive-fonts-extra \
+       texlive-fonts-extra-links \
+       fonts-noto-core fonts-noto-extra
+
+
+COPY requirements/requirements.txt requirements.txt
+
+RUN pip install --no-cache-dir -r requirements.txt
+RUN pip install --no-cache-dir \
+    psycopg2-binary \
+    django-debug-toolbar==3.2.2
+
+RUN addgroup --gid $GID app && \
+    adduser --gid $GID --home /app --uid $UID app
+
+
+# fonts
+RUN cp -a /usr/local/lib/python*/site-packages/librarian/fonts /usr/share/fonts
+RUN fc-cache
+
+USER app
+
+WORKDIR /app/src
+
+RUN mkdir /app/.ipython
+
+FROM base AS dev
+
+#RUN pip install --no-cache-dir coverage
+
+
+FROM base AS prod
+
+RUN pip install --no-cache-dir gunicorn
+
+COPY src /app/src
index d43ff1e..98a6898 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,8 @@
-.PHONY: deploy test
+.PHONY: deploy test up down build shell logs restart
+
+
+UID := $(shell id -u)
+GID := $(shell id -g)
 
 
 deploy: src/wolnelektury/localsettings.py
@@ -17,3 +21,22 @@ test:
        mv ../htmlcov.new ../htmlcov
        coverage report
        rm .coverage
+
+
+up:
+       UID=$(UID) GID=$(GID) docker compose up --build -d
+
+down:
+       docker compose down
+
+build:
+       UID=$(UID) GID=$(GID) docker compose build
+
+shell:
+       UID=$(UID) GID=$(GID) docker compose run --rm web bash
+
+logs:
+       docker compose logs -f
+
+restart:
+       docker compose restart
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644 (file)
index 0000000..b651509
--- /dev/null
@@ -0,0 +1,48 @@
+x-app-base: &app-base
+  build:
+    context: .
+    target: dev
+    args:
+      UID: ${UID:-1000}
+      GID: ${GID:-1000}
+  env_file:
+   - .env
+  volumes:
+   - ./src:/app/src
+   - ./var/media:/app/var/media
+   - ./var/static:/app/var/static
+   - ./var/counters/:/app/var/counters
+   - ipython:/app/.ipython
+  depends_on:
+   - db
+   - redis
+   - memcached
+
+services:
+  web:
+    <<: *app-base
+    ports:
+     - "8000:8000"
+    command: ./manage.py runserver 0.0.0.0:8000
+
+  celery:
+    <<: *app-base
+    command: celery -A wolnelektury worker -l info
+
+  db:
+    image: postgres:18
+    container_name: db
+    env_file:
+      - .env
+    volumes:
+      - ./var/postgresql-data/:/var/lib/postgresql/
+
+  redis:
+    image: redis:8-alpine
+
+  memcached:
+    image: memcached:1.6-alpine
+
+
+volumes:
+  ipython:
\ No newline at end of file
index 6b650e3..1fba04c 100644 (file)
@@ -5,6 +5,8 @@ Django==4.0.8
 fnpdjango==0.6
 docutils==0.20
 
+python-memcached==1.59
+
 django-pipeline==3.1.0
 libsasscompiler==0.2.0
 jsmin==3.0.1
@@ -14,6 +16,7 @@ django-modeltranslation==0.18.12
 django-allauth==0.51
 django-extensions==3.2.3
 djangorestframework==3.15.1
+django-filter==23.5
 djangorestframework-xml==2.0.0
 django-admin-ordering==0.18
 django-countries==7.6.1
@@ -34,12 +37,12 @@ python-fb==0.2
 
 Feedparser==6.0.11
 
-Pillow==10.4
+Pillow==9.5.0
 mutagen==1.47
 sorl-thumbnail==12.10.0
 
 # home-brewed & dependencies
-librarian==24.5.4
+librarian==26.2
 
 # celery tasks
 celery[redis]==5.4.0
@@ -59,3 +62,4 @@ python-slugify==8.0.4
 firebase-admin==6.6.0
 Wikidata==0.8.1
 
+ipython
index ab7be6a..ed651b2 100644 (file)
@@ -5,6 +5,9 @@ from modeltranslation.admin import TranslationAdmin
 from . import models
 
 
+admin.site.register(models.Campaign)
+
+
 class BannerAdmin(TranslationAdmin):
     list_display = [
             'place', 'text',
diff --git a/src/annoy/migrations/0019_campaign_banner_books_alter_banner_place_and_more.py b/src/annoy/migrations/0019_campaign_banner_books_alter_banner_place_and_more.py
new file mode 100644 (file)
index 0000000..51e3339
--- /dev/null
@@ -0,0 +1,38 @@
+# Generated by Django 4.0.8 on 2025-11-25 15:13
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0051_book_has_audio'),
+        ('annoy', '0018_alter_banner_style'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Campaign',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(help_text='Dla zespołu', max_length=255)),
+                ('image', models.FileField(blank=True, upload_to='annoy/banners/', verbose_name='obraz')),
+            ],
+        ),
+        migrations.AddField(
+            model_name='banner',
+            name='books',
+            field=models.ManyToManyField(blank=True, to='catalogue.book'),
+        ),
+        migrations.AlterField(
+            model_name='banner',
+            name='place',
+            field=models.SlugField(choices=[('top', 'U góry wszystkich stron'), ('book-page', 'Strona książki'), ('book-page-center', 'Strona książki, środek'), ('book-text-intermission', 'Przerwa w treści książki'), ('book-fragment-list', 'Obok listy fragmentów książki'), ('blackout', 'Blackout'), ('crisis', 'Kryzysowa')], verbose_name='miejsce'),
+        ),
+        migrations.AddField(
+            model_name='banner',
+            name='campaign',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='annoy.campaign'),
+        ),
+    ]
index 0d88792..6af1cc0 100644 (file)
@@ -8,8 +8,18 @@ from django.utils.timezone import now
 from .places import PLACES, PLACE_CHOICES, STYLES
 
 
+class Campaign(models.Model):
+    name = models.CharField(max_length=255, help_text='Dla zespołu')
+    image = models.FileField('obraz', upload_to='annoy/banners/', blank=True)
+
+    def __str__(self):
+        return self.name
+
+
 class Banner(models.Model):
     place = models.SlugField('miejsce', choices=PLACE_CHOICES)
+    campaign = models.ForeignKey(Campaign, models.PROTECT, null=True, blank=True)
+
     style = models.CharField(
         'styl', max_length=255, blank=True,
         choices=STYLES,
@@ -32,6 +42,8 @@ class Banner(models.Model):
         help_text='Bannery z wyższym priorytetem mają pierwszeństwo.')
     since = models.DateTimeField('od', null=True, blank=True)
     until = models.DateTimeField('do', null=True, blank=True)
+    books = models.ManyToManyField('catalogue.Book', blank=True)
+
     target = models.IntegerField('cel', null=True, blank=True)
     progress = models.IntegerField('postęp', null=True, blank=True)
     show_members = models.BooleanField('widoczny dla członków klubu', default=False)
@@ -49,8 +61,20 @@ class Banner(models.Model):
     def get_text(self):
         return Template(self.text).render(Context())
 
+    def get_image(self):
+        if self.campaign and self.campaign.image:
+            return self.campaign.image
+        else:
+            return self.image
+
+    def is_external(self):
+        return (self.url and
+                not self.url.startswith('/') and
+                not self.url.startswith('https://wolnelektury.pl/')
+                )
+
     @classmethod
-    def choice(cls, place, request, exemptions=True):
+    def choice(cls, place, request, exemptions=True, book=None):
         Membership = apps.get_model('club', 'Membership')
 
         if exemptions and hasattr(request, 'annoy_banner_exempt'):
@@ -68,6 +92,12 @@ class Banner(models.Model):
             until__lt=n
         ).order_by('-priority', '?')
 
+        if book is None:
+            banners = banners.filter(books=None)
+        else:
+            banners = banners.filter(models.Q(books=None) | models.Q(books=book))
+            
+        
         if not request.user.is_authenticated:
             banners = banners.filter(only_authenticated=False)
 
index 8fa767b..0de0483 100644 (file)
@@ -1,6 +1,7 @@
 PLACE_DEFINITIONS = [
     ('top', 'U góry wszystkich stron', True),
     ('book-page', 'Strona książki', False),
+    ('book-page-center', 'Strona książki, środek', False),
     ('book-text-intermission', 'Przerwa w treści książki', False),
     ('book-fragment-list', 'Obok listy fragmentów książki', False),
     ('blackout', 'Blackout', True, (
index 4b2bdcf..9a67fcd 100644 (file)
@@ -11,7 +11,7 @@
               annoy-banner
               annoy-banner_{{ banner.place }}
               annoy-banner-style_{{ banner.style }}
-              {% if banner.image %}with-image{% endif %}
+              {% if banner.get_image %}with-image{% endif %}
               {% if banner.smallfont %}banner-smallfont{% endif %}
               "
         id="annoy-banner-{{ banner.id }}"
            {% if banner.background_color %}background-color: {{ banner.background_color }};{% endif %}
         ">
     {% if not banner.action_label %}
-      <a href="{{ banner.url }}">
+    <a
+      {% if banner.is_external %}target="_blank"{% endif %}
+      href="{{ banner.url }}">
     {% endif %}
     <div class="annoy-banner-inner">
 
-      {% if banner.image %}
-        <img src="{{ banner.image.url }}">
+      {% if banner.get_image %}
+      <div>
+        <img src="{{ banner.get_image.url }}">
+       </div>
       {% endif %}
       <div class="text">
         {{ banner.get_text|safe|linebreaks }}
       </div>
 
       {% if banner.action_label %}
-        <a class="action" href="{{ banner.url }}">
+      <a class="action"
+        {% if banner.is_external %}target="_blank"{% endif %}
+        href="{{ banner.url }}">
           {{ banner.action_label }}
         </a>
       {% endif %}
diff --git a/src/annoy/templates/annoy/banner_top.html b/src/annoy/templates/annoy/banner_top.html
new file mode 100644 (file)
index 0000000..2a62d11
--- /dev/null
@@ -0,0 +1,51 @@
+{% load l10n %}
+
+{% if banner %}
+<div class="
+           annoy-banner_{{ banner.place }}-container
+            annoy-banner-style_{{ banner.style }}
+           ">
+  <div class="
+              annoy-banner
+              annoy-banner_{{ banner.place }}
+              {% if banner.image %}with-image{% endif %}
+              {% if banner.smallfont %}banner-smallfont{% endif %}
+              "
+        id="annoy-banner-{{ banner.id }}"
+       style="
+           {% if banner.text_color %}color: {{ banner.text_color }};{% endif %}
+           {% if banner.background_color %}background-color: {{ banner.background_color }};{% endif %}
+
+        ">
+    <div class="annoy-banner-inner">
+
+      <div class="image-box">
+       {% if banner.image %}
+       <img src="{{ banner.image.url }}">
+       {% endif %}
+      </div>
+
+      <div class="text-box">
+       <div class="text">
+          {{ banner.get_text|safe|linebreaks }}
+       </div>
+
+       <div class="state-box">
+         <div class="action-box">
+           {% if banner.action_label %}
+            <a class="action" href="{{ banner.url }}">
+              {{ banner.action_label }}
+            </a>
+           {% endif %}
+         </div>
+       </div>
+      </div>
+
+
+
+    </div>
+  </div>
+  </div>
+
+{% endif %}
+
index 40f7511..cdf4dbc 100644 (file)
@@ -7,8 +7,8 @@ register = template.Library()
 
 
 @register.inclusion_tag('annoy/banner.html', takes_context=True)
-def annoy_banner(context, place):
-    banners = Banner.choice(place, request=context['request'])
+def annoy_banner(context, place, **kwargs):
+    banners = Banner.choice(place, request=context['request'], **kwargs)
     return {
         'banner': banners.first(),
         'closable': PLACES.get(place, False),
@@ -22,6 +22,13 @@ def annoy_banner_blackout(context):
         'closable': True,
     }
 
+@register.inclusion_tag('annoy/banner_top.html', takes_context=True)
+def annoy_banner_top(context):
+    banners = Banner.choice('top', request=context['request'])
+    return {
+        'banner': banners.first(),
+        'closable': True,
+    }
 
 @register.inclusion_tag('annoy/banners.html', takes_context=True)
 def annoy_banners(context, place):
diff --git a/src/api/migrations/0008_alter_token_token_type.py b/src/api/migrations/0008_alter_token_token_type.py
new file mode 100644 (file)
index 0000000..b2ba7ba
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-02-24 15:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0007_alter_token_consumer'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='token',
+            name='token_type',
+            field=models.IntegerField(choices=[(1, 'Request'), (2, 'Access'), (3, 'Refresh')]),
+        ),
+    ]
diff --git a/src/api/migrations/0009_sessiontransfertoken.py b/src/api/migrations/0009_sessiontransfertoken.py
new file mode 100644 (file)
index 0000000..a277811
--- /dev/null
@@ -0,0 +1,27 @@
+# Generated by Django 4.0.8 on 2025-11-19 13:00
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('api', '0008_alter_token_token_type'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='SessionTransferToken',
+            fields=[
+                ('token', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('expires_at', models.DateTimeField()),
+                ('used', models.BooleanField(default=False)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
index 4f05565..eade037 100644 (file)
@@ -1,12 +1,14 @@
 # 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 timedelta
+import uuid
 from django.conf import settings
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.db import models
 from django.db.models.signals import pre_delete
-
+from django.utils import timezone
 from catalogue.models import Book, Tag
 
 
@@ -121,3 +123,26 @@ class Token(models.Model):
 
     def __str__(self):
         return "%s Token %s for %s" % (self.get_token_type_display(), self.key, self.consumer)
+
+
+class SessionTransferToken(models.Model):
+    token = models.UUIDField(default=uuid.uuid4, primary_key=True, editable=False)
+    user = models.ForeignKey(
+        settings.AUTH_USER_MODEL, models.CASCADE)
+    created_at = models.DateTimeField(auto_now_add=True)
+    expires_at = models.DateTimeField()
+    used = models.BooleanField(default=False)
+
+    @classmethod
+    def create_for_user(cls, user, lifetime_seconds=30):
+        return cls.objects.create(
+            user=user,
+            expires_at=timezone.now() + timedelta(seconds=lifetime_seconds)
+        )
+
+    def is_valid(self):
+        if self.used:
+            return False
+        if timezone.now() > self.expires_at:
+            return False
+        return True
index 0c4ae09..0436622 100644 (file)
@@ -3,6 +3,9 @@ from rest_framework.response import Response
 
 
 class WLLimitOffsetPagination(LimitOffsetPagination):
+    def get_results(self, data):
+        return data['member']
+
     def get_paginated_response(self, data):
         return Response({
             "member": data,
index 4ba660e..8d160b0 100644 (file)
@@ -37,8 +37,43 @@ class LoginSerializer(serializers.Serializer):
 class RegisterSerializer(serializers.Serializer):
     email = serializers.CharField()
     password = serializers.CharField(style={'input_type': 'password'})
-    options = serializers.ListField(child=serializers.IntegerField())
+    options = serializers.ListField(child=serializers.IntegerField(), required=False)
 
 
 class RefreshTokenSerializer(serializers.Serializer):
     refresh_token = serializers.CharField(style={'input_type': 'password'})
+
+
+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
+
+
+class ResetPasswordSerializer(serializers.Serializer):
+    email = serializers.EmailField()
index de3dba7..182e0dd 100644 (file)
@@ -15,7 +15,17 @@ 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('password-reset/', views.ResetPasswordView.as_view()),
+    path("session-transfer/", views.SessionTransferTokenView.as_view()),
+    path("session-transfer/continue/", views.ConsumeSessionTransferTokenView.as_view(), name="api-session-continue"),
+
     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 08d1650..5d52678 100644 (file)
@@ -2,12 +2,16 @@
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 from time import time
-from django.contrib.auth import authenticate
+from allauth.account.forms import ResetPasswordForm
+from allauth.account.utils import filter_users_by_email
+from django.conf import settings
+from django.contrib.auth import authenticate, login
 from django.contrib.auth.decorators import login_required
+from django.contrib.auth.models import User
 from django import forms
-from django.http import HttpResponse
+from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
 from django.http import Http404
-from django.shortcuts import render
+from django.shortcuts import redirect, render
 from django.views.generic.base import View
 from oauthlib.common import urlencode, generate_token
 from oauthlib.oauth1 import RequestTokenEndpoint, AccessTokenEndpoint
@@ -17,7 +21,8 @@ from rest_framework.response import Response
 from rest_framework.views import APIView
 from rest_framework.generics import GenericAPIView, RetrieveAPIView, get_object_or_404
 from catalogue.models import Book
-from .models import BookUserData, KEY_SIZE, SECRET_SIZE, Token
+from .models import BookUserData, KEY_SIZE, SECRET_SIZE, Token, SessionTransferToken
+from social.models import UserConfirmation
 from . import serializers
 from .request_validator import PistonRequestValidator
 from .utils import oauthlib_request, oauthlib_response, vary_on_auth
@@ -227,7 +232,7 @@ class BlogView(APIView):
 
 
 
-class RegisterView(APIView):
+class RegisterView(GenericAPIView):
     serializer_class = serializers.RegisterSerializer
 
     def get(self, request):
@@ -245,8 +250,46 @@ class RegisterView(APIView):
         })
 
     def post(self, request):
-        pass
-    
+        if not settings.FEATURE_API_REGISTER:
+            return Response(
+                {
+                    "detail": "Rejestracja aktualnie niedostępna."
+                },
+                status=400
+            )
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+
+        email = d['email']
+
+        user = User(
+            username=email,
+            email=email,
+            is_active=True
+        )
+        user.set_password(d['password'])
+
+        if settings.FEATURE_CONFIRM_USER:
+            user.is_active = False
+
+        try:
+            assert not filter_users_by_email(email)
+            user.save()
+        except:
+            return Response(
+                {
+                    "detail": "Nie można utworzyć konta.",
+                },
+                status=400
+            )
+
+        if settings.FEATURE_CONFIRM_USER:
+            UserConfirmation.request(user)
+        return Response({
+            'emailConfirmationRequired': settings.FEATURE_CONFIRM_USER,
+        })
+
 
 class RefreshTokenView(APIView):
     serializer_class = serializers.RefreshTokenSerializer
@@ -284,4 +327,102 @@ class RefreshTokenView(APIView):
 
 
 class RequestConfirmView(APIView):
-    pass
+    serializer_class = serializers.RequestConfirmSerializer
+
+    def post(self, request):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        d = serializer.validated_data
+
+        try:
+            user = User.objects.get(
+                username=d['email'],
+                is_active=False
+            )
+        except User.DoesNotExist:
+            raise Http404
+
+        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({})
+
+
+class ResetPasswordView(GenericAPIView):
+    serializer_class = serializers.ResetPasswordSerializer
+
+    def post(self, request):
+        serializer = serializers.ResetPasswordSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        form = ResetPasswordForm({"email": serializer.validated_data['email']})
+        form.is_valid()
+        form.save(request)
+        return Response({})
+
+
+class SessionTransferTokenView(APIView):
+    permission_classes = [IsAuthenticated]
+
+    def post(self, request):
+        ott = SessionTransferToken.create_for_user(request.user)
+        return Response({
+            "token": str(ott.token)
+        })
+
+
+class ConsumeSessionTransferTokenView(View):
+    def get(self, request):
+        token_str = request.GET.get("token")
+        next_url = request.GET.get("next", "/") #TODO: validate
+
+        if not token_str:
+            return HttpResponseBadRequest("Missing token")
+
+        try:
+            ott = SessionTransferToken.objects.get(token=token_str)
+        except SessionTransferToken.DoesNotExist:
+            return HttpResponseBadRequest("Invalid token")
+
+        if not ott.is_valid():
+            return HttpResponseForbidden("Token expired or already used")
+
+        # Mark token as used
+        ott.used = True
+        ott.save(update_fields=["used"])
+
+        # Log in the user via Django session
+        login(request, ott.user)
+
+        return redirect(next_url)
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..b500a66
--- /dev/null
@@ -0,0 +1,69 @@
+from api.utils import never_cache
+
+from django.db.models import Q
+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 SAFE_METHODS, IsAuthenticated, IsAuthenticatedOrReadOnly
+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 = [IsAuthenticatedOrReadOnly]
+    serializer_class = BookmarkSerializer
+    lookup_field = 'uuid'
+
+    def get_queryset(self):
+        if self.request.method in SAFE_METHODS:
+            q = Q(deleted=False)
+            if self.request.user.is_authenticated:
+                q |= Q(user=self.request.user)
+            return models.Bookmark.objects.filter(q)
+        else:
+            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..098a8b0 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'
+            self.audio_timestamp = self.book.sync_elid(self.anchor)
+        if self.audio_timestamp:
+            self.mode = 'audio'
+            self.anchor = self.book.sync_ts(self.audio_timestamp) or ''
+        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 0ff5104..50390df 100644 (file)
@@ -62,6 +62,8 @@ def bookmark_delete(request, uuid):
 
 @cache.never_cache
 def quotes(request):
+    if not request.user.is_authenticated:
+        return JsonResponse({})
     try:
         slug = request.headers['Referer'].rsplit('.', 1)[0].rsplit('/', 1)[-1]
     except:
index ee3c506..9fa05f5 100644 (file)
@@ -40,6 +40,96 @@ class TagDetailSerializer(serializers.ModelSerializer):
         ]
 
 
+class AuthorItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_author',
+        view_args=('slug',)
+    )
+
+    class Meta:
+        model = Tag
+        fields = [
+            'id', 'url', 'href', 'name', 'slug'
+        ]
+
+class AuthorSerializer(AuthorItemSerializer):
+    photo_thumb = ThumbnailField('139x193', source='photo')
+
+    class Meta:
+        model = Tag
+        fields = [
+            'id', 'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'genitive', 'photo', 'photo_thumb', 'photo_attribution',
+        ]
+
+class EpochItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_epoch',
+        view_args=('slug',)
+    )
+    class Meta:
+        model = Tag
+        fields = ['id', 'url', 'href', 'name', 'slug']
+
+class EpochSerializer(EpochItemSerializer):
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'adjective_feminine_singular', 'adjective_nonmasculine_plural',
+        ]
+
+class GenreItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_genre',
+        view_args=('slug',)
+    )
+    class Meta:
+        model = Tag
+        fields = ['id', 'url', 'href', 'name', 'slug']
+
+class GenreSerializer(GenreItemSerializer):
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            'plural', 'genre_epoch_specific',
+        ]
+
+class KindItemSerializer(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(
+        view_name='catalogue_api_kind',
+        view_args=('slug',)
+    )
+    class Meta:
+        model = Tag
+        fields = ['id', 'url', 'href', 'name', 'slug']
+
+class KindSerializer(KindItemSerializer):
+    class Meta:
+        model = Tag
+        fields = [
+            'url', 'href', 'name', 'slug', 'sort_key', 'description',
+            '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='*')
 
@@ -55,19 +145,71 @@ class BookSerializer2(serializers.ModelSerializer):
     mobi = EmbargoURLField(source='mobi_url')
     pdf = EmbargoURLField(source='pdf_url')
 
+    authors = AuthorItemSerializer(many=True)
+    translators = AuthorItemSerializer(many=True)
+    epochs = EpochItemSerializer(many=True)
+    genres = GenreItemSerializer(many=True)
+    kinds = KindItemSerializer(many=True)
+    parent = serializers.HyperlinkedRelatedField(
+        read_only=True,
+        view_name='catalogue_api_book',
+        lookup_field='slug'
+    )
+    children = serializers.SerializerMethodField()
+    audiences = serializers.ListField(source='audiences_pl')
+
     class Meta:
         model = Book
         fields = [
-            'full_sort_key', 'title',
+            'slug', 'title', 'full_sort_key',
             'href', 'url', 'language',
-            #'epochs', 'genres', 'kinds', 'authors', 'translators',
-            #'children',
+            'authors', 'translators',
+            'epochs', 'genres', 'kinds',
+            'children',
             'parent', 'preview',
             'epub', 'mobi', 'pdf', 'html', 'txt', 'fb2', 'xml',
             'cover_thumb', 'cover',
             'isbn_pdf', 'isbn_epub', 'isbn_mobi',
+            'abstract',
+            'has_mp3_file', 'has_sync_file',
+            'elevenreader_link', 'content_warnings', 'audiences',
+            'changed_at', 'read_time', 'pages', 'redakcja'
         ]
 
+    def get_children(self, obj):
+        return list(obj.get_children().values('slug', 'title'))
+
+class BookSerializer11Labs(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    href = AbsoluteURLField(view_name='catalogue_api_book', view_args=['slug'])
+    html = EmbargoURLField(source='html_nonotes_url')
+
+    authors = AuthorItemSerializer(many=True)
+    translators = AuthorItemSerializer(many=True)
+    epochs = EpochItemSerializer(many=True)
+    genres = GenreItemSerializer(many=True)
+    kinds = KindItemSerializer(many=True)
+    parent = serializers.HyperlinkedRelatedField(
+        read_only=True,
+        view_name='catalogue_api_book',
+        lookup_field='slug'
+    )
+
+    class Meta:
+        model = Book
+        fields = [
+            'slug', 'title', 'full_sort_key',
+            'href', 'url', 'language',
+            'authors', 'translators',
+            'epochs', 'genres', 'kinds',
+            #'children',
+            'parent', 'preview',
+            'html',
+            'cover_thumb', 'cover',
+            'isbn_pdf', 'isbn_epub', 'isbn_mobi',
+        ]
+
+
 class BookSerializer(LegacyMixin, serializers.ModelSerializer):
     author = serializers.CharField(source='author_unicode')
     kind = serializers.CharField(source='kind_unicode')
@@ -116,6 +258,17 @@ class MediaSerializer(LegacyMixin, serializers.ModelSerializer):
         legacy_non_null_fields = ['director', 'artist']
 
 
+class MediaSerializer2(MediaSerializer):
+    size = serializers.SerializerMethodField()
+
+    class Meta:
+        model = BookMedia
+        fields = ['url', 'director', 'type', 'name', 'part_name', 'artist', 'duration', 'size']
+
+    def get_size(self, obj):
+        return obj.file.size
+
+
 class BookDetailSerializer(LegacyMixin, serializers.ModelSerializer):
     url = AbsoluteURLField()
 
@@ -229,3 +382,18 @@ class FragmentDetailSerializer(serializers.ModelSerializer):
     class Meta:
         model = Fragment
         fields = ['book', 'anchor', 'text', 'url', 'themes']
+
+
+class FragmentSerializer2(serializers.ModelSerializer):
+    url = AbsoluteURLField()
+    html = serializers.CharField(source='text')
+
+    class Meta:
+        model = Fragment
+        fields = ['anchor', 'html', 'url']
+
+
+class FilterTagSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Tag
+        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..b803e73
--- /dev/null
@@ -0,0 +1,264 @@
+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', True, {'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': ('_ignore', 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),
+
+    'werset': ('p', True, {'class': 'werset'}, None, True),
+    'br': ('br', False, None, None, None),
+    'indeks_dolny': ('em', True, {'class': 'indeks_dolny'}, None, False),
+    'mat': ('span', True, {'class': 'mat'}, None, False),
+
+    'mfenced': ('math_mfenced', True, None, None, False),
+    'mfrac': ('math_mfrac', True, None, None, False),
+    'mrow': ('math_mrow', True, None, None, False),
+    'mi': ('math_mi', True, None, None, False),
+    'mn': ('math_mn', True, None, None, False),
+    'mo': ('math_mo', True, None, None, False),
+    'msup': ('math_msup', True, None, None, False),
+
+    'list': ('blockquote', False, {'class': 'list'}, None, None),
+    'wywiad_pyt': ('blockquote', False, {'class': 'wywiad_pyt'}, None, None),
+    'wywiad_odp': ('blockquote', False, {'class': 'wywiad_odp'}, None, None),
+    'rownolegle': ('blockquote', False, {'class': 'rownolegle'}, None, None),
+    'animacja': ('div', False, {'class': 'animacja'}, None, None),
+    'data': ('div', True, {'class': 'data'}, None, None),
+    'podpis': ('div', True, {'class': 'podpis'}, None, None),
+    'naglowek_listu': ('div', True, {'class': 'naglowek_listu'}, None, None),
+    'pozdrowienie': ('div', True, {'class': 'pozdrowienie'}, None, None),
+    'adresat': ('div', True, {'class': 'adresat'}, None, None),
+    'tytul_oryg': ('div', True, {'class': 'tytul_oryg'}, None, None),
+    'miejsce_data': ('div', True, {'class': 'miejsce_data'}, None, None),
+    'audio': ('_ignore', False, None, None, None),
+    'www': ('a', True, {'class': 'www'}, {'href': '.text'}, False),
+
+    'tabela': ('table', False, None, None, None),
+    'tabelka': ('table', False, None, None, None),
+    'wiersz': ('tr', False, None, None, None),
+    'kol': ('td', True, None, None, None),
+
+    'ilustr': ('img', False, None, {'src': 'src'}, False),
+    'tab': ('span', False, {'class': 'tab'}, {'szer': 'szer'}, False),
+    
+}
+
+id_prefixes = {
+    'pa': 'fn',
+    'pe': 'fn',
+    'pr': 'fn',
+    'pt': 'fn',
+    'ptrad': 'fn',
+    'wers': 'f',
+    'wers_wciety': 'f',
+    'wers_cd': 'f',
+    'wers_akap': 'f',
+    'zastepnik_wersu': 'f',
+    'wers_do_prawej': 'f',
+    'wers_srodek': 'f',
+    'akap': 'f',
+    'akap_cd': 'f',
+    'akap_dialog': 'f',
+}
+
+
+#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('"', '”').replace('\n', ' ')
+    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(elem.tag, 'i')
+        S['id'][id_prefix] += 1
+        output['id'] = id_prefix + str(S['id'][id_prefix])
+        if elem.attrib.get('id'):
+            output['id'] = 'wl-' + elem.attrib.get('id')
+        if attrs:
+            output['attr'] = attrs.copy()
+        if attr_map:
+            output.setdefault('attr', {})
+            for k, v in attr_map.items():
+                if v == '.text':
+                    val = elem.text
+                else:
+                    val = elem.attrib[v]
+                output['attr'][k] = val
+        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 3fd0023..b8885af 100644 (file)
@@ -11,8 +11,66 @@ urlpatterns = [
          piwik_track_view(views.BookList2.as_view()),
          name='catalogue_api_book_list'
          ),
+    path('11labs/books/',
+         piwik_track_view(views.BookList11Labs.as_view()),
+         name='catalogue_api_book_list'
+         ),
     path('books/<slug:slug>/',
          piwik_track_view(views.BookDetail2.as_view()),
          name='catalogue_api_book'
          ),
+    path('books/<slug:slug>/fragment/',
+         piwik_track_view(views.BookFragmentView.as_view()),
+         name='catalogue_api_book_fragment'
+         ),
+    path('books/<slug:slug>/children/',
+         views.BookChildrenView.as_view()
+         ),
+    path('books/<slug:slug>/media/<slug:type>/',
+         views.BookMediaView.as_view()
+         ),
+    path('books/<slug:slug>.json',
+        views.BookJsonView.as_view()),
+    path('books/<slug:slug>/sync/',
+         views.BookSyncView.as_view()
+         ),
+    path('books/<slug:slug>/recommended/',
+         views.BookRecommendationsView.as_view()
+         ),
+
+    path('suggested-tags/',
+         piwik_track_view(views.SuggestedTags.as_view()),
+         name='catalogue_api_suggested_tags'
+         ),
+
+    path('authors/',
+         piwik_track_view(views.AuthorList.as_view()),
+         name="catalogue_api_author_list"),
+    path('authors/<slug:slug>/',
+         piwik_track_view(views.AuthorView.as_view()),
+         name='catalogue_api_author'),
+    path('epochs/',
+         piwik_track_view(views.EpochList.as_view()),
+         name="catalogue_api_epoch_list"),
+    path('epochs/<slug:slug>/',
+         piwik_track_view(views.EpochView.as_view()),
+         name='catalogue_api_epoch'),
+    path('kinds/',
+         piwik_track_view(views.KindList.as_view()),
+         name="catalogue_api_kind_list"),
+    path('kinds/<slug:slug>/',
+         piwik_track_view(views.KindView.as_view()),
+         name='catalogue_api_kind'),
+    path('genres/',
+         piwik_track_view(views.GenreList.as_view()),
+         name="catalogue_api_genre_list"),
+    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 abe8064..a4c0173 100644 (file)
@@ -6,9 +6,11 @@ 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
+from rest_framework import filters
 from rest_framework.generics import (ListAPIView, RetrieveAPIView,
                                      RetrieveUpdateAPIView, get_object_or_404)
 from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
@@ -17,6 +19,7 @@ from rest_framework import status
 from api.handlers import read_tags
 from api.utils import vary_on_auth
 from catalogue.forms import BookImportForm
+from catalogue.helpers import get_top_level_related_tags
 from catalogue.models import Book, Collection, Tag, Fragment, BookMedia
 from catalogue.models.tag import prefetch_relations
 from club.models import Membership
@@ -183,10 +186,38 @@ class BookList(LegacyListAPIView):
         return Response({}, status=status.HTTP_201_CREATED)
 
 
+class BookFilter(dfilters.FilterSet):
+    sort = dfilters.OrderingFilter(
+        fields=(
+            ('sort_key_author', 'alpha'),
+            ('popularity', 'popularity'),
+        )
+    )
+    tag = dfilters.ModelMultipleChoiceFilter(
+        field_name='tag_relations__tag',
+        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,
+    )
+    has_audio = dfilters.BooleanFilter()
+
+
 class BookList2(ListAPIView):
     permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
     queryset = Book.objects.none()  # Required for DjangoModelPermissions
     serializer_class = serializers.BookSerializer2
+    filter_backends = (
+        dfilters.DjangoFilterBackend,
+        filters.SearchFilter,
+    )
+    filterset_class = BookFilter
+    search_fields = [
+        'title',
+    ]
 
     def get_queryset(self):
         books = Book.objects.all()
@@ -196,6 +227,30 @@ class BookList2(ListAPIView):
         return books
 
 
+class BookRecommendationsView(ListAPIView):
+    serializer_class = serializers.BookSerializer2
+    pagination_class = None
+
+    def get_queryset(self):
+        book = get_object_or_404(
+            Book,
+            slug=self.kwargs['slug']
+        )
+        return book.get_recommended(limit=3)
+
+
+class BookList11Labs(BookList2):
+    serializer_class = serializers.BookSerializer11Labs
+
+    def get_queryset(self):
+        books = Book.objects.all()
+        books = books.filter(findable=True)
+        books = books.filter(license='')
+        books = order_books(books, True)
+
+        return books
+
+
 @vary_on_auth  # Because of 'liked'.
 class BookDetail(RetrieveAPIView):
     queryset = Book.objects.all()
@@ -209,6 +264,18 @@ class BookDetail2(RetrieveAPIView):
     serializer_class = serializers.BookSerializer2
 
 
+class BookSyncView(RetrieveAPIView):
+    queryset = Book.objects.all()
+    lookup_field = 'slug'
+
+    def retrieve(self, request, *args, **kwargs):
+        instance = self.get_object()
+        return Response([
+            {"id": i, "timestamp": ts}
+            for (i, ts) in instance.get_sync()
+        ])
+
+    
 @vary_on_auth  # Because of embargo links.
 class EbookList(BookList):
     serializer_class = serializers.EbookSerializer
@@ -328,6 +395,51 @@ class TagCategoryView(LegacyListAPIView):
 
         return tags
 
+class AuthorList(ListAPIView):
+    serializer_class = serializers.AuthorSerializer
+    queryset = Tag.objects.filter(category='author')
+
+class AuthorView(RetrieveAPIView):
+    serializer_class = serializers.AuthorSerializer
+    queryset = Tag.objects.filter(category='author')
+    lookup_field = 'slug'
+
+class EpochList(ListAPIView):
+    serializer_class = serializers.EpochSerializer
+    queryset = Tag.objects.filter(category='epoch')
+
+class EpochView(RetrieveAPIView):
+    serializer_class = serializers.EpochSerializer
+    queryset = Tag.objects.filter(category='epoch')
+    lookup_field = 'slug'
+
+class GenreList(ListAPIView):
+    serializer_class = serializers.GenreSerializer
+    queryset = Tag.objects.filter(category='genre')
+
+class GenreView(RetrieveAPIView):
+    serializer_class = serializers.GenreSerializer
+    queryset = Tag.objects.filter(category='genre')
+    lookup_field = 'slug'
+
+class KindList(ListAPIView):
+    serializer_class = serializers.KindSerializer
+    queryset = Tag.objects.filter(category='kind')
+
+class KindView(RetrieveAPIView):
+    serializer_class = serializers.KindSerializer
+    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]
@@ -421,3 +533,56 @@ class FragmentView(RetrieveAPIView):
             book__slug=self.kwargs['book'],
             anchor=self.kwargs['anchor']
         )
+
+
+class SuggestedTags(ListAPIView):
+    serializer_class = serializers.FilterTagSerializer
+
+    def get_queryset(self):
+        tag_ids = self.request.GET.getlist('tag', [])
+        search = self.request.GET.get('search')
+        tags = [get_object_or_404(Tag, id=tid) for tid in tag_ids]
+        related_tags = list(t.id for t in get_top_level_related_tags(tags))
+        tags = Tag.objects.filter(id__in=related_tags)
+        if search:
+            tags = tags.filter(name__icontains=search)
+        return tags
+
+
+class BookFragmentView(RetrieveAPIView):
+    serializer_class = serializers.FragmentSerializer2
+
+    def get_object(self):
+        book = get_object_or_404(Book, slug=self.kwargs['slug'])
+        return book.choose_fragment()
+
+
+class BookChildrenView(ListAPIView):
+    serializer_class = serializers.BookSerializer2
+    pagination_class = None
+
+    def get_queryset(self):
+        book = get_object_or_404(Book, slug=self.kwargs['slug'])
+        return book.get_children()
+
+
+class BookMediaView(ListAPIView):
+    serializer_class = serializers.MediaSerializer2
+    pagination_class = None
+
+    def get_queryset(self):
+        return BookMedia.objects.filter(
+            book__slug=self.kwargs['slug'],
+            type=self.kwargs['type']
+        ).order_by('index')
+
+
+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 9c5696f..2d35357 100644 (file)
@@ -398,6 +398,26 @@ class HtmlField(EbookField):
         return wldoc.as_html(gallery_path=gal_path, gallery_url=gal_url, base_url=absolute_url(gal_url))
 
 
+class HtmlNonotesField(EbookField):
+    ext = 'html'
+    for_parents = False
+    directory = 'html_nonotes'
+
+    @staticmethod
+    def transform(wldoc, book):
+        # ugly, but we can't use wldoc.book_info here
+        from librarian import DCNS
+        url_elem = wldoc.edoc.getroot().find('.//' + DCNS('identifier.url'))
+        if url_elem is None:
+            gal_url = ''
+            gal_path = ''
+        else:
+            slug = url_elem.text.rstrip('/').rsplit('/', 1)[1]
+            gal_url = gallery_url(slug=slug)
+            gal_path = gallery_path(slug=slug)
+        return wldoc.as_html(gallery_path=gal_path, gallery_url=gal_url, base_url=absolute_url(gal_url), flags=['nonotes'])
+
+
 class CoverField(EbookField):
     ext = 'jpg'
     directory = 'cover'
diff --git a/src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py b/src/catalogue/migrations/0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more.py
new file mode 100644 (file)
index 0000000..bd297d4
--- /dev/null
@@ -0,0 +1,30 @@
+# Generated by Django 4.0.8 on 2025-02-26 14:46
+
+import catalogue.fields
+from django.db import migrations, models
+import fnpdjango.storage
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0048_remove_collection_kind_remove_tag_for_books_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='html_nonotes_file',
+            field=catalogue.fields.HtmlNonotesField(etag_field_name='html_nonotes_file_etag', storage=fnpdjango.storage.BofhFileSystemStorage()),
+        ),
+        migrations.AddField(
+            model_name='book',
+            name='html_nonotes_file_etag',
+            field=models.CharField(db_index=True, default='', editable=False, max_length=255),
+        ),
+        migrations.AddField(
+            model_name='book',
+            name='license',
+            field=models.CharField(blank=True, db_index=True, max_length=255, verbose_name='licencja'),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0050_book_narrators.py b/src/catalogue/migrations/0050_book_narrators.py
new file mode 100644 (file)
index 0000000..388f150
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-08-12 09:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0049_book_html_nonotes_file_book_html_nonotes_file_etag_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='narrators',
+            field=models.ManyToManyField(blank=True, related_name='narrated', to='catalogue.tag'),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0051_book_has_audio.py b/src/catalogue/migrations/0051_book_has_audio.py
new file mode 100644 (file)
index 0000000..e333bff
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2025-08-12 10:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0050_book_narrators'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='has_audio',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0052_book_pages_book_read_time.py b/src/catalogue/migrations/0052_book_pages_book_read_time.py
new file mode 100644 (file)
index 0000000..dd66d27
--- /dev/null
@@ -0,0 +1,23 @@
+# Generated by Django 4.0.8 on 2026-02-17 14:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0051_book_has_audio'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='pages',
+            field=models.FloatField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='book',
+            name='read_time',
+            field=models.FloatField(blank=True, null=True),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0053_alter_book_pages_alter_book_read_time.py b/src/catalogue/migrations/0053_alter_book_pages_alter_book_read_time.py
new file mode 100644 (file)
index 0000000..ea4339e
--- /dev/null
@@ -0,0 +1,23 @@
+# Generated by Django 4.0.8 on 2026-02-17 14:38
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0052_book_pages_book_read_time'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='book',
+            name='pages',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='book',
+            name='read_time',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0054_alter_book_sort_key_alter_book_sort_key_author_and_more.py b/src/catalogue/migrations/0054_alter_book_sort_key_alter_book_sort_key_author_and_more.py
new file mode 100644 (file)
index 0000000..d59c8ef
--- /dev/null
@@ -0,0 +1,28 @@
+# Generated by Django 4.0.8 on 2026-02-18 12:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0053_alter_book_pages_alter_book_read_time'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='book',
+            name='sort_key',
+            field=models.CharField(db_collation='C', db_index=True, editable=False, max_length=120, verbose_name='klucz sortowania'),
+        ),
+        migrations.AlterField(
+            model_name='book',
+            name='sort_key_author',
+            field=models.CharField(db_collation='C', db_index=True, default='', editable=False, max_length=120, verbose_name='klucz sortowania wg autora'),
+        ),
+        migrations.AlterField(
+            model_name='tag',
+            name='sort_key',
+            field=models.CharField(db_collation='C', db_index=True, max_length=120, verbose_name='klucz sortowania'),
+        ),
+    ]
index 29e3754..375c79a 100644 (file)
@@ -7,6 +7,8 @@ from datetime import date, timedelta
 from random import randint
 import os.path
 import re
+from slugify import slugify
+from sortify import sortify
 from urllib.request import urlretrieve
 from django.apps import apps
 from django.conf import settings
@@ -36,13 +38,14 @@ bofh_storage = BofhFileSystemStorage()
 class Book(models.Model):
     """Represents a book imported from WL-XML."""
     title = models.CharField('tytuł', max_length=32767)
-    sort_key = models.CharField('klucz sortowania', max_length=120, db_index=True, editable=False)
+    sort_key = models.CharField('klucz sortowania', max_length=120, db_index=True, db_collation='C', editable=False)
     sort_key_author = models.CharField(
-        'klucz sortowania wg autora', max_length=120, db_index=True, editable=False, default='')
+        'klucz sortowania wg autora', max_length=120, db_index=True, db_collation='C', editable=False, default='')
     slug = models.SlugField('slug', max_length=120, db_index=True, unique=True)
     common_slug = models.SlugField('wspólny slug', max_length=120, db_index=True)
     language = models.CharField('kod języka', max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
     description = models.TextField('opis', blank=True)
+    license = models.CharField('licencja', max_length=255, blank=True, db_index=True)
     abstract = models.TextField('abstrakt', blank=True)
     toc = models.TextField('spis treści', blank=True)
     created_at = models.DateTimeField('data utworzenia', auto_now_add=True, db_index=True)
@@ -62,6 +65,7 @@ class Book(models.Model):
     # files generated during publication
     xml_file = fields.XmlField(storage=bofh_storage, with_etag=False)
     html_file = fields.HtmlField(storage=bofh_storage)
+    html_nonotes_file = fields.HtmlNonotesField(storage=bofh_storage)
     fb2_file = fields.Fb2Field(storage=bofh_storage)
     txt_file = fields.TxtField(storage=bofh_storage)
     epub_file = fields.EpubField(storage=bofh_storage)
@@ -79,7 +83,7 @@ class Book(models.Model):
         'okładka dla Ebookpoint')
 
     ebook_formats = constants.EBOOK_FORMATS
-    formats = ebook_formats + ['html', 'xml']
+    formats = ebook_formats + ['html', 'xml', 'html_nonotes']
 
     parent = models.ForeignKey('self', models.CASCADE, blank=True, null=True, related_name='children')
     ancestor = models.ManyToManyField('self', blank=True, editable=False, related_name='descendant', symmetrical=False)
@@ -90,9 +94,13 @@ 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)
+    read_time = models.IntegerField(blank=True, null=True)
+    pages = models.IntegerField(blank=True, null=True)
+    
     html_built = django.dispatch.Signal()
     published = django.dispatch.Signal()
 
@@ -181,6 +189,10 @@ class Book(models.Model):
     def isbn_mobi(self):
         return self.get_extra_info_json().get('isbn_mobi')
 
+    @property
+    def redakcja(self):
+        return self.get_extra_info_json().get('about')
+    
     def is_accessible_to(self, user):
         if not self.preview:
             return True
@@ -264,17 +276,6 @@ class Book(models.Model):
             return sibling.get_first_text()
         return self.parent.get_next_text(inside=False)
 
-    def get_child_audiobook(self):
-        BookMedia = apps.get_model('catalogue', 'BookMedia')
-        if not BookMedia.objects.filter(book__ancestor=self).exists():
-            return None
-        for child in self.children.order_by('parent_number').all():
-            if child.has_mp3_file():
-                return child
-            child_sub = child.get_child_audiobook()
-            if child_sub is not None:
-                return child_sub
-
     def get_siblings(self):
         if not self.parent:
             return []
@@ -335,9 +336,6 @@ class Book(models.Model):
         else:
             return self.media.filter(type=type_).exists()
 
-    def has_audio(self):
-        return self.has_media('mp3')
-
     def get_media(self, type_):
         if self.has_media(type_):
             if type_ in Book.formats:
@@ -375,6 +373,9 @@ class Book(models.Model):
     def html_url(self):
         return self.media_url('html')
 
+    def html_nonotes_url(self):
+        return self.media_url('html_nonotes')
+
     def pdf_url(self):
         return self.media_url('pdf')
 
@@ -416,7 +417,69 @@ class Book(models.Model):
     def has_sync_file(self):
         return settings.FEATURE_SYNCHRO and self.has_media("sync")
 
+    def build_sync_file(self):
+        from lxml import html
+        from django.core.files.base import ContentFile
+        with self.html_file.open('rb') as f:
+            h = html.fragment_fromstring(f.read().decode('utf-8'))
+
+        durations = [
+            m['mp3'].duration
+            for m in self.get_audiobooks()[0]
+        ]
+        if settings.MOCK_DURATIONS:
+            durations = settings.MOCK_DURATIONS
+
+        sync = []
+        ts = None
+        sid = 1
+        dirty = False
+        for elem in h.iter():
+            if elem.get('data-audio-ts'):
+                part, ts = int(elem.get('data-audio-part')), float(elem.get('data-audio-ts'))
+                ts = str(round(sum(durations[:part - 1]) + ts, 3))
+                # check if inside verse
+                p = elem.getparent()
+                while p is not None:
+                    # Workaround for missing ids.
+                    if 'verse' in p.get('class', ''):
+                        if not p.get('id'):
+                            p.set('id', f'syn{sid}')
+                            dirty = True
+                            sid += 1
+                        sync.append((ts, p.get('id')))
+                        ts = None
+                        break
+                    p = p.getparent()
+            elif ts:
+                cls = elem.get('class', '')
+                # Workaround for missing ids.
+                if 'paragraph' in cls or 'verse' in cls or elem.tag in ('h1', 'h2', 'h3', 'h4'):
+                    if not elem.get('id'):
+                        elem.set('id', f'syn{sid}')
+                        dirty = True
+                        sid += 1
+                    sync.append((ts, elem.get('id')))
+                    ts = None
+        if dirty:
+            htext = html.tostring(h, encoding='utf-8')
+            with open(self.html_file.path, 'wb') as f:
+                f.write(htext)
+        try:
+            bm = self.media.get(type='sync')
+        except:
+            bm = BookMedia(book=self, type='sync')
+        sync = (
+            '27\n' + '\n'.join(
+                f'{s[0]}\t{sync[i+1][0]}\t{s[1]}' for i, s in enumerate(sync[:-1])
+            )).encode('latin1')
+        bm.file.save(
+            None, ContentFile(sync)
+            )
+
     def get_sync(self):
+        if not self.has_sync_file():
+            return []
         with self.get_media('sync').first().file.open('r') as f:
             sync = f.read().split('\n')
         offset = float(sync[0])
@@ -426,8 +489,22 @@ class Book(models.Model):
                 continue
             start, end, elid = line.split()
             items.append([elid, float(start) + offset])
-        return json.dumps(items)
-    
+        return items
+
+    def sync_ts(self, ts):
+        elid = None
+        for cur_id, t in self.get_sync():
+            if ts >= t:
+                elid = cur_id
+            else:
+                break
+        return elid
+
+    def sync_elid(self, elid):
+        for cur_id, t in self.get_sync():
+            if cur_id == elid:
+                return t
+
     def has_audio_epub_file(self):
         return self.has_media("audio.epub")
 
@@ -439,7 +516,7 @@ class Book(models.Model):
     def media_audio_epub(self):
         return self.get_media('audio.epub')
 
-    def get_audiobooks(self):
+    def get_audiobooks(self, with_children=False, processing=False):
         ogg_files = {}
         for m in self.media.filter(type='ogg').order_by().iterator():
             ogg_files[m.name] = m
@@ -465,13 +542,27 @@ class Book(models.Model):
                 media['ogg'] = ogg
             audiobooks.append(media)
 
-        projects = sorted(projects)
-        total_duration = '%d:%02d' % (
-            total_duration // 60,
-            total_duration % 60
-        )
+        if with_children:
+            for child in self.get_children():
+                ch_audiobooks, ch_projects, ch_duration = child.get_audiobooks(
+                    with_children=True, processing=True)
+                audiobooks.append({'part': child})
+                audiobooks += ch_audiobooks
+                projects.update(ch_projects)
+                total_duration += ch_duration
+
+        if not processing:
+            projects = sorted(projects)
+            total_duration = '%d:%02d' % (
+                total_duration // 60,
+                total_duration % 60
+            )
+
         return audiobooks, projects, total_duration
 
+    def get_audiobooks_with_children(self):
+        return self.get_audiobooks(with_children=True)
+    
     def wldocument(self, parse_dublincore=True, inherit=True):
         from catalogue.import_utils import ORMDocProvider
         from librarian.parser import WLDocument
@@ -635,6 +726,7 @@ class Book(models.Model):
         book.findable = findable
         book.language = book_info.language
         book.title = book_info.title
+        book.license = book_info.license or ''
         if book_info.variant_of:
             book.common_slug = book_info.variant_of.slug
         else:
@@ -651,6 +743,8 @@ class Book(models.Model):
         book.load_toc()
         book.save()
 
+        book.update_stats()
+        
         meta_tags = Tag.tags_from_info(book_info)
 
         just_tags = [t for (t, rel) in meta_tags if not rel]
@@ -704,6 +798,7 @@ class Book(models.Model):
         for format_ in constants.EBOOK_FORMATS_WITH_CHILDREN:
             if format_ not in dont_build:
                 getattr(book, '%s_file' % format_).build_delay()
+        book.html_nonotes_file.build_delay()
 
         if not settings.NO_SEARCH_INDEX and search_index and findable:
             tasks.index_book.delay(book.id)
@@ -717,6 +812,16 @@ class Book(models.Model):
         cls.published.send(sender=cls, instance=book)
         return book
 
+    def update_stats(self):
+        stats = self.wldocument2().get_statistics()['total']
+        self.pages = round(
+            stats.get('verses_with_fn', 0) / 30 +
+            stats.get('chars_out_verse_with_fn', 0) / 1800)
+        self.read_time = round(self.get_time())
+        self.save(update_fields=['pages', 'read_time'])
+        if self.parent is not None:
+            self.parent.update_stats()
+
     def update_references(self):
         Entity = apps.get_model('references', 'Entity')
         doc = self.wldocument2()
@@ -762,6 +867,42 @@ class Book(models.Model):
     def references(self):
         return self.reference_set.all().select_related('entity')
 
+    def update_has_audio(self):
+        self.has_audio = False
+        if self.media.filter(type='mp3').exists():
+            self.has_audio = True
+        if self.descendant.filter(has_audio=True).exists():
+            self.has_audio = True
+        self.save(update_fields=['has_audio'])
+        if self.parent is not None:
+            self.parent.update_has_audio()
+
+    def update_narrators(self):
+        narrator_names = set()
+        for bm in self.media.filter(type='mp3'):
+            narrator_names.update(set(
+                a.strip() for a in re.split(r',|\si\s', bm.artist)
+            ))
+        narrators = []
+
+        for name in narrator_names:
+            if not name: continue
+            slug = slugify(name)
+            try:
+                t = Tag.objects.get(category='author', slug=slug)
+            except Tag.DoesNotExist:
+                sort_key = sortify(
+                    ' '.join(name.rsplit(' ', 1)[::-1]).lower()
+                )
+                t = Tag.objects.create(
+                    category='author',
+                    name_pl=name,
+                    slug=slug,
+                    sort_key=sort_key,
+                )
+            narrators.append(t)
+        self.narrators.set(narrators)
+
     @classmethod
     @transaction.atomic
     def repopulate_ancestors(cls):
@@ -878,6 +1019,12 @@ class Book(models.Model):
         elif isinstance(publisher, list):
             return ', '.join(publisher)
 
+    def get_recommended(self, limit=4):
+        books_qs = type(self).objects.filter(findable=True)
+        books_qs = books_qs.exclude(common_slug=self.common_slug).exclude(ancestor=self)
+        books = type(self).tagged.related_to(self, books_qs)[:limit]
+        return books
+
     @classmethod
     def tagged_top_level(cls, tags):
         """ Returns top-level books tagged with `tags`.
@@ -986,7 +1133,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
@@ -997,16 +1144,23 @@ 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 elevenreader_link(self):
+        first_text = self.get_first_text()
+        if first_text is None:
+            return None
+        return 'https://elevenreader.io/audiobooks/wolnelektury:' + first_text.slug
 
-    def unlike(self, user):
-        from social.utils import likes, set_sets
-        if likes(user, self):
-            set_sets(user, self, [])
+    def content_warnings(self):
+        warnings_def = {
+            'wulgaryzmy': _('wulgaryzmy'),
+        }
+        warnings = self.get_extra_info_json().get('content_warnings', [])
+        warnings = [
+            warnings_def.get(w, w)
+            for w in warnings
+        ]
+        warnings.sort()
+        return warnings
 
     def full_sort_key(self):
         return self.SORT_KEY_SEP.join((self.sort_key_author, self.sort_key, str(self.id)))
index acb1881..a5df657 100644 (file)
@@ -70,6 +70,8 @@ class BookMedia(models.Model):
         return f'{name}.{ext}'
 
     def save(self, parts_count=None, *args, **kwargs):
+        if self.type in ('daisy', 'audio.epub'):
+            return super().save(*args, **kwargs)
         from catalogue.utils import ExistingFile, remove_zip
 
         if not parts_count:
@@ -87,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:
@@ -99,7 +101,9 @@ class BookMedia(models.Model):
         self.extra_info = json.dumps(extra_info)
         self.source_sha1 = self.read_source_sha1(self.file.path, self.type)
         self.duration = self.read_duration()
-        return super(BookMedia, self).save(*args, **kwargs)
+        super(BookMedia, self).save(*args, **kwargs)
+        self.book.update_narrators()
+        self.book.update_has_audio()
 
     def read_duration(self):
         try:
index 13d8a64..d7b02de 100644 (file)
@@ -52,7 +52,7 @@ class Tag(models.Model):
     fragment themes (motifs) and some book hierarchy related kludges."""
     name = models.CharField('nazwa', max_length=120, db_index=True)
     slug = models.SlugField('slug', max_length=120, db_index=True)
-    sort_key = models.CharField('klucz sortowania', max_length=120, db_index=True)
+    sort_key = models.CharField('klucz sortowania', max_length=120, db_index=True, db_collation='C')
     category = models.CharField(
         'kategoria', max_length=50, blank=False, null=False, db_index=True, choices=TAG_CATEGORIES)
     description = models.TextField('opis', blank=True)
@@ -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
 
index 2ff4262..37613bf 100644 (file)
     }
 
     $("#paginator").on('click', 'a', function() {
-        get_page_by_url(url=$(this).attr('href'));
+        get_page_by_url(url=$(this).attr('href'), () => {
+            $("html").animate({
+                scrollTop: $("#book-list").offset().top - 50
+            });
+        });
         return false;
     });
 
index cbf9026..4857087 100644 (file)
                 $(".c-media__caption .license", $root).html($(".license", elem).html());
                 $(".c-media__caption .project-logo", $root).html($(".project-icon", elem).html());
 
-                console.log('sm 1');
                 doesUpdateSynchro = false;
                 if (!$currentMedia || $currentMedia[0] != elem[0]) {
-                    console.log('set', player.jPlayer("setMedia", media))
+                    player.jPlayer("setMedia", media);
                     player.jPlayer("option", "playbackRate", speed);
                 }
                 doesUpdateSynchro = true;
                 player.jPlayer(cmd, time);
 
                 $currentMedia = elem;
-                $(".play-next", $root).prop("disabled", !elem.next().length);
+                $(".play-next", $root).prop("disabled", !elem.nextAll('li').length);
 
                 let du = parseFloat(elem.data('duration'));
                 currentDuration = du;
-                elem.nextAll().each(function() {
+                elem.nextAll('li').each(function() {
                     du += parseFloat($(this).data('duration'));
                 });
                 totalDurationLeft = du;
 
                 let pdu = 0;
-                elem.prevAll().each(function() {
+                elem.prevAll('li').each(function() {
                     pdu += parseFloat($(this).data('duration'));
                 });
                 totalDurationBefore = pdu;
-                console.log('sm 3', du, pdu);
 
                 return player;
             };
                     // TODO: if snap then roll
                     locator.removeClass('up').removeClass('down');
                     if (locator.hasClass('snap')) {
-                        console.log('SCROLL!');
                         scrollTo();
                     } else {
                         if (y < miny) {
                 });
 
                 $('.play-next', $root).click(function() {
-                    let p = $currentMedia.next();
+                    let p = $currentMedia.nextAll('li').first();
                     if (p.length) {
                         setMedia(p).jPlayer("play");
                         _paq.push(['trackEvent', 'audiobook', 'next']);
                     }
                 });
                 $('.play-prev', $root).click(function() {
-                    let p = $currentMedia.prev();
+                    let p = $currentMedia.prevAll('li').first();
                     if (p.length) {
                         setMedia(p).jPlayer("play");
                         _paq.push(['trackEvent', 'audiobook', 'prev']);
                     _paq.push(['trackEvent', 'audiobook', 'chapter']);
                 });
 
-                console.log('READY 3!');
                 var initialElem = $('.jp-playlist li', $root).first();
                 var initialTime = 0;
-                console.log('READY 4!');
                 if (true || Modernizr.localstorage) {
                     try {
                         let speedStr = localStorage['audiobook-speed'];
                         initialTime = last[2];
                     }
                 }
-                console.log('READY 5!', initialElem, initialTime);
                 setMedia($(initialElem), initialTime);
-                console.log('READY 6!');
             },
 
             timeupdate: function(event) {
 
 
             ended: function(event) {
-                let p = $currentMedia.next();
+                let p = $currentMedia.nextAll('li');
                 if (p.length) {
                     setMedia(p).jPlayer("play");
                 }
index 4458b5a..b82d926 100644 (file)
@@ -34,7 +34,7 @@
     {% if book.is_book %}
       <span class="icon icon-book-alt" title="{% trans 'książka' %}"></span>
     {% endif %}
-    {% if book.has_mp3_file %}
+    {% if book.has_audio %}
       <span class="icon icon-audio" title="{% trans 'audiobook' %}"></span>
     {% endif %}
     {% if book.is_picture %}
index eb1ab8a..2f51787 100644 (file)
@@ -5,9 +5,14 @@
 {% load choose_cites from social_tags %}
 {% load catalogue_tags %}
 {% load likes_book from social_tags %}
+{% load annoy %}
 
 
 {% block global-content %}
+<div class="l-container">
+  {% annoy_banner 'book-page' %}
+  </div>
+
     <div class="l-container">
       <div class="l-breadcrumb">
         <a href="/"><span>{% trans "Strona główna" %}</span></a>
                         <div class="c-media__btn">
                           <button class="l-button l-button--media" id="ebook"><i class="icon icon-book"></i> {% trans "pobierz książkę" %}</button>
                         </div>
+                        <div class="c-media__btn">
+                          {% if first_text %}
+                            <a href="https://elevenreader.io/audiobooks/wolnelektury:{{ first_text.slug }}" target="_blank" class="l-button l-button--media"><img src="{% static 'img/elevenreader-21.png' %}" title="{% trans "Posłuchaj w ElevenReader" %}" alt="{% trans "Posłuchaj w ElevenReader" %}"></a>
+                          {% endif %}
+                        </div>
                         <div class="c-media__btn">
                           {% if first_text %}
                             <a href="{% url 'book_text' first_text.slug %}" class="l-button l-button--media l-button--media--full"><i class="icon icon-eye"></i> {% trans "czytaj online" %}</a>
                   </div>
 
 
-
-                  {% if book.has_mp3_file %}
+                  {% if book.has_audio %}
                     {% include 'catalogue/snippets/jplayer.html' %}
-                  {% else %}
-                    {% with ch=book.get_child_audiobook %}
-                      {% if ch %}
-                        {% include 'catalogue/snippets/jplayer_link.html' with book=ch %}
-                      {% endif %}
-                    {% endwith %}
-
                   {% endif %}
 
 
-
-
                   <div class="c-media__popup" data-popup="ebook">
                     <div class="c-media__popup__box">
                       <div class="c-media__popup__box__lead">
               <button class="l-article__read-more" aria-label="{% trans 'Kliknij aby rozwinąć' %}" data-label="{% trans 'Czytaj więcej' %}" data-action="{% trans 'Zwiń tekst' %}">{% trans 'Czytaj więcej' %}</button>
             </article>
             {% if accessible %}
-            <div class="c-support">
-              <div>
-                <h2>
-                  {% blocktrans trimmed %}
-                    Ta książka jest dostępna dla tysięcy dzieciaków dzięki
-                    <span>darowiznom</span> od osób takich jak <span>Ty</span>!
-                  {% endblocktrans %}
-                </h2>
-                <a href="{% url 'club_join' %}?pk_campaign=layout">{% trans "Dorzuć się!" %}</a>
-              </div>
-              <div class="bg">
-                <!-- img src="{% static '2022/images/dziecko.jpeg' %}" alt="Dorzuć się!" -->
-              </div>
-            </div>
+           {% annoy_banner 'book-page-center' book=book %}
             {% endif %}
           </div>
         {% endwith %}
             {% endif %}
 
 
-            {% related_books_2022 book taken=book.other_versions|length as related_books %}
+            {% related_books book taken=book.other_versions|length as related_books %}
             {% for rel in related_books %}
               <article class="l-books__item">
                 <figure class="l-books__item__img">
index c57bdd3..2a3db14 100644 (file)
     </div>
   {% endif %}
 
+  {% if narrated %}
+    <div class="l-section l-section--col">
+      <h2 class="header">Audiobooki</h2>
+      <div class="l-books__grid" id="book-list">
+        {% for book in narrated %}
+          {% include "catalogue/book_box.html" %}
+        {% endfor %}
+      </div>
+    </div>
+  {% endif %}
+
   {% if main_tag %}
     <section class="l-section">
       <div class="l-author">
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 dbe9259..e06f0f2 100644 (file)
 
 
   <article id="main-text" {% if book.has_sync_file %}class="has-sync"{% endif %}>
-    {% with next=book.get_next_text prev=book.get_prev_text %}
+<div id="sidebar">
+      {% if book.other_versions.exists %}
+        <div class="box" id="other">
+          <h2>{% trans "Inne wersje tekstu" %}</h2>
+          <a class="other-text-close" href="#">{% trans "Zamknij drugą wersję" %}</a>
+          <ul>
+            {% spaceless %}
+              {% for other_version in book.other_versions %}
+                <li>
+                  <a class="display-other"
+                     data-other="{{ other_version.html_url }}"
+                     href="{% url 'book_text' other_version.slug %}">
+                    {{ other_version.mini_box_nolink }}
+                  </a>
+                </li>
+              {% endfor %}
+            {% endspaceless %}
+          </ul>
+        </div>
+      {% endif %}
+</div>
+
+{% with next=book.get_next_text prev=book.get_prev_text %}
       {% if next %}
         <a class="text_next-book" href="{% url 'book_text' next.slug %}">{{ next.title }}&nbsp;&rarr;</a>
       {% endif %}
             <div class="pointer pointer-top"></div>
         </div>
       </div>
+</div>
 
 
-      {% if book.other_versions.exists %}
-        <div class="box" id="other">
-          <h2>{% trans "Inne wersje utworu" %}</h2>
-          <a class="other-text-close" href="#">{% trans "Zamknij drugą wersję" %}</a>
-          <ul>
-            {% spaceless %}
-              {% for other_version in book.other_versions %}
-                <li>
-                  <a class="display-other"
-                     data-other="{{ other_version.html_url }}"
-                     href="{% url 'book_text' other_version.slug %}">
-                    {{ other_version.mini_box_nolink }}
-                  </a>
-                </li>
-              {% endfor %}
-            {% endspaceless %}
-          </ul>
-        </div>
-      {% endif %}
-
       <div id="annoy-stubs">
         {% annoy_banners 'book-text-intermission' %}
 
     <div id="player-bar">
       {% include 'catalogue/snippets/jplayer_reader.html' %}
     </div>
-    <script type="application/json" id="smil">
-     {{ book.get_sync|safe }}
-    </script>
+    {{ book.get_sync|json_script:"smil" }}
   {% endif %}
 
 
index e0893fa..2399a09 100644 (file)
@@ -1,6 +1,6 @@
 {% load i18n l10n catalogue_tags %}
 
-{% with audiobooks=book.get_audiobooks %}
+{% with audiobooks=book.get_audiobooks_with_children %}
   <div class="c-media__player" id="jp_container_{{ book.pk }}" data-book-slug="{{ book.slug }}">
     <div class="jp-jplayer" data-player="jp_container_{{ book.pk }}"
          data-supplied="oga,mp3"></div>
@@ -38,6 +38,9 @@
           <ul class="jp-playlist">
             {% localize off %}
             {% for i in audiobooks.0 %}
+            {% if i.part %}
+              <div class="title"><strong>{{ i.part.title }}</strong></div>
+            {% else %}
               <li
                   data-mp3='{{ i.mp3.file.url }}'
                   data-ogg='{{ i.ogg.file.url }}'
@@ -67,6 +70,7 @@
                   </span>
                {% endwith %}
               </li>
+              {% endif %}
             {% endfor %}
             {% endlocalize %}
           </ul>
diff --git a/src/catalogue/templates/catalogue/snippets/jplayer_link.html b/src/catalogue/templates/catalogue/snippets/jplayer_link.html
deleted file mode 100644 (file)
index 7bbddcd..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-{% load i18n catalogue_tags %}
-
-<div class="c-media__player" id="jp_container_{{ book.pk }}">
-  <div class="c-player__head">
-    <span>&nbsp;</span>
-  </div>
-
-  <div class="c-player">
-    <div class="c-player__btns">
-      <button disabled class="play-prev"><i class="icon icon-prev"></i></button>
-      <button disabled class="c-player__btn--md"><i class="icon icon-play"></i></button>
-      <form action='{{ book.get_absolute_url }}'>
-        <button class="play-next"><i class="icon icon-next"></i></button>
-      </form>
-    </div>
-
-    <div class="c-player__timeline">
-      <div class="c-player__info">{{ book.pretty_title }}</div>
-    </div>
-
-  </div>
-  <div class="c-media__caption">
-  </div>
-</div>
index d2298d9..e50ab58 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)
@@ -305,16 +309,8 @@ def plain_list(context, object_list, with_initials=True, by_author=False, choice
 
 
 @register.simple_tag
-def related_books_2022(book=None, limit=4, taken=0):
-    limit -= taken
-    max_books = limit
-
-    books_qs = Book.objects.filter(findable=True)
-    if book is not None:
-        books_qs = books_qs.exclude(common_slug=book.common_slug).exclude(ancestor=book)
-    books = Book.tagged.related_to(book, books_qs)[:max_books]
-
-    return books
+def related_books(book, limit=4, taken=0):
+    return book.get_recommended(limit=limit - taken)
 
 
 @register.simple_tag
@@ -420,16 +416,8 @@ def status(book, user):
 
 @register.inclusion_tag('catalogue/snippets/content_warning.html')
 def content_warning(book):
-    warnings_def = {
-        'wulgaryzmy': _('wulgaryzmy'),
-    }
-    warnings = book.get_extra_info_json().get('content_warnings', [])
-    warnings = sorted(
-        warnings_def.get(w, w)
-        for w in warnings
-    )
     return {
-        "warnings": warnings
+        "warnings": book.content_warnings(),
     }
 
 
index 3c4a55f..e754e05 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,25 +212,45 @@ 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']]
+        if len(self.ctx['tags']) > 4:
+            raise Http404
+        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'])
@@ -241,6 +262,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 = []
@@ -252,6 +276,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(
@@ -292,6 +317,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(
@@ -309,7 +335,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']:
@@ -324,7 +350,7 @@ def object_list(request, objects, list_type='books'):
     }
 
     template = 'catalogue/author_detail.html'
-        
+
     return render(
         request, template, result,
     )
index 4425928..8620e74 100644 (file)
@@ -11,6 +11,7 @@ from django.utils.safestring import mark_safe
 from fnpdjango.actions import export_as_csv_action
 from modeltranslation.admin import TranslationAdmin
 import annoy.models
+from messaging.models import Contact, Level
 from wolnelektury.utils import YesNoFilter
 from . import models
 
@@ -127,6 +128,11 @@ class CrisisFilter(admin.SimpleListFilter):
         )
 
 
+class OptOutFilter(YesNoFilter):
+    title = 'opt out'
+    parameter_name = 'optout'
+    q = Q(email__in=Contact.objects.filter(level=Level.OPT_OUT).values_list('email', flat=True))
+
 
 class ScheduleAdmin(admin.ModelAdmin):
     form = ScheduleForm
@@ -139,6 +145,7 @@ class ScheduleAdmin(admin.ModelAdmin):
     search_fields = ['email', 'source']
     list_filter = [
         'is_cancelled', 'monthly', 'yearly', 'method',
+        'consent', OptOutFilter,
         PayedFilter, ActiveFilter, ExpiredFilter,
         SourceFilter, CrisisFilter
     ]
index 20b287a..dffeaa7 100644 (file)
@@ -27,12 +27,12 @@ class CiviCRM:
         d = response.json()
         return d
 
-    def create_or_update_contact(self, email, key=None):
+    def create_or_update_contact(self, email, fields=None):
         contact_id = self.get_contact_id(email)
         if contact_id is None:
-            contact_id = self.create_contact(email, key)
-        elif key:
-            self.update_contact(contact_id, key)
+            contact_id = self.create_contact(email, fields)
+        elif fields:
+            self.update_contact(contact_id, fields)
         return contact_id
 
     def get_contact_id(self, email):
@@ -49,7 +49,7 @@ class CiviCRM:
         if result:
             return result[0]['id']
 
-    def create_contact(self, email, key=None):
+    def create_contact(self, email, fields):
         data = {
             'values': {},
             'chain': {
@@ -65,19 +65,22 @@ class CiviCRM:
                 ]
             }
         }
-        if key:
-            data['values']['WL.TPWL_key'] =  key
+        if fields:
+            data['values'].update(fields)
         result = self.request('Contact', 'create', data)
         return result['values'][0]['id']
-    
-    def update_contact(self, contact_id, key):
+
+    def update_phone(self, contact_id, phone):
+        if self.request('Phone', 'get', {'where': [['phone', "=", phone], ['contact_id', "=",  contact_id]]})['count']:
+            return
+        self.request('Phone', 'create', {'values': {'phone': phone, 'contact_id': contact_id}})
+
+    def update_contact(self, contact_id, fields):
         return self.request(
             'Contact',
             'update',
             {
-                'values': {
-                    'WL.TPWL_key': key,
-                },
+                'values': fields,
                 'where': [
                     ['id', '=', contact_id]
                 ]
@@ -89,7 +92,8 @@ class CiviCRM:
         if not self.enabled:
             return
 
-        contact_id = self.create_or_update_contact(email, tpwl_key)
+        fields = {'WL.TPWL_key': tpwl_key}
+        contact_id = self.create_or_update_contact(email, fields)
 
         activity_id = self.get_activity_id(key)
         if activity_id is None:
index b75877d..e096303 100644 (file)
@@ -35,6 +35,8 @@ class DonationStep1Form(forms.ModelForm):
         self.referer = referer
         super().__init__(*args, **kwargs)
         club = models.Club.objects.first()
+        if self.instance.is_custom_amount():
+            self.fields['custom_amount'].initial = int(self.instance.amount)
         if club is not None:
             self.fields['custom_amount'].widget.attrs['min'] = club.min_amount
 
@@ -50,7 +52,8 @@ class DonationStep1Form(forms.ModelForm):
         return state
 
     def save(self, *args, **kwargs):
-        self.instance.source = self.referer
+        if self.referer is not None:
+            self.instance.source = self.referer
         return super().save(*args, **kwargs)
 
 
index c09769a..de2d3c6 100644 (file)
@@ -161,6 +161,15 @@ class Schedule(models.Model):
         club = Club.objects.first()
         return club.get_description_for_amount(self.amount, self.monthly)
 
+    def is_custom_amount(self):
+        club = Club.objects.first()
+        if not self.amount:
+            return False
+        if self.monthly:
+            return not club.monthlyamount_set.filter(amount=self.amount).exists()
+        else:
+            return not club.singleamount_set.filter(amount=self.amount).exists()
+
     def initiate_payment(self, request):
         return self.get_payment_method().initiate(request, self)
 
@@ -417,7 +426,8 @@ class PayUOrder(payu_models.Order):
         )            
 
     @classmethod
-    def send_receipt(cls, email, year, resend=False):
+    def generate_receipt(cls, email, year):
+        # TODO: abstract out
         Contact = apps.get_model('messaging', 'Contact')
         Funding = apps.get_model('funding', 'Funding')
         BillingAgreement = apps.get_model('paypal', 'BillingAgreement')
@@ -485,11 +495,8 @@ class PayUOrder(payu_models.Order):
         ctx = {
             "email": email,
             "year": year,
-            "next_year": year + 1,
             "total": sum(x['amount'] for x in payments),
             "payments": payments,
-            "optout": optout,
-            "resend": resend,
         }
         temp = tempfile.NamedTemporaryFile(prefix='receipt-', suffix='.pdf', delete=False)
         temp.close()
@@ -497,15 +504,34 @@ class PayUOrder(payu_models.Order):
             "wl.eps": os.path.join(settings.STATIC_ROOT, "img/wl.eps"),
             })
 
+        with open(temp.name, 'rb') as f:
+            content = f.read()
+        os.unlink(f.name)
+        return content, optout, payments
+
+    @classmethod
+    def send_receipt(cls, email, year, resend=False):
+        receipt = cls.generate_receipt(email, year)
+        if receipt:
+            content, optout, payments = receipt
+        else:
+            return
+        ctx = {
+            "email": email,
+            "year": year,
+            "next_year": year + 1,
+            "total": sum(x['amount'] for x in payments),
+            "payments": payments,
+            "optout": optout,
+            "resend": resend,
+        }
         message = EmailMessage(
                 'Odlicz darowiznę na Wolne Lektury od podatku',
                 template.loader.render_to_string('club/receipt_email.txt', ctx),
                 settings.CLUB_CONTACT_EMAIL, [email]
             )
-        with open(temp.name, 'rb') as f:
-            message.attach('wolnelektury-darowizny.pdf', f.read(), 'application/pdf')
+        message.attach('wolnelektury-darowizny.pdf', content, 'application/pdf')
         message.send()
-        os.unlink(f.name)
 
 
 class PayUCardToken(payu_models.CardToken):
index 1b6de08..3d9cd55 100644 (file)
@@ -2,6 +2,7 @@
 {% load club %}
 
 {% block content %}
+<div style="display: flex; gap:50px;">
   <table class="table">
     <tr>
       <td>Aktywne miesięczne wpłaty cykliczne:</td>
       <td>{% club_active_30day_sum %} zł.</td>
     </tr>
   </table>
+  <div>
+    <form method="post" action="{% url 'club_receipt' %}" style="display: flex; flex-direction: column;">
+      {% csrf_token %}
+      <span>Pobierz zestawienie roczne</span>
+      <input name="email" placeholder="email" required></input>
+      <input name="year" type="number" min="2013" max="2024" value="2024" placeholder="rok" required></input>
+      <button>pobierz zestawienie</button>
+    </form>
+  </div>
+  </div>
   {{ block.super }}
 {% endblock content %}
diff --git a/src/club/templates/club/donation_infobox.html b/src/club/templates/club/donation_infobox.html
new file mode 100644 (file)
index 0000000..093480b
--- /dev/null
@@ -0,0 +1,10 @@
+<div class="checkout-infobar">
+  <div class="if-monthly">
+    Dziękujemy, że decydujesz się wspierać nas co miesiąc.<br/>
+    Jeśli to pomyłka, możesz zmienić darowiznę na <a class="donation-mod-monthly" data-url="{% url 'donation_set_monthly' schedule.key %}" data-monthly="false" href="{% url 'donation_step1' schedule.key %}">jednorazową</a>.
+  </div>
+  <div class="if-not-monthly">
+    Wolę wspierać co miesiąc! 
+    <a class="donation-mod-monthly" data-url="{% url 'donation_set_monthly' schedule.key %}" data-monthly="true" href="{% url 'donation_step1' schedule.key %}">Zmień na comiesięczną wpłatę.</a>
+  </div>
+</div>
index 033b0ea..4980555 100644 (file)
@@ -1,7 +1,7 @@
 {% load static %}
 {% load i18n %}
 
-<form method="post" action="{% url 'club_join' %}">
+<form method="post" action="{% if schedule %}{% url 'donation_step1' key=schedule.key %}{% else %}{% url 'club_join' %}{% endif %}">
   {% csrf_token %}
   {{ form.errors }}
   <input type="radio" name="switch" id="switch-once" value="single" class="toggle-input" {% if schedule and not schedule.monthly %}checked{% endif %}>
   {% with amounts=club.get_amounts %}
     <div class="l-checkout__payments payments-once wide-spot-{{ amounts.single_wide_spot }}">
       {% for amount in amounts.single %}
-        <div class="l-checkout__payments__box once{% if not schedule.monthly and schedule.amount == amount.amount or not schedule and club.default_single_amount == amount.amount %} is-active{% endif %}{% if amount.wide %} l-checkout__payments__box--special{% endif %} l-checkout__payments__box--{{ amount.box_variant }}">
+        <div class="l-checkout__payments__box once{% if not schedule.monthly and schedule.amount == amount.amount or not schedule and club.default_single_amount == amount.amount %} is-active initial-active{% endif %}{% if amount.wide %} l-checkout__payments__box--special{% endif %} l-checkout__payments__box--{{ amount.box_variant }}">
 
-          <h3>{{ amount.amount }} zł</h3>
           <div class="l-checkout__payments__box__btn-wrp">
-            {% if amount.description %}
-              <p>{{ amount.description|safe }}</p>
-            {% endif %}
-            <button name="single_amount" value="{{ amount.amount }}">{% trans "Wybierz" %}</button>
+            <button name="single_amount" value="{{ amount.amount }}">{{ amount.amount }} zł</button>
           </div>
         </div>
       {% endfor %}
 
     <div class="l-checkout__payments payments-recurring wide-spot-{{ amounts.monthly_wide_spot }}">
     {% for amount in amounts.monthly %}
-      <div class="l-checkout__payments__box{% if schedule.monthly and schedule.amount == amount.amount or not schedule and amount.amount == club.default_monthly_amount %} is-active{% endif %}{% if amount.wide %} l-checkout__payments__box--special{% endif %} l-checkout__payments__box--{{ amount.box_variant }}">
-        <h3>{{ amount.amount }} zł <span>{% trans "/mies." context "kwota na miesiąc" %}</span></h3>
+      <div class="l-checkout__payments__box{% if schedule.monthly and schedule.amount == amount.amount or not schedule and amount.amount == club.default_monthly_amount %} is-active initial-active{% endif %}{% if amount.wide %} l-checkout__payments__box--special{% endif %} l-checkout__payments__box--{{ amount.box_variant }}">
         <div class="l-checkout__payments__box__btn-wrp">
-          {% if amount.description %}
-            <p>{{ amount.description|safe }}</p>
-          {% endif %}
-          <button name="monthly_amount" value="{{ amount.amount }}">{% trans "Wybierz" %}</button>
+          <button name="monthly_amount" value="{{ amount.amount }}">{{ amount.amount }} zł <span> /mies.</span></button>
         </div>
       </div>
     {% endfor %}
@@ -58,7 +50,7 @@
 
   <div class="l-checkout__amount">
     <div class="l-checkout__input">
-      <label for="kwota">{% trans "Inna kwota" %}</label>
+      <label for="id_custom_amount">{% trans "Inna kwota" %}</label>
       {{ form.custom_amount }}
     </div>
     <button>{% trans "Dalej" %}</button>
index 3864ad5..5d60213 100644 (file)
@@ -8,26 +8,27 @@
 
 {% block donation-step-content %}
 
-  <div class="l-checkout__cols">
+  <div class="l-checkout__cols q-is-monthly {% if schedule.monthly %}is-monthly{% endif %}">
     <div class="l-checkout__col">
       <div class="l-checkout__payments__box is-active">
         <h3>
           {{ schedule.amount|floatformat }} zł
-          {% if schedule.monthly %}
-            <span>{% trans "/mies." context "kwota na miesiąc" %}</span>
-          {% endif %}
+          <span class="if-monthly">{% trans "miesięcznie" %}</span>
+          <span class="if-not-monthly">{% trans "jednorazowo" %}</span>
         </h3>
         <img src="{% static '2022/images/checkout-img-3.jpg' %}" alt="">
+       {% if schedule.get_description %}
         <p>{{ schedule.get_description }}</p>
+       {% endif %}
       </div>
     </div>
     <div class="l-checkout__col">
 
+      {% include "club/donation_infobox.html" %}
+      
       <form method='post'>
         {% csrf_token %}
         {{ form.errors }}
-        {{ form.amount }}
-        {{ form.monthly }}
         <div class="l-checkout__form">
           <div class="l-checkout__form__row">
             <div class="l-checkout__input">
index baeee9f..999c708 100644 (file)
@@ -8,21 +8,33 @@
 
 
 {% block donation-step-content %}
-  <div class="l-checkout__cols">
+  <div class="l-checkout__cols q-reload-is-monthly {% if schedule.monthly %}is-monthly{% endif %}">
     <div class="l-checkout__col">
       <div class="l-checkout__payments__box is-active">
         <h3>
           {{ schedule.amount|floatformat }} zł
           {% if schedule.monthly %}
-            <span>{% trans "/mies." context "kwota na miesiąc" %}</span>
+          <span>{% trans "miesięcznie" %}</span>
+         {% else %}
+         <span>{% trans "jednorazowo" %}</span>
           {% endif %}</h3>
           <img src="{% static '2022/images/checkout-img-3.jpg' %}" alt="">
       </div>
     </div>
     <div class="l-checkout__col">
+      {% include "club/donation_infobox.html" %}
+
+      {% if schedule.monthly %}
+      <h3>Darowizna będzie pobierana automatycznie co miesiąc.</h3>
+      <p>Możesz z niej zrezygnować w dowolnej chwili, korzystając z linku który dostaniesz mailem.</p>
+      {% endif %}
+
       <div class="l-checkout__form">
         <div class="l-checkout__form__row full">
-          <div class="iframe">
+
+
+      <div class="iframe">
+           
             {% for method in schedule.get_payment_methods %}
               {% invite_payment method schedule %}
             {% endfor %}
index cede1b4..3889324 100644 (file)
 
     <div class="l-checkout__box">
       <div class="l-checkout__box__header">
-        <img src="{% block donation-jumbo-image %}{% static '2022/images/checkout-img-1.jpg' %}{% endblock %}" alt="Wspieraj Wolne Lektury">
+        <div class="l-checkout__box__header__img"
+             style="background-image: url({% block donation-jumbo-image %}{% static '2022/images/checkout-img-1.jpg' %}{% endblock %}">
+          </div>
         <div class="l-checkout__box__header__content">
-          <h1>{% trans "Wspieraj Wolne Lektury" %}</h1>
-          <p>{% trans "Dziękujemy, że chcesz razem z nami uwalniać książki!" %}</p>
-          <p>{% trans "Wspieraj Wolne Lektury stałą wpłatą – nawet niewielka ma wielką moc! Możesz też wesprzeć Wolne Lektury jednorazowo." %}</p>
+          {% chunk "donate-top" %}
         </div>
       </div>
       <div class="l-checkout__steps">
@@ -43,7 +43,7 @@
         {% endif %}
         <div class="{% if view.step == 1 %}is-current{% else %}is-completed{% endif %}">
           <span>1</span>
-          <p>{% trans "Rodzaj wsparcia" %}</p>
+          <p>{% trans "Kwota wsparcia" %}</p>
         </div>
         {% if view.step > 1 and view.step != 4 %}
           </a>
@@ -84,6 +84,8 @@
 
     <div class="l-checkout__footer">
       <div class="l-checkout__footer__content">
+        {% chunk 'donate-bottom' %}
+
         <div class="l-checkout__footer__content__item">
           <h3>{% trans "Transparentność jest dla nas bardzo ważna." %}</h3>
           <div>
index 5ac0c4c..baaf09d 100644 (file)
@@ -1,5 +1,11 @@
 {% load i18n static %}
-<h3>{% trans "Wolisz wpłacić przez PayPal?" %}</h3>
+<h3>
+  {% if schedule.monthly %}
+  {% trans "Wolisz ustawić comiesięczną darowiznę przez PayPal?" %}
+  {% else %}
+  {% trans "Wolisz wpłacić przez PayPal?" %}
+  {% endif %}
+</h3>
 <a href="{% url 'paypal_init' schedule.key %}">
   <div class="iframe">
     <img src="{% static 'club/paypal.png' %}" alt="PayPal">
index 3d99902..9f3e8ae 100644 (file)
@@ -17,17 +17,17 @@ PS Poniżej znajdziesz email sprzed kilku tygodni, który być może Ci umknął
 
 czy wiesz, że możesz odliczyć od podstawy opodatkowania wszystkie darowizny przekazane na prowadzenie biblioteki Wolne Lektury i działalność Fundacji?
 
-Łącznie w {{ year }} otrzymaliśmy od Ciebie {{ total }} zł na zapewnienie dostępu do książek wszystkim dzieciakom.
+Łącznie w {{ year }} otrzymaliśmy od Ciebie {{ total }} zł na udostępnianie nowych ebooków i audiobooków.
 
 Zestawienie darowizn za rok {{ year }} znajdziesz w załączniku!
 
 Dane z załącznika wprowadź do formularza PIT. Pamiętaj, że w przypadku kontroli z urzędu skarbowego musisz mieć potwierdzenie wykonanych przelewów z Twojego banku.
 
-Podczas wypełniania swojego PIT-u możesz także przekazać 1,5% swojego podatku na Wolne Lektury. Dzięki temu ufundujesz darmową e-książkę, która trafi do tysięcy dzieciaków. Wystarczy, że w odpowiednim polu wpiszesz nazwę organizacji „fundacja Wolne Lektury” oraz numer KRS 0000070056. Wspierasz Wolne Lektury w ten sposób już od dawna? W takim razie nic nie musisz zmieniać, bo system ponownie sam wprowadzi dane fundacji.
+Podczas wypełniania swojego PIT-u możesz także przekazać 1,5% swojego podatku na Wolne Lektury. Dzięki temu ufundujesz pomożesz nam udostępniać kolejne ebooki i audiobooki w tym roku . Wystarczy, że w odpowiednim polu wpiszesz nazwę organizacji „fundacja Wolne Lektury” oraz numer KRS 0000070056. Wspierasz Wolne Lektury w ten sposób już od dawna? W takim razie nic nie musisz zmieniać, bo system ponownie sam wprowadzi dane fundacji.
 
 Serdecznie dziękujemy za Twoje wsparcie!
 
-Paulina Choromańska i Jarosław Lipszyc
+Paulina Choromańska i Radosław Czajka
 w imieniu całego zespołu Wolnych Lektur
 
 
index 13cf53b..be61a53 100644 (file)
@@ -1,5 +1,5 @@
 {% load i18n %}
-<h3>{% trans "Podaj dane karty płatniczej" %}</h3>
+<h3>{% trans "Podaj dane karty płatniczej do comiesięcznej darowizny" %}</h3>
 <div class="iframe">
   <form id="theform" method='POST'  action='{% url "club_payu_rec_payment" schedule.key %}'>
     {% csrf_token %}
index 0267580..c7bab6a 100644 (file)
@@ -18,6 +18,7 @@ urlpatterns = [
     path('plan/<key>/zestawienie/<int:year>/', banner_exempt(views.YearSummaryView.as_view()), name='club_year_summary'),
     path('plan/<key>/rodzaj/', banner_exempt(views.DonationStep1.as_view()), name='donation_step1'),
     path('plan/<key>/dane/', banner_exempt(views.DonationStep2.as_view()), name='donation_step2'),
+    path('plan/<key>/ustaw-miesiecznie/', views.set_monthly, name='donation_set_monthly'),
 
     path('przylacz/<key>/', views.claim, name='club_claim'),
     path('anuluj/<key>/', views.cancel, name='club_cancel'),
@@ -28,4 +29,6 @@ urlpatterns = [
     path('notify/<int:pk>/', views.PayUNotifyView.as_view(), name='club_payu_notify'),
 
     path('weryfikacja/', views.member_verify, name='club_member_verify'),
+
+    path('potwierdzenie/', views.receipt, name='club_receipt'),
 ]
index c6136b6..503a431 100644 (file)
@@ -4,7 +4,7 @@
 from django.conf import settings
 from django.contrib.auth.decorators import login_required, permission_required
 from django.db.models import Sum
-from django.http import HttpResponseRedirect
+from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
 from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
 from django.utils.decorators import method_decorator
@@ -30,6 +30,7 @@ class ClubView(TemplateView):
 
 
 
+@method_decorator(never_cache, name='dispatch')
 class DonationStep1(UpdateView):
     queryset = models.Schedule.objects.filter(payed_at=None)
     form_class = forms.DonationStep1Form
@@ -46,6 +47,7 @@ class DonationStep1(UpdateView):
         return reverse('donation_step2', args=[self.object.key])
 
 
+@method_decorator(never_cache, name='dispatch')
 class DonationStep2(UpdateView):
     queryset = models.Schedule.objects.filter(payed_at=None)
     form_class = forms.DonationStep2Form
@@ -59,6 +61,17 @@ class DonationStep2(UpdateView):
         return c
 
 
+def set_monthly(request, key):
+    schedule = get_object_or_404(models.Schedule, payed_at=None, key=key)
+    if request.POST:
+        schedule.monthly = request.POST.get('monthly') == 'true'
+        schedule.save(update_fields=['monthly'])
+    return JsonResponse({
+        "amount": schedule.amount,
+        "monthly": schedule.monthly,
+    })
+    
+
 class JoinView(CreateView):
     form_class = forms.DonationStep1Form
     template_name = 'club/donation_step1.html'
@@ -249,3 +262,26 @@ def member_verify(request):
             'result': rows
         }
     )
+
+
+@permission_required('club.schedule_view')
+def receipt(request):
+    email = request.POST.get('email')
+    try:
+        year = int(request.POST.get('year'))
+    except:
+        return HttpResponse('no content')
+
+    receipt = models.PayUOrder.generate_receipt(email, year)
+    if receipt:
+        content, optout, payments = receipt
+    else:
+        return HttpResponse('no content')
+    return HttpResponse(
+        content,
+        headers={
+            "Content-Type": "application/pdf",
+            "Content-Disposition": f'attachment; filename="wolnelektury-{year}-{email}.pdf"',
+        }
+    )
+
diff --git a/src/dictionary/migrations/0005_alter_note_sort_key.py b/src/dictionary/migrations/0005_alter_note_sort_key.py
new file mode 100644 (file)
index 0000000..3df99ae
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2026-02-18 12:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dictionary', '0004_auto_20151221_1225'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='note',
+            name='sort_key',
+            field=models.CharField(db_collation='C', db_index=True, max_length=128),
+        ),
+    ]
index 1068a96..a3ac1ac 100644 (file)
@@ -25,7 +25,7 @@ class Qualifier(models.Model):
 class Note(models.Model):
     """Represents a single annotation from a book."""
     html = models.TextField()
-    sort_key = models.CharField(max_length=128, db_index=True)
+    sort_key = models.CharField(max_length=128, db_index=True, db_collation='C')
     fn_type = models.CharField(max_length=10, db_index=True)
     qualifiers = models.ManyToManyField(Qualifier)
     language = models.CharField(max_length=10, db_index=True)
index fbcee04..e32dd04 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,17 @@ 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()]
+        if not cont_tabs: return {}
         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 +163,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/pdcounter/migrations/0004_alter_author_sort_key.py b/src/pdcounter/migrations/0004_alter_author_sort_key.py
new file mode 100644 (file)
index 0000000..2e42771
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2026-02-18 12:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('pdcounter', '0003_alter_author_options_alter_bookstub_options_and_more'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='author',
+            name='sort_key',
+            field=models.CharField(db_collation='C', db_index=True, max_length=120, verbose_name='klucz sortowania'),
+        ),
+    ]
index 622dc46..4bdfc8d 100644 (file)
@@ -13,7 +13,7 @@ from search.utils import UnaccentSearchVector
 class Author(models.Model):
     name = models.CharField('imię i nazwisko', max_length=50, db_index=True)
     slug = models.SlugField('slug', max_length=120, db_index=True, unique=True)
-    sort_key = models.CharField('klucz sortowania', max_length=120, db_index=True)
+    sort_key = models.CharField('klucz sortowania', max_length=120, db_index=True, db_collation='C')
     description = models.TextField('opis', blank=True)
     death = models.IntegerField('rok śmierci', blank=True, null=True)
     gazeta_link = models.CharField(blank=True, max_length=240)
index 8e3153a..2e3adb8 100644 (file)
@@ -25,8 +25,8 @@
     {% if author.alive %}
       <p>
         {% trans "Dzieła tego autora objęte są prawem autorskim." %}
-        {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-          <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+        {% blocktrans trimmed %}
+          Biblioteki internetowe nie mogą
           udostępniać dzieł tego autora.
         {% endblocktrans %}
       </p>
@@ -46,8 +46,8 @@
           </p>
           <div class='countdown' data-until='{{ pd_counter|date_to_utc|utc_for_js }}'></div>
           <p>
-            {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-              <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+            {% blocktrans trimmed %}
+              Biblioteki internetowe nie mogą
               udostępniać dzieł tego autora.
             {% endblocktrans %}
           </p>
index 655d7f0..02a099d 100644 (file)
               {% endblocktrans %}</p>
               <div class='countdown' data-until='{{ pd_counter|date_to_utc|utc_for_js }}'></div>
               <p>
-                {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-                  <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+                {% blocktrans trimmed %}
+                  Biblioteki internetowe nie mogą
                   udostępniać tego utworu.
                 {% endblocktrans %}
               </p>
             {% else %}
               <p>
                 {% trans "Ten utwór objęty jest prawem autorskim." %}
-                {% blocktrans trimmed with url='https://domenapubliczna.org/co-to-jest-domena-publiczna/' %}
-                  <a href="{{ url }}">Dowiedz się</a>, dlaczego biblioteki internetowe nie mogą
+                {% blocktrans trimmed %}
+                  Biblioteki internetowe nie mogą
                   udostępniać tego utworu.
                 {% endblocktrans %}
               </p>
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 d4733ff..24a6dad 100644 (file)
@@ -31,6 +31,9 @@ class Entity(models.Model):
 
     def populate_from_wikidata(self, wikidata_id):
         client = Client()
+        client.opener.addheaders = [(
+            'User-Agent', 'Wolne Lektury / https://wolnelektury.pl / Python-wikidata'
+        )]
         entity = client.get(wikidata_id)
 
         self.label = entity.label.get('pl', entity.label) or ''
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)
diff --git a/src/social/api/urls2.py b/src/social/api/urls2.py
new file mode 100644 (file)
index 0000000..3863ec6
--- /dev/null
@@ -0,0 +1,33 @@
+# This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
+# Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
+#
+from django.urls import path
+from stats.utils import piwik_track_view
+from . import views
+
+
+urlpatterns = [
+    path('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 a299304..f89de27 100644 (file)
@@ -1,20 +1,43 @@
 # 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.db.models import Q
 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 SAFE_METHODS, 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.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]
 
@@ -26,12 +49,229 @@ 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({})
 
 
+@never_cache
+class LikeView2(APIView):
+    permission_classes = [IsAuthenticated]
+
+    def get(self, request, slug):
+        book = get_object_or_404(Book, slug=slug)
+        return Response({"likes": likes(request.user, book)})
+
+    def put(self, request, slug):
+        book = get_object_or_404(Book, slug=slug)
+        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)
+        models.UserList.unlike(request.user, book)
+        return Response({"likes": likes(request.user, book)})
+
+
+@never_cache
+class LikesView(APIView):
+    permission_classes = [IsAuthenticated]
+
+    def get(self, request):
+        slugs = request.GET.getlist('slug')
+        books = Book.objects.filter(slug__in=slugs)
+        books = {b.id: b.slug for b in books}
+        ids = books.keys()
+        res = get_sets_for_book_ids(ids, request.user)
+        res = {books[bid]: v for bid, v in res.items()}
+
+        return Response(res)
+
+
+@never_cache
+class MyLikesView(APIView):
+    permission_classes = [IsAuthenticated]
+
+    def get(self, request):
+        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',
+            'slug',
+        ]
+        extra_kwargs = {
+            'slug': {
+                'required': False
+            }
+        }
+
+    def create(self, validated_data):
+        instance = models.UserList.get_by_name(
+            validated_data['user'],
+            validated_data['name'],
+            create=True
+        )
+        if 'books' in validated_data:
+            instance.userlistitem_set.all().delete()
+            for book in validated_data['books']:
+                instance.append(book)
+        return instance
+
+    def update(self, instance, validated_data):
+        super().update(instance, validated_data)
+        if 'books' in 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 = [IsAuthenticatedOrReadOnly]
+    serializer_class = UserListSerializer
+
+    def get_object(self):
+        if self.request.method in SAFE_METHODS:
+            q = Q(deleted=False)
+            if self.request.user.is_authenticated:
+                q |= Q(user=self.request.user)
+            return get_object_or_404(
+                models.UserList,
+                q,
+                slug=self.kwargs['slug'],
+            )
+        else:
+            return get_object_or_404(
+                models.UserList.all_objects.all(),
+                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.deleted = True
+        instance.updated_at = now()
+        instance.save()
+
+
+@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
 class ShelfView(ListAPIView):
     permission_classes = [IsAuthenticated]
@@ -46,7 +286,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)
@@ -59,3 +299,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.all_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/0017_userconfirmation.py b/src/social/migrations/0017_userconfirmation.py
new file mode 100644 (file)
index 0000000..fe39336
--- /dev/null
@@ -0,0 +1,25 @@
+# Generated by Django 4.0.8 on 2025-02-24 15:19
+
+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', '0016_alter_bannergroup_options_alter_carousel_options_and_more'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UserConfirmation',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created_at', models.DateTimeField(auto_now_add=True)),
+                ('key', models.CharField(max_length=128, unique=True)),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
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 cb1326b..c8d0d10 100644 (file)
@@ -1,13 +1,21 @@
 # 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
 from django.conf import settings
+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):
@@ -170,3 +178,276 @@ class CarouselItem(models.Model):
 
     def get_banner(self):
         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)
+    key = models.CharField(max_length=128, unique=True)
+
+    def send(self):
+        send_mail(
+            'Potwierdź konto w bibliotece Wolne Lektury',
+            f'https://beta.wolnelektury.pl/ludzie/potwierdz/{self.key}/',
+            settings.CONTACT_EMAIL,
+            [self.user.email]
+        )
+
+    def use(self):
+        user = self.user
+        user.is_active = True
+        user.save()
+        self.delete()
+    
+    @classmethod
+    def request(cls, user):
+        cls.objects.create(
+            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 ActiveManager(models.Manager):
+    def get_queryset(self):
+        return super().get_queryset().filter(deleted=False)
+
+
+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']
+
+    objects = ActiveManager()
+    all_objects = models.Manager()
+
+    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:]:
+                l.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):
+        n = now()
+        items = self.userlistitem_set.filter(
+            book=book,
+        )
+        if items.exists():
+            items.update(
+                deleted=False,
+                reported_timestamp=n,
+            )
+            item = items.first()
+        else:
+            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']
+
+    objects = ActiveManager()
+    all_objects = models.Manager()
+
+    @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
index c23eeaf..9e3a21f 100644 (file)
 {% endblock %}
 
 {% block main %}
+{% for list in favs %}
   <div class="l-section">
     <div class="l-author__header">
-      <h1>{% trans "Półka" %}</h1>
+      <h1>{% trans "Ulubione" %}</h1>
     </div>
   </div>
 
     <div class="l-books__header">
       <div class="l-books__input">
         <i class="icon icon-filter"></i>
-        <input type="text" placeholder="{% trans 'filtry, tytuł' %}" class="quick-filter" data-for="book-list" data-filters="with-filter">
-      </div>
-      <div class="l-books__sorting">
-        <span>{% trans "Sortuj:" %}</span>
-        <div>
-          <button data-order="data-pop">{% trans "najpopularniejsze" %}</button>
-          <button class="is-active">{% trans "alfabetycznie" %}</button>
-          <!--button>chronologicznie</button-->
-        </div>
+        <input type="text" placeholder="{% trans 'filtry, tytuł' %}" class="quick-filter" data-for="book-list-l{{ list.id }}" data-filters="with-filter">
       </div>
     </div>
   </div>
-
-  <div class="with-filter">
-
-    <div class="row">
-      <h2 class="header">&nbsp;</h2>
-      {% if suggest %}
-        <div class="filter-container">
-          {% with list_type='book' %}
-            {% for tag in suggest %}
-              <span class="filter filter-category-{{ tag.category }}">
-                <a href="{% catalogue_url list_type tag %}">{{ tag }}</a>
-              </span>
-            {% endfor %}
-          {% endwith %}
-        </div>
+  <div class="l-section l-section--col">
+    <div class="l-books__grid" id="book-list-l{{ list.id }}">
+      {% for item in list.userlistitem_set.all %}
+      {% with book=item.book %}
+      {% if book %}
+      {% include "catalogue/book_box.html" %}
       {% endif %}
+      {% endwith %}
+      {% endfor %}
     </div>
   </div>
+{% endfor %}
 
 
+{% for list in others %}
+  <div class="l-section" style="margin-top:60px;">
+    <div class="l-author__header">
+      <h1>{{ list.name }}</h1>
+    </div>
+  </div>
 
+  <div class="l-section">
+    <div class="l-books__header">
+      <div class="l-books__input">
+        <i class="icon icon-filter"></i>
+        <input type="text" placeholder="{% trans 'filtry, tytuł' %}" class="quick-filter" data-for="book-list-l{{ list.id }}" data-filters="with-filter">
+      </div>
+    </div>
+  </div>
   <div class="l-section l-section--col">
-    <div class="l-books__grid" id="book-list">
-      {% for book in books %}
-        {% include "catalogue/book_box.html" %}
+    <div class="l-books__grid" id="book-list-l{{ list.id }}">
+      {% for item in list.userlistitem_set.all %}
+      {% with book=item.book %}
+      {% if book %}
+      {% include "catalogue/book_box.html" %}
+      {% endif %}
+      {% endwith %}
       {% endfor %}
     </div>
   </div>
+{% endfor %}
 
 {% endblock %}
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
diff --git a/src/social/templates/social/user_confirmation.html b/src/social/templates/social/user_confirmation.html
new file mode 100644 (file)
index 0000000..f56d2f2
--- /dev/null
@@ -0,0 +1,12 @@
+{% extends "base_simple.html" %}
+{% load i18n %}
+
+
+{% block body %}
+  <h1>{% trans "Konto potwierdzone" %}</h1>
+
+  <p class="normal-text">
+    {% blocktrans with user=user %}Konto <strong>{{ user }}</strong> zostało potwierdzone. Możesz się teraz zalogować.{% endblocktrans %}
+  </p>
+{% endblock body %}
+
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 299f620..10d9240 100644 (file)
@@ -7,6 +7,7 @@ from . import views
 
 
 urlpatterns = [
+    path('potwierdz/<str:key>/', views.confirm_user, name='social_confirm_user'),
     path('lektura/<slug:slug>/lubie/', views.like_book, name='social_like_book'),
     path('dodaj-tag/', views.AddSetView.as_view(), name='social_add_set_tag'),
     path('usun-tag/', views.RemoveSetView.as_view(), name='social_remove_set_tag'),
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 989771a..d9e5848 100644 (file)
@@ -8,9 +8,8 @@ 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 social import forms
+from catalogue.models import Book
+from social import forms, models
 from wolnelektury.utils import is_ajax
 
 
@@ -19,13 +18,14 @@ from wolnelektury.utils import is_ajax
 # ====================
 
 
-@require_POST
+@login_required
 def like_book(request, slug):
-    if not request.user.is_authenticated:
-        return HttpResponseForbidden('Login required.')
     book = get_object_or_404(Book, slug=slug)
 
-    book.like(request.user)
+    if request.method != 'POST':
+        return redirect(book)
+
+    models.UserList.like(request.user, book)
 
     if is_ajax(request):
         return JsonResponse({"success": True, "msg": "ok", "like": True})
@@ -50,13 +50,14 @@ class RemoveSetView(AddSetView):
     form_class = forms.RemoveSetForm
 
 
-@require_POST
+@login_required
 def unlike_book(request, slug):
-    if not request.user.is_authenticated:
-        return HttpResponseForbidden('Login required.')
     book = get_object_or_404(Book, slug=slug)
 
-    book.unlike(request.user)
+    if request.method != 'POST':
+        return redirect(book)
+
+    models.UserList.unlike(request.user, book)
 
     if is_ajax(request):
         return JsonResponse({"success": True, "msg": "ok", "like": False})
@@ -64,36 +65,38 @@ def unlike_book(request, slug):
         return redirect(book)
 
 
+@never_cache
 @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)
+    favs = request.user.userlist_set.filter(deleted=False, favorites=True)
+    others = request.user.userlist_set.filter(deleted=False, favorites=False)
+    ulists = list(request.user.userlist_set.filter(deleted=False))
+    suggest = [t for t in ulists if t.name]
         
     return render(request, template_name, {
-        'tags': tags,
-        'books': Book.tagged.with_any(tags),
-        'suggest': suggest,
+        'favs': favs,
+        'others': others,
+        #'books': Book.objects.filter(userlistitem__list__user=request.user),
     })
 
 
 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,
+        deleted=False,
+        list__deleted=False
+    ).order_by('list__name')
     for t in tagged:
-        # related?
-        item = data.setdefault(t.object_id, [])
-        if t.tag.name:
-            item.append({
-                "slug": t.tag.slug,
-                "url": t.tag.get_absolute_url(),
-                "name": t.tag.name,
-            })
+        item = data.setdefault(t.book_id, [])
+        item.append({
+            "slug": t.list.slug,
+            "url": t.list.get_absolute_url(),
+            "name": t.list.name,
+            "favorites": t.list.favorites,
+        })
     for b in book_ids:
         if b not in data:
             data[b] = None
@@ -117,11 +120,20 @@ 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
     )
+
+
+def confirm_user(request, key):
+    uc = get_object_or_404(models.UserConfirmation, key=key)
+    user = uc.user
+    uc.use()
+    return render(request, 'social/user_confirmation.html', {
+        'user': user,
+    })
index 2fb4d89..80412fc 100644 (file)
@@ -24,6 +24,11 @@ class Sponsor(models.Model):
     def __str__(self):
         return self.name
 
+    def save(self, *args, **kwargs):
+        super().save(*args, **kwargs)
+        for page in SponsorPage.objects.all():
+            page.save()
+
     def description(self):
         if len(self._description):
             return self._description
index d4faa8b..b9d507e 100644 (file)
@@ -59,6 +59,7 @@ INSTALLED_APPS_CONTRIB = [
     'django.contrib.postgres',
     'admin_ordering',
     'rest_framework',
+    'django_filters',
     'fnp_django_pagination',
     'pipeline',
     'sorl.thumbnail',
index 372490f..89e520f 100644 (file)
@@ -2,10 +2,13 @@
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 AUTHENTICATION_BACKENDS = [
-    'django.contrib.auth.backends.ModelBackend',
-    'allauth.account.auth_backends.AuthenticationBackend',
+    #'django.contrib.auth.backends.ModelBackend',
+    'allauth.account.auth_backends.AuthenticationBackend',
 ]
 ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 2
+ACCOUNT_AUTHENTICATION_METHOD = "username_email"
+FEATURE_CONFIRM_USER = False
+
 LOGIN_URL = '/uzytkownik/login/'
 
 LOGIN_REDIRECT_URL = '/'
index aa50736..869a78d 100644 (file)
@@ -1,8 +1,7 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
-from os import path
-from .paths import PROJECT_DIR
+import os
 
 DEBUG = True
 
@@ -14,15 +13,34 @@ MANAGERS = ADMINS
 
 CONTACT_EMAIL = 'fundacja@wolnelektury.pl'
 
+ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split()
+
 CACHE_MIDDLEWARE_SECONDS = 3 * 60
 
+CACHES = {
+    'default': {
+        'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
+        'TIMEOUT': 180,
+        'LOCATION': [
+            'memcached:11211',
+        ],
+    },
+    'template_fragments': {
+        'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
+        'TIMEOUT': 86400,
+        'LOCATION': [
+            'memcached:11211',
+        ],
+    },
+}
+
 DATABASES = {
     'default': {
-        'ENGINE': 'django.db.backends.sqlite3',  # 'postgresql_psycopg2'
-        'NAME': path.join(PROJECT_DIR, 'dev.db'),
-        'USER': '',                      # Not used with sqlite3.
-        'PASSWORD': '',                  # Not used with sqlite3.
-        'HOST': '',                      # Set to empty string for localhost. Not used with sqlite3.
+        'ENGINE': 'django.db.backends.postgresql',
+        'HOST': 'db',
+        'USER': os.environ.get('POSTGRES_USER'),
+        'PASSWORD': os.environ.get('POSTGRES_PASSWORD'),
+        'NAME': os.environ.get('POSTGRES_USER'),
     }
 }
 
index 3789863..1abda08 100644 (file)
@@ -1,6 +1,7 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
+CELERY_BROKER_URL = 'redis://redis'
 CELERY_TASK_EAGER_PROPAGATES = True
 CELERY_SEND_TASK_ERROR_EMAILS = True
 CELERY_ACCEPT_CONTENT = ['pickle']
index e5b2f60..465286b 100644 (file)
@@ -33,7 +33,7 @@ REST_FRAMEWORK = {
         'rest_framework.authentication.SessionAuthentication',
     ),
     'DEFAULT_PAGINATION_CLASS': 'api.pagination.WLLimitOffsetPagination',
-    'PAGE_SIZE': 10,
+    'PAGE_SIZE': 20,
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
 }
 
@@ -43,13 +43,6 @@ DEBUG_TOOLBAR_CONFIG = {
 }
 
 
-HAYSTACK_CONNECTIONS = {
-    'default': {
-        'ENGINE': 'haystack.backends.simple_backend.SimpleEngine',
-    },
-}
-
-
 FORMS_BUILDER_USE_SITES = False
 FORMS_BUILDER_EDITABLE_FIELD_MAX_LENGTH = True
 FORMS_BUILDER_EDITABLE_SLUGS = True
index 16b5e0a..4400319 100644 (file)
@@ -22,7 +22,10 @@ LIBRARIAN_PDF_MOREFLOATS = None
 
 LATEST_BLOG_POSTS = "https://fundacja.wolnelektury.pl/feed/?cat=-135"
 
-CATALOGUE_COUNTERS_FILE = os.path.join(VAR_DIR, 'catalogue_counters.p')
+CATALOGUE_COUNTERS_FILE = os.path.join(VAR_DIR, 'counters/catalogue_counters.p')
+LESMIANATOR_PICKLE = os.path.join(VAR_DIR, 'counters/lesmianator.p')
+
+NO_SEARCH_INDEX = False
 
 CATALOGUE_MIN_INITIALS = 60
 
@@ -55,8 +58,6 @@ NEWSLETTER_PHPLIST_SUBSCRIBE_URL = None
 VARIANTS = {
 }
 
-EPUB_FUNDRAISING = []
-
 CIVICRM_BASE = None
 CIVICRM_KEY = None
 
@@ -76,3 +77,5 @@ SEARCH_CONFIG_SIMPLE = 'simple'
 SEARCH_USE_UNACCENT = False
 
 FEATURE_SYNCHRO = False
+
+FEATURE_API_REGISTER = False
index c4af4eb..9c5f34f 100644 (file)
@@ -10,7 +10,7 @@
 
 .c-media__actions {
     display: flex;
-    column-gap: 38px;
+    column-gap: 20px;
     row-gap: 10px;
 }
 
index 52729f9..d1fb096 100644 (file)
           display: inline;
       }
     }
+    div.title {
+      font-size: 12px;
+      line-height: 140%;
+      letter-spacing: 0.05em;
+      list-style-type: decimal;
+      margin: 5px 0;
+      color: white;
+      cursor: pointer;
+    }
   }
 }
 
index 9d90c65..5c76498 100644 (file)
@@ -51,7 +51,7 @@
     .annoy-banner-off {
         font-size: 1.2rem !important;
     }
-    
+
     @include rwd($break-flow) {
         .annoy-banner-inner {
             padding: 0;
 
         }
         .annoy-banner-off {
-            
+
         }
     }
 }
 
 
-.annoy-banner_crisis-container {
+.annoy-banner_crisis-container,
+.annoy-banner_top-container {
     position: sticky;
     top: 0;
-    height: 160px;
     z-index: 10;
     box-shadow: 0 0 10px black;
     display: flex;
     align-items:center;
     cursor: pointer;
 
-    @media screen and (min-height: 480px) {
-       height: 33vh;
-       top: calc(-33vh + 160px);
+    &.annoy-banner_top-container {
+       padding: 16px 0;
+
+       @media screen and (max-height: 700px) {
+           padding: 5px 0;
+       }
     }
+    &.annoy-banner_crisis-container {
+       height: 160px;
 
-    @media screen and (max-width: 940px) {
-       padding: 10px 0;
-       height: auto;
-       top: 0;
+       @media screen and (min-height: 480px) {
+           height: 33vh;
+           top: calc(-33vh + 160px);
+       }
+
+       @media screen and (max-width: 940px) {
+           padding: 10px 0;
+           height: auto;
+           top: 0;
+       }
     }
 
-    .annoy-banner_crisis {
+
+    .annoy-banner_crisis,
+    .annoy-banner_top {
        position: sticky;
        top: 0;
        width: 100%;
            .image-box {
                position: relative;
                img {
-                   height: 159px;
+                   max-height: 159px;
                    display: block;
 
                    @media screen and (max-width: 700px) {
-                       height: 120px;
+                       max-height: 120px;
                    }
                }
            }
                    }
                }
 
+               @media screen and (max-height: 700px) {
+                   flex-direction: row;
+                   justify-content: space-between;
+                   p {
+                       font-size: .9em;
+                   }
+               }
+
+
                .text {
                    background: #edc016;
                    padding: 1em;
                    border: 3px solid black;
                }
+
+               @media screen and (max-height: 400px) {
+                   align-items: center;
+                   .text {
+                       height: 1.2em;
+                       overflow: hidden;
+                   }
+               }
+
                a {
                    color: #c32721;
                }
                display: block;
                transition: background-color .2s;
 
+               @media screen and (max-height: 700px) {
+                   font-size: .9em;
+               }
+               @media screen and (max-height: 400px) {
+                   white-space: nowrap;
+               }
+
                &:hover {
                    background: #ffd430;
                    text-decoration: none;
            }
        }
     }
-    &.annoy-banner-style_crisis_quiet {
+    &.annoy-banner-style_crisis_quiet,
+    &.annoy-banner_top-container {
        background: black;
        color: white;
        .annoy-banner-inner {
        }
     }
 }
+
+
+
+.annoy-banner_book-page {
+    background-color: #ffd430;
+    color: #083F4D;
+    border-radius: 10px;
+    padding: 15px 20px;
+    margin-top: 20px;
+
+    .annoy-banner-inner {
+       display: flex;
+       flex-direction: row;
+       gap: 20px;
+       align-items: flex-start;
+       justify-content: space-between;
+
+       p {
+           margin: 0;
+       }
+       a {
+           line-height: 1.35;
+           color: #c32721;
+           white-space: nowrap;
+           border: solid #c32721;
+           border-width: 0 0 1px;
+
+           &:hover {
+               text-decoration: none;
+               border-bottom-width: 2px;
+           }
+       }
+    }
+}
+
+.annoy-banner_book-page-center {
+    background: white;
+    margin-top: 50px;
+    padding: 20px;
+    border: 1px solid #018189;
+    border-radius: 15px;
+    color:#018189;
+
+    .annoy-banner-inner {
+       display: flex;
+       gap: 15px;
+       align-items: center;
+
+       img {
+           width: 150px;
+       }
+       p {
+           margin: 0;
+       }
+    }
+}
index 78b33bb..b1844d8 100644 (file)
     @include rwd($break-flow) {
         @include font-size(16px);
         line-height: 20px;
-        padding: 19px 20px;
+        padding: 19px 0;
     }
     
     .icon {
       font-size: 21px;
-      margin-right: 15px;
+      margin-right: 10px;
+    }
+
+    img {
+        height: 21px;
     }
 
     &:hover {
index 593794b..888df46 100644 (file)
   display: flex;
   background: #083F4D;
 
-  img {
+  .l-checkout__box__header__img {
       display: none;
+      background-position: center;
+      background-size: cover;
       @include rwd($break-flow) {
           display: block;
+          width: 50%;
       }
   }
 }
     }
   }
   .l-checkout__payments__box__btn-wrp {
-    padding: 0 20px 20px 20px;
+      padding: 20px;
     margin-bottom: 0;
     margin-top: auto;
-    @include rwd($break-flow) {
-        padding-top: 20px;
-    }
   }
   p {
     margin-top: 0;
     }
   }
   button {
-    height: 56px;
+      margin: 0;
+      font-family: "Source Sans Pro",sans-serif;
+    font-weight: bold;
+    font-size: 44px;
+    letter-spacing: -0.01em;
+    min-height: 90px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: all $ease-out 250ms;
+
     background: #FFFFFF;
     border: 1px solid #92BD39;
     border-radius: 3px;
     width: 100%;
     outline: 0;
     cursor: pointer;
-    font-weight: 600;
-    font-size: 16px;
-    line-height: 24px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
     text-align: center;
     color: #083F4D;
-    transition: background $ease-out 250ms;
 
+    flex-direction: column;
     @include rwd($break-flow) {
-        font-size: 20px;
-        line-height: 25px;
+        flex-direction: row;
+        line-height: 130%;
+    }
+
+    span {
+      font-weight: 600;
+      font-size: 25px;
+      letter-spacing: -0.01em;
+      color: #92BD39;
+      margin-left: 10px;
+      transition: opacity $ease-out 250ms;
     }
     
     &:hover {
       &:hover {
         background: #83AD2B;
       }
+      span {
+         color: white;
+      }
     }
   }
 
         }
     }
 }
+
+
+.if-monthly { display: none; }
+.is-monthly {
+    .if-monthly {
+       display: block;
+    }
+    .if-not-monthly {
+       display: none;
+    }
+}
+
+
+.checkout-infobar {
+    margin: 0 0 20px;
+    padding: 20px;
+    border-radius: 10px;
+    border: 1px solid #edc016;
+    background: #edc016;
+}
index c4d4f7a..a47875b 100644 (file)
@@ -161,6 +161,15 @@ body {
               color: white;
           }
       }
+
+      .menubar-donate {
+         color: #fff;
+         background: #c92834;
+         padding: 9px 20px 11px;
+         font-weight: 600;
+         margin-right: 20px;
+         border-radius: 15px;
+      }
   }
 
     .user {
@@ -267,6 +276,10 @@ body {
               display: block;
               left: 16px;
           }
+
+         .menubar-donate {
+             display: none;
+         }
       }
   }
 }
index 65bc48a..c4ff0f3 100644 (file)
@@ -294,6 +294,7 @@ h2 {
 h3, .subtitle2 {
     font-size: 1.5em;
     margin: 1.5em 0 1em 0;
+    padding-right: 48px;
     font-weight: normal;
     line-height: 1.5em;
 }
@@ -1044,3 +1045,101 @@ background: #fff;
         }
     }
 }
+
+
+
+#sidebar {
+    position: absolute;
+    left: 0;
+    top: 20px;
+    width: 200px;
+
+    h2 {
+       font-size: 20px;
+       margin-bottom: 1em;
+    }
+    
+    .other-text-close {
+       display: none;
+    }
+
+    #other {
+        display: none;
+        @include rwd($break-wide) {
+            display: block;
+        }
+       ul {
+           list-style: none;
+           margin: 0;
+           padding: 0;
+
+           .book-mini-box {
+               position: relative;
+               width: 137px;
+               .language {
+                   position: absolute;
+                   top: 163px;
+                   left: 10px;
+                   color: white;
+                   background: black;
+                   padding: 0 10px;
+                   border-radius: 10px;
+                   font-size: 14px;
+                   line-height: 20px;
+                   
+               }
+               .author, .title {
+                   display: block;
+                   font-size: 14px;
+                   line-height: 18px;
+               }
+           }
+       }
+    }
+}
+.with-other-text {
+    #sidebar {
+       .other-text-close {
+           display: block;
+           background: red;
+           padding: 10px;
+           margin: 10px 0;
+           border-radius: 10px;
+           color: white;
+           text-decoration: none;
+       }
+    }
+    #main-text {
+       #book-text {
+           .other {
+               overflow: hidden;
+               margin: 10px 40px 10px 50px;
+               padding: 10px 20px 0 10px;
+               background: #eee;
+               border-left: 10px double #ddd;
+
+               h3 {
+                   margin: 0;
+                   padding: 0;
+               }
+
+               .paragraph {
+                   padding: 0;
+               }
+           }
+       }
+    }
+}
+
+:lang(pl),
+:lang(en),
+:lang(de),
+:lang(fr),
+:lang(lt),
+:lang(uk)
+{
+    direction: ltr;
+}
+:lang(he) {
+    direction: rtl;
+}
index bf0fb2b..4f85a03 100644 (file)
 
 
 #menu {
-    padding-bottom: 50px;
     * {
         box-sizing: content-box;
     }
diff --git a/src/wolnelektury/static/img/elevenreader-21.png b/src/wolnelektury/static/img/elevenreader-21.png
new file mode 100644 (file)
index 0000000..86e433d
Binary files /dev/null and b/src/wolnelektury/static/img/elevenreader-21.png differ
diff --git a/src/wolnelektury/static/img/elevenreader.png b/src/wolnelektury/static/img/elevenreader.png
new file mode 100644 (file)
index 0000000..16dcade
Binary files /dev/null and b/src/wolnelektury/static/img/elevenreader.png differ
index eff64a0..dde96ed 100644 (file)
@@ -94,33 +94,85 @@ $("#menu a").each(function() {
 $("#menu-other").show();
 
 
+    function insertOtherText(text) {
+       let tree = $(text);
+       let lang = tree.attr('lang') || 'pl';
+       
+       // toc?
+       // themes?
+
+       let cursor = $(".main-text-body #book-text").children().first();
+       // wstawiamy przed kursorem
+       lastTarget = '';
+       tree.children().each((i, e) => {
+           let $e = $(e);
+
+           if ($e.hasClass('anchor')) return;
+           if ($e.hasClass('numeracja')) return;
+           if ($e.attr('id') == 'toc') return;
+           if ($e.attr('id') == 'nota_red') return;
+           if ($e.attr('id') == 'themes') return;
+           if ($e.attr('name') && $e.attr('name').startsWith('sec')) return;
+           
+           if ($e.hasClass('target')) {
+               let target = $e.attr('name');
+
+               while (lastTarget != target) {
+                   let nc = cursor.next();
+                   if (!nc.length) {
+                       break;
+                   }
+                   cursor = nc;
+                   lastTarget = cursor.attr('name');
+               }
+
+               while (true) {
+                   let nc = cursor.next();
+                   if (!nc.length) {
+                       break;
+                   }
+                   cursor = nc;
+                   lastTarget = cursor.attr('name');
+                   if (lastTarget) break;
+               }
+               
+           } else {
+               let d = $('<div class="other">');
+               d.attr('lang', lang);
+               d.append(e);
+               d.insertBefore(cursor);
+           }
+       });
+    }
+    
 /* Load other version of text. */
 $(".display-other").click(function(e) {
     e.preventDefault();
     release_menu();
 
-    $("#other-text").show();
+    $(".other").remove();
     $("body").addClass('with-other-text');
 
     $.ajax($(this).attr('data-other'), {
         success: function(text) {
-            $("#other-text-body").html(text);
+           insertOtherText(text);
             $("#other-text-waiter").hide();
-            $("#other-text-body").show();
-            loaded_text($("#other-text-body"));
+            loaded_text($(".other"));
         }
     });
     _paq.push(['trackEvent', 'html', 'other-text']);
 });
 
 
+
+    
+
 /* Remove other version of text. */
 $(".other-text-close").click(function(e) {
     release_menu();
     e.preventDefault();
-    $("#other-text").hide();
+    $(".other").remove();
     $("body").removeClass('with-other-text');
-    $("#other-text-body").html("");
     _paq.push(['trackEvent', 'html', 'other-text-close']);
 });
 
index ae5b14a..723e03e 100644 (file)
@@ -4,6 +4,8 @@
 
     function upd_t() {
         $text = $('#main-text #book-text');
+        if (!$text.length) return;
+
         texttop = $text.offset().top;
 
         $footnotes = $('#footnotes', $text);
index fa4a35a..3f5b7e3 100644 (file)
     }
 
     var map_enabled = false;
-    var marker = L.circleMarker([0,0]);
+    var marker = null;
     var map = null;
 
     function enable_map() {
+        if (!$("#reference-map").length) return;
+
         $("#reference-map").show('slow');
 
         if (map_enabled) return;
@@ -22,6 +24,7 @@
         L.tileLayer('https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=a8a97f0ae5134403ac38c1a075b03e15', {
             attribution: 'Maps © <a href="http://www.thunderforest.com">Thunderforest</a>, Data © <a href="http://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>'
         }).addTo(map);
+        marker = L.circleMarker([0,0]);
 
         map_enabled = true;
     }
index 842f31b..0ab3569 100644 (file)
  * Search terms: "jQuery Switch" and "Zepto Switch"
  */
 
+/* WL changes:
+   support times > 24h
+*/
+
 (function (root, factory) {
        if (typeof define === 'function' && define.amd) {
                // AMD. Register as an anonymous module.
                        s = (s && typeof s === 'number') ? s : 0;
 
                        var myTime = new Date(s * 1000),
-                               hour = myTime.getUTCHours(),
+                               hour = myTime.getUTCHours() + (myTime.getUTCDate() - 1) * 24,
                                min = this.options.timeFormat.showHour ? myTime.getUTCMinutes() : myTime.getUTCMinutes() + hour * 60,
                                sec = this.options.timeFormat.showMin ? myTime.getUTCSeconds() : myTime.getUTCSeconds() + min * 60,
                                strHour = (this.options.timeFormat.padHour && hour < 10) ? "0" + hour : hour,
index 99f64d7..4390d8c 100644 (file)
@@ -1,3 +1,3 @@
 /*! jPlayer 2.9.2 for jQuery ~ (c) 2009-2014 Happyworm Ltd ~ MIT License */
-!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],b):b("object"==typeof exports?require("jquery"):a.jQuery?a.jQuery:a.Zepto)}(this,function(a,b){a.fn.jPlayer=function(c){var d="jPlayer",e="string"==typeof c,f=Array.prototype.slice.call(arguments,1),g=this;return c=!e&&f.length?a.extend.apply(null,[!0,c].concat(f)):c,e&&"_"===c.charAt(0)?g:(this.each(e?function(){var e=a(this).data(d),h=e&&a.isFunction(e[c])?e[c].apply(e,f):e;return h!==e&&h!==b?(g=h,!1):void 0}:function(){var b=a(this).data(d);b?b.option(c||{}):a(this).data(d,new a.jPlayer(c,this))}),g)},a.jPlayer=function(b,c){if(arguments.length){this.element=a(c),this.options=a.extend(!0,{},this.options,b);var d=this;this.element.bind("remove.jPlayer",function(){d.destroy()}),this._init()}},"function"!=typeof a.fn.stop&&(a.fn.stop=function(){}),a.jPlayer.emulateMethods="load play pause",a.jPlayer.emulateStatus="src readyState networkState currentTime duration paused ended playbackRate",a.jPlayer.emulateOptions="muted volume",a.jPlayer.reservedEvent="ready flashreset resize repeat error warning",a.jPlayer.event={},a.each(["ready","setmedia","flashreset","resize","repeat","click","error","warning","loadstart","progress","suspend","abort","emptied","stalled","play","pause","loadedmetadata","loadeddata","waiting","playing","canplay","canplaythrough","seeking","seeked","timeupdate","ended","ratechange","durationchange","volumechange"],function(){a.jPlayer.event[this]="jPlayer_"+this}),a.jPlayer.htmlEvent=["loadstart","abort","emptied","stalled","loadedmetadata","canplay","canplaythrough"],a.jPlayer.pause=function(){a.jPlayer.prototype.destroyRemoved(),a.each(a.jPlayer.prototype.instances,function(a,b){b.data("jPlayer").status.srcSet&&b.jPlayer("pause")})},a.jPlayer.timeFormat={showHour:!1,showMin:!0,showSec:!0,padHour:!1,padMin:!0,padSec:!0,sepHour:":",sepMin:":",sepSec:""};var c=function(){this.init()};c.prototype={init:function(){this.options={timeFormat:a.jPlayer.timeFormat}},time:function(a){a=a&&"number"==typeof a?a:0;var b=new Date(1e3*a),c=b.getUTCHours(),d=this.options.timeFormat.showHour?b.getUTCMinutes():b.getUTCMinutes()+60*c,e=this.options.timeFormat.showMin?b.getUTCSeconds():b.getUTCSeconds()+60*d,f=this.options.timeFormat.padHour&&10>c?"0"+c:c,g=this.options.timeFormat.padMin&&10>d?"0"+d:d,h=this.options.timeFormat.padSec&&10>e?"0"+e:e,i="";return i+=this.options.timeFormat.showHour?f+this.options.timeFormat.sepHour:"",i+=this.options.timeFormat.showMin?g+this.options.timeFormat.sepMin:"",i+=this.options.timeFormat.showSec?h+this.options.timeFormat.sepSec:""}};var d=new c;a.jPlayer.convertTime=function(a){return d.time(a)},a.jPlayer.uaBrowser=function(a){var b=a.toLowerCase(),c=/(webkit)[ \/]([\w.]+)/,d=/(opera)(?:.*version)?[ \/]([\w.]+)/,e=/(msie) ([\w.]+)/,f=/(mozilla)(?:.*? rv:([\w.]+))?/,g=c.exec(b)||d.exec(b)||e.exec(b)||b.indexOf("compatible")<0&&f.exec(b)||[];return{browser:g[1]||"",version:g[2]||"0"}},a.jPlayer.uaPlatform=function(a){var b=a.toLowerCase(),c=/(ipad|iphone|ipod|android|blackberry|playbook|windows ce|webos)/,d=/(ipad|playbook)/,e=/(android)/,f=/(mobile)/,g=c.exec(b)||[],h=d.exec(b)||!f.exec(b)&&e.exec(b)||[];return g[1]&&(g[1]=g[1].replace(/\s/g,"_")),{platform:g[1]||"",tablet:h[1]||""}},a.jPlayer.browser={},a.jPlayer.platform={};var e=a.jPlayer.uaBrowser(navigator.userAgent);e.browser&&(a.jPlayer.browser[e.browser]=!0,a.jPlayer.browser.version=e.version);var f=a.jPlayer.uaPlatform(navigator.userAgent);f.platform&&(a.jPlayer.platform[f.platform]=!0,a.jPlayer.platform.mobile=!f.tablet,a.jPlayer.platform.tablet=!!f.tablet),a.jPlayer.getDocMode=function(){var b;return a.jPlayer.browser.msie&&(document.documentMode?b=document.documentMode:(b=5,document.compatMode&&"CSS1Compat"===document.compatMode&&(b=7))),b},a.jPlayer.browser.documentMode=a.jPlayer.getDocMode(),a.jPlayer.nativeFeatures={init:function(){var a,b,c,d=document,e=d.createElement("video"),f={w3c:["fullscreenEnabled","fullscreenElement","requestFullscreen","exitFullscreen","fullscreenchange","fullscreenerror"],moz:["mozFullScreenEnabled","mozFullScreenElement","mozRequestFullScreen","mozCancelFullScreen","mozfullscreenchange","mozfullscreenerror"],webkit:["","webkitCurrentFullScreenElement","webkitRequestFullScreen","webkitCancelFullScreen","webkitfullscreenchange",""],webkitVideo:["webkitSupportsFullscreen","webkitDisplayingFullscreen","webkitEnterFullscreen","webkitExitFullscreen","",""],ms:["","msFullscreenElement","msRequestFullscreen","msExitFullscreen","MSFullscreenChange","MSFullscreenError"]},g=["w3c","moz","webkit","webkitVideo","ms"];for(this.fullscreen=a={support:{w3c:!!d[f.w3c[0]],moz:!!d[f.moz[0]],webkit:"function"==typeof d[f.webkit[3]],webkitVideo:"function"==typeof e[f.webkitVideo[2]],ms:"function"==typeof e[f.ms[2]]},used:{}},b=0,c=g.length;c>b;b++){var h=g[b];if(a.support[h]){a.spec=h,a.used[h]=!0;break}}if(a.spec){var i=f[a.spec];a.api={fullscreenEnabled:!0,fullscreenElement:function(a){return a=a?a:d,a[i[1]]},requestFullscreen:function(a){return a[i[2]]()},exitFullscreen:function(a){return a=a?a:d,a[i[3]]()}},a.event={fullscreenchange:i[4],fullscreenerror:i[5]}}else a.api={fullscreenEnabled:!1,fullscreenElement:function(){return null},requestFullscreen:function(){},exitFullscreen:function(){}},a.event={}}},a.jPlayer.nativeFeatures.init(),a.jPlayer.focus=null,a.jPlayer.keyIgnoreElementNames="A INPUT TEXTAREA SELECT BUTTON";var g=function(b){var c,d=a.jPlayer.focus;d&&(a.each(a.jPlayer.keyIgnoreElementNames.split(/\s+/g),function(a,d){return b.target.nodeName.toUpperCase()===d.toUpperCase()?(c=!0,!1):void 0}),c||a.each(d.options.keyBindings,function(c,e){return e&&a.isFunction(e.fn)&&("number"==typeof e.key&&b.which===e.key||"string"==typeof e.key&&b.key===e.key)?(b.preventDefault(),e.fn(d),!1):void 0}))};a.jPlayer.keys=function(b){var c="keydown.jPlayer";a(document.documentElement).unbind(c),b&&a(document.documentElement).bind(c,g)},a.jPlayer.keys(!0),a.jPlayer.prototype={count:0,version:{script:"2.9.2",needFlash:"2.9.0",flash:"unknown"},options:{swfPath:"js",solution:"html, flash",supplied:"mp3",auroraFormats:"wav",preload:"metadata",volume:.8,muted:!1,remainingDuration:!1,toggleDuration:!1,captureDuration:!0,playbackRate:1,defaultPlaybackRate:1,minPlaybackRate:.5,maxPlaybackRate:4,wmode:"opaque",backgroundColor:"#000000",cssSelectorAncestor:"#jp_container_1",cssSelector:{videoPlay:".jp-video-play",play:".jp-play",pause:".jp-pause",stop:".jp-stop",seekBar:".jp-seek-bar",playBar:".jp-play-bar",mute:".jp-mute",unmute:".jp-unmute",volumeBar:".jp-volume-bar",volumeBarValue:".jp-volume-bar-value",volumeMax:".jp-volume-max",playbackRateBar:".jp-playback-rate-bar",playbackRateBarValue:".jp-playback-rate-bar-value",currentTime:".jp-current-time",duration:".jp-duration",title:".jp-title",fullScreen:".jp-full-screen",restoreScreen:".jp-restore-screen",repeat:".jp-repeat",repeatOff:".jp-repeat-off",gui:".jp-gui",noSolution:".jp-no-solution"},stateClass:{playing:"jp-state-playing",seeking:"jp-state-seeking",muted:"jp-state-muted",looped:"jp-state-looped",fullScreen:"jp-state-full-screen",noVolume:"jp-state-no-volume"},useStateClassSkin:!1,autoBlur:!0,smoothPlayBar:!1,fullScreen:!1,fullWindow:!1,autohide:{restored:!1,full:!0,fadeIn:200,fadeOut:600,hold:1e3},loop:!1,repeat:function(b){b.jPlayer.options.loop?a(this).unbind(".jPlayerRepeat").bind(a.jPlayer.event.ended+".jPlayer.jPlayerRepeat",function(){a(this).jPlayer("play")}):a(this).unbind(".jPlayerRepeat")},nativeVideoControls:{},noFullWindow:{msie:/msie [0-6]\./,ipad:/ipad.*?os [0-4]\./,iphone:/iphone/,ipod:/ipod/,android_pad:/android [0-3]\.(?!.*?mobile)/,android_phone:/(?=.*android)(?!.*chrome)(?=.*mobile)/,blackberry:/blackberry/,windows_ce:/windows ce/,iemobile:/iemobile/,webos:/webos/},noVolume:{ipad:/ipad/,iphone:/iphone/,ipod:/ipod/,android_pad:/android(?!.*?mobile)/,android_phone:/android.*?mobile/,blackberry:/blackberry/,windows_ce:/windows ce/,iemobile:/iemobile/,webos:/webos/,playbook:/playbook/},timeFormat:{},keyEnabled:!1,audioFullScreen:!1,keyBindings:{play:{key:80,fn:function(a){a.status.paused?a.play():a.pause()}},fullScreen:{key:70,fn:function(a){(a.status.video||a.options.audioFullScreen)&&a._setOption("fullScreen",!a.options.fullScreen)}},muted:{key:77,fn:function(a){a._muted(!a.options.muted)}},volumeUp:{key:190,fn:function(a){a.volume(a.options.volume+.1)}},volumeDown:{key:188,fn:function(a){a.volume(a.options.volume-.1)}},loop:{key:76,fn:function(a){a._loop(!a.options.loop)}}},verticalVolume:!1,verticalPlaybackRate:!1,globalVolume:!1,idPrefix:"jp",noConflict:"jQuery",emulateHtml:!1,consoleAlerts:!0,errorAlerts:!1,warningAlerts:!1},optionsAudio:{size:{width:"0px",height:"0px",cssClass:""},sizeFull:{width:"0px",height:"0px",cssClass:""}},optionsVideo:{size:{width:"480px",height:"270px",cssClass:"jp-video-270p"},sizeFull:{width:"100%",height:"100%",cssClass:"jp-video-full"}},instances:{},status:{src:"",media:{},paused:!0,format:{},formatType:"",waitForPlay:!0,waitForLoad:!0,srcSet:!1,video:!1,seekPercent:0,currentPercentRelative:0,currentPercentAbsolute:0,currentTime:0,duration:0,remaining:0,videoWidth:0,videoHeight:0,readyState:0,networkState:0,playbackRate:1,ended:0},internal:{ready:!1},solution:{html:!0,aurora:!0,flash:!0},format:{mp3:{codec:"audio/mpeg",flashCanPlay:!0,media:"audio"},m4a:{codec:'audio/mp4; codecs="mp4a.40.2"',flashCanPlay:!0,media:"audio"},m3u8a:{codec:'application/vnd.apple.mpegurl; codecs="mp4a.40.2"',flashCanPlay:!1,media:"audio"},m3ua:{codec:"audio/mpegurl",flashCanPlay:!1,media:"audio"},oga:{codec:'audio/ogg; codecs="vorbis, opus"',flashCanPlay:!1,media:"audio"},flac:{codec:"audio/x-flac",flashCanPlay:!1,media:"audio"},wav:{codec:'audio/wav; codecs="1"',flashCanPlay:!1,media:"audio"},webma:{codec:'audio/webm; codecs="vorbis"',flashCanPlay:!1,media:"audio"},fla:{codec:"audio/x-flv",flashCanPlay:!0,media:"audio"},rtmpa:{codec:'audio/rtmp; codecs="rtmp"',flashCanPlay:!0,media:"audio"},m4v:{codec:'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',flashCanPlay:!0,media:"video"},m3u8v:{codec:'application/vnd.apple.mpegurl; codecs="avc1.42E01E, mp4a.40.2"',flashCanPlay:!1,media:"video"},m3uv:{codec:"audio/mpegurl",flashCanPlay:!1,media:"video"},ogv:{codec:'video/ogg; codecs="theora, vorbis"',flashCanPlay:!1,media:"video"},webmv:{codec:'video/webm; codecs="vorbis, vp8"',flashCanPlay:!1,media:"video"},flv:{codec:"video/x-flv",flashCanPlay:!0,media:"video"},rtmpv:{codec:'video/rtmp; codecs="rtmp"',flashCanPlay:!0,media:"video"}},_init:function(){var c=this;if(this.element.empty(),this.status=a.extend({},this.status),this.internal=a.extend({},this.internal),this.options.timeFormat=a.extend({},a.jPlayer.timeFormat,this.options.timeFormat),this.internal.cmdsIgnored=a.jPlayer.platform.ipad||a.jPlayer.platform.iphone||a.jPlayer.platform.ipod,this.internal.domNode=this.element.get(0),this.options.keyEnabled&&!a.jPlayer.focus&&(a.jPlayer.focus=this),this.androidFix={setMedia:!1,play:!1,pause:!1,time:0/0},a.jPlayer.platform.android&&(this.options.preload="auto"!==this.options.preload?"metadata":"auto"),this.formats=[],this.solutions=[],this.require={},this.htmlElement={},this.html={},this.html.audio={},this.html.video={},this.aurora={},this.aurora.formats=[],this.aurora.properties=[],this.flash={},this.css={},this.css.cs={},this.css.jq={},this.ancestorJq=[],this.options.volume=this._limitValue(this.options.volume,0,1),a.each(this.options.supplied.toLowerCase().split(","),function(b,d){var e=d.replace(/^\s+|\s+$/g,"");if(c.format[e]){var f=!1;a.each(c.formats,function(a,b){return e===b?(f=!0,!1):void 0}),f||c.formats.push(e)}}),a.each(this.options.solution.toLowerCase().split(","),function(b,d){var e=d.replace(/^\s+|\s+$/g,"");if(c.solution[e]){var f=!1;a.each(c.solutions,function(a,b){return e===b?(f=!0,!1):void 0}),f||c.solutions.push(e)}}),a.each(this.options.auroraFormats.toLowerCase().split(","),function(b,d){var e=d.replace(/^\s+|\s+$/g,"");if(c.format[e]){var f=!1;a.each(c.aurora.formats,function(a,b){return e===b?(f=!0,!1):void 0}),f||c.aurora.formats.push(e)}}),this.internal.instance="jp_"+this.count,this.instances[this.internal.instance]=this.element,this.element.attr("id")||this.element.attr("id",this.options.idPrefix+"_jplayer_"+this.count),this.internal.self=a.extend({},{id:this.element.attr("id"),jq:this.element}),this.internal.audio=a.extend({},{id:this.options.idPrefix+"_audio_"+this.count,jq:b}),this.internal.video=a.extend({},{id:this.options.idPrefix+"_video_"+this.count,jq:b}),this.internal.flash=a.extend({},{id:this.options.idPrefix+"_flash_"+this.count,jq:b,swf:this.options.swfPath+(".swf"!==this.options.swfPath.toLowerCase().slice(-4)?(this.options.swfPath&&"/"!==this.options.swfPath.slice(-1)?"/":"")+"jquery.jplayer.swf":"")}),this.internal.poster=a.extend({},{id:this.options.idPrefix+"_poster_"+this.count,jq:b}),a.each(a.jPlayer.event,function(a,d){c.options[a]!==b&&(c.element.bind(d+".jPlayer",c.options[a]),c.options[a]=b)}),this.require.audio=!1,this.require.video=!1,a.each(this.formats,function(a,b){c.require[c.format[b].media]=!0}),this.options=this.require.video?a.extend(!0,{},this.optionsVideo,this.options):a.extend(!0,{},this.optionsAudio,this.options),this._setSize(),this.status.nativeVideoControls=this._uaBlocklist(this.options.nativeVideoControls),this.status.noFullWindow=this._uaBlocklist(this.options.noFullWindow),this.status.noVolume=this._uaBlocklist(this.options.noVolume),a.jPlayer.nativeFeatures.fullscreen.api.fullscreenEnabled&&this._fullscreenAddEventListeners(),this._restrictNativeVideoControls(),this.htmlElement.poster=document.createElement("img"),this.htmlElement.poster.id=this.internal.poster.id,this.htmlElement.poster.onload=function(){(!c.status.video||c.status.waitForPlay)&&c.internal.poster.jq.show()},this.element.append(this.htmlElement.poster),this.internal.poster.jq=a("#"+this.internal.poster.id),this.internal.poster.jq.css({width:this.status.width,height:this.status.height}),this.internal.poster.jq.hide(),this.internal.poster.jq.bind("click.jPlayer",function(){c._trigger(a.jPlayer.event.click)}),this.html.audio.available=!1,this.require.audio&&(this.htmlElement.audio=document.createElement("audio"),this.htmlElement.audio.id=this.internal.audio.id,this.html.audio.available=!!this.htmlElement.audio.canPlayType&&this._testCanPlayType(this.htmlElement.audio)),this.html.video.available=!1,this.require.video&&(this.htmlElement.video=document.createElement("video"),this.htmlElement.video.id=this.internal.video.id,this.html.video.available=!!this.htmlElement.video.canPlayType&&this._testCanPlayType(this.htmlElement.video)),this.flash.available=this._checkForFlash(10.1),this.html.canPlay={},this.aurora.canPlay={},this.flash.canPlay={},a.each(this.formats,function(b,d){c.html.canPlay[d]=c.html[c.format[d].media].available&&""!==c.htmlElement[c.format[d].media].canPlayType(c.format[d].codec),c.aurora.canPlay[d]=a.inArray(d,c.aurora.formats)>-1,c.flash.canPlay[d]=c.format[d].flashCanPlay&&c.flash.available}),this.html.desired=!1,this.aurora.desired=!1,this.flash.desired=!1,a.each(this.solutions,function(b,d){if(0===b)c[d].desired=!0;else{var e=!1,f=!1;a.each(c.formats,function(a,b){c[c.solutions[0]].canPlay[b]&&("video"===c.format[b].media?f=!0:e=!0)}),c[d].desired=c.require.audio&&!e||c.require.video&&!f}}),this.html.support={},this.aurora.support={},this.flash.support={},a.each(this.formats,function(a,b){c.html.support[b]=c.html.canPlay[b]&&c.html.desired,c.aurora.support[b]=c.aurora.canPlay[b]&&c.aurora.desired,c.flash.support[b]=c.flash.canPlay[b]&&c.flash.desired}),this.html.used=!1,this.aurora.used=!1,this.flash.used=!1,a.each(this.solutions,function(b,d){a.each(c.formats,function(a,b){return c[d].support[b]?(c[d].used=!0,!1):void 0})}),this._resetActive(),this._resetGate(),this._cssSelectorAncestor(this.options.cssSelectorAncestor),this.html.used||this.aurora.used||this.flash.used?this.css.jq.noSolution.length&&this.css.jq.noSolution.hide():(this._error({type:a.jPlayer.error.NO_SOLUTION,context:"{solution:'"+this.options.solution+"', supplied:'"+this.options.supplied+"'}",message:a.jPlayer.errorMsg.NO_SOLUTION,hint:a.jPlayer.errorHint.NO_SOLUTION}),this.css.jq.noSolution.length&&this.css.jq.noSolution.show()),this.flash.used){var d,e="jQuery="+encodeURI(this.options.noConflict)+"&id="+encodeURI(this.internal.self.id)+"&vol="+this.options.volume+"&muted="+this.options.muted;if(a.jPlayer.browser.msie&&(Number(a.jPlayer.browser.version)<9||a.jPlayer.browser.documentMode<9)){var f='<object id="'+this.internal.flash.id+'" classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" width="0" height="0" tabindex="-1"></object>',g=['<param name="movie" value="'+this.internal.flash.swf+'" />','<param name="FlashVars" value="'+e+'" />','<param name="allowScriptAccess" value="always" />','<param name="bgcolor" value="'+this.options.backgroundColor+'" />','<param name="wmode" value="'+this.options.wmode+'" />'];d=document.createElement(f);for(var h=0;h<g.length;h++)d.appendChild(document.createElement(g[h]))}else{var i=function(a,b,c){var d=document.createElement("param");d.setAttribute("name",b),d.setAttribute("value",c),a.appendChild(d)};d=document.createElement("object"),d.setAttribute("id",this.internal.flash.id),d.setAttribute("name",this.internal.flash.id),d.setAttribute("data",this.internal.flash.swf),d.setAttribute("type","application/x-shockwave-flash"),d.setAttribute("width","1"),d.setAttribute("height","1"),d.setAttribute("tabindex","-1"),i(d,"flashvars",e),i(d,"allowscriptaccess","always"),i(d,"bgcolor",this.options.backgroundColor),i(d,"wmode",this.options.wmode)}this.element.append(d),this.internal.flash.jq=a(d)}this.status.playbackRateEnabled=this.html.used&&!this.flash.used?this._testPlaybackRate("audio"):!1,this._updatePlaybackRate(),this.html.used&&(this.html.audio.available&&(this._addHtmlEventListeners(this.htmlElement.audio,this.html.audio),this.element.append(this.htmlElement.audio),this.internal.audio.jq=a("#"+this.internal.audio.id)),this.html.video.available&&(this._addHtmlEventListeners(this.htmlElement.video,this.html.video),this.element.append(this.htmlElement.video),this.internal.video.jq=a("#"+this.internal.video.id),this.internal.video.jq.css(this.status.nativeVideoControls?{width:this.status.width,height:this.status.height}:{width:"0px",height:"0px"}),this.internal.video.jq.bind("click.jPlayer",function(){c._trigger(a.jPlayer.event.click)}))),this.aurora.used,this.options.emulateHtml&&this._emulateHtmlBridge(),!this.html.used&&!this.aurora.used||this.flash.used||setTimeout(function(){c.internal.ready=!0,c.version.flash="n/a",c._trigger(a.jPlayer.event.repeat),c._trigger(a.jPlayer.event.ready)},100),this._updateNativeVideoControls(),this.css.jq.videoPlay.length&&this.css.jq.videoPlay.hide(),a.jPlayer.prototype.count++},destroy:function(){this.clearMedia(),this._removeUiClass(),this.css.jq.currentTime.length&&this.css.jq.currentTime.text(""),this.css.jq.duration.length&&this.css.jq.duration.text(""),a.each(this.css.jq,function(a,b){b.length&&b.unbind(".jPlayer")}),this.internal.poster.jq.unbind(".jPlayer"),this.internal.video.jq&&this.internal.video.jq.unbind(".jPlayer"),this._fullscreenRemoveEventListeners(),this===a.jPlayer.focus&&(a.jPlayer.focus=null),this.options.emulateHtml&&this._destroyHtmlBridge(),this.element.removeData("jPlayer"),this.element.unbind(".jPlayer"),this.element.empty(),delete this.instances[this.internal.instance]},destroyRemoved:function(){var b=this;a.each(this.instances,function(a,c){b.element!==c&&(c.data("jPlayer")||(c.jPlayer("destroy"),delete b.instances[a]))})},enable:function(){},disable:function(){},_testCanPlayType:function(a){try{return a.canPlayType(this.format.mp3.codec),!0}catch(b){return!1}},_testPlaybackRate:function(a){var b,c=.5;a="string"==typeof a?a:"audio",b=document.createElement(a);try{return"playbackRate"in b?(b.playbackRate=c,b.playbackRate===c):!1}catch(d){return!1}},_uaBlocklist:function(b){var c=navigator.userAgent.toLowerCase(),d=!1;return a.each(b,function(a,b){return b&&b.test(c)?(d=!0,!1):void 0}),d},_restrictNativeVideoControls:function(){this.require.audio&&this.status.nativeVideoControls&&(this.status.nativeVideoControls=!1,this.status.noFullWindow=!0)},_updateNativeVideoControls:function(){this.html.video.available&&this.html.used&&(this.htmlElement.video.controls=this.status.nativeVideoControls,this._updateAutohide(),this.status.nativeVideoControls&&this.require.video?(this.internal.poster.jq.hide(),this.internal.video.jq.css({width:this.status.width,height:this.status.height})):this.status.waitForPlay&&this.status.video&&(this.internal.poster.jq.show(),this.internal.video.jq.css({width:"0px",height:"0px"})))},_addHtmlEventListeners:function(b,c){var d=this;b.preload=this.options.preload,b.muted=this.options.muted,b.volume=this.options.volume,this.status.playbackRateEnabled&&(b.defaultPlaybackRate=this.options.defaultPlaybackRate,b.playbackRate=this.options.playbackRate),b.addEventListener("progress",function(){c.gate&&(d.internal.cmdsIgnored&&this.readyState>0&&(d.internal.cmdsIgnored=!1),d._getHtmlStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.progress))},!1),b.addEventListener("loadeddata",function(){c.gate&&(d.androidFix.setMedia=!1,d.androidFix.play&&(d.androidFix.play=!1,d.play(d.androidFix.time)),d.androidFix.pause&&(d.androidFix.pause=!1,d.pause(d.androidFix.time)),d._trigger(a.jPlayer.event.loadeddata))},!1),b.addEventListener("timeupdate",function(){c.gate&&(d._getHtmlStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.timeupdate))},!1),b.addEventListener("durationchange",function(){c.gate&&(d._getHtmlStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.durationchange))},!1),b.addEventListener("play",function(){c.gate&&(d._updateButtons(!0),d._html_checkWaitForPlay(),d._trigger(a.jPlayer.event.play))},!1),b.addEventListener("playing",function(){c.gate&&(d._updateButtons(!0),d._seeked(),d._trigger(a.jPlayer.event.playing))},!1),b.addEventListener("pause",function(){c.gate&&(d._updateButtons(!1),d._trigger(a.jPlayer.event.pause))},!1),b.addEventListener("waiting",function(){c.gate&&(d._seeking(),d._trigger(a.jPlayer.event.waiting))},!1),b.addEventListener("seeking",function(){c.gate&&(d._seeking(),d._trigger(a.jPlayer.event.seeking))},!1),b.addEventListener("seeked",function(){c.gate&&(d._seeked(),d._trigger(a.jPlayer.event.seeked))},!1),b.addEventListener("volumechange",function(){c.gate&&(d.options.volume=b.volume,d.options.muted=b.muted,d._updateMute(),d._updateVolume(),d._trigger(a.jPlayer.event.volumechange))},!1),b.addEventListener("ratechange",function(){c.gate&&(d.options.defaultPlaybackRate=b.defaultPlaybackRate,d.options.playbackRate=b.playbackRate,d._updatePlaybackRate(),d._trigger(a.jPlayer.event.ratechange))},!1),b.addEventListener("suspend",function(){c.gate&&(d._seeked(),d._trigger(a.jPlayer.event.suspend))},!1),b.addEventListener("ended",function(){c.gate&&(a.jPlayer.browser.webkit||(d.htmlElement.media.currentTime=0),d.htmlElement.media.pause(),d._updateButtons(!1),d._getHtmlStatus(b,!0),d._updateInterface(),d._trigger(a.jPlayer.event.ended))},!1),b.addEventListener("error",function(){c.gate&&(d._updateButtons(!1),d._seeked(),d.status.srcSet&&(clearTimeout(d.internal.htmlDlyCmdId),d.status.waitForLoad=!0,d.status.waitForPlay=!0,d.status.video&&!d.status.nativeVideoControls&&d.internal.video.jq.css({width:"0px",height:"0px"}),d._validString(d.status.media.poster)&&!d.status.nativeVideoControls&&d.internal.poster.jq.show(),d.css.jq.videoPlay.length&&d.css.jq.videoPlay.show(),d._error({type:a.jPlayer.error.URL,context:d.status.src,message:a.jPlayer.errorMsg.URL,hint:a.jPlayer.errorHint.URL})))},!1),a.each(a.jPlayer.htmlEvent,function(e,f){b.addEventListener(this,function(){c.gate&&d._trigger(a.jPlayer.event[f])},!1)})},_addAuroraEventListeners:function(b,c){var d=this;b.volume=100*this.options.volume,b.on("progress",function(){c.gate&&(d.internal.cmdsIgnored&&this.readyState>0&&(d.internal.cmdsIgnored=!1),d._getAuroraStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.progress),b.duration>0&&d._trigger(a.jPlayer.event.timeupdate))},!1),b.on("ready",function(){c.gate&&d._trigger(a.jPlayer.event.loadeddata)},!1),b.on("duration",function(){c.gate&&(d._getAuroraStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.durationchange))},!1),b.on("end",function(){c.gate&&(d._updateButtons(!1),d._getAuroraStatus(b,!0),d._updateInterface(),d._trigger(a.jPlayer.event.ended))},!1),b.on("error",function(){c.gate&&(d._updateButtons(!1),d._seeked(),d.status.srcSet&&(d.status.waitForLoad=!0,d.status.waitForPlay=!0,d.status.video&&!d.status.nativeVideoControls&&d.internal.video.jq.css({width:"0px",height:"0px"}),d._validString(d.status.media.poster)&&!d.status.nativeVideoControls&&d.internal.poster.jq.show(),d.css.jq.videoPlay.length&&d.css.jq.videoPlay.show(),d._error({type:a.jPlayer.error.URL,context:d.status.src,message:a.jPlayer.errorMsg.URL,hint:a.jPlayer.errorHint.URL})))},!1)},_getHtmlStatus:function(a,b){var c=0,d=0,e=0,f=0;isFinite(a.duration)&&(this.status.duration=a.duration),c=a.currentTime,d=this.status.duration>0?100*c/this.status.duration:0,"object"==typeof a.seekable&&a.seekable.length>0?(e=this.status.duration>0?100*a.seekable.end(a.seekable.length-1)/this.status.duration:100,f=this.status.duration>0?100*a.currentTime/a.seekable.end(a.seekable.length-1):0):(e=100,f=d),b&&(c=0,f=0,d=0),this.status.seekPercent=e,this.status.currentPercentRelative=f,this.status.currentPercentAbsolute=d,this.status.currentTime=c,this.status.remaining=this.status.duration-this.status.currentTime,this.status.videoWidth=a.videoWidth,this.status.videoHeight=a.videoHeight,this.status.readyState=a.readyState,this.status.networkState=a.networkState,this.status.playbackRate=a.playbackRate,this.status.ended=a.ended},_getAuroraStatus:function(a,b){var c=0,d=0,e=0,f=0;this.status.duration=a.duration/1e3,c=a.currentTime/1e3,d=this.status.duration>0?100*c/this.status.duration:0,a.buffered>0?(e=this.status.duration>0?a.buffered*this.status.duration/this.status.duration:100,f=this.status.duration>0?c/(a.buffered*this.status.duration):0):(e=100,f=d),b&&(c=0,f=0,d=0),this.status.seekPercent=e,this.status.currentPercentRelative=f,this.status.currentPercentAbsolute=d,this.status.currentTime=c,this.status.remaining=this.status.duration-this.status.currentTime,this.status.readyState=4,this.status.networkState=0,this.status.playbackRate=1,this.status.ended=!1},_resetStatus:function(){this.status=a.extend({},this.status,a.jPlayer.prototype.status)},_trigger:function(b,c,d){var e=a.Event(b);e.jPlayer={},e.jPlayer.version=a.extend({},this.version),e.jPlayer.options=a.extend(!0,{},this.options),e.jPlayer.status=a.extend(!0,{},this.status),e.jPlayer.html=a.extend(!0,{},this.html),e.jPlayer.aurora=a.extend(!0,{},this.aurora),e.jPlayer.flash=a.extend(!0,{},this.flash),c&&(e.jPlayer.error=a.extend({},c)),d&&(e.jPlayer.warning=a.extend({},d)),this.element.trigger(e)},jPlayerFlashEvent:function(b,c){if(b===a.jPlayer.event.ready)if(this.internal.ready){if(this.flash.gate){if(this.status.srcSet){var d=this.status.currentTime,e=this.status.paused;this.setMedia(this.status.media),this.volumeWorker(this.options.volume),d>0&&(e?this.pause(d):this.play(d))}this._trigger(a.jPlayer.event.flashreset)}}else this.internal.ready=!0,this.internal.flash.jq.css({width:"0px",height:"0px"}),this.version.flash=c.version,this.version.needFlash!==this.version.flash&&this._error({type:a.jPlayer.error.VERSION,context:this.version.flash,message:a.jPlayer.errorMsg.VERSION+this.version.flash,hint:a.jPlayer.errorHint.VERSION}),this._trigger(a.jPlayer.event.repeat),this._trigger(b);if(this.flash.gate)switch(b){case a.jPlayer.event.progress:this._getFlashStatus(c),this._updateInterface(),this._trigger(b);break;case a.jPlayer.event.timeupdate:this._getFlashStatus(c),this._updateInterface(),this._trigger(b);break;case a.jPlayer.event.play:this._seeked(),this._updateButtons(!0),this._trigger(b);break;case a.jPlayer.event.pause:this._updateButtons(!1),this._trigger(b);break;case a.jPlayer.event.ended:this._updateButtons(!1),this._trigger(b);break;case a.jPlayer.event.click:this._trigger(b);break;case a.jPlayer.event.error:this.status.waitForLoad=!0,this.status.waitForPlay=!0,this.status.video&&this.internal.flash.jq.css({width:"0px",height:"0px"}),this._validString(this.status.media.poster)&&this.internal.poster.jq.show(),this.css.jq.videoPlay.length&&this.status.video&&this.css.jq.videoPlay.show(),this.status.video?this._flash_setVideo(this.status.media):this._flash_setAudio(this.status.media),this._updateButtons(!1),this._error({type:a.jPlayer.error.URL,context:c.src,message:a.jPlayer.errorMsg.URL,hint:a.jPlayer.errorHint.URL});break;case a.jPlayer.event.seeking:this._seeking(),this._trigger(b);break;case a.jPlayer.event.seeked:this._seeked(),this._trigger(b);break;case a.jPlayer.event.ready:break;default:this._trigger(b)}return!1},_getFlashStatus:function(a){this.status.seekPercent=a.seekPercent,this.status.currentPercentRelative=a.currentPercentRelative,this.status.currentPercentAbsolute=a.currentPercentAbsolute,this.status.currentTime=a.currentTime,this.status.duration=a.duration,this.status.remaining=a.duration-a.currentTime,this.status.videoWidth=a.videoWidth,this.status.videoHeight=a.videoHeight,this.status.readyState=4,this.status.networkState=0,this.status.playbackRate=1,this.status.ended=!1},_updateButtons:function(a){a===b?a=!this.status.paused:this.status.paused=!a,a?this.addStateClass("playing"):this.removeStateClass("playing"),!this.status.noFullWindow&&this.options.fullWindow?this.addStateClass("fullScreen"):this.removeStateClass("fullScreen"),this.options.loop?this.addStateClass("looped"):this.removeStateClass("looped"),this.css.jq.play.length&&this.css.jq.pause.length&&(a?(this.css.jq.play.hide(),this.css.jq.pause.show()):(this.css.jq.play.show(),this.css.jq.pause.hide())),this.css.jq.restoreScreen.length&&this.css.jq.fullScreen.length&&(this.status.noFullWindow?(this.css.jq.fullScreen.hide(),this.css.jq.restoreScreen.hide()):this.options.fullWindow?(this.css.jq.fullScreen.hide(),this.css.jq.restoreScreen.show()):(this.css.jq.fullScreen.show(),this.css.jq.restoreScreen.hide())),this.css.jq.repeat.length&&this.css.jq.repeatOff.length&&(this.options.loop?(this.css.jq.repeat.hide(),this.css.jq.repeatOff.show()):(this.css.jq.repeat.show(),this.css.jq.repeatOff.hide()))},_updateInterface:function(){this.css.jq.seekBar.length&&this.css.jq.seekBar.width(this.status.seekPercent+"%"),this.css.jq.playBar.length&&(this.options.smoothPlayBar?this.css.jq.playBar.stop().animate({width:this.status.currentPercentAbsolute+"%"},250,"linear"):this.css.jq.playBar.width(this.status.currentPercentRelative+"%"));var a="";this.css.jq.currentTime.length&&(a=this._convertTime(this.status.currentTime),a!==this.css.jq.currentTime.text()&&this.css.jq.currentTime.text(this._convertTime(this.status.currentTime)));var b="",c=this.status.duration,d=this.status.remaining;this.css.jq.duration.length&&("string"==typeof this.status.media.duration?b=this.status.media.duration:("number"==typeof this.status.media.duration&&(c=this.status.media.duration,d=c-this.status.currentTime),b=this.options.remainingDuration?(d>0?"-":"")+this._convertTime(d):this._convertTime(c)),b!==this.css.jq.duration.text()&&this.css.jq.duration.text(b))},_convertTime:c.prototype.time,_seeking:function(){this.css.jq.seekBar.length&&this.css.jq.seekBar.addClass("jp-seeking-bg"),this.addStateClass("seeking")},_seeked:function(){this.css.jq.seekBar.length&&this.css.jq.seekBar.removeClass("jp-seeking-bg"),this.removeStateClass("seeking")},_resetGate:function(){this.html.audio.gate=!1,this.html.video.gate=!1,this.aurora.gate=!1,this.flash.gate=!1},_resetActive:function(){this.html.active=!1,this.aurora.active=!1,this.flash.active=!1},_escapeHtml:function(a){return a.split("&").join("&amp;").split("<").join("&lt;").split(">").join("&gt;").split('"').join("&quot;")},_qualifyURL:function(a){var b=document.createElement("div");
-return b.innerHTML='<a href="'+this._escapeHtml(a)+'">x</a>',b.firstChild.href},_absoluteMediaUrls:function(b){var c=this;return a.each(b,function(a,d){d&&c.format[a]&&"data:"!==d.substr(0,5)&&(b[a]=c._qualifyURL(d))}),b},addStateClass:function(a){this.ancestorJq.length&&this.ancestorJq.addClass(this.options.stateClass[a])},removeStateClass:function(a){this.ancestorJq.length&&this.ancestorJq.removeClass(this.options.stateClass[a])},setMedia:function(b){var c=this,d=!1,e=this.status.media.poster!==b.poster;this._resetMedia(),this._resetGate(),this._resetActive(),this.androidFix.setMedia=!1,this.androidFix.play=!1,this.androidFix.pause=!1,b=this._absoluteMediaUrls(b),a.each(this.formats,function(e,f){var g="video"===c.format[f].media;return a.each(c.solutions,function(e,h){if(c[h].support[f]&&c._validString(b[f])){var i="html"===h,j="aurora"===h;return g?(i?(c.html.video.gate=!0,c._html_setVideo(b),c.html.active=!0):(c.flash.gate=!0,c._flash_setVideo(b),c.flash.active=!0),c.css.jq.videoPlay.length&&c.css.jq.videoPlay.show(),c.status.video=!0):(i?(c.html.audio.gate=!0,c._html_setAudio(b),c.html.active=!0,a.jPlayer.platform.android&&(c.androidFix.setMedia=!0)):j?(c.aurora.gate=!0,c._aurora_setAudio(b),c.aurora.active=!0):(c.flash.gate=!0,c._flash_setAudio(b),c.flash.active=!0),c.css.jq.videoPlay.length&&c.css.jq.videoPlay.hide(),c.status.video=!1),d=!0,!1}}),d?!1:void 0}),d?(this.status.nativeVideoControls&&this.html.video.gate||this._validString(b.poster)&&(e?this.htmlElement.poster.src=b.poster:this.internal.poster.jq.show()),"string"==typeof b.title&&(this.css.jq.title.length&&this.css.jq.title.html(b.title),this.htmlElement.audio&&this.htmlElement.audio.setAttribute("title",b.title),this.htmlElement.video&&this.htmlElement.video.setAttribute("title",b.title)),this.status.srcSet=!0,this.status.media=a.extend({},b),this._updateButtons(!1),this._updateInterface(),this._trigger(a.jPlayer.event.setmedia)):this._error({type:a.jPlayer.error.NO_SUPPORT,context:"{supplied:'"+this.options.supplied+"'}",message:a.jPlayer.errorMsg.NO_SUPPORT,hint:a.jPlayer.errorHint.NO_SUPPORT})},_resetMedia:function(){this._resetStatus(),this._updateButtons(!1),this._updateInterface(),this._seeked(),this.internal.poster.jq.hide(),clearTimeout(this.internal.htmlDlyCmdId),this.html.active?this._html_resetMedia():this.aurora.active?this._aurora_resetMedia():this.flash.active&&this._flash_resetMedia()},clearMedia:function(){this._resetMedia(),this.html.active?this._html_clearMedia():this.aurora.active?this._aurora_clearMedia():this.flash.active&&this._flash_clearMedia(),this._resetGate(),this._resetActive()},load:function(){this.status.srcSet?this.html.active?this._html_load():this.aurora.active?this._aurora_load():this.flash.active&&this._flash_load():this._urlNotSetError("load")},focus:function(){this.options.keyEnabled&&(a.jPlayer.focus=this)},play:function(a){var b="object"==typeof a;b&&this.options.useStateClassSkin&&!this.status.paused?this.pause(a):(a="number"==typeof a?a:0/0,this.status.srcSet?(this.focus(),this.html.active?this._html_play(a):this.aurora.active?this._aurora_play(a):this.flash.active&&this._flash_play(a)):this._urlNotSetError("play"))},videoPlay:function(){this.play()},pause:function(a){a="number"==typeof a?a:0/0,this.status.srcSet?this.html.active?this._html_pause(a):this.aurora.active?this._aurora_pause(a):this.flash.active&&this._flash_pause(a):this._urlNotSetError("pause")},tellOthers:function(b,c){var d=this,e="function"==typeof c,f=Array.prototype.slice.call(arguments);"string"==typeof b&&(e&&f.splice(1,1),a.jPlayer.prototype.destroyRemoved(),a.each(this.instances,function(){d.element!==this&&(!e||c.call(this.data("jPlayer"),d))&&this.jPlayer.apply(this,f)}))},pauseOthers:function(a){this.tellOthers("pause",function(){return this.status.srcSet},a)},stop:function(){this.status.srcSet?this.html.active?this._html_pause(0):this.aurora.active?this._aurora_pause(0):this.flash.active&&this._flash_pause(0):this._urlNotSetError("stop")},playHead:function(a){a=this._limitValue(a,0,100),this.status.srcSet?this.html.active?this._html_playHead(a):this.aurora.active?this._aurora_playHead(a):this.flash.active&&this._flash_playHead(a):this._urlNotSetError("playHead")},_muted:function(a){this.mutedWorker(a),this.options.globalVolume&&this.tellOthers("mutedWorker",function(){return this.options.globalVolume},a)},mutedWorker:function(b){this.options.muted=b,this.html.used&&this._html_setProperty("muted",b),this.aurora.used&&this._aurora_mute(b),this.flash.used&&this._flash_mute(b),this.html.video.gate||this.html.audio.gate||(this._updateMute(b),this._updateVolume(this.options.volume),this._trigger(a.jPlayer.event.volumechange))},mute:function(a){var c="object"==typeof a;c&&this.options.useStateClassSkin&&this.options.muted?this._muted(!1):(a=a===b?!0:!!a,this._muted(a))},unmute:function(a){a=a===b?!0:!!a,this._muted(!a)},_updateMute:function(a){a===b&&(a=this.options.muted),a?this.addStateClass("muted"):this.removeStateClass("muted"),this.css.jq.mute.length&&this.css.jq.unmute.length&&(this.status.noVolume?(this.css.jq.mute.hide(),this.css.jq.unmute.hide()):a?(this.css.jq.mute.hide(),this.css.jq.unmute.show()):(this.css.jq.mute.show(),this.css.jq.unmute.hide()))},volume:function(a){this.volumeWorker(a),this.options.globalVolume&&this.tellOthers("volumeWorker",function(){return this.options.globalVolume},a)},volumeWorker:function(b){b=this._limitValue(b,0,1),this.options.volume=b,this.html.used&&this._html_setProperty("volume",b),this.aurora.used&&this._aurora_volume(b),this.flash.used&&this._flash_volume(b),this.html.video.gate||this.html.audio.gate||(this._updateVolume(b),this._trigger(a.jPlayer.event.volumechange))},volumeBar:function(b){if(this.css.jq.volumeBar.length){var c=a(b.currentTarget),d=c.offset(),e=b.pageX-d.left,f=c.width(),g=c.height()-b.pageY+d.top,h=c.height();this.volume(this.options.verticalVolume?g/h:e/f)}this.options.muted&&this._muted(!1)},_updateVolume:function(a){a===b&&(a=this.options.volume),a=this.options.muted?0:a,this.status.noVolume?(this.addStateClass("noVolume"),this.css.jq.volumeBar.length&&this.css.jq.volumeBar.hide(),this.css.jq.volumeBarValue.length&&this.css.jq.volumeBarValue.hide(),this.css.jq.volumeMax.length&&this.css.jq.volumeMax.hide()):(this.removeStateClass("noVolume"),this.css.jq.volumeBar.length&&this.css.jq.volumeBar.show(),this.css.jq.volumeBarValue.length&&(this.css.jq.volumeBarValue.show(),this.css.jq.volumeBarValue[this.options.verticalVolume?"height":"width"](100*a+"%")),this.css.jq.volumeMax.length&&this.css.jq.volumeMax.show())},volumeMax:function(){this.volume(1),this.options.muted&&this._muted(!1)},_cssSelectorAncestor:function(b){var c=this;this.options.cssSelectorAncestor=b,this._removeUiClass(),this.ancestorJq=b?a(b):[],b&&1!==this.ancestorJq.length&&this._warning({type:a.jPlayer.warning.CSS_SELECTOR_COUNT,context:b,message:a.jPlayer.warningMsg.CSS_SELECTOR_COUNT+this.ancestorJq.length+" found for cssSelectorAncestor.",hint:a.jPlayer.warningHint.CSS_SELECTOR_COUNT}),this._addUiClass(),a.each(this.options.cssSelector,function(a,b){c._cssSelector(a,b)}),this._updateInterface(),this._updateButtons(),this._updateAutohide(),this._updateVolume(),this._updateMute()},_cssSelector:function(b,c){var d=this;if("string"==typeof c)if(a.jPlayer.prototype.options.cssSelector[b]){if(this.css.jq[b]&&this.css.jq[b].length&&this.css.jq[b].unbind(".jPlayer"),this.options.cssSelector[b]=c,this.css.cs[b]=this.options.cssSelectorAncestor+" "+c,this.css.jq[b]=c?a(this.css.cs[b]):[],this.css.jq[b].length&&this[b]){var e=function(c){c.preventDefault(),d[b](c),d.options.autoBlur?a(this).blur():a(this).focus()};this.css.jq[b].bind("click.jPlayer",e)}c&&1!==this.css.jq[b].length&&this._warning({type:a.jPlayer.warning.CSS_SELECTOR_COUNT,context:this.css.cs[b],message:a.jPlayer.warningMsg.CSS_SELECTOR_COUNT+this.css.jq[b].length+" found for "+b+" method.",hint:a.jPlayer.warningHint.CSS_SELECTOR_COUNT})}else this._warning({type:a.jPlayer.warning.CSS_SELECTOR_METHOD,context:b,message:a.jPlayer.warningMsg.CSS_SELECTOR_METHOD,hint:a.jPlayer.warningHint.CSS_SELECTOR_METHOD});else this._warning({type:a.jPlayer.warning.CSS_SELECTOR_STRING,context:c,message:a.jPlayer.warningMsg.CSS_SELECTOR_STRING,hint:a.jPlayer.warningHint.CSS_SELECTOR_STRING})},duration:function(a){this.options.toggleDuration&&(this.options.captureDuration&&a.stopPropagation(),this._setOption("remainingDuration",!this.options.remainingDuration))},seekBar:function(b){if(this.css.jq.seekBar.length){var c=a(b.currentTarget),d=c.offset(),e=b.pageX-d.left,f=c.width(),g=100*e/f;this.playHead(g)}},playbackRate:function(a){this._setOption("playbackRate",a)},playbackRateBar:function(b){if(this.css.jq.playbackRateBar.length){var c,d,e=a(b.currentTarget),f=e.offset(),g=b.pageX-f.left,h=e.width(),i=e.height()-b.pageY+f.top,j=e.height();c=this.options.verticalPlaybackRate?i/j:g/h,d=c*(this.options.maxPlaybackRate-this.options.minPlaybackRate)+this.options.minPlaybackRate,this.playbackRate(d)}},_updatePlaybackRate:function(){var a=this.options.playbackRate,b=(a-this.options.minPlaybackRate)/(this.options.maxPlaybackRate-this.options.minPlaybackRate);this.status.playbackRateEnabled?(this.css.jq.playbackRateBar.length&&this.css.jq.playbackRateBar.show(),this.css.jq.playbackRateBarValue.length&&(this.css.jq.playbackRateBarValue.show(),this.css.jq.playbackRateBarValue[this.options.verticalPlaybackRate?"height":"width"](100*b+"%"))):(this.css.jq.playbackRateBar.length&&this.css.jq.playbackRateBar.hide(),this.css.jq.playbackRateBarValue.length&&this.css.jq.playbackRateBarValue.hide())},repeat:function(a){var b="object"==typeof a;this._loop(b&&this.options.useStateClassSkin&&this.options.loop?!1:!0)},repeatOff:function(){this._loop(!1)},_loop:function(b){this.options.loop!==b&&(this.options.loop=b,this._updateButtons(),this._trigger(a.jPlayer.event.repeat))},option:function(c,d){var e=c;if(0===arguments.length)return a.extend(!0,{},this.options);if("string"==typeof c){var f=c.split(".");if(d===b){for(var g=a.extend(!0,{},this.options),h=0;h<f.length;h++){if(g[f[h]]===b)return this._warning({type:a.jPlayer.warning.OPTION_KEY,context:c,message:a.jPlayer.warningMsg.OPTION_KEY,hint:a.jPlayer.warningHint.OPTION_KEY}),b;g=g[f[h]]}return g}e={};for(var i=e,j=0;j<f.length;j++)j<f.length-1?(i[f[j]]={},i=i[f[j]]):i[f[j]]=d}return this._setOptions(e),this},_setOptions:function(b){var c=this;return a.each(b,function(a,b){c._setOption(a,b)}),this},_setOption:function(b,c){var d=this;switch(b){case"volume":this.volume(c);break;case"muted":this._muted(c);break;case"globalVolume":this.options[b]=c;break;case"cssSelectorAncestor":this._cssSelectorAncestor(c);break;case"cssSelector":a.each(c,function(a,b){d._cssSelector(a,b)});break;case"playbackRate":this.options[b]=c=this._limitValue(c,this.options.minPlaybackRate,this.options.maxPlaybackRate),this.html.used&&this._html_setProperty("playbackRate",c),this._updatePlaybackRate();break;case"defaultPlaybackRate":this.options[b]=c=this._limitValue(c,this.options.minPlaybackRate,this.options.maxPlaybackRate),this.html.used&&this._html_setProperty("defaultPlaybackRate",c),this._updatePlaybackRate();break;case"minPlaybackRate":this.options[b]=c=this._limitValue(c,.1,this.options.maxPlaybackRate-.1),this._updatePlaybackRate();break;case"maxPlaybackRate":this.options[b]=c=this._limitValue(c,this.options.minPlaybackRate+.1,16),this._updatePlaybackRate();break;case"fullScreen":if(this.options[b]!==c){var e=a.jPlayer.nativeFeatures.fullscreen.used.webkitVideo;(!e||e&&!this.status.waitForPlay)&&(e||(this.options[b]=c),c?this._requestFullscreen():this._exitFullscreen(),e||this._setOption("fullWindow",c))}break;case"fullWindow":this.options[b]!==c&&(this._removeUiClass(),this.options[b]=c,this._refreshSize());break;case"size":this.options.fullWindow||this.options[b].cssClass===c.cssClass||this._removeUiClass(),this.options[b]=a.extend({},this.options[b],c),this._refreshSize();break;case"sizeFull":this.options.fullWindow&&this.options[b].cssClass!==c.cssClass&&this._removeUiClass(),this.options[b]=a.extend({},this.options[b],c),this._refreshSize();break;case"autohide":this.options[b]=a.extend({},this.options[b],c),this._updateAutohide();break;case"loop":this._loop(c);break;case"remainingDuration":this.options[b]=c,this._updateInterface();break;case"toggleDuration":this.options[b]=c;break;case"nativeVideoControls":this.options[b]=a.extend({},this.options[b],c),this.status.nativeVideoControls=this._uaBlocklist(this.options.nativeVideoControls),this._restrictNativeVideoControls(),this._updateNativeVideoControls();break;case"noFullWindow":this.options[b]=a.extend({},this.options[b],c),this.status.nativeVideoControls=this._uaBlocklist(this.options.nativeVideoControls),this.status.noFullWindow=this._uaBlocklist(this.options.noFullWindow),this._restrictNativeVideoControls(),this._updateButtons();break;case"noVolume":this.options[b]=a.extend({},this.options[b],c),this.status.noVolume=this._uaBlocklist(this.options.noVolume),this._updateVolume(),this._updateMute();break;case"emulateHtml":this.options[b]!==c&&(this.options[b]=c,c?this._emulateHtmlBridge():this._destroyHtmlBridge());break;case"timeFormat":this.options[b]=a.extend({},this.options[b],c);break;case"keyEnabled":this.options[b]=c,c||this!==a.jPlayer.focus||(a.jPlayer.focus=null);break;case"keyBindings":this.options[b]=a.extend(!0,{},this.options[b],c);break;case"audioFullScreen":this.options[b]=c;break;case"autoBlur":this.options[b]=c}return this},_refreshSize:function(){this._setSize(),this._addUiClass(),this._updateSize(),this._updateButtons(),this._updateAutohide(),this._trigger(a.jPlayer.event.resize)},_setSize:function(){this.options.fullWindow?(this.status.width=this.options.sizeFull.width,this.status.height=this.options.sizeFull.height,this.status.cssClass=this.options.sizeFull.cssClass):(this.status.width=this.options.size.width,this.status.height=this.options.size.height,this.status.cssClass=this.options.size.cssClass),this.element.css({width:this.status.width,height:this.status.height})},_addUiClass:function(){this.ancestorJq.length&&this.ancestorJq.addClass(this.status.cssClass)},_removeUiClass:function(){this.ancestorJq.length&&this.ancestorJq.removeClass(this.status.cssClass)},_updateSize:function(){this.internal.poster.jq.css({width:this.status.width,height:this.status.height}),!this.status.waitForPlay&&this.html.active&&this.status.video||this.html.video.available&&this.html.used&&this.status.nativeVideoControls?this.internal.video.jq.css({width:this.status.width,height:this.status.height}):!this.status.waitForPlay&&this.flash.active&&this.status.video&&this.internal.flash.jq.css({width:this.status.width,height:this.status.height})},_updateAutohide:function(){var a=this,b="mousemove.jPlayer",c=".jPlayerAutohide",d=b+c,e=function(b){var c,d,e=!1;"undefined"!=typeof a.internal.mouse?(c=a.internal.mouse.x-b.pageX,d=a.internal.mouse.y-b.pageY,e=Math.floor(c)>0||Math.floor(d)>0):e=!0,a.internal.mouse={x:b.pageX,y:b.pageY},e&&a.css.jq.gui.fadeIn(a.options.autohide.fadeIn,function(){clearTimeout(a.internal.autohideId),a.internal.autohideId=setTimeout(function(){a.css.jq.gui.fadeOut(a.options.autohide.fadeOut)},a.options.autohide.hold)})};this.css.jq.gui.length&&(this.css.jq.gui.stop(!0,!0),clearTimeout(this.internal.autohideId),delete this.internal.mouse,this.element.unbind(c),this.css.jq.gui.unbind(c),this.status.nativeVideoControls?this.css.jq.gui.hide():this.options.fullWindow&&this.options.autohide.full||!this.options.fullWindow&&this.options.autohide.restored?(this.element.bind(d,e),this.css.jq.gui.bind(d,e),this.css.jq.gui.hide()):this.css.jq.gui.show())},fullScreen:function(a){var b="object"==typeof a;b&&this.options.useStateClassSkin&&this.options.fullScreen?this._setOption("fullScreen",!1):this._setOption("fullScreen",!0)},restoreScreen:function(){this._setOption("fullScreen",!1)},_fullscreenAddEventListeners:function(){var b=this,c=a.jPlayer.nativeFeatures.fullscreen;c.api.fullscreenEnabled&&c.event.fullscreenchange&&("function"!=typeof this.internal.fullscreenchangeHandler&&(this.internal.fullscreenchangeHandler=function(){b._fullscreenchange()}),document.addEventListener(c.event.fullscreenchange,this.internal.fullscreenchangeHandler,!1))},_fullscreenRemoveEventListeners:function(){var b=a.jPlayer.nativeFeatures.fullscreen;this.internal.fullscreenchangeHandler&&document.removeEventListener(b.event.fullscreenchange,this.internal.fullscreenchangeHandler,!1)},_fullscreenchange:function(){this.options.fullScreen&&!a.jPlayer.nativeFeatures.fullscreen.api.fullscreenElement()&&this._setOption("fullScreen",!1)},_requestFullscreen:function(){var b=this.ancestorJq.length?this.ancestorJq[0]:this.element[0],c=a.jPlayer.nativeFeatures.fullscreen;c.used.webkitVideo&&(b=this.htmlElement.video),c.api.fullscreenEnabled&&c.api.requestFullscreen(b)},_exitFullscreen:function(){var b,c=a.jPlayer.nativeFeatures.fullscreen;c.used.webkitVideo&&(b=this.htmlElement.video),c.api.fullscreenEnabled&&c.api.exitFullscreen(b)},_html_initMedia:function(b){var c=a(this.htmlElement.media).empty();a.each(b.track||[],function(a,b){var d=document.createElement("track");d.setAttribute("kind",b.kind?b.kind:""),d.setAttribute("src",b.src?b.src:""),d.setAttribute("srclang",b.srclang?b.srclang:""),d.setAttribute("label",b.label?b.label:""),b.def&&d.setAttribute("default",b.def),c.append(d)}),this.htmlElement.media.src=this.status.src,"none"!==this.options.preload&&this._html_load(),this._trigger(a.jPlayer.event.timeupdate)},_html_setFormat:function(b){var c=this;a.each(this.formats,function(a,d){return c.html.support[d]&&b[d]?(c.status.src=b[d],c.status.format[d]=!0,c.status.formatType=d,!1):void 0})},_html_setAudio:function(a){this._html_setFormat(a),this.htmlElement.media=this.htmlElement.audio,this._html_initMedia(a)},_html_setVideo:function(a){this._html_setFormat(a),this.status.nativeVideoControls&&(this.htmlElement.video.poster=this._validString(a.poster)?a.poster:""),this.htmlElement.media=this.htmlElement.video,this._html_initMedia(a)},_html_resetMedia:function(){this.htmlElement.media&&(this.htmlElement.media.id!==this.internal.video.id||this.status.nativeVideoControls||this.internal.video.jq.css({width:"0px",height:"0px"}),this.htmlElement.media.pause())},_html_clearMedia:function(){this.htmlElement.media&&(this.htmlElement.media.src="about:blank",this.htmlElement.media.load())},_html_load:function(){this.status.waitForLoad&&(this.status.waitForLoad=!1,this.htmlElement.media.load()),clearTimeout(this.internal.htmlDlyCmdId)},_html_play:function(a){var b=this,c=this.htmlElement.media;if(this.androidFix.pause=!1,this._html_load(),this.androidFix.setMedia)this.androidFix.play=!0,this.androidFix.time=a;else if(isNaN(a))c.play();else{this.internal.cmdsIgnored&&c.play();try{if(c.seekable&&!("object"==typeof c.seekable&&c.seekable.length>0))throw 1;c.currentTime=a,c.play()}catch(d){return void(this.internal.htmlDlyCmdId=setTimeout(function(){b.play(a)},250))}}this._html_checkWaitForPlay()},_html_pause:function(a){var b=this,c=this.htmlElement.media;if(this.androidFix.play=!1,a>0?this._html_load():clearTimeout(this.internal.htmlDlyCmdId),c.pause(),this.androidFix.setMedia)this.androidFix.pause=!0,this.androidFix.time=a;else if(!isNaN(a))try{if(c.seekable&&!("object"==typeof c.seekable&&c.seekable.length>0))throw 1;c.currentTime=a}catch(d){return void(this.internal.htmlDlyCmdId=setTimeout(function(){b.pause(a)},250))}a>0&&this._html_checkWaitForPlay()},_html_playHead:function(a){var b=this,c=this.htmlElement.media;this._html_load();try{if("object"==typeof c.seekable&&c.seekable.length>0)c.currentTime=a*c.seekable.end(c.seekable.length-1)/100;else{if(!(c.duration>0)||isNaN(c.duration))throw"e";c.currentTime=a*c.duration/100}}catch(d){return void(this.internal.htmlDlyCmdId=setTimeout(function(){b.playHead(a)},250))}this.status.waitForLoad||this._html_checkWaitForPlay()},_html_checkWaitForPlay:function(){this.status.waitForPlay&&(this.status.waitForPlay=!1,this.css.jq.videoPlay.length&&this.css.jq.videoPlay.hide(),this.status.video&&(this.internal.poster.jq.hide(),this.internal.video.jq.css({width:this.status.width,height:this.status.height})))},_html_setProperty:function(a,b){this.html.audio.available&&(this.htmlElement.audio[a]=b),this.html.video.available&&(this.htmlElement.video[a]=b)},_aurora_setAudio:function(b){var c=this;a.each(this.formats,function(a,d){return c.aurora.support[d]&&b[d]?(c.status.src=b[d],c.status.format[d]=!0,c.status.formatType=d,!1):void 0}),this.aurora.player=new AV.Player.fromURL(this.status.src),this._addAuroraEventListeners(this.aurora.player,this.aurora),"auto"===this.options.preload&&(this._aurora_load(),this.status.waitForLoad=!1)},_aurora_resetMedia:function(){this.aurora.player&&this.aurora.player.stop()},_aurora_clearMedia:function(){},_aurora_load:function(){this.status.waitForLoad&&(this.status.waitForLoad=!1,this.aurora.player.preload())},_aurora_play:function(b){this.status.waitForLoad||isNaN(b)||this.aurora.player.seek(b),this.aurora.player.playing||this.aurora.player.play(),this.status.waitForLoad=!1,this._aurora_checkWaitForPlay(),this._updateButtons(!0),this._trigger(a.jPlayer.event.play)},_aurora_pause:function(b){isNaN(b)||this.aurora.player.seek(1e3*b),this.aurora.player.pause(),b>0&&this._aurora_checkWaitForPlay(),this._updateButtons(!1),this._trigger(a.jPlayer.event.pause)},_aurora_playHead:function(a){this.aurora.player.duration>0&&this.aurora.player.seek(a*this.aurora.player.duration/100),this.status.waitForLoad||this._aurora_checkWaitForPlay()},_aurora_checkWaitForPlay:function(){this.status.waitForPlay&&(this.status.waitForPlay=!1)},_aurora_volume:function(a){this.aurora.player.volume=100*a},_aurora_mute:function(a){a?(this.aurora.properties.lastvolume=this.aurora.player.volume,this.aurora.player.volume=0):this.aurora.player.volume=this.aurora.properties.lastvolume,this.aurora.properties.muted=a},_flash_setAudio:function(b){var c=this;try{a.each(this.formats,function(a,d){if(c.flash.support[d]&&b[d]){switch(d){case"m4a":case"fla":c._getMovie().fl_setAudio_m4a(b[d]);break;case"mp3":c._getMovie().fl_setAudio_mp3(b[d]);break;case"rtmpa":c._getMovie().fl_setAudio_rtmp(b[d])}return c.status.src=b[d],c.status.format[d]=!0,c.status.formatType=d,!1}}),"auto"===this.options.preload&&(this._flash_load(),this.status.waitForLoad=!1)}catch(d){this._flashError(d)}},_flash_setVideo:function(b){var c=this;try{a.each(this.formats,function(a,d){if(c.flash.support[d]&&b[d]){switch(d){case"m4v":case"flv":c._getMovie().fl_setVideo_m4v(b[d]);break;case"rtmpv":c._getMovie().fl_setVideo_rtmp(b[d])}return c.status.src=b[d],c.status.format[d]=!0,c.status.formatType=d,!1}}),"auto"===this.options.preload&&(this._flash_load(),this.status.waitForLoad=!1)}catch(d){this._flashError(d)}},_flash_resetMedia:function(){this.internal.flash.jq.css({width:"0px",height:"0px"}),this._flash_pause(0/0)},_flash_clearMedia:function(){try{this._getMovie().fl_clearMedia()}catch(a){this._flashError(a)}},_flash_load:function(){try{this._getMovie().fl_load()}catch(a){this._flashError(a)}this.status.waitForLoad=!1},_flash_play:function(a){try{this._getMovie().fl_play(a)}catch(b){this._flashError(b)}this.status.waitForLoad=!1,this._flash_checkWaitForPlay()},_flash_pause:function(a){try{this._getMovie().fl_pause(a)}catch(b){this._flashError(b)}a>0&&(this.status.waitForLoad=!1,this._flash_checkWaitForPlay())},_flash_playHead:function(a){try{this._getMovie().fl_play_head(a)}catch(b){this._flashError(b)}this.status.waitForLoad||this._flash_checkWaitForPlay()},_flash_checkWaitForPlay:function(){this.status.waitForPlay&&(this.status.waitForPlay=!1,this.css.jq.videoPlay.length&&this.css.jq.videoPlay.hide(),this.status.video&&(this.internal.poster.jq.hide(),this.internal.flash.jq.css({width:this.status.width,height:this.status.height})))},_flash_volume:function(a){try{this._getMovie().fl_volume(a)}catch(b){this._flashError(b)}},_flash_mute:function(a){try{this._getMovie().fl_mute(a)}catch(b){this._flashError(b)}},_getMovie:function(){return document[this.internal.flash.id]},_getFlashPluginVersion:function(){var a,b=0;if(window.ActiveXObject)try{if(a=new ActiveXObject("ShockwaveFlash.ShockwaveFlash")){var c=a.GetVariable("$version");c&&(c=c.split(" ")[1].split(","),b=parseInt(c[0],10)+"."+parseInt(c[1],10))}}catch(d){}else navigator.plugins&&navigator.mimeTypes.length>0&&(a=navigator.plugins["Shockwave Flash"],a&&(b=navigator.plugins["Shockwave Flash"].description.replace(/.*\s(\d+\.\d+).*/,"$1")));return 1*b},_checkForFlash:function(a){var b=!1;return this._getFlashPluginVersion()>=a&&(b=!0),b},_validString:function(a){return a&&"string"==typeof a},_limitValue:function(a,b,c){return b>a?b:a>c?c:a},_urlNotSetError:function(b){this._error({type:a.jPlayer.error.URL_NOT_SET,context:b,message:a.jPlayer.errorMsg.URL_NOT_SET,hint:a.jPlayer.errorHint.URL_NOT_SET})},_flashError:function(b){var c;c=this.internal.ready?"FLASH_DISABLED":"FLASH",this._error({type:a.jPlayer.error[c],context:this.internal.flash.swf,message:a.jPlayer.errorMsg[c]+b.message,hint:a.jPlayer.errorHint[c]}),this.internal.flash.jq.css({width:"1px",height:"1px"})},_error:function(b){this._trigger(a.jPlayer.event.error,b),this.options.errorAlerts&&this._alert("Error!"+(b.message?"\n"+b.message:"")+(b.hint?"\n"+b.hint:"")+"\nContext: "+b.context)},_warning:function(c){this._trigger(a.jPlayer.event.warning,b,c),this.options.warningAlerts&&this._alert("Warning!"+(c.message?"\n"+c.message:"")+(c.hint?"\n"+c.hint:"")+"\nContext: "+c.context)},_alert:function(a){var b="jPlayer "+this.version.script+" : id='"+this.internal.self.id+"' : "+a;this.options.consoleAlerts?window.console&&window.console.log&&window.console.log(b):alert(b)},_emulateHtmlBridge:function(){var b=this;a.each(a.jPlayer.emulateMethods.split(/\s+/g),function(a,c){b.internal.domNode[c]=function(a){b[c](a)}}),a.each(a.jPlayer.event,function(c,d){var e=!0;a.each(a.jPlayer.reservedEvent.split(/\s+/g),function(a,b){return b===c?(e=!1,!1):void 0}),e&&b.element.bind(d+".jPlayer.jPlayerHtml",function(){b._emulateHtmlUpdate();var a=document.createEvent("Event");a.initEvent(c,!1,!0),b.internal.domNode.dispatchEvent(a)})})},_emulateHtmlUpdate:function(){var b=this;a.each(a.jPlayer.emulateStatus.split(/\s+/g),function(a,c){b.internal.domNode[c]=b.status[c]}),a.each(a.jPlayer.emulateOptions.split(/\s+/g),function(a,c){b.internal.domNode[c]=b.options[c]})},_destroyHtmlBridge:function(){var b=this;this.element.unbind(".jPlayerHtml");var c=a.jPlayer.emulateMethods+" "+a.jPlayer.emulateStatus+" "+a.jPlayer.emulateOptions;a.each(c.split(/\s+/g),function(a,c){delete b.internal.domNode[c]})}},a.jPlayer.error={FLASH:"e_flash",FLASH_DISABLED:"e_flash_disabled",NO_SOLUTION:"e_no_solution",NO_SUPPORT:"e_no_support",URL:"e_url",URL_NOT_SET:"e_url_not_set",VERSION:"e_version"},a.jPlayer.errorMsg={FLASH:"jPlayer's Flash fallback is not configured correctly, or a command was issued before the jPlayer Ready event. Details: ",FLASH_DISABLED:"jPlayer's Flash fallback has been disabled by the browser due to the CSS rules you have used. Details: ",NO_SOLUTION:"No solution can be found by jPlayer in this browser. Neither HTML nor Flash can be used.",NO_SUPPORT:"It is not possible to play any media format provided in setMedia() on this browser using your current options.",URL:"Media URL could not be loaded.",URL_NOT_SET:"Attempt to issue media playback commands, while no media url is set.",VERSION:"jPlayer "+a.jPlayer.prototype.version.script+" needs Jplayer.swf version "+a.jPlayer.prototype.version.needFlash+" but found "},a.jPlayer.errorHint={FLASH:"Check your swfPath option and that Jplayer.swf is there.",FLASH_DISABLED:"Check that you have not display:none; the jPlayer entity or any ancestor.",NO_SOLUTION:"Review the jPlayer options: support and supplied.",NO_SUPPORT:"Video or audio formats defined in the supplied option are missing.",URL:"Check media URL is valid.",URL_NOT_SET:"Use setMedia() to set the media URL.",VERSION:"Update jPlayer files."},a.jPlayer.warning={CSS_SELECTOR_COUNT:"e_css_selector_count",CSS_SELECTOR_METHOD:"e_css_selector_method",CSS_SELECTOR_STRING:"e_css_selector_string",OPTION_KEY:"e_option_key"},a.jPlayer.warningMsg={CSS_SELECTOR_COUNT:"The number of css selectors found did not equal one: ",CSS_SELECTOR_METHOD:"The methodName given in jPlayer('cssSelector') is not a valid jPlayer method.",CSS_SELECTOR_STRING:"The methodCssSelector given in jPlayer('cssSelector') is not a String or is empty.",OPTION_KEY:"The option requested in jPlayer('option') is undefined."},a.jPlayer.warningHint={CSS_SELECTOR_COUNT:"Check your css selector and the ancestor.",CSS_SELECTOR_METHOD:"Check your method name.",CSS_SELECTOR_STRING:"Check your css selector is a string.",OPTION_KEY:"Check your option name."}});
\ No newline at end of file
+!function(a,b){"function"==typeof define&&define.amd?define(["jquery"],b):b("object"==typeof exports?require("jquery"):a.jQuery?a.jQuery:a.Zepto)}(this,function(a,b){a.fn.jPlayer=function(c){var d="jPlayer",e="string"==typeof c,f=Array.prototype.slice.call(arguments,1),g=this;return c=!e&&f.length?a.extend.apply(null,[!0,c].concat(f)):c,e&&"_"===c.charAt(0)?g:(this.each(e?function(){var e=a(this).data(d),h=e&&a.isFunction(e[c])?e[c].apply(e,f):e;return h!==e&&h!==b?(g=h,!1):void 0}:function(){var b=a(this).data(d);b?b.option(c||{}):a(this).data(d,new a.jPlayer(c,this))}),g)},a.jPlayer=function(b,c){if(arguments.length){this.element=a(c),this.options=a.extend(!0,{},this.options,b);var d=this;this.element.bind("remove.jPlayer",function(){d.destroy()}),this._init()}},"function"!=typeof a.fn.stop&&(a.fn.stop=function(){}),a.jPlayer.emulateMethods="load play pause",a.jPlayer.emulateStatus="src readyState networkState currentTime duration paused ended playbackRate",a.jPlayer.emulateOptions="muted volume",a.jPlayer.reservedEvent="ready flashreset resize repeat error warning",a.jPlayer.event={},a.each(["ready","setmedia","flashreset","resize","repeat","click","error","warning","loadstart","progress","suspend","abort","emptied","stalled","play","pause","loadedmetadata","loadeddata","waiting","playing","canplay","canplaythrough","seeking","seeked","timeupdate","ended","ratechange","durationchange","volumechange"],function(){a.jPlayer.event[this]="jPlayer_"+this}),a.jPlayer.htmlEvent=["loadstart","abort","emptied","stalled","loadedmetadata","canplay","canplaythrough"],a.jPlayer.pause=function(){a.jPlayer.prototype.destroyRemoved(),a.each(a.jPlayer.prototype.instances,function(a,b){b.data("jPlayer").status.srcSet&&b.jPlayer("pause")})},a.jPlayer.timeFormat={showHour:!1,showMin:!0,showSec:!0,padHour:!1,padMin:!0,padSec:!0,sepHour:":",sepMin:":",sepSec:""};var c=function(){this.init()};c.prototype={init:function(){this.options={timeFormat:a.jPlayer.timeFormat}},time:function(a){a=a&&"number"==typeof a?a:0;var b=new Date(1e3*a),c=b.getUTCHours()+(b.getUTCDate()-1)*24,d=this.options.timeFormat.showHour?b.getUTCMinutes():b.getUTCMinutes()+60*c,e=this.options.timeFormat.showMin?b.getUTCSeconds():b.getUTCSeconds()+60*d,f=this.options.timeFormat.padHour&&10>c?"0"+c:c,g=this.options.timeFormat.padMin&&10>d?"0"+d:d,h=this.options.timeFormat.padSec&&10>e?"0"+e:e,i="";return i+=this.options.timeFormat.showHour?f+this.options.timeFormat.sepHour:"",i+=this.options.timeFormat.showMin?g+this.options.timeFormat.sepMin:"",i+=this.options.timeFormat.showSec?h+this.options.timeFormat.sepSec:""}};var d=new c;a.jPlayer.convertTime=function(a){return d.time(a)},a.jPlayer.uaBrowser=function(a){var b=a.toLowerCase(),c=/(webkit)[ \/]([\w.]+)/,d=/(opera)(?:.*version)?[ \/]([\w.]+)/,e=/(msie) ([\w.]+)/,f=/(mozilla)(?:.*? rv:([\w.]+))?/,g=c.exec(b)||d.exec(b)||e.exec(b)||b.indexOf("compatible")<0&&f.exec(b)||[];return{browser:g[1]||"",version:g[2]||"0"}},a.jPlayer.uaPlatform=function(a){var b=a.toLowerCase(),c=/(ipad|iphone|ipod|android|blackberry|playbook|windows ce|webos)/,d=/(ipad|playbook)/,e=/(android)/,f=/(mobile)/,g=c.exec(b)||[],h=d.exec(b)||!f.exec(b)&&e.exec(b)||[];return g[1]&&(g[1]=g[1].replace(/\s/g,"_")),{platform:g[1]||"",tablet:h[1]||""}},a.jPlayer.browser={},a.jPlayer.platform={};var e=a.jPlayer.uaBrowser(navigator.userAgent);e.browser&&(a.jPlayer.browser[e.browser]=!0,a.jPlayer.browser.version=e.version);var f=a.jPlayer.uaPlatform(navigator.userAgent);f.platform&&(a.jPlayer.platform[f.platform]=!0,a.jPlayer.platform.mobile=!f.tablet,a.jPlayer.platform.tablet=!!f.tablet),a.jPlayer.getDocMode=function(){var b;return a.jPlayer.browser.msie&&(document.documentMode?b=document.documentMode:(b=5,document.compatMode&&"CSS1Compat"===document.compatMode&&(b=7))),b},a.jPlayer.browser.documentMode=a.jPlayer.getDocMode(),a.jPlayer.nativeFeatures={init:function(){var a,b,c,d=document,e=d.createElement("video"),f={w3c:["fullscreenEnabled","fullscreenElement","requestFullscreen","exitFullscreen","fullscreenchange","fullscreenerror"],moz:["mozFullScreenEnabled","mozFullScreenElement","mozRequestFullScreen","mozCancelFullScreen","mozfullscreenchange","mozfullscreenerror"],webkit:["","webkitCurrentFullScreenElement","webkitRequestFullScreen","webkitCancelFullScreen","webkitfullscreenchange",""],webkitVideo:["webkitSupportsFullscreen","webkitDisplayingFullscreen","webkitEnterFullscreen","webkitExitFullscreen","",""],ms:["","msFullscreenElement","msRequestFullscreen","msExitFullscreen","MSFullscreenChange","MSFullscreenError"]},g=["w3c","moz","webkit","webkitVideo","ms"];for(this.fullscreen=a={support:{w3c:!!d[f.w3c[0]],moz:!!d[f.moz[0]],webkit:"function"==typeof d[f.webkit[3]],webkitVideo:"function"==typeof e[f.webkitVideo[2]],ms:"function"==typeof e[f.ms[2]]},used:{}},b=0,c=g.length;c>b;b++){var h=g[b];if(a.support[h]){a.spec=h,a.used[h]=!0;break}}if(a.spec){var i=f[a.spec];a.api={fullscreenEnabled:!0,fullscreenElement:function(a){return a=a?a:d,a[i[1]]},requestFullscreen:function(a){return a[i[2]]()},exitFullscreen:function(a){return a=a?a:d,a[i[3]]()}},a.event={fullscreenchange:i[4],fullscreenerror:i[5]}}else a.api={fullscreenEnabled:!1,fullscreenElement:function(){return null},requestFullscreen:function(){},exitFullscreen:function(){}},a.event={}}},a.jPlayer.nativeFeatures.init(),a.jPlayer.focus=null,a.jPlayer.keyIgnoreElementNames="A INPUT TEXTAREA SELECT BUTTON";var g=function(b){var c,d=a.jPlayer.focus;d&&(a.each(a.jPlayer.keyIgnoreElementNames.split(/\s+/g),function(a,d){return b.target.nodeName.toUpperCase()===d.toUpperCase()?(c=!0,!1):void 0}),c||a.each(d.options.keyBindings,function(c,e){return e&&a.isFunction(e.fn)&&("number"==typeof e.key&&b.which===e.key||"string"==typeof e.key&&b.key===e.key)?(b.preventDefault(),e.fn(d),!1):void 0}))};a.jPlayer.keys=function(b){var c="keydown.jPlayer";a(document.documentElement).unbind(c),b&&a(document.documentElement).bind(c,g)},a.jPlayer.keys(!0),a.jPlayer.prototype={count:0,version:{script:"2.9.2",needFlash:"2.9.0",flash:"unknown"},options:{swfPath:"js",solution:"html, flash",supplied:"mp3",auroraFormats:"wav",preload:"metadata",volume:.8,muted:!1,remainingDuration:!1,toggleDuration:!1,captureDuration:!0,playbackRate:1,defaultPlaybackRate:1,minPlaybackRate:.5,maxPlaybackRate:4,wmode:"opaque",backgroundColor:"#000000",cssSelectorAncestor:"#jp_container_1",cssSelector:{videoPlay:".jp-video-play",play:".jp-play",pause:".jp-pause",stop:".jp-stop",seekBar:".jp-seek-bar",playBar:".jp-play-bar",mute:".jp-mute",unmute:".jp-unmute",volumeBar:".jp-volume-bar",volumeBarValue:".jp-volume-bar-value",volumeMax:".jp-volume-max",playbackRateBar:".jp-playback-rate-bar",playbackRateBarValue:".jp-playback-rate-bar-value",currentTime:".jp-current-time",duration:".jp-duration",title:".jp-title",fullScreen:".jp-full-screen",restoreScreen:".jp-restore-screen",repeat:".jp-repeat",repeatOff:".jp-repeat-off",gui:".jp-gui",noSolution:".jp-no-solution"},stateClass:{playing:"jp-state-playing",seeking:"jp-state-seeking",muted:"jp-state-muted",looped:"jp-state-looped",fullScreen:"jp-state-full-screen",noVolume:"jp-state-no-volume"},useStateClassSkin:!1,autoBlur:!0,smoothPlayBar:!1,fullScreen:!1,fullWindow:!1,autohide:{restored:!1,full:!0,fadeIn:200,fadeOut:600,hold:1e3},loop:!1,repeat:function(b){b.jPlayer.options.loop?a(this).unbind(".jPlayerRepeat").bind(a.jPlayer.event.ended+".jPlayer.jPlayerRepeat",function(){a(this).jPlayer("play")}):a(this).unbind(".jPlayerRepeat")},nativeVideoControls:{},noFullWindow:{msie:/msie [0-6]\./,ipad:/ipad.*?os [0-4]\./,iphone:/iphone/,ipod:/ipod/,android_pad:/android [0-3]\.(?!.*?mobile)/,android_phone:/(?=.*android)(?!.*chrome)(?=.*mobile)/,blackberry:/blackberry/,windows_ce:/windows ce/,iemobile:/iemobile/,webos:/webos/},noVolume:{ipad:/ipad/,iphone:/iphone/,ipod:/ipod/,android_pad:/android(?!.*?mobile)/,android_phone:/android.*?mobile/,blackberry:/blackberry/,windows_ce:/windows ce/,iemobile:/iemobile/,webos:/webos/,playbook:/playbook/},timeFormat:{},keyEnabled:!1,audioFullScreen:!1,keyBindings:{play:{key:80,fn:function(a){a.status.paused?a.play():a.pause()}},fullScreen:{key:70,fn:function(a){(a.status.video||a.options.audioFullScreen)&&a._setOption("fullScreen",!a.options.fullScreen)}},muted:{key:77,fn:function(a){a._muted(!a.options.muted)}},volumeUp:{key:190,fn:function(a){a.volume(a.options.volume+.1)}},volumeDown:{key:188,fn:function(a){a.volume(a.options.volume-.1)}},loop:{key:76,fn:function(a){a._loop(!a.options.loop)}}},verticalVolume:!1,verticalPlaybackRate:!1,globalVolume:!1,idPrefix:"jp",noConflict:"jQuery",emulateHtml:!1,consoleAlerts:!0,errorAlerts:!1,warningAlerts:!1},optionsAudio:{size:{width:"0px",height:"0px",cssClass:""},sizeFull:{width:"0px",height:"0px",cssClass:""}},optionsVideo:{size:{width:"480px",height:"270px",cssClass:"jp-video-270p"},sizeFull:{width:"100%",height:"100%",cssClass:"jp-video-full"}},instances:{},status:{src:"",media:{},paused:!0,format:{},formatType:"",waitForPlay:!0,waitForLoad:!0,srcSet:!1,video:!1,seekPercent:0,currentPercentRelative:0,currentPercentAbsolute:0,currentTime:0,duration:0,remaining:0,videoWidth:0,videoHeight:0,readyState:0,networkState:0,playbackRate:1,ended:0},internal:{ready:!1},solution:{html:!0,aurora:!0,flash:!0},format:{mp3:{codec:"audio/mpeg",flashCanPlay:!0,media:"audio"},m4a:{codec:'audio/mp4; codecs="mp4a.40.2"',flashCanPlay:!0,media:"audio"},m3u8a:{codec:'application/vnd.apple.mpegurl; codecs="mp4a.40.2"',flashCanPlay:!1,media:"audio"},m3ua:{codec:"audio/mpegurl",flashCanPlay:!1,media:"audio"},oga:{codec:'audio/ogg; codecs="vorbis, opus"',flashCanPlay:!1,media:"audio"},flac:{codec:"audio/x-flac",flashCanPlay:!1,media:"audio"},wav:{codec:'audio/wav; codecs="1"',flashCanPlay:!1,media:"audio"},webma:{codec:'audio/webm; codecs="vorbis"',flashCanPlay:!1,media:"audio"},fla:{codec:"audio/x-flv",flashCanPlay:!0,media:"audio"},rtmpa:{codec:'audio/rtmp; codecs="rtmp"',flashCanPlay:!0,media:"audio"},m4v:{codec:'video/mp4; codecs="avc1.42E01E, mp4a.40.2"',flashCanPlay:!0,media:"video"},m3u8v:{codec:'application/vnd.apple.mpegurl; codecs="avc1.42E01E, mp4a.40.2"',flashCanPlay:!1,media:"video"},m3uv:{codec:"audio/mpegurl",flashCanPlay:!1,media:"video"},ogv:{codec:'video/ogg; codecs="theora, vorbis"',flashCanPlay:!1,media:"video"},webmv:{codec:'video/webm; codecs="vorbis, vp8"',flashCanPlay:!1,media:"video"},flv:{codec:"video/x-flv",flashCanPlay:!0,media:"video"},rtmpv:{codec:'video/rtmp; codecs="rtmp"',flashCanPlay:!0,media:"video"}},_init:function(){var c=this;if(this.element.empty(),this.status=a.extend({},this.status),this.internal=a.extend({},this.internal),this.options.timeFormat=a.extend({},a.jPlayer.timeFormat,this.options.timeFormat),this.internal.cmdsIgnored=a.jPlayer.platform.ipad||a.jPlayer.platform.iphone||a.jPlayer.platform.ipod,this.internal.domNode=this.element.get(0),this.options.keyEnabled&&!a.jPlayer.focus&&(a.jPlayer.focus=this),this.androidFix={setMedia:!1,play:!1,pause:!1,time:0/0},a.jPlayer.platform.android&&(this.options.preload="auto"!==this.options.preload?"metadata":"auto"),this.formats=[],this.solutions=[],this.require={},this.htmlElement={},this.html={},this.html.audio={},this.html.video={},this.aurora={},this.aurora.formats=[],this.aurora.properties=[],this.flash={},this.css={},this.css.cs={},this.css.jq={},this.ancestorJq=[],this.options.volume=this._limitValue(this.options.volume,0,1),a.each(this.options.supplied.toLowerCase().split(","),function(b,d){var e=d.replace(/^\s+|\s+$/g,"");if(c.format[e]){var f=!1;a.each(c.formats,function(a,b){return e===b?(f=!0,!1):void 0}),f||c.formats.push(e)}}),a.each(this.options.solution.toLowerCase().split(","),function(b,d){var e=d.replace(/^\s+|\s+$/g,"");if(c.solution[e]){var f=!1;a.each(c.solutions,function(a,b){return e===b?(f=!0,!1):void 0}),f||c.solutions.push(e)}}),a.each(this.options.auroraFormats.toLowerCase().split(","),function(b,d){var e=d.replace(/^\s+|\s+$/g,"");if(c.format[e]){var f=!1;a.each(c.aurora.formats,function(a,b){return e===b?(f=!0,!1):void 0}),f||c.aurora.formats.push(e)}}),this.internal.instance="jp_"+this.count,this.instances[this.internal.instance]=this.element,this.element.attr("id")||this.element.attr("id",this.options.idPrefix+"_jplayer_"+this.count),this.internal.self=a.extend({},{id:this.element.attr("id"),jq:this.element}),this.internal.audio=a.extend({},{id:this.options.idPrefix+"_audio_"+this.count,jq:b}),this.internal.video=a.extend({},{id:this.options.idPrefix+"_video_"+this.count,jq:b}),this.internal.flash=a.extend({},{id:this.options.idPrefix+"_flash_"+this.count,jq:b,swf:this.options.swfPath+(".swf"!==this.options.swfPath.toLowerCase().slice(-4)?(this.options.swfPath&&"/"!==this.options.swfPath.slice(-1)?"/":"")+"jquery.jplayer.swf":"")}),this.internal.poster=a.extend({},{id:this.options.idPrefix+"_poster_"+this.count,jq:b}),a.each(a.jPlayer.event,function(a,d){c.options[a]!==b&&(c.element.bind(d+".jPlayer",c.options[a]),c.options[a]=b)}),this.require.audio=!1,this.require.video=!1,a.each(this.formats,function(a,b){c.require[c.format[b].media]=!0}),this.options=this.require.video?a.extend(!0,{},this.optionsVideo,this.options):a.extend(!0,{},this.optionsAudio,this.options),this._setSize(),this.status.nativeVideoControls=this._uaBlocklist(this.options.nativeVideoControls),this.status.noFullWindow=this._uaBlocklist(this.options.noFullWindow),this.status.noVolume=this._uaBlocklist(this.options.noVolume),a.jPlayer.nativeFeatures.fullscreen.api.fullscreenEnabled&&this._fullscreenAddEventListeners(),this._restrictNativeVideoControls(),this.htmlElement.poster=document.createElement("img"),this.htmlElement.poster.id=this.internal.poster.id,this.htmlElement.poster.onload=function(){(!c.status.video||c.status.waitForPlay)&&c.internal.poster.jq.show()},this.element.append(this.htmlElement.poster),this.internal.poster.jq=a("#"+this.internal.poster.id),this.internal.poster.jq.css({width:this.status.width,height:this.status.height}),this.internal.poster.jq.hide(),this.internal.poster.jq.bind("click.jPlayer",function(){c._trigger(a.jPlayer.event.click)}),this.html.audio.available=!1,this.require.audio&&(this.htmlElement.audio=document.createElement("audio"),this.htmlElement.audio.id=this.internal.audio.id,this.html.audio.available=!!this.htmlElement.audio.canPlayType&&this._testCanPlayType(this.htmlElement.audio)),this.html.video.available=!1,this.require.video&&(this.htmlElement.video=document.createElement("video"),this.htmlElement.video.id=this.internal.video.id,this.html.video.available=!!this.htmlElement.video.canPlayType&&this._testCanPlayType(this.htmlElement.video)),this.flash.available=this._checkForFlash(10.1),this.html.canPlay={},this.aurora.canPlay={},this.flash.canPlay={},a.each(this.formats,function(b,d){c.html.canPlay[d]=c.html[c.format[d].media].available&&""!==c.htmlElement[c.format[d].media].canPlayType(c.format[d].codec),c.aurora.canPlay[d]=a.inArray(d,c.aurora.formats)>-1,c.flash.canPlay[d]=c.format[d].flashCanPlay&&c.flash.available}),this.html.desired=!1,this.aurora.desired=!1,this.flash.desired=!1,a.each(this.solutions,function(b,d){if(0===b)c[d].desired=!0;else{var e=!1,f=!1;a.each(c.formats,function(a,b){c[c.solutions[0]].canPlay[b]&&("video"===c.format[b].media?f=!0:e=!0)}),c[d].desired=c.require.audio&&!e||c.require.video&&!f}}),this.html.support={},this.aurora.support={},this.flash.support={},a.each(this.formats,function(a,b){c.html.support[b]=c.html.canPlay[b]&&c.html.desired,c.aurora.support[b]=c.aurora.canPlay[b]&&c.aurora.desired,c.flash.support[b]=c.flash.canPlay[b]&&c.flash.desired}),this.html.used=!1,this.aurora.used=!1,this.flash.used=!1,a.each(this.solutions,function(b,d){a.each(c.formats,function(a,b){return c[d].support[b]?(c[d].used=!0,!1):void 0})}),this._resetActive(),this._resetGate(),this._cssSelectorAncestor(this.options.cssSelectorAncestor),this.html.used||this.aurora.used||this.flash.used?this.css.jq.noSolution.length&&this.css.jq.noSolution.hide():(this._error({type:a.jPlayer.error.NO_SOLUTION,context:"{solution:'"+this.options.solution+"', supplied:'"+this.options.supplied+"'}",message:a.jPlayer.errorMsg.NO_SOLUTION,hint:a.jPlayer.errorHint.NO_SOLUTION}),this.css.jq.noSolution.length&&this.css.jq.noSolution.show()),this.flash.used){var d,e="jQuery="+encodeURI(this.options.noConflict)+"&id="+encodeURI(this.internal.self.id)+"&vol="+this.options.volume+"&muted="+this.options.muted;if(a.jPlayer.browser.msie&&(Number(a.jPlayer.browser.version)<9||a.jPlayer.browser.documentMode<9)){var f='<object id="'+this.internal.flash.id+'" classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" width="0" height="0" tabindex="-1"></object>',g=['<param name="movie" value="'+this.internal.flash.swf+'" />','<param name="FlashVars" value="'+e+'" />','<param name="allowScriptAccess" value="always" />','<param name="bgcolor" value="'+this.options.backgroundColor+'" />','<param name="wmode" value="'+this.options.wmode+'" />'];d=document.createElement(f);for(var h=0;h<g.length;h++)d.appendChild(document.createElement(g[h]))}else{var i=function(a,b,c){var d=document.createElement("param");d.setAttribute("name",b),d.setAttribute("value",c),a.appendChild(d)};d=document.createElement("object"),d.setAttribute("id",this.internal.flash.id),d.setAttribute("name",this.internal.flash.id),d.setAttribute("data",this.internal.flash.swf),d.setAttribute("type","application/x-shockwave-flash"),d.setAttribute("width","1"),d.setAttribute("height","1"),d.setAttribute("tabindex","-1"),i(d,"flashvars",e),i(d,"allowscriptaccess","always"),i(d,"bgcolor",this.options.backgroundColor),i(d,"wmode",this.options.wmode)}this.element.append(d),this.internal.flash.jq=a(d)}this.status.playbackRateEnabled=this.html.used&&!this.flash.used?this._testPlaybackRate("audio"):!1,this._updatePlaybackRate(),this.html.used&&(this.html.audio.available&&(this._addHtmlEventListeners(this.htmlElement.audio,this.html.audio),this.element.append(this.htmlElement.audio),this.internal.audio.jq=a("#"+this.internal.audio.id)),this.html.video.available&&(this._addHtmlEventListeners(this.htmlElement.video,this.html.video),this.element.append(this.htmlElement.video),this.internal.video.jq=a("#"+this.internal.video.id),this.internal.video.jq.css(this.status.nativeVideoControls?{width:this.status.width,height:this.status.height}:{width:"0px",height:"0px"}),this.internal.video.jq.bind("click.jPlayer",function(){c._trigger(a.jPlayer.event.click)}))),this.aurora.used,this.options.emulateHtml&&this._emulateHtmlBridge(),!this.html.used&&!this.aurora.used||this.flash.used||setTimeout(function(){c.internal.ready=!0,c.version.flash="n/a",c._trigger(a.jPlayer.event.repeat),c._trigger(a.jPlayer.event.ready)},100),this._updateNativeVideoControls(),this.css.jq.videoPlay.length&&this.css.jq.videoPlay.hide(),a.jPlayer.prototype.count++},destroy:function(){this.clearMedia(),this._removeUiClass(),this.css.jq.currentTime.length&&this.css.jq.currentTime.text(""),this.css.jq.duration.length&&this.css.jq.duration.text(""),a.each(this.css.jq,function(a,b){b.length&&b.unbind(".jPlayer")}),this.internal.poster.jq.unbind(".jPlayer"),this.internal.video.jq&&this.internal.video.jq.unbind(".jPlayer"),this._fullscreenRemoveEventListeners(),this===a.jPlayer.focus&&(a.jPlayer.focus=null),this.options.emulateHtml&&this._destroyHtmlBridge(),this.element.removeData("jPlayer"),this.element.unbind(".jPlayer"),this.element.empty(),delete this.instances[this.internal.instance]},destroyRemoved:function(){var b=this;a.each(this.instances,function(a,c){b.element!==c&&(c.data("jPlayer")||(c.jPlayer("destroy"),delete b.instances[a]))})},enable:function(){},disable:function(){},_testCanPlayType:function(a){try{return a.canPlayType(this.format.mp3.codec),!0}catch(b){return!1}},_testPlaybackRate:function(a){var b,c=.5;a="string"==typeof a?a:"audio",b=document.createElement(a);try{return"playbackRate"in b?(b.playbackRate=c,b.playbackRate===c):!1}catch(d){return!1}},_uaBlocklist:function(b){var c=navigator.userAgent.toLowerCase(),d=!1;return a.each(b,function(a,b){return b&&b.test(c)?(d=!0,!1):void 0}),d},_restrictNativeVideoControls:function(){this.require.audio&&this.status.nativeVideoControls&&(this.status.nativeVideoControls=!1,this.status.noFullWindow=!0)},_updateNativeVideoControls:function(){this.html.video.available&&this.html.used&&(this.htmlElement.video.controls=this.status.nativeVideoControls,this._updateAutohide(),this.status.nativeVideoControls&&this.require.video?(this.internal.poster.jq.hide(),this.internal.video.jq.css({width:this.status.width,height:this.status.height})):this.status.waitForPlay&&this.status.video&&(this.internal.poster.jq.show(),this.internal.video.jq.css({width:"0px",height:"0px"})))},_addHtmlEventListeners:function(b,c){var d=this;b.preload=this.options.preload,b.muted=this.options.muted,b.volume=this.options.volume,this.status.playbackRateEnabled&&(b.defaultPlaybackRate=this.options.defaultPlaybackRate,b.playbackRate=this.options.playbackRate),b.addEventListener("progress",function(){c.gate&&(d.internal.cmdsIgnored&&this.readyState>0&&(d.internal.cmdsIgnored=!1),d._getHtmlStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.progress))},!1),b.addEventListener("loadeddata",function(){c.gate&&(d.androidFix.setMedia=!1,d.androidFix.play&&(d.androidFix.play=!1,d.play(d.androidFix.time)),d.androidFix.pause&&(d.androidFix.pause=!1,d.pause(d.androidFix.time)),d._trigger(a.jPlayer.event.loadeddata))},!1),b.addEventListener("timeupdate",function(){c.gate&&(d._getHtmlStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.timeupdate))},!1),b.addEventListener("durationchange",function(){c.gate&&(d._getHtmlStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.durationchange))},!1),b.addEventListener("play",function(){c.gate&&(d._updateButtons(!0),d._html_checkWaitForPlay(),d._trigger(a.jPlayer.event.play))},!1),b.addEventListener("playing",function(){c.gate&&(d._updateButtons(!0),d._seeked(),d._trigger(a.jPlayer.event.playing))},!1),b.addEventListener("pause",function(){c.gate&&(d._updateButtons(!1),d._trigger(a.jPlayer.event.pause))},!1),b.addEventListener("waiting",function(){c.gate&&(d._seeking(),d._trigger(a.jPlayer.event.waiting))},!1),b.addEventListener("seeking",function(){c.gate&&(d._seeking(),d._trigger(a.jPlayer.event.seeking))},!1),b.addEventListener("seeked",function(){c.gate&&(d._seeked(),d._trigger(a.jPlayer.event.seeked))},!1),b.addEventListener("volumechange",function(){c.gate&&(d.options.volume=b.volume,d.options.muted=b.muted,d._updateMute(),d._updateVolume(),d._trigger(a.jPlayer.event.volumechange))},!1),b.addEventListener("ratechange",function(){c.gate&&(d.options.defaultPlaybackRate=b.defaultPlaybackRate,d.options.playbackRate=b.playbackRate,d._updatePlaybackRate(),d._trigger(a.jPlayer.event.ratechange))},!1),b.addEventListener("suspend",function(){c.gate&&(d._seeked(),d._trigger(a.jPlayer.event.suspend))},!1),b.addEventListener("ended",function(){c.gate&&(a.jPlayer.browser.webkit||(d.htmlElement.media.currentTime=0),d.htmlElement.media.pause(),d._updateButtons(!1),d._getHtmlStatus(b,!0),d._updateInterface(),d._trigger(a.jPlayer.event.ended))},!1),b.addEventListener("error",function(){c.gate&&(d._updateButtons(!1),d._seeked(),d.status.srcSet&&(clearTimeout(d.internal.htmlDlyCmdId),d.status.waitForLoad=!0,d.status.waitForPlay=!0,d.status.video&&!d.status.nativeVideoControls&&d.internal.video.jq.css({width:"0px",height:"0px"}),d._validString(d.status.media.poster)&&!d.status.nativeVideoControls&&d.internal.poster.jq.show(),d.css.jq.videoPlay.length&&d.css.jq.videoPlay.show(),d._error({type:a.jPlayer.error.URL,context:d.status.src,message:a.jPlayer.errorMsg.URL,hint:a.jPlayer.errorHint.URL})))},!1),a.each(a.jPlayer.htmlEvent,function(e,f){b.addEventListener(this,function(){c.gate&&d._trigger(a.jPlayer.event[f])},!1)})},_addAuroraEventListeners:function(b,c){var d=this;b.volume=100*this.options.volume,b.on("progress",function(){c.gate&&(d.internal.cmdsIgnored&&this.readyState>0&&(d.internal.cmdsIgnored=!1),d._getAuroraStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.progress),b.duration>0&&d._trigger(a.jPlayer.event.timeupdate))},!1),b.on("ready",function(){c.gate&&d._trigger(a.jPlayer.event.loadeddata)},!1),b.on("duration",function(){c.gate&&(d._getAuroraStatus(b),d._updateInterface(),d._trigger(a.jPlayer.event.durationchange))},!1),b.on("end",function(){c.gate&&(d._updateButtons(!1),d._getAuroraStatus(b,!0),d._updateInterface(),d._trigger(a.jPlayer.event.ended))},!1),b.on("error",function(){c.gate&&(d._updateButtons(!1),d._seeked(),d.status.srcSet&&(d.status.waitForLoad=!0,d.status.waitForPlay=!0,d.status.video&&!d.status.nativeVideoControls&&d.internal.video.jq.css({width:"0px",height:"0px"}),d._validString(d.status.media.poster)&&!d.status.nativeVideoControls&&d.internal.poster.jq.show(),d.css.jq.videoPlay.length&&d.css.jq.videoPlay.show(),d._error({type:a.jPlayer.error.URL,context:d.status.src,message:a.jPlayer.errorMsg.URL,hint:a.jPlayer.errorHint.URL})))},!1)},_getHtmlStatus:function(a,b){var c=0,d=0,e=0,f=0;isFinite(a.duration)&&(this.status.duration=a.duration),c=a.currentTime,d=this.status.duration>0?100*c/this.status.duration:0,"object"==typeof a.seekable&&a.seekable.length>0?(e=this.status.duration>0?100*a.seekable.end(a.seekable.length-1)/this.status.duration:100,f=this.status.duration>0?100*a.currentTime/a.seekable.end(a.seekable.length-1):0):(e=100,f=d),b&&(c=0,f=0,d=0),this.status.seekPercent=e,this.status.currentPercentRelative=f,this.status.currentPercentAbsolute=d,this.status.currentTime=c,this.status.remaining=this.status.duration-this.status.currentTime,this.status.videoWidth=a.videoWidth,this.status.videoHeight=a.videoHeight,this.status.readyState=a.readyState,this.status.networkState=a.networkState,this.status.playbackRate=a.playbackRate,this.status.ended=a.ended},_getAuroraStatus:function(a,b){var c=0,d=0,e=0,f=0;this.status.duration=a.duration/1e3,c=a.currentTime/1e3,d=this.status.duration>0?100*c/this.status.duration:0,a.buffered>0?(e=this.status.duration>0?a.buffered*this.status.duration/this.status.duration:100,f=this.status.duration>0?c/(a.buffered*this.status.duration):0):(e=100,f=d),b&&(c=0,f=0,d=0),this.status.seekPercent=e,this.status.currentPercentRelative=f,this.status.currentPercentAbsolute=d,this.status.currentTime=c,this.status.remaining=this.status.duration-this.status.currentTime,this.status.readyState=4,this.status.networkState=0,this.status.playbackRate=1,this.status.ended=!1},_resetStatus:function(){this.status=a.extend({},this.status,a.jPlayer.prototype.status)},_trigger:function(b,c,d){var e=a.Event(b);e.jPlayer={},e.jPlayer.version=a.extend({},this.version),e.jPlayer.options=a.extend(!0,{},this.options),e.jPlayer.status=a.extend(!0,{},this.status),e.jPlayer.html=a.extend(!0,{},this.html),e.jPlayer.aurora=a.extend(!0,{},this.aurora),e.jPlayer.flash=a.extend(!0,{},this.flash),c&&(e.jPlayer.error=a.extend({},c)),d&&(e.jPlayer.warning=a.extend({},d)),this.element.trigger(e)},jPlayerFlashEvent:function(b,c){if(b===a.jPlayer.event.ready)if(this.internal.ready){if(this.flash.gate){if(this.status.srcSet){var d=this.status.currentTime,e=this.status.paused;this.setMedia(this.status.media),this.volumeWorker(this.options.volume),d>0&&(e?this.pause(d):this.play(d))}this._trigger(a.jPlayer.event.flashreset)}}else this.internal.ready=!0,this.internal.flash.jq.css({width:"0px",height:"0px"}),this.version.flash=c.version,this.version.needFlash!==this.version.flash&&this._error({type:a.jPlayer.error.VERSION,context:this.version.flash,message:a.jPlayer.errorMsg.VERSION+this.version.flash,hint:a.jPlayer.errorHint.VERSION}),this._trigger(a.jPlayer.event.repeat),this._trigger(b);if(this.flash.gate)switch(b){case a.jPlayer.event.progress:this._getFlashStatus(c),this._updateInterface(),this._trigger(b);break;case a.jPlayer.event.timeupdate:this._getFlashStatus(c),this._updateInterface(),this._trigger(b);break;case a.jPlayer.event.play:this._seeked(),this._updateButtons(!0),this._trigger(b);break;case a.jPlayer.event.pause:this._updateButtons(!1),this._trigger(b);break;case a.jPlayer.event.ended:this._updateButtons(!1),this._trigger(b);break;case a.jPlayer.event.click:this._trigger(b);break;case a.jPlayer.event.error:this.status.waitForLoad=!0,this.status.waitForPlay=!0,this.status.video&&this.internal.flash.jq.css({width:"0px",height:"0px"}),this._validString(this.status.media.poster)&&this.internal.poster.jq.show(),this.css.jq.videoPlay.length&&this.status.video&&this.css.jq.videoPlay.show(),this.status.video?this._flash_setVideo(this.status.media):this._flash_setAudio(this.status.media),this._updateButtons(!1),this._error({type:a.jPlayer.error.URL,context:c.src,message:a.jPlayer.errorMsg.URL,hint:a.jPlayer.errorHint.URL});break;case a.jPlayer.event.seeking:this._seeking(),this._trigger(b);break;case a.jPlayer.event.seeked:this._seeked(),this._trigger(b);break;case a.jPlayer.event.ready:break;default:this._trigger(b)}return!1},_getFlashStatus:function(a){this.status.seekPercent=a.seekPercent,this.status.currentPercentRelative=a.currentPercentRelative,this.status.currentPercentAbsolute=a.currentPercentAbsolute,this.status.currentTime=a.currentTime,this.status.duration=a.duration,this.status.remaining=a.duration-a.currentTime,this.status.videoWidth=a.videoWidth,this.status.videoHeight=a.videoHeight,this.status.readyState=4,this.status.networkState=0,this.status.playbackRate=1,this.status.ended=!1},_updateButtons:function(a){a===b?a=!this.status.paused:this.status.paused=!a,a?this.addStateClass("playing"):this.removeStateClass("playing"),!this.status.noFullWindow&&this.options.fullWindow?this.addStateClass("fullScreen"):this.removeStateClass("fullScreen"),this.options.loop?this.addStateClass("looped"):this.removeStateClass("looped"),this.css.jq.play.length&&this.css.jq.pause.length&&(a?(this.css.jq.play.hide(),this.css.jq.pause.show()):(this.css.jq.play.show(),this.css.jq.pause.hide())),this.css.jq.restoreScreen.length&&this.css.jq.fullScreen.length&&(this.status.noFullWindow?(this.css.jq.fullScreen.hide(),this.css.jq.restoreScreen.hide()):this.options.fullWindow?(this.css.jq.fullScreen.hide(),this.css.jq.restoreScreen.show()):(this.css.jq.fullScreen.show(),this.css.jq.restoreScreen.hide())),this.css.jq.repeat.length&&this.css.jq.repeatOff.length&&(this.options.loop?(this.css.jq.repeat.hide(),this.css.jq.repeatOff.show()):(this.css.jq.repeat.show(),this.css.jq.repeatOff.hide()))},_updateInterface:function(){this.css.jq.seekBar.length&&this.css.jq.seekBar.width(this.status.seekPercent+"%"),this.css.jq.playBar.length&&(this.options.smoothPlayBar?this.css.jq.playBar.stop().animate({width:this.status.currentPercentAbsolute+"%"},250,"linear"):this.css.jq.playBar.width(this.status.currentPercentRelative+"%"));var a="";this.css.jq.currentTime.length&&(a=this._convertTime(this.status.currentTime),a!==this.css.jq.currentTime.text()&&this.css.jq.currentTime.text(this._convertTime(this.status.currentTime)));var b="",c=this.status.duration,d=this.status.remaining;this.css.jq.duration.length&&("string"==typeof this.status.media.duration?b=this.status.media.duration:("number"==typeof this.status.media.duration&&(c=this.status.media.duration,d=c-this.status.currentTime),b=this.options.remainingDuration?(d>0?"-":"")+this._convertTime(d):this._convertTime(c)),b!==this.css.jq.duration.text()&&this.css.jq.duration.text(b))},_convertTime:c.prototype.time,_seeking:function(){this.css.jq.seekBar.length&&this.css.jq.seekBar.addClass("jp-seeking-bg"),this.addStateClass("seeking")},_seeked:function(){this.css.jq.seekBar.length&&this.css.jq.seekBar.removeClass("jp-seeking-bg"),this.removeStateClass("seeking")},_resetGate:function(){this.html.audio.gate=!1,this.html.video.gate=!1,this.aurora.gate=!1,this.flash.gate=!1},_resetActive:function(){this.html.active=!1,this.aurora.active=!1,this.flash.active=!1},_escapeHtml:function(a){return a.split("&").join("&amp;").split("<").join("&lt;").split(">").join("&gt;").split('"').join("&quot;")},_qualifyURL:function(a){var b=document.createElement("div");
+return b.innerHTML='<a href="'+this._escapeHtml(a)+'">x</a>',b.firstChild.href},_absoluteMediaUrls:function(b){var c=this;return a.each(b,function(a,d){d&&c.format[a]&&"data:"!==d.substr(0,5)&&(b[a]=c._qualifyURL(d))}),b},addStateClass:function(a){this.ancestorJq.length&&this.ancestorJq.addClass(this.options.stateClass[a])},removeStateClass:function(a){this.ancestorJq.length&&this.ancestorJq.removeClass(this.options.stateClass[a])},setMedia:function(b){var c=this,d=!1,e=this.status.media.poster!==b.poster;this._resetMedia(),this._resetGate(),this._resetActive(),this.androidFix.setMedia=!1,this.androidFix.play=!1,this.androidFix.pause=!1,b=this._absoluteMediaUrls(b),a.each(this.formats,function(e,f){var g="video"===c.format[f].media;return a.each(c.solutions,function(e,h){if(c[h].support[f]&&c._validString(b[f])){var i="html"===h,j="aurora"===h;return g?(i?(c.html.video.gate=!0,c._html_setVideo(b),c.html.active=!0):(c.flash.gate=!0,c._flash_setVideo(b),c.flash.active=!0),c.css.jq.videoPlay.length&&c.css.jq.videoPlay.show(),c.status.video=!0):(i?(c.html.audio.gate=!0,c._html_setAudio(b),c.html.active=!0,a.jPlayer.platform.android&&(c.androidFix.setMedia=!0)):j?(c.aurora.gate=!0,c._aurora_setAudio(b),c.aurora.active=!0):(c.flash.gate=!0,c._flash_setAudio(b),c.flash.active=!0),c.css.jq.videoPlay.length&&c.css.jq.videoPlay.hide(),c.status.video=!1),d=!0,!1}}),d?!1:void 0}),d?(this.status.nativeVideoControls&&this.html.video.gate||this._validString(b.poster)&&(e?this.htmlElement.poster.src=b.poster:this.internal.poster.jq.show()),"string"==typeof b.title&&(this.css.jq.title.length&&this.css.jq.title.html(b.title),this.htmlElement.audio&&this.htmlElement.audio.setAttribute("title",b.title),this.htmlElement.video&&this.htmlElement.video.setAttribute("title",b.title)),this.status.srcSet=!0,this.status.media=a.extend({},b),this._updateButtons(!1),this._updateInterface(),this._trigger(a.jPlayer.event.setmedia)):this._error({type:a.jPlayer.error.NO_SUPPORT,context:"{supplied:'"+this.options.supplied+"'}",message:a.jPlayer.errorMsg.NO_SUPPORT,hint:a.jPlayer.errorHint.NO_SUPPORT})},_resetMedia:function(){this._resetStatus(),this._updateButtons(!1),this._updateInterface(),this._seeked(),this.internal.poster.jq.hide(),clearTimeout(this.internal.htmlDlyCmdId),this.html.active?this._html_resetMedia():this.aurora.active?this._aurora_resetMedia():this.flash.active&&this._flash_resetMedia()},clearMedia:function(){this._resetMedia(),this.html.active?this._html_clearMedia():this.aurora.active?this._aurora_clearMedia():this.flash.active&&this._flash_clearMedia(),this._resetGate(),this._resetActive()},load:function(){this.status.srcSet?this.html.active?this._html_load():this.aurora.active?this._aurora_load():this.flash.active&&this._flash_load():this._urlNotSetError("load")},focus:function(){this.options.keyEnabled&&(a.jPlayer.focus=this)},play:function(a){var b="object"==typeof a;b&&this.options.useStateClassSkin&&!this.status.paused?this.pause(a):(a="number"==typeof a?a:0/0,this.status.srcSet?(this.focus(),this.html.active?this._html_play(a):this.aurora.active?this._aurora_play(a):this.flash.active&&this._flash_play(a)):this._urlNotSetError("play"))},videoPlay:function(){this.play()},pause:function(a){a="number"==typeof a?a:0/0,this.status.srcSet?this.html.active?this._html_pause(a):this.aurora.active?this._aurora_pause(a):this.flash.active&&this._flash_pause(a):this._urlNotSetError("pause")},tellOthers:function(b,c){var d=this,e="function"==typeof c,f=Array.prototype.slice.call(arguments);"string"==typeof b&&(e&&f.splice(1,1),a.jPlayer.prototype.destroyRemoved(),a.each(this.instances,function(){d.element!==this&&(!e||c.call(this.data("jPlayer"),d))&&this.jPlayer.apply(this,f)}))},pauseOthers:function(a){this.tellOthers("pause",function(){return this.status.srcSet},a)},stop:function(){this.status.srcSet?this.html.active?this._html_pause(0):this.aurora.active?this._aurora_pause(0):this.flash.active&&this._flash_pause(0):this._urlNotSetError("stop")},playHead:function(a){a=this._limitValue(a,0,100),this.status.srcSet?this.html.active?this._html_playHead(a):this.aurora.active?this._aurora_playHead(a):this.flash.active&&this._flash_playHead(a):this._urlNotSetError("playHead")},_muted:function(a){this.mutedWorker(a),this.options.globalVolume&&this.tellOthers("mutedWorker",function(){return this.options.globalVolume},a)},mutedWorker:function(b){this.options.muted=b,this.html.used&&this._html_setProperty("muted",b),this.aurora.used&&this._aurora_mute(b),this.flash.used&&this._flash_mute(b),this.html.video.gate||this.html.audio.gate||(this._updateMute(b),this._updateVolume(this.options.volume),this._trigger(a.jPlayer.event.volumechange))},mute:function(a){var c="object"==typeof a;c&&this.options.useStateClassSkin&&this.options.muted?this._muted(!1):(a=a===b?!0:!!a,this._muted(a))},unmute:function(a){a=a===b?!0:!!a,this._muted(!a)},_updateMute:function(a){a===b&&(a=this.options.muted),a?this.addStateClass("muted"):this.removeStateClass("muted"),this.css.jq.mute.length&&this.css.jq.unmute.length&&(this.status.noVolume?(this.css.jq.mute.hide(),this.css.jq.unmute.hide()):a?(this.css.jq.mute.hide(),this.css.jq.unmute.show()):(this.css.jq.mute.show(),this.css.jq.unmute.hide()))},volume:function(a){this.volumeWorker(a),this.options.globalVolume&&this.tellOthers("volumeWorker",function(){return this.options.globalVolume},a)},volumeWorker:function(b){b=this._limitValue(b,0,1),this.options.volume=b,this.html.used&&this._html_setProperty("volume",b),this.aurora.used&&this._aurora_volume(b),this.flash.used&&this._flash_volume(b),this.html.video.gate||this.html.audio.gate||(this._updateVolume(b),this._trigger(a.jPlayer.event.volumechange))},volumeBar:function(b){if(this.css.jq.volumeBar.length){var c=a(b.currentTarget),d=c.offset(),e=b.pageX-d.left,f=c.width(),g=c.height()-b.pageY+d.top,h=c.height();this.volume(this.options.verticalVolume?g/h:e/f)}this.options.muted&&this._muted(!1)},_updateVolume:function(a){a===b&&(a=this.options.volume),a=this.options.muted?0:a,this.status.noVolume?(this.addStateClass("noVolume"),this.css.jq.volumeBar.length&&this.css.jq.volumeBar.hide(),this.css.jq.volumeBarValue.length&&this.css.jq.volumeBarValue.hide(),this.css.jq.volumeMax.length&&this.css.jq.volumeMax.hide()):(this.removeStateClass("noVolume"),this.css.jq.volumeBar.length&&this.css.jq.volumeBar.show(),this.css.jq.volumeBarValue.length&&(this.css.jq.volumeBarValue.show(),this.css.jq.volumeBarValue[this.options.verticalVolume?"height":"width"](100*a+"%")),this.css.jq.volumeMax.length&&this.css.jq.volumeMax.show())},volumeMax:function(){this.volume(1),this.options.muted&&this._muted(!1)},_cssSelectorAncestor:function(b){var c=this;this.options.cssSelectorAncestor=b,this._removeUiClass(),this.ancestorJq=b?a(b):[],b&&1!==this.ancestorJq.length&&this._warning({type:a.jPlayer.warning.CSS_SELECTOR_COUNT,context:b,message:a.jPlayer.warningMsg.CSS_SELECTOR_COUNT+this.ancestorJq.length+" found for cssSelectorAncestor.",hint:a.jPlayer.warningHint.CSS_SELECTOR_COUNT}),this._addUiClass(),a.each(this.options.cssSelector,function(a,b){c._cssSelector(a,b)}),this._updateInterface(),this._updateButtons(),this._updateAutohide(),this._updateVolume(),this._updateMute()},_cssSelector:function(b,c){var d=this;if("string"==typeof c)if(a.jPlayer.prototype.options.cssSelector[b]){if(this.css.jq[b]&&this.css.jq[b].length&&this.css.jq[b].unbind(".jPlayer"),this.options.cssSelector[b]=c,this.css.cs[b]=this.options.cssSelectorAncestor+" "+c,this.css.jq[b]=c?a(this.css.cs[b]):[],this.css.jq[b].length&&this[b]){var e=function(c){c.preventDefault(),d[b](c),d.options.autoBlur?a(this).blur():a(this).focus()};this.css.jq[b].bind("click.jPlayer",e)}c&&1!==this.css.jq[b].length&&this._warning({type:a.jPlayer.warning.CSS_SELECTOR_COUNT,context:this.css.cs[b],message:a.jPlayer.warningMsg.CSS_SELECTOR_COUNT+this.css.jq[b].length+" found for "+b+" method.",hint:a.jPlayer.warningHint.CSS_SELECTOR_COUNT})}else this._warning({type:a.jPlayer.warning.CSS_SELECTOR_METHOD,context:b,message:a.jPlayer.warningMsg.CSS_SELECTOR_METHOD,hint:a.jPlayer.warningHint.CSS_SELECTOR_METHOD});else this._warning({type:a.jPlayer.warning.CSS_SELECTOR_STRING,context:c,message:a.jPlayer.warningMsg.CSS_SELECTOR_STRING,hint:a.jPlayer.warningHint.CSS_SELECTOR_STRING})},duration:function(a){this.options.toggleDuration&&(this.options.captureDuration&&a.stopPropagation(),this._setOption("remainingDuration",!this.options.remainingDuration))},seekBar:function(b){if(this.css.jq.seekBar.length){var c=a(b.currentTarget),d=c.offset(),e=b.pageX-d.left,f=c.width(),g=100*e/f;this.playHead(g)}},playbackRate:function(a){this._setOption("playbackRate",a)},playbackRateBar:function(b){if(this.css.jq.playbackRateBar.length){var c,d,e=a(b.currentTarget),f=e.offset(),g=b.pageX-f.left,h=e.width(),i=e.height()-b.pageY+f.top,j=e.height();c=this.options.verticalPlaybackRate?i/j:g/h,d=c*(this.options.maxPlaybackRate-this.options.minPlaybackRate)+this.options.minPlaybackRate,this.playbackRate(d)}},_updatePlaybackRate:function(){var a=this.options.playbackRate,b=(a-this.options.minPlaybackRate)/(this.options.maxPlaybackRate-this.options.minPlaybackRate);this.status.playbackRateEnabled?(this.css.jq.playbackRateBar.length&&this.css.jq.playbackRateBar.show(),this.css.jq.playbackRateBarValue.length&&(this.css.jq.playbackRateBarValue.show(),this.css.jq.playbackRateBarValue[this.options.verticalPlaybackRate?"height":"width"](100*b+"%"))):(this.css.jq.playbackRateBar.length&&this.css.jq.playbackRateBar.hide(),this.css.jq.playbackRateBarValue.length&&this.css.jq.playbackRateBarValue.hide())},repeat:function(a){var b="object"==typeof a;this._loop(b&&this.options.useStateClassSkin&&this.options.loop?!1:!0)},repeatOff:function(){this._loop(!1)},_loop:function(b){this.options.loop!==b&&(this.options.loop=b,this._updateButtons(),this._trigger(a.jPlayer.event.repeat))},option:function(c,d){var e=c;if(0===arguments.length)return a.extend(!0,{},this.options);if("string"==typeof c){var f=c.split(".");if(d===b){for(var g=a.extend(!0,{},this.options),h=0;h<f.length;h++){if(g[f[h]]===b)return this._warning({type:a.jPlayer.warning.OPTION_KEY,context:c,message:a.jPlayer.warningMsg.OPTION_KEY,hint:a.jPlayer.warningHint.OPTION_KEY}),b;g=g[f[h]]}return g}e={};for(var i=e,j=0;j<f.length;j++)j<f.length-1?(i[f[j]]={},i=i[f[j]]):i[f[j]]=d}return this._setOptions(e),this},_setOptions:function(b){var c=this;return a.each(b,function(a,b){c._setOption(a,b)}),this},_setOption:function(b,c){var d=this;switch(b){case"volume":this.volume(c);break;case"muted":this._muted(c);break;case"globalVolume":this.options[b]=c;break;case"cssSelectorAncestor":this._cssSelectorAncestor(c);break;case"cssSelector":a.each(c,function(a,b){d._cssSelector(a,b)});break;case"playbackRate":this.options[b]=c=this._limitValue(c,this.options.minPlaybackRate,this.options.maxPlaybackRate),this.html.used&&this._html_setProperty("playbackRate",c),this._updatePlaybackRate();break;case"defaultPlaybackRate":this.options[b]=c=this._limitValue(c,this.options.minPlaybackRate,this.options.maxPlaybackRate),this.html.used&&this._html_setProperty("defaultPlaybackRate",c),this._updatePlaybackRate();break;case"minPlaybackRate":this.options[b]=c=this._limitValue(c,.1,this.options.maxPlaybackRate-.1),this._updatePlaybackRate();break;case"maxPlaybackRate":this.options[b]=c=this._limitValue(c,this.options.minPlaybackRate+.1,16),this._updatePlaybackRate();break;case"fullScreen":if(this.options[b]!==c){var e=a.jPlayer.nativeFeatures.fullscreen.used.webkitVideo;(!e||e&&!this.status.waitForPlay)&&(e||(this.options[b]=c),c?this._requestFullscreen():this._exitFullscreen(),e||this._setOption("fullWindow",c))}break;case"fullWindow":this.options[b]!==c&&(this._removeUiClass(),this.options[b]=c,this._refreshSize());break;case"size":this.options.fullWindow||this.options[b].cssClass===c.cssClass||this._removeUiClass(),this.options[b]=a.extend({},this.options[b],c),this._refreshSize();break;case"sizeFull":this.options.fullWindow&&this.options[b].cssClass!==c.cssClass&&this._removeUiClass(),this.options[b]=a.extend({},this.options[b],c),this._refreshSize();break;case"autohide":this.options[b]=a.extend({},this.options[b],c),this._updateAutohide();break;case"loop":this._loop(c);break;case"remainingDuration":this.options[b]=c,this._updateInterface();break;case"toggleDuration":this.options[b]=c;break;case"nativeVideoControls":this.options[b]=a.extend({},this.options[b],c),this.status.nativeVideoControls=this._uaBlocklist(this.options.nativeVideoControls),this._restrictNativeVideoControls(),this._updateNativeVideoControls();break;case"noFullWindow":this.options[b]=a.extend({},this.options[b],c),this.status.nativeVideoControls=this._uaBlocklist(this.options.nativeVideoControls),this.status.noFullWindow=this._uaBlocklist(this.options.noFullWindow),this._restrictNativeVideoControls(),this._updateButtons();break;case"noVolume":this.options[b]=a.extend({},this.options[b],c),this.status.noVolume=this._uaBlocklist(this.options.noVolume),this._updateVolume(),this._updateMute();break;case"emulateHtml":this.options[b]!==c&&(this.options[b]=c,c?this._emulateHtmlBridge():this._destroyHtmlBridge());break;case"timeFormat":this.options[b]=a.extend({},this.options[b],c);break;case"keyEnabled":this.options[b]=c,c||this!==a.jPlayer.focus||(a.jPlayer.focus=null);break;case"keyBindings":this.options[b]=a.extend(!0,{},this.options[b],c);break;case"audioFullScreen":this.options[b]=c;break;case"autoBlur":this.options[b]=c}return this},_refreshSize:function(){this._setSize(),this._addUiClass(),this._updateSize(),this._updateButtons(),this._updateAutohide(),this._trigger(a.jPlayer.event.resize)},_setSize:function(){this.options.fullWindow?(this.status.width=this.options.sizeFull.width,this.status.height=this.options.sizeFull.height,this.status.cssClass=this.options.sizeFull.cssClass):(this.status.width=this.options.size.width,this.status.height=this.options.size.height,this.status.cssClass=this.options.size.cssClass),this.element.css({width:this.status.width,height:this.status.height})},_addUiClass:function(){this.ancestorJq.length&&this.ancestorJq.addClass(this.status.cssClass)},_removeUiClass:function(){this.ancestorJq.length&&this.ancestorJq.removeClass(this.status.cssClass)},_updateSize:function(){this.internal.poster.jq.css({width:this.status.width,height:this.status.height}),!this.status.waitForPlay&&this.html.active&&this.status.video||this.html.video.available&&this.html.used&&this.status.nativeVideoControls?this.internal.video.jq.css({width:this.status.width,height:this.status.height}):!this.status.waitForPlay&&this.flash.active&&this.status.video&&this.internal.flash.jq.css({width:this.status.width,height:this.status.height})},_updateAutohide:function(){var a=this,b="mousemove.jPlayer",c=".jPlayerAutohide",d=b+c,e=function(b){var c,d,e=!1;"undefined"!=typeof a.internal.mouse?(c=a.internal.mouse.x-b.pageX,d=a.internal.mouse.y-b.pageY,e=Math.floor(c)>0||Math.floor(d)>0):e=!0,a.internal.mouse={x:b.pageX,y:b.pageY},e&&a.css.jq.gui.fadeIn(a.options.autohide.fadeIn,function(){clearTimeout(a.internal.autohideId),a.internal.autohideId=setTimeout(function(){a.css.jq.gui.fadeOut(a.options.autohide.fadeOut)},a.options.autohide.hold)})};this.css.jq.gui.length&&(this.css.jq.gui.stop(!0,!0),clearTimeout(this.internal.autohideId),delete this.internal.mouse,this.element.unbind(c),this.css.jq.gui.unbind(c),this.status.nativeVideoControls?this.css.jq.gui.hide():this.options.fullWindow&&this.options.autohide.full||!this.options.fullWindow&&this.options.autohide.restored?(this.element.bind(d,e),this.css.jq.gui.bind(d,e),this.css.jq.gui.hide()):this.css.jq.gui.show())},fullScreen:function(a){var b="object"==typeof a;b&&this.options.useStateClassSkin&&this.options.fullScreen?this._setOption("fullScreen",!1):this._setOption("fullScreen",!0)},restoreScreen:function(){this._setOption("fullScreen",!1)},_fullscreenAddEventListeners:function(){var b=this,c=a.jPlayer.nativeFeatures.fullscreen;c.api.fullscreenEnabled&&c.event.fullscreenchange&&("function"!=typeof this.internal.fullscreenchangeHandler&&(this.internal.fullscreenchangeHandler=function(){b._fullscreenchange()}),document.addEventListener(c.event.fullscreenchange,this.internal.fullscreenchangeHandler,!1))},_fullscreenRemoveEventListeners:function(){var b=a.jPlayer.nativeFeatures.fullscreen;this.internal.fullscreenchangeHandler&&document.removeEventListener(b.event.fullscreenchange,this.internal.fullscreenchangeHandler,!1)},_fullscreenchange:function(){this.options.fullScreen&&!a.jPlayer.nativeFeatures.fullscreen.api.fullscreenElement()&&this._setOption("fullScreen",!1)},_requestFullscreen:function(){var b=this.ancestorJq.length?this.ancestorJq[0]:this.element[0],c=a.jPlayer.nativeFeatures.fullscreen;c.used.webkitVideo&&(b=this.htmlElement.video),c.api.fullscreenEnabled&&c.api.requestFullscreen(b)},_exitFullscreen:function(){var b,c=a.jPlayer.nativeFeatures.fullscreen;c.used.webkitVideo&&(b=this.htmlElement.video),c.api.fullscreenEnabled&&c.api.exitFullscreen(b)},_html_initMedia:function(b){var c=a(this.htmlElement.media).empty();a.each(b.track||[],function(a,b){var d=document.createElement("track");d.setAttribute("kind",b.kind?b.kind:""),d.setAttribute("src",b.src?b.src:""),d.setAttribute("srclang",b.srclang?b.srclang:""),d.setAttribute("label",b.label?b.label:""),b.def&&d.setAttribute("default",b.def),c.append(d)}),this.htmlElement.media.src=this.status.src,"none"!==this.options.preload&&this._html_load(),this._trigger(a.jPlayer.event.timeupdate)},_html_setFormat:function(b){var c=this;a.each(this.formats,function(a,d){return c.html.support[d]&&b[d]?(c.status.src=b[d],c.status.format[d]=!0,c.status.formatType=d,!1):void 0})},_html_setAudio:function(a){this._html_setFormat(a),this.htmlElement.media=this.htmlElement.audio,this._html_initMedia(a)},_html_setVideo:function(a){this._html_setFormat(a),this.status.nativeVideoControls&&(this.htmlElement.video.poster=this._validString(a.poster)?a.poster:""),this.htmlElement.media=this.htmlElement.video,this._html_initMedia(a)},_html_resetMedia:function(){this.htmlElement.media&&(this.htmlElement.media.id!==this.internal.video.id||this.status.nativeVideoControls||this.internal.video.jq.css({width:"0px",height:"0px"}),this.htmlElement.media.pause())},_html_clearMedia:function(){this.htmlElement.media&&(this.htmlElement.media.src="about:blank",this.htmlElement.media.load())},_html_load:function(){this.status.waitForLoad&&(this.status.waitForLoad=!1,this.htmlElement.media.load()),clearTimeout(this.internal.htmlDlyCmdId)},_html_play:function(a){var b=this,c=this.htmlElement.media;if(this.androidFix.pause=!1,this._html_load(),this.androidFix.setMedia)this.androidFix.play=!0,this.androidFix.time=a;else if(isNaN(a))c.play();else{this.internal.cmdsIgnored&&c.play();try{if(c.seekable&&!("object"==typeof c.seekable&&c.seekable.length>0))throw 1;c.currentTime=a,c.play()}catch(d){return void(this.internal.htmlDlyCmdId=setTimeout(function(){b.play(a)},250))}}this._html_checkWaitForPlay()},_html_pause:function(a){var b=this,c=this.htmlElement.media;if(this.androidFix.play=!1,a>0?this._html_load():clearTimeout(this.internal.htmlDlyCmdId),c.pause(),this.androidFix.setMedia)this.androidFix.pause=!0,this.androidFix.time=a;else if(!isNaN(a))try{if(c.seekable&&!("object"==typeof c.seekable&&c.seekable.length>0))throw 1;c.currentTime=a}catch(d){return void(this.internal.htmlDlyCmdId=setTimeout(function(){b.pause(a)},250))}a>0&&this._html_checkWaitForPlay()},_html_playHead:function(a){var b=this,c=this.htmlElement.media;this._html_load();try{if("object"==typeof c.seekable&&c.seekable.length>0)c.currentTime=a*c.seekable.end(c.seekable.length-1)/100;else{if(!(c.duration>0)||isNaN(c.duration))throw"e";c.currentTime=a*c.duration/100}}catch(d){return void(this.internal.htmlDlyCmdId=setTimeout(function(){b.playHead(a)},250))}this.status.waitForLoad||this._html_checkWaitForPlay()},_html_checkWaitForPlay:function(){this.status.waitForPlay&&(this.status.waitForPlay=!1,this.css.jq.videoPlay.length&&this.css.jq.videoPlay.hide(),this.status.video&&(this.internal.poster.jq.hide(),this.internal.video.jq.css({width:this.status.width,height:this.status.height})))},_html_setProperty:function(a,b){this.html.audio.available&&(this.htmlElement.audio[a]=b),this.html.video.available&&(this.htmlElement.video[a]=b)},_aurora_setAudio:function(b){var c=this;a.each(this.formats,function(a,d){return c.aurora.support[d]&&b[d]?(c.status.src=b[d],c.status.format[d]=!0,c.status.formatType=d,!1):void 0}),this.aurora.player=new AV.Player.fromURL(this.status.src),this._addAuroraEventListeners(this.aurora.player,this.aurora),"auto"===this.options.preload&&(this._aurora_load(),this.status.waitForLoad=!1)},_aurora_resetMedia:function(){this.aurora.player&&this.aurora.player.stop()},_aurora_clearMedia:function(){},_aurora_load:function(){this.status.waitForLoad&&(this.status.waitForLoad=!1,this.aurora.player.preload())},_aurora_play:function(b){this.status.waitForLoad||isNaN(b)||this.aurora.player.seek(b),this.aurora.player.playing||this.aurora.player.play(),this.status.waitForLoad=!1,this._aurora_checkWaitForPlay(),this._updateButtons(!0),this._trigger(a.jPlayer.event.play)},_aurora_pause:function(b){isNaN(b)||this.aurora.player.seek(1e3*b),this.aurora.player.pause(),b>0&&this._aurora_checkWaitForPlay(),this._updateButtons(!1),this._trigger(a.jPlayer.event.pause)},_aurora_playHead:function(a){this.aurora.player.duration>0&&this.aurora.player.seek(a*this.aurora.player.duration/100),this.status.waitForLoad||this._aurora_checkWaitForPlay()},_aurora_checkWaitForPlay:function(){this.status.waitForPlay&&(this.status.waitForPlay=!1)},_aurora_volume:function(a){this.aurora.player.volume=100*a},_aurora_mute:function(a){a?(this.aurora.properties.lastvolume=this.aurora.player.volume,this.aurora.player.volume=0):this.aurora.player.volume=this.aurora.properties.lastvolume,this.aurora.properties.muted=a},_flash_setAudio:function(b){var c=this;try{a.each(this.formats,function(a,d){if(c.flash.support[d]&&b[d]){switch(d){case"m4a":case"fla":c._getMovie().fl_setAudio_m4a(b[d]);break;case"mp3":c._getMovie().fl_setAudio_mp3(b[d]);break;case"rtmpa":c._getMovie().fl_setAudio_rtmp(b[d])}return c.status.src=b[d],c.status.format[d]=!0,c.status.formatType=d,!1}}),"auto"===this.options.preload&&(this._flash_load(),this.status.waitForLoad=!1)}catch(d){this._flashError(d)}},_flash_setVideo:function(b){var c=this;try{a.each(this.formats,function(a,d){if(c.flash.support[d]&&b[d]){switch(d){case"m4v":case"flv":c._getMovie().fl_setVideo_m4v(b[d]);break;case"rtmpv":c._getMovie().fl_setVideo_rtmp(b[d])}return c.status.src=b[d],c.status.format[d]=!0,c.status.formatType=d,!1}}),"auto"===this.options.preload&&(this._flash_load(),this.status.waitForLoad=!1)}catch(d){this._flashError(d)}},_flash_resetMedia:function(){this.internal.flash.jq.css({width:"0px",height:"0px"}),this._flash_pause(0/0)},_flash_clearMedia:function(){try{this._getMovie().fl_clearMedia()}catch(a){this._flashError(a)}},_flash_load:function(){try{this._getMovie().fl_load()}catch(a){this._flashError(a)}this.status.waitForLoad=!1},_flash_play:function(a){try{this._getMovie().fl_play(a)}catch(b){this._flashError(b)}this.status.waitForLoad=!1,this._flash_checkWaitForPlay()},_flash_pause:function(a){try{this._getMovie().fl_pause(a)}catch(b){this._flashError(b)}a>0&&(this.status.waitForLoad=!1,this._flash_checkWaitForPlay())},_flash_playHead:function(a){try{this._getMovie().fl_play_head(a)}catch(b){this._flashError(b)}this.status.waitForLoad||this._flash_checkWaitForPlay()},_flash_checkWaitForPlay:function(){this.status.waitForPlay&&(this.status.waitForPlay=!1,this.css.jq.videoPlay.length&&this.css.jq.videoPlay.hide(),this.status.video&&(this.internal.poster.jq.hide(),this.internal.flash.jq.css({width:this.status.width,height:this.status.height})))},_flash_volume:function(a){try{this._getMovie().fl_volume(a)}catch(b){this._flashError(b)}},_flash_mute:function(a){try{this._getMovie().fl_mute(a)}catch(b){this._flashError(b)}},_getMovie:function(){return document[this.internal.flash.id]},_getFlashPluginVersion:function(){var a,b=0;if(window.ActiveXObject)try{if(a=new ActiveXObject("ShockwaveFlash.ShockwaveFlash")){var c=a.GetVariable("$version");c&&(c=c.split(" ")[1].split(","),b=parseInt(c[0],10)+"."+parseInt(c[1],10))}}catch(d){}else navigator.plugins&&navigator.mimeTypes.length>0&&(a=navigator.plugins["Shockwave Flash"],a&&(b=navigator.plugins["Shockwave Flash"].description.replace(/.*\s(\d+\.\d+).*/,"$1")));return 1*b},_checkForFlash:function(a){var b=!1;return this._getFlashPluginVersion()>=a&&(b=!0),b},_validString:function(a){return a&&"string"==typeof a},_limitValue:function(a,b,c){return b>a?b:a>c?c:a},_urlNotSetError:function(b){this._error({type:a.jPlayer.error.URL_NOT_SET,context:b,message:a.jPlayer.errorMsg.URL_NOT_SET,hint:a.jPlayer.errorHint.URL_NOT_SET})},_flashError:function(b){var c;c=this.internal.ready?"FLASH_DISABLED":"FLASH",this._error({type:a.jPlayer.error[c],context:this.internal.flash.swf,message:a.jPlayer.errorMsg[c]+b.message,hint:a.jPlayer.errorHint[c]}),this.internal.flash.jq.css({width:"1px",height:"1px"})},_error:function(b){this._trigger(a.jPlayer.event.error,b),this.options.errorAlerts&&this._alert("Error!"+(b.message?"\n"+b.message:"")+(b.hint?"\n"+b.hint:"")+"\nContext: "+b.context)},_warning:function(c){this._trigger(a.jPlayer.event.warning,b,c),this.options.warningAlerts&&this._alert("Warning!"+(c.message?"\n"+c.message:"")+(c.hint?"\n"+c.hint:"")+"\nContext: "+c.context)},_alert:function(a){var b="jPlayer "+this.version.script+" : id='"+this.internal.self.id+"' : "+a;this.options.consoleAlerts?window.console&&window.console.log&&window.console.log(b):alert(b)},_emulateHtmlBridge:function(){var b=this;a.each(a.jPlayer.emulateMethods.split(/\s+/g),function(a,c){b.internal.domNode[c]=function(a){b[c](a)}}),a.each(a.jPlayer.event,function(c,d){var e=!0;a.each(a.jPlayer.reservedEvent.split(/\s+/g),function(a,b){return b===c?(e=!1,!1):void 0}),e&&b.element.bind(d+".jPlayer.jPlayerHtml",function(){b._emulateHtmlUpdate();var a=document.createEvent("Event");a.initEvent(c,!1,!0),b.internal.domNode.dispatchEvent(a)})})},_emulateHtmlUpdate:function(){var b=this;a.each(a.jPlayer.emulateStatus.split(/\s+/g),function(a,c){b.internal.domNode[c]=b.status[c]}),a.each(a.jPlayer.emulateOptions.split(/\s+/g),function(a,c){b.internal.domNode[c]=b.options[c]})},_destroyHtmlBridge:function(){var b=this;this.element.unbind(".jPlayerHtml");var c=a.jPlayer.emulateMethods+" "+a.jPlayer.emulateStatus+" "+a.jPlayer.emulateOptions;a.each(c.split(/\s+/g),function(a,c){delete b.internal.domNode[c]})}},a.jPlayer.error={FLASH:"e_flash",FLASH_DISABLED:"e_flash_disabled",NO_SOLUTION:"e_no_solution",NO_SUPPORT:"e_no_support",URL:"e_url",URL_NOT_SET:"e_url_not_set",VERSION:"e_version"},a.jPlayer.errorMsg={FLASH:"jPlayer's Flash fallback is not configured correctly, or a command was issued before the jPlayer Ready event. Details: ",FLASH_DISABLED:"jPlayer's Flash fallback has been disabled by the browser due to the CSS rules you have used. Details: ",NO_SOLUTION:"No solution can be found by jPlayer in this browser. Neither HTML nor Flash can be used.",NO_SUPPORT:"It is not possible to play any media format provided in setMedia() on this browser using your current options.",URL:"Media URL could not be loaded.",URL_NOT_SET:"Attempt to issue media playback commands, while no media url is set.",VERSION:"jPlayer "+a.jPlayer.prototype.version.script+" needs Jplayer.swf version "+a.jPlayer.prototype.version.needFlash+" but found "},a.jPlayer.errorHint={FLASH:"Check your swfPath option and that Jplayer.swf is there.",FLASH_DISABLED:"Check that you have not display:none; the jPlayer entity or any ancestor.",NO_SOLUTION:"Review the jPlayer options: support and supplied.",NO_SUPPORT:"Video or audio formats defined in the supplied option are missing.",URL:"Check media URL is valid.",URL_NOT_SET:"Use setMedia() to set the media URL.",VERSION:"Update jPlayer files."},a.jPlayer.warning={CSS_SELECTOR_COUNT:"e_css_selector_count",CSS_SELECTOR_METHOD:"e_css_selector_method",CSS_SELECTOR_STRING:"e_css_selector_string",OPTION_KEY:"e_option_key"},a.jPlayer.warningMsg={CSS_SELECTOR_COUNT:"The number of css selectors found did not equal one: ",CSS_SELECTOR_METHOD:"The methodName given in jPlayer('cssSelector') is not a valid jPlayer method.",CSS_SELECTOR_STRING:"The methodCssSelector given in jPlayer('cssSelector') is not a String or is empty.",OPTION_KEY:"The option requested in jPlayer('option') is undefined."},a.jPlayer.warningHint={CSS_SELECTOR_COUNT:"Check your css selector and the ancestor.",CSS_SELECTOR_METHOD:"Check your method name.",CSS_SELECTOR_STRING:"Check your css selector is a string.",OPTION_KEY:"Check your option name."}});
index 2feb7b8..666d7a0 100644 (file)
         $('input', container).val($(this).val());
         $('.is-active', container).removeClass('is-active');
         $(this).closest('.l-checkout__payments__box').addClass('is-active');
-        $('#kwota').val('');
-        return false;
+        $('#id_custom_amount').val('');
+    });
+
+    $('#id_custom_amount').on('input', function() {
+       if ($(this).val() > 0) {
+           $('.l-checkout__payments__box.is-active').removeClass('is-active');
+       } else {
+           $('.l-checkout__payments__box.initial-active').addClass('is-active');
+       }
+    });
+
+    $('.donation-mod-monthly').on('click', function() {
+       $.ajax({
+           method: 'POST',
+           data: {
+               csrfmiddlewaretoken: $("[name=csrfmiddlewaretoken]").val(),
+               monthly: $(this).data('monthly'),
+           },
+           url: $(this).data('url'),
+           success: function(data) {
+               if ($(".q-reload-is-monthly").length) {
+                   window.location.reload()
+               } else {
+                   $(".q-is-monthly").toggleClass('is-monthly', data.monthly);
+               }
+           }
+       });
+       return false;
     });
     
 })();
             $.post({
                 url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/nie_lubie/',
                 data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()},
+                dataType: 'json',
                 success: function() {
                     delete state.liked[$btn.attr('data-book')];
                     updateLiked($btn);
+                },
+                error: function() {
+                    window.location.href = $('#login-link').attr('href');
                 }
             })
         } else {
             $.post({
                 url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/lubie/',
                 data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()},
+                dataType: 'json',
                 success: function() {
-                    state.liked[$btn.attr('data-book')] = [];
+                    state.liked[$btn.attr('data-book')] = [{'favorites': true}];
                     updateLiked($btn);
                 },
-                error: function(e) {
-                    if (e.status == 403) {
-                        $('#login-link').click();
-                    }
-                },
+                error: function() {
+                    window.location.href = $('#login-link').attr('href')
+                }
             });
         }
     })
 
     function updateLiked(e) {
         let bookId = $(e).attr('data-book');
-        let liked = bookId in state.liked;
-        $(e).toggleClass('icon-liked', liked);
+        let liked = false;
         let $bookContainer = $('.book-container-' + bookId);
-        $bookContainer.toggleClass('book-liked', liked);
         let $sets = $(".sets", $bookContainer);
         $sets.empty();
         $.each(state.liked[bookId], (i,e) => {
-            let $set = $("<span>");
-            $set.attr("data-set", e.slug);
-            let $setA = $("<a>").appendTo($set);
-            $setA.attr("href", e.url);
-            $setA.text(e.name);
-            let $setX = $("<a class='close'></a>").appendTo($set);
-            $sets.append($set);
+            if (e.favorites) {
+                liked = true;
+            } else {
+                let $set = $("<span>");
+                $set.attr("data-set", e.slug);
+                let $setA = $("<a>").appendTo($set);
+                $setA.attr("href", e.url);
+                $setA.text(e.name);
+                let $setX = $("<a class='close'></a>").appendTo($set);
+                $sets.append($set);
+            }
         });
+
+        $(e).toggleClass('icon-liked', liked);
+        $bookContainer.toggleClass('book-liked', liked);
     }
     
 })();
index b9574d9..db33084 100644 (file)
@@ -4,7 +4,7 @@
   <form action="{% url 'import_book' %}" method="post" enctype="multipart/form-data">
     {% csrf_token %}
     <p>
-      <input type="file" id="id_book_xml_file" name="book_xml_file"/>
+      <input type="file" id="id_book_xml_file" name="book_xml_file" required/>
       <input type="submit" value="Import książki" />
     </p>
   </form>
index 17e2379..3cc4851 100644 (file)
@@ -7,6 +7,7 @@
 {% load preview_ad from catalogue_tags %}
 
 {% annoy_banner_crisis %}
+{% annoy_banner_top %}
 
 {% annoy_banner_blackout %}
 
index 7b9ea2d..1a804f1 100644 (file)
     </ul>
   </div>
   <a href="{% url 'user_settings' %}" class="user">
-    {% if request.user.is_staffs %}
+    {% if request.user.is_staff %}
       <img src="{% static '2022/images/icons/user-staff.svg' %}">
     {% elif request.user.membership %}
       <img src="{% static '2022/images/icons/user-vip.svg' %}">
     {% else %}
       <img src="{% static '2022/images/icons/user.svg' %}">
     {% endif %}
 </a>
+ </a>
 {% else %}
   <div class="l-navigation__login">
     <a id="login-link" href='{% url 'login' %}?next={{ request.path }}'>{% trans "Zaloguj się" %}</a>
@@ -38,3 +38,6 @@
     <a href='{% url 'register' %}?next={{ request.path }}'>{% trans "Załóż konto" %}</a>
   </div>
 {% endif %}
+
+<a href="/pomagam/?pk_campaign=menubar" class="menubar-donate">Wspieram!</a>
+