enable audio dl button master
authorRadek Czajka <rczajka@rczajka.pl>
Tue, 16 Jun 2026 12:48:47 +0000 (14:48 +0200)
committerRadek Czajka <rczajka@rczajka.pl>
Tue, 16 Jun 2026 12:48:47 +0000 (14:48 +0200)
181 files changed:
Dockerfile [new file with mode: 0644]
Makefile
docker-compose.yml [new file with mode: 0644]
requirements/requirements.txt
scripts/entrypoint.sh [new file with mode: 0755]
src/annoy/admin.py
src/annoy/migrations/0019_campaign_banner_books_alter_banner_place_and_more.py [new file with mode: 0644]
src/annoy/migrations/0020_campaign_end_campaign_landing_campaign_priority_and_more.py [new file with mode: 0644]
src/annoy/migrations/0021_campaign_data.py [new file with mode: 0644]
src/annoy/migrations/0022_remove_banner_progress_remove_banner_target.py [new file with mode: 0644]
src/annoy/migrations/0023_banner_video_alter_banner_place.py [new file with mode: 0644]
src/annoy/models.py
src/annoy/places.py
src/annoy/signals.py
src/annoy/templates/annoy/banner.html
src/annoy/templates/annoy/banner_blackout.html
src/annoy/templates/annoy/banner_crisis.html
src/annoy/templates/annoy/banner_seasonal.html [new file with mode: 0644]
src/annoy/templates/annoy/banner_seasonal_overlay.html [new file with mode: 0644]
src/annoy/templates/annoy/banner_top.html [new file with mode: 0644]
src/annoy/templates/annoy/checkout_header.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/forms.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/migrations/0055_book_can_sell.py [new file with mode: 0644]
src/catalogue/migrations/0056_book_isbn_mp3.py [new file with mode: 0644]
src/catalogue/migrations/0057_book_can_sell_mp3.py [new file with mode: 0644]
src/catalogue/migrations/0058_alter_book_can_sell_mp3.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/audiobook_zip_readme.txt
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/urls.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.html
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/club/stats.html [new file with mode: 0644]
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/dictionary/urls.py
src/lesmianator/models.py
src/lesmianator/views.py
src/opds/views.py
src/partners/__init__.py [new file with mode: 0644]
src/partners/admin.py [new file with mode: 0644]
src/partners/api/urls.py [new file with mode: 0644]
src/partners/api/views.py [new file with mode: 0644]
src/partners/apps.py [new file with mode: 0644]
src/partners/migrations/0001_initial.py [new file with mode: 0644]
src/partners/migrations/0002_alter_pricelevel_price.py [new file with mode: 0644]
src/partners/migrations/0003_audiopricelevel.py [new file with mode: 0644]
src/partners/migrations/__init__.py [new file with mode: 0644]
src/partners/models.py [new file with mode: 0644]
src/partners/tests.py [new file with mode: 0644]
src/partners/views.py [new file with mode: 0644]
src/paypal/migrations/0005_donation.py [new file with mode: 0644]
src/paypal/models.py
src/paypal/rest.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/serializers.py [new file with mode: 0644]
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/migrations/0027_alter_userlistitem_order_and_more.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/_article.scss
src/wolnelektury/static/2022/styles/layout/_books.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/img/favicon.png
src/wolnelektury/static/img/favicon.svg [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/static/root/robots.txt
src/wolnelektury/templates/account/base.html
src/wolnelektury/templates/admin/catalogue/book/change_list.html
src/wolnelektury/templates/base.html
src/wolnelektury/templates/header.html
src/wolnelektury/templates/socialaccount/connections.html
src/wolnelektury/templates/socialaccount/login_cancelled.html
src/wolnelektury/templates/user.html
src/wolnelektury/templates/user_actions.html

diff --git a/Dockerfile b/Dockerfile
new file mode 100644 (file)
index 0000000..b23530b
--- /dev/null
@@ -0,0 +1,73 @@
+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
+
+USER root
+
+RUN pip install --no-cache-dir gunicorn
+
+USER app
+
+COPY src /app/src
+
+
+
+
+FROM prod AS static-builder
+
+RUN python manage.py collectstatic --noinput
+
+
+
+FROM nginx:alpine AS nginx
+
+# COPY nginx.conf conf.d/...
+
+COPY --from=static-builder /app/var/static /app/var/static
index d43ff1e..59e222a 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
 
 
 deploy: src/wolnelektury/localsettings.py
@@ -17,3 +21,22 @@ test:
        mv ../htmlcov.new ../htmlcov
        coverage report
        rm .coverage
        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 exec 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..bda15bb
--- /dev/null
@@ -0,0 +1,52 @@
+x-app-base: &app-base
+  build:
+    context: .
+    target: dev
+    args:
+      UID: ${UID:-1000}
+      GID: ${GID:-1000}
+  env_file:
+   - .env
+  environment:
+    DEBUG: "True"
+  volumes:
+   - ./src:/app/src
+   - ./scripts:/app/scripts
+   - ./var/media:/app/var/media
+   - ./var/static:/app/var/static
+   - ./var/counters/:/app/var/counters
+   - ${LIBRARIAN:-/nonexistent}:/${LIBRARIAN:+edita}ble/librarian
+   - ipython:/app/.ipython
+  depends_on:
+   - db
+   - redis
+   - memcached
+  entrypoint: /app/scripts/entrypoint.sh
+
+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
+    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..56887b9 100644 (file)
@@ -5,6 +5,8 @@ Django==4.0.8
 fnpdjango==0.6
 docutils==0.20
 
 fnpdjango==0.6
 docutils==0.20
 
+python-memcached==1.59
+
 django-pipeline==3.1.0
 libsasscompiler==0.2.0
 jsmin==3.0.1
 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-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
 djangorestframework-xml==2.0.0
 django-admin-ordering==0.18
 django-countries==7.6.1
@@ -24,7 +27,7 @@ django-countries==7.6.1
 oauthlib>=3.0.1,<3.1
 
 # contact
 oauthlib>=3.0.1,<3.1
 
 # contact
-pyyaml==5.4.1
+pyyaml==6.0.3
 
 polib==1.2
 
 
 polib==1.2
 
@@ -34,12 +37,14 @@ python-fb==0.2
 
 Feedparser==6.0.11
 
 
 Feedparser==6.0.11
 
-Pillow==10.4
+Pillow==9.5.0
 mutagen==1.47
 sorl-thumbnail==12.10.0
 
 mutagen==1.47
 sorl-thumbnail==12.10.0
 
+zipstream-ng==1.9.2
+
 # home-brewed & dependencies
 # home-brewed & dependencies
-librarian==24.5.4
+librarian==26.5
 
 # celery tasks
 celery[redis]==5.4.0
 
 # celery tasks
 celery[redis]==5.4.0
@@ -59,3 +64,4 @@ python-slugify==8.0.4
 firebase-admin==6.6.0
 Wikidata==0.8.1
 
 firebase-admin==6.6.0
 Wikidata==0.8.1
 
+ipython
diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh
new file mode 100755 (executable)
index 0000000..7d809ef
--- /dev/null
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+# For use in development only.
+# Installs in editable mode any libs mounted under /editable/
+# This way, we can easily work on e.g. librarian.
+
+for lib in /editable/*
+do
+    [ -d "$lib" ] || continue
+    pip install -e $lib
+done
+
+exec "$@"
index ab7be6a..74a4dbc 100644 (file)
@@ -1,16 +1,38 @@
+from django.db.models import Q
 from django.contrib import admin
 from django import forms
 from django.contrib import admin
 from django import forms
+from django.utils.timezone import now
 from admin_ordering.admin import OrderableAdmin
 from modeltranslation.admin import TranslationAdmin
 from admin_ordering.admin import OrderableAdmin
 from modeltranslation.admin import TranslationAdmin
+from wolnelektury.utils import YesNoFilter
 from . import models
 
 
 from . import models
 
 
+
+admin.site.register(models.Campaign)
+
+
+class IsCurrentFilter(YesNoFilter):
+    title = 'Aktualny'
+    parameter_name = 'current'
+
+    @property
+    def q(self):
+        n = now()
+        return ~(Q(since__gt=n) | Q(until__lt=n) | Q(campaign__start__gt=n) | Q(campaign__end__lt=n))
+
+
 class BannerAdmin(TranslationAdmin):
     list_display = [
             'place', 'text',
 class BannerAdmin(TranslationAdmin):
     list_display = [
             'place', 'text',
-            'text_color', 'background_color',
-            'priority', 'since', 'until',
+            'campaign',
+            'since', 'until',
             'show_members', 'staff_preview', 'only_authenticated']
             'show_members', 'staff_preview', 'only_authenticated']
+    list_filter = [
+        'campaign',
+        IsCurrentFilter,
+    ]
+    autocomplete_fields = ['books']
 
 
 admin.site.register(models.Banner, BannerAdmin)
 
 
 admin.site.register(models.Banner, BannerAdmin)
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'),
+        ),
+    ]
diff --git a/src/annoy/migrations/0020_campaign_end_campaign_landing_campaign_priority_and_more.py b/src/annoy/migrations/0020_campaign_end_campaign_landing_campaign_priority_and_more.py
new file mode 100644 (file)
index 0000000..611fc79
--- /dev/null
@@ -0,0 +1,53 @@
+# Generated by Django 4.0.8 on 2026-03-31 11:09
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('annoy', '0019_campaign_banner_books_alter_banner_place_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='campaign',
+            name='end',
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='campaign',
+            name='landing',
+            field=models.CharField(blank=True, max_length=1024),
+        ),
+        migrations.AddField(
+            model_name='campaign',
+            name='priority',
+            field=models.PositiveSmallIntegerField(default=0, help_text='Kampanie z wyższym priorytetem mają pierwszeństwo.', verbose_name='priorytet'),
+        ),
+        migrations.AddField(
+            model_name='campaign',
+            name='progress',
+            field=models.IntegerField(blank=True, null=True, verbose_name='postęp'),
+        ),
+        migrations.AddField(
+            model_name='campaign',
+            name='start',
+            field=models.DateTimeField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='campaign',
+            name='target',
+            field=models.IntegerField(blank=True, null=True, verbose_name='cel'),
+        ),
+        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'), ('seasonal', 'Sezonowa')], verbose_name='miejsce'),
+        ),
+        migrations.AlterField(
+            model_name='campaign',
+            name='name',
+            field=models.CharField(help_text='Wewnętrzna nazwa kampanii', max_length=255),
+        ),
+    ]
diff --git a/src/annoy/migrations/0021_campaign_data.py b/src/annoy/migrations/0021_campaign_data.py
new file mode 100644 (file)
index 0000000..a6a2d2b
--- /dev/null
@@ -0,0 +1,41 @@
+# Generated by Django 4.0.8 on 2026-03-31 11:10
+
+from django.db import migrations
+
+def data_campaign(apps, schema_editor):
+    Banner = apps.get_model('annoy', 'Banner')
+    Campaign = apps.get_model('annoy', 'Campaign')
+    for b in Banner.objects.exclude(target=None):
+        if b.campaign is None:
+            b.campaign = Campaign.objects.create(name='auto')
+            b.save()
+        c = b.campaign
+        c.target = b.target
+        c.progress = b.progress
+        c.start = b.since
+        c.end = b.until
+        c.save()
+
+
+def data_campaign_rev(apps, schema_editor):
+    Banner = apps.get_model('annoy', 'Banner')
+    Campaign = apps.get_model('annoy', 'Campaign')
+    for c in Campaign.objects.exclude(target=None):
+        for b in c.banner_set.all():
+            b.target = c.target
+            b.progress = c.progress
+            b.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('annoy', '0020_campaign_end_campaign_landing_campaign_priority_and_more'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            data_campaign,
+            data_campaign_rev
+        )
+    ]
diff --git a/src/annoy/migrations/0022_remove_banner_progress_remove_banner_target.py b/src/annoy/migrations/0022_remove_banner_progress_remove_banner_target.py
new file mode 100644 (file)
index 0000000..d74b73a
--- /dev/null
@@ -0,0 +1,21 @@
+# Generated by Django 4.0.8 on 2026-03-31 11:21
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('annoy', '0021_campaign_data'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='banner',
+            name='progress',
+        ),
+        migrations.RemoveField(
+            model_name='banner',
+            name='target',
+        ),
+    ]
diff --git a/src/annoy/migrations/0023_banner_video_alter_banner_place.py b/src/annoy/migrations/0023_banner_video_alter_banner_place.py
new file mode 100644 (file)
index 0000000..7ba8961
--- /dev/null
@@ -0,0 +1,23 @@
+# Generated by Django 4.0.8 on 2026-04-29 11:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('annoy', '0022_remove_banner_progress_remove_banner_target'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='banner',
+            name='video',
+            field=models.FileField(blank=True, upload_to='annoy/banners/', verbose_name='video'),
+        ),
+        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'), ('seasonal', 'Sezonowa'), ('seasonal-overlay', 'Sezonowa-overlay')], verbose_name='miejsce'),
+        ),
+    ]
index 0d88792..c4f4ff0 100644 (file)
@@ -5,11 +5,72 @@ from django.conf import settings
 from django.db import models
 from django.template import Context, Template
 from django.utils.timezone import now
 from django.db import models
 from django.template import Context, Template
 from django.utils.timezone import now
+from lxml import etree, html
 from .places import PLACES, PLACE_CHOICES, STYLES
 
 
 from .places import PLACES, PLACE_CHOICES, STYLES
 
 
+class Campaign(models.Model):
+    name = models.CharField(max_length=255, help_text='Wewnętrzna nazwa kampanii')
+    image = models.FileField('obraz', upload_to='annoy/banners/', blank=True)
+    start = models.DateTimeField(null=True, blank=True)
+    end = models.DateTimeField(null=True, blank=True)
+    landing = models.CharField(blank=True, max_length=1024)
+    target = models.IntegerField('cel', null=True, blank=True)
+    progress = models.IntegerField('postęp', null=True, blank=True)
+    priority = models.PositiveSmallIntegerField(
+        'priorytet', default=0,
+        help_text='Kampanie z wyższym priorytetem mają pierwszeństwo.')
+
+    def __str__(self):
+        return self.name
+
+    @classmethod
+    def get_active(cls):
+        n = now()
+        return cls.objects.exclude(start__gt=n).exclude(end__lt=n)
+
+    def has_progress(self):
+        return self.target is not None and self.start is not None and self.end is not None
+
+    @property
+    def progress_percent(self):
+        if not self.target:
+            return 0
+        return (self.progress or 0) / self.target * 100
+
+    @property
+    def progress_percent_pretty(self):
+        return int(self.progress_percent)
+
+    def update_progress(self):
+        if not self.start or not self.end or not self.target:
+            return
+        Schedule = apps.get_model('club', 'Schedule')
+        PayUOrder = apps.get_model('club', 'PayUOrder')
+        progress = PayUOrder.objects.filter(
+            completed_at__gte=self.start,
+            completed_at__lte=self.end,
+        ).aggregate(c=models.Sum('schedule__amount'))['c'] or 0
+
+        for schedule in Schedule.objects.filter(
+                method='paypal',
+                expires_at__gt=self.start
+        ):
+            progress += schedule.n_paypal_payments(self.start, self.end) * schedule.amount
+        self.progress = progress
+        self.save(update_fields=['progress'])
+
+    @classmethod
+    def update_all_progress(cls):
+        n = now()
+        for obj in cls.get_active().exclude(target=None):
+            obj.update_progress()
+
+
 class Banner(models.Model):
     place = models.SlugField('miejsce', choices=PLACE_CHOICES)
 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,
     style = models.CharField(
         'styl', max_length=255, blank=True,
         choices=STYLES,
@@ -26,14 +87,15 @@ class Banner(models.Model):
     close_label = models.CharField('etykieta zamykania', max_length=255, blank=True)
     text = models.TextField('tekst')
     image = models.FileField('obraz', upload_to='annoy/banners/', blank=True)
     close_label = models.CharField('etykieta zamykania', max_length=255, blank=True)
     text = models.TextField('tekst')
     image = models.FileField('obraz', upload_to='annoy/banners/', blank=True)
+    video = models.FileField('video', upload_to='annoy/banners/', blank=True)
     url = models.CharField('URL', max_length=1024)
     priority = models.PositiveSmallIntegerField(
         'priorytet', default=0,
         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)
     url = models.CharField('URL', max_length=1024)
     priority = models.PositiveSmallIntegerField(
         'priorytet', default=0,
         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)
-    target = models.IntegerField('cel', null=True, blank=True)
-    progress = models.IntegerField('postęp', null=True, blank=True)
+    books = models.ManyToManyField('catalogue.Book', blank=True)
+
     show_members = models.BooleanField('widoczny dla członków klubu', default=False)
     staff_preview = models.BooleanField('podgląd tylko dla zespołu', default=False)
     only_authenticated = models.BooleanField('tylko dla zalogowanych', default=False)
     show_members = models.BooleanField('widoczny dla członków klubu', default=False)
     staff_preview = models.BooleanField('podgląd tylko dla zespołu', default=False)
     only_authenticated = models.BooleanField('tylko dla zalogowanych', default=False)
@@ -49,8 +111,26 @@ class Banner(models.Model):
     def get_text(self):
         return Template(self.text).render(Context())
 
     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 get_url(self):
+        if self.url:
+            return self.url
+        if self.campaign:
+            return self.campaign.landing
+
+    def is_external(self):
+        return (self.url and
+                not self.url.startswith('/') and
+                not self.url.startswith('https://wolnelektury.pl/')
+                )
+
     @classmethod
     @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'):
         Membership = apps.get_model('club', 'Membership')
 
         if exemptions and hasattr(request, 'annoy_banner_exempt'):
@@ -62,12 +142,22 @@ class Banner(models.Model):
         n = now()
         banners = cls.objects.filter(
             place=place
         n = now()
         banners = cls.objects.filter(
             place=place
+        ).exclude(
+            campaign__start__gt=n
+        ).exclude(
+            campaign__end__lt=n
         ).exclude(
             since__gt=n
         ).exclude(
             until__lt=n
         ).exclude(
             since__gt=n
         ).exclude(
             until__lt=n
-        ).order_by('-priority', '?')
-
+        ).order_by('-campaign__priority', '-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)
 
         if not request.user.is_authenticated:
             banners = banners.filter(only_authenticated=False)
 
@@ -79,32 +169,6 @@ class Banner(models.Model):
 
         return banners
 
 
         return banners
 
-    @property
-    def progress_percent(self):
-        if not self.target:
-            return 0
-        return (self.progress or 0) / self.target * 100
-
-    @property
-    def progress_percent_pretty(self):
-        return int(self.progress_percent)
-
-    def update_progress(self):
-        # Total of new payments during the action.
-        # This definition will need to change for longer timespans.
-        if not self.since or not self.until or not self.target:
-            return
-        Schedule = apps.get_model('club', 'Schedule')
-        self.progress = Schedule.objects.filter(
-            payed_at__gte=self.since,
-            payed_at__lte=self.until,
-        ).aggregate(c=models.Sum('amount'))['c']
-        self.save(update_fields=['progress'])
-
-    @classmethod
-    def update_all_progress(cls):
-        for obj in cls.objects.exclude(target=None):
-            obj.update_progress()
 
 
 class DynamicTextInsert(models.Model):
 
 
 class DynamicTextInsert(models.Model):
@@ -153,7 +217,10 @@ class MediaInsertSet(models.Model):
         self.save(update_fields=['etag'])
 
     def get_texts(self):
         self.save(update_fields=['etag'])
 
     def get_texts(self):
-        return [t.text for t in self.mediainserttext_set.all()]
+        return [
+            etree.tostring(html.fromstring(t.text), encoding='unicode')
+            for t in self.mediainserttext_set.all()
+        ]
 
     @classmethod
     def get_for_format(cls, file_format):
 
     @classmethod
     def get_for_format(cls, file_format):
index 8fa767b..ae7fe44 100644 (file)
@@ -1,6 +1,7 @@
 PLACE_DEFINITIONS = [
     ('top', 'U góry wszystkich stron', True),
     ('book-page', 'Strona książki', False),
 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, (
     ('book-text-intermission', 'Przerwa w treści książki', False),
     ('book-fragment-list', 'Obok listy fragmentów książki', False),
     ('blackout', 'Blackout', True, (
@@ -12,6 +13,8 @@ PLACE_DEFINITIONS = [
         ('quiet', 'Spokojny'),
         ('loud', 'Ostry'),
     )),
         ('quiet', 'Spokojny'),
         ('loud', 'Ostry'),
     )),
+    ('seasonal', 'Sezonowa', False),
+    ('seasonal-overlay', 'Sezonowa-overlay', True),
 ]
 
 PLACE_CHOICES = [p[:2] for p in PLACE_DEFINITIONS]
 ]
 
 PLACE_CHOICES = [p[:2] for p in PLACE_DEFINITIONS]
index c985282..5618996 100644 (file)
@@ -13,6 +13,6 @@ def update_etag(sender, instance, **kwargs):
 @receiver(post_save, sender=club.models.Schedule)
 def update_progress(sender, instance, **kwargs):
     try:
 @receiver(post_save, sender=club.models.Schedule)
 def update_progress(sender, instance, **kwargs):
     try:
-        models.Banner.update_all_progress()
+        models.Campaign.update_all_progress()
     except:
         pass
     except:
         pass
index 4b2bdcf..871fbba 100644 (file)
@@ -1,7 +1,7 @@
 {% if banner %}
   {% if closable %}
     <a class='annoy-banner-on annoy-banner-on_{{ banner.place }}'
 {% if banner %}
   {% if closable %}
     <a class='annoy-banner-on annoy-banner-on_{{ banner.place }}'
-       href="{{ banner.url }}"
+       href="{{ banner.get_url }}"
        data-target="#annoy-banner-{{ banner.id }}"
     >
       {{ banner.open_label }}
        data-target="#annoy-banner-{{ banner.id }}"
     >
       {{ banner.open_label }}
@@ -11,7 +11,7 @@
               annoy-banner
               annoy-banner_{{ banner.place }}
               annoy-banner-style_{{ banner.style }}
               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.smallfont %}banner-smallfont{% endif %}
               "
         id="annoy-banner-{{ banner.id }}"
            {% if banner.background_color %}background-color: {{ banner.background_color }};{% endif %}
         ">
     {% if not banner.action_label %}
            {% 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.get_url }}">
     {% endif %}
     <div class="annoy-banner-inner">
 
     {% 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 %}
       {% 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.get_url }}">
           {{ banner.action_label }}
         </a>
       {% endif %}
           {{ banner.action_label }}
         </a>
       {% endif %}
index c96751f..c5a5e3d 100644 (file)
@@ -1,7 +1,7 @@
 {% if banner %}
   {% if closable %}
     <a class='annoy-banner-on annoy-banner-on_{{ banner.place }}'
 {% if banner %}
   {% if closable %}
     <a class='annoy-banner-on annoy-banner-on_{{ banner.place }}'
-       href="{{ banner.url }}"
+       href="{{ banner.get_url }}"
        data-target="#annoy-banner-{{ banner.id }}"
     >
     </a>
        data-target="#annoy-banner-{{ banner.id }}"
     >
     </a>
@@ -23,7 +23,7 @@
       <div class="text">
         {{ banner.get_text|safe|linebreaks }}
         <div class="annoy-banner_actions">
       <div class="text">
         {{ banner.get_text|safe|linebreaks }}
         <div class="annoy-banner_actions">
-          <a class="action l-button l-button--default l-button--default--dark" href="{{ banner.url }}">
+          <a class="action l-button l-button--default l-button--default--dark" href="{{ banner.get_url }}">
             {{ banner.action_label }}
           </a>
           <a class='annoy-banner-off annoy-banner-off_{{ banner.place }}  l-button l-button--default'>{{ banner.close_label|default:"x" }}</a>
             {{ banner.action_label }}
           </a>
           <a class='annoy-banner-off annoy-banner-off_{{ banner.place }}  l-button l-button--default'>{{ banner.close_label|default:"x" }}</a>
index 9feffbf..22f52b0 100644 (file)
           {{ banner.get_text|safe|linebreaks }}
        </div>
 
           {{ banner.get_text|safe|linebreaks }}
        </div>
 
+        {% if banner.campaign.has_progress %}
        <div class="state-box">
          <div class="progress-box">
            <div>
              <div class="l-checkout__support__bar">
        <div class="state-box">
          <div class="progress-box">
            <div>
              <div class="l-checkout__support__bar">
-               <span data-label="{{ banner.progress_percent_pretty }}%" style="width: {{ banner.progress_percent|stringformat:".3f" }}%;"></span>
+               <span data-label="{{ banner.campaign.progress_percent_pretty }}%" style="width: {{ banner.campaign.progress_percent|stringformat:".3f" }}%;"></span>
              </div>
            </div>
          </div>
          <div class="time-box">
              </div>
            </div>
          </div>
          <div class="time-box">
-           <strong class="countdown inline" data-until='{{ banner.until|date_to_utc:True|utc_for_js }}'>&nbsp;</strong>
+           <strong class="countdown inline" data-until='{{ banner.campaign.end|date_to_utc:True|utc_for_js }}'>&nbsp;</strong>
          </div>
          <div class="action-box">
            {% if banner.action_label %}
          </div>
          <div class="action-box">
            {% if banner.action_label %}
-            <a class="action" href="{{ banner.url }}">
+            <a class="action" href="{{ banner.get_url }}">
               {{ banner.action_label }}
             </a>
            {% endif %}
          </div>
        </div>
               {{ banner.action_label }}
             </a>
            {% endif %}
          </div>
        </div>
+        {% endif %}
       </div>
 
 
       </div>
 
 
diff --git a/src/annoy/templates/annoy/banner_seasonal.html b/src/annoy/templates/annoy/banner_seasonal.html
new file mode 100644 (file)
index 0000000..7f9dbf6
--- /dev/null
@@ -0,0 +1,71 @@
+{% load l10n %}
+{% load time_tags %}
+
+{% if banner %}
+<div class="l-container">
+  <div class="
+              annoy-banner
+              annoy-banner_{{ banner.place }}
+              annoy-banner-style_{{ banner.style }}
+              {% if banner.get_image %}with-image{% endif %}
+              {% if banner.smallfont %}banner-smallfont{% endif %}
+              annoy-banner-clickable
+              "
+        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 %}
+        ">
+    {% if not banner.action_label %}
+    <a
+      {% if banner.is_external %}target="_blank"{% endif %}
+      href="{{ banner.get_url }}">
+    {% endif %}
+    <div class="annoy-banner-inner">
+
+      {% 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"
+        {% if banner.is_external %}target="_blank"{% endif %}
+        href="{{ banner.get_url }}">
+          {{ banner.action_label }}
+        </a>
+      {% endif %}
+    </div>
+    {% if not banner.action_label %}
+      </a>
+    {% endif %}
+
+    {% if banner.campaign.has_progress %}
+       <div class="state-box">
+         <div class="progress-box">
+           <div>
+             <div class="l-checkout__support__bar">
+               <span class="{% if banner.campaign.progress_percent < 15 %}little-progress{% endif %}" data-label="{{ banner.campaign.progress_percent|floatformat:"2" }}% ({{ banner.campaign.progress|floatformat:"g" }} zł)" style="width: {{ banner.campaign.progress_percent|stringformat:".3f" }}%;"></span>
+             </div>
+           </div>
+         </div>
+          <div class="bar-status-box">
+         <div class="time-box">
+           <strong class="countdown inline" data-until='{{ banner.campaign.end|date_to_utc:True|utc_for_js }}'>&nbsp;</strong>
+         </div>
+          <strong>{{ banner.campaign.target|floatformat:"g" }} zł</strong>
+          </div>
+       </div>
+    {% endif %}
+
+
+  </div>
+</div>
+{% endif %}
diff --git a/src/annoy/templates/annoy/banner_seasonal_overlay.html b/src/annoy/templates/annoy/banner_seasonal_overlay.html
new file mode 100644 (file)
index 0000000..62882e3
--- /dev/null
@@ -0,0 +1,41 @@
+{% if banner %}
+<span class="annoy-banner-on"
+      data-target="#annoy-banner-{{ banner.id }}"
+      ></span>
+<div class="l-container t-relative">
+  <div class="
+              annoy-banner
+              annoy-banner_{{ banner.place }}
+              {% if banner.get_image %}with-image{% endif %}
+              {% if banner.video %}with-video{% 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">
+
+      {% if banner.get_image %}
+      <div>
+        <img src="{{ banner.get_image.url }}">
+       </div>
+      {% endif %}
+      {% if banner.video %}
+      <video src="{{ banner.video.url }}" controls></video>
+      {% endif %}
+      <div class="text">
+        {{ banner.get_text|safe }}
+        <a class="l-button l-button--default" href="{{ banner.url }}">{{ banner.action_label }}</a>
+      </div>
+
+      
+      <a class='annoy-banner-off annoy-banner-off_{{ banner.place }}  l-button l-button--default'>
+        <span class="c-hamburger"><span class="bar animate"></span></span>
+      </a>
+
+    </div>
+
+  </div>
+</div>
+{% 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..c66d31e
--- /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.get_url }}">
+              {{ banner.action_label }}
+            </a>
+           {% endif %}
+         </div>
+       </div>
+      </div>
+
+
+
+    </div>
+  </div>
+  </div>
+
+{% endif %}
+
diff --git a/src/annoy/templates/annoy/checkout_header.html b/src/annoy/templates/annoy/checkout_header.html
new file mode 100644 (file)
index 0000000..225d3b5
--- /dev/null
@@ -0,0 +1,9 @@
+
+<div class="l-checkout__box__header__img l-checkout__box__header__real">
+  {% if banner.video %}
+  <video src="{{ banner.video.url }}" controls></video>
+  {% endif %}
+</div>
+<div class="l-checkout__box__header__content">
+  {{ banner.text|safe }}
+</div>
index 40f7511..ac1b628 100644 (file)
@@ -7,8 +7,8 @@ register = template.Library()
 
 
 @register.inclusion_tag('annoy/banner.html', takes_context=True)
 
 
 @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),
     return {
         'banner': banners.first(),
         'closable': PLACES.get(place, False),
@@ -22,6 +22,13 @@ def annoy_banner_blackout(context):
         'closable': True,
     }
 
         '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):
 
 @register.inclusion_tag('annoy/banners.html', takes_context=True)
 def annoy_banners(context, place):
@@ -38,3 +45,31 @@ def annoy_banner_crisis(context):
         'banner': banners.first(),
         'closable': True,
     }
         'banner': banners.first(),
         'closable': True,
     }
+
+@register.inclusion_tag('annoy/banner_seasonal.html', takes_context=True)
+def annoy_banner_seasonal(context):
+    banners = Banner.choice('seasonal', request=context['request'], exemptions=False)
+    return {
+        'banner': banners.first(),
+        'closable': False,
+    }
+
+@register.inclusion_tag('annoy/banner_seasonal_overlay.html', takes_context=True)
+def annoy_banner_seasonal_overlay(context):
+    banners = Banner.choice('seasonal-overlay', request=context['request'])
+    return {
+        'banner': banners.first(),
+    }
+
+
+@register.simple_tag(takes_context=True)
+def seasonal_overlay_exists(context):
+    return Banner.choice('seasonal-overlay', request=context['request'], exemptions=False).exists()
+
+
+@register.inclusion_tag('annoy/checkout_header.html', takes_context=True)
+def annoy_checkout_header(context):
+    banners = Banner.choice('seasonal-overlay', request=context['request'], exemptions=False)
+    return {
+        'banner': banners.first(),
+    }
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.
 #
 # 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.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
 
 
 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)
 
     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):
 
 
 class WLLimitOffsetPagination(LimitOffsetPagination):
+    def get_results(self, data):
+        return data['member']
+
     def get_paginated_response(self, data):
         return Response({
             "member": data,
     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'})
 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 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..1597811 100644 (file)
@@ -15,12 +15,27 @@ urlpatterns1 = [
     path('requestConfirm/', csrf_exempt(views.RequestConfirmView.as_view())),
     path('login/', csrf_exempt(views.Login2View.as_view())),
     path('me/', views.UserView.as_view()),
     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('catalogue.api.urls2')),
+    path('', include('social.api.urls2')),
+    path('', include('bookmarks.api.urls')),
+    path('', include('search.api.urls')),
+    path('', include('push.api.urls')),
+
+    path('partners/', include('partners.api.urls')),
 ]
 
 
 urlpatterns = [
     path('2/', include((urlpatterns1, 'api'), namespace="v2")),
 ]
 
 
 urlpatterns = [
     path('2/', include((urlpatterns1, 'api'), namespace="v2")),
+    path('3/', include((urlpatterns1, 'api'), namespace="v3")),
+    path('4/', include((urlpatterns1, 'api'), namespace="v4")),
+    path('1/<path:path>', views.Unsupported.as_view()),
 
     path('oauth/request_token/', csrf_exempt(views.OAuth1RequestTokenView.as_view())),
     path('oauth/authorize/', views.oauth_user_auth, name='oauth_user_auth'),
 
     path('oauth/request_token/', csrf_exempt(views.OAuth1RequestTokenView.as_view())),
     path('oauth/authorize/', views.oauth_user_auth, name='oauth_user_auth'),
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
 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):
 
 
 def oauthlib_request(request):
@@ -36,6 +37,7 @@ def oauthlib_response(response_tuple):
 
 
 vary_on_auth = method_decorator(vary_on_headers('Authorization'), 'dispatch')
 
 
 vary_on_auth = method_decorator(vary_on_headers('Authorization'), 'dispatch')
+never_cache = method_decorator(django.views.decorators.cache.never_cache, 'dispatch')
 
 
 class HttpResponseAppRedirect(HttpResponseRedirect):
 
 
 class HttpResponseAppRedirect(HttpResponseRedirect):
index 08d1650..290dc93 100644 (file)
@@ -2,12 +2,16 @@
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 from time import time
 # 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.decorators import login_required
+from django.contrib.auth.models import User
 from django import forms
 from django import forms
-from django.http import HttpResponse
+from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
 from django.http import Http404
 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
 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 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
 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):
     serializer_class = serializers.RegisterSerializer
 
     def get(self, request):
@@ -245,8 +250,46 @@ class RegisterView(APIView):
         })
 
     def post(self, request):
         })
 
     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
 
 class RefreshTokenView(APIView):
     serializer_class = serializers.RefreshTokenSerializer
@@ -284,4 +327,108 @@ class RefreshTokenView(APIView):
 
 
 class RequestConfirmView(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)
+
+
+class Unsupported(APIView):
+    get = post = put = delete = lambda self, request, path: Response({
+        "error": "unsupported-api",
+    }, status=410)
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..6aeb82d
--- /dev/null
@@ -0,0 +1,119 @@
+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.utils.timezone import now
+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
+from catalogue.api.serializers import BookSerializer2
+
+
+class BookmarkSerializerV3(serializers.ModelSerializer):
+    """Replaced in API v4."""
+    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']
+
+
+class BookmarkSerializer(serializers.ModelSerializer):
+    book_slug = serializers.SlugRelatedField(
+        queryset=catalogue.models.Book.objects.all(), slug_field='slug',
+        write_only=True,
+        required=False
+    )
+    book = BookSerializer2(read_only=True)
+    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_slug', 'book', 'anchor', 'audio_timestamp', 'mode', 'note', 'href', 'uuid', 'location', 'timestamp', 'deleted']
+        read_only_fields = ['uuid', 'mode']
+
+    def create(self, validated_data):
+        book = validated_data.pop('book_slug', None)
+        if book is not None:
+            validated_data['book'] = book
+        return super().create(validated_data)
+
+    def update(self, instance, validated_data):
+        book = validated_data.pop('book_slug', None)
+        if book is not None:
+            validated_data['book'] = book
+        return super().update(instance, validated_data)
+
+@never_cache
+class BookmarksView(ListCreateAPIView):
+    permission_classes = [IsAuthenticated]
+
+    def get_serializer_class(self):
+        if self.request.version < 'v4':
+            return BookmarkSerializerV3
+        else:
+            return 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]
+    pagination_class = None
+
+    def get_serializer_class(self):
+        if self.request.version < 'v4':
+            return BookmarkSerializerV3
+        else:
+            return BookmarkSerializer
+    
+    def get_queryset(self):
+        return self.request.user.bookmark_set.filter(book__slug=self.kwargs['book'])
+
+
+@never_cache
+class BookmarkView(RetrieveUpdateDestroyAPIView):
+    permission_classes = [IsAuthenticatedOrReadOnly]
+    lookup_field = 'uuid'
+
+    def get_serializer_class(self):
+        if self.request.version < 'v4':
+            return BookmarkSerializerV3
+        else:
+            return BookmarkSerializer
+    
+    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()
+
+    def perform_destroy(self, instance):
+        instance.deleted = True
+        instance.updated_at = now()
+        instance.save()
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
 import uuid
+from django.apps import apps
 from django.db import models
 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)
     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)
     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 __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 {
     
     def get_for_json(self):
         return {
index 0ff5104..c2b40e1 100644 (file)
@@ -1,5 +1,6 @@
 from django.http import Http404, JsonResponse
 from django.shortcuts import render, get_object_or_404
 from django.http import Http404, JsonResponse
 from django.shortcuts import render, get_object_or_404
+from django.utils.timezone import now
 from django.views.decorators import cache
 import catalogue.models
 from wolnelektury.utils import is_ajax
 from django.views.decorators import cache
 import catalogue.models
 from wolnelektury.utils import is_ajax
@@ -41,6 +42,7 @@ def bookmarks(request):
             for bm in models.Bookmark.objects.filter(
                     user=request.user,
                     book=book,
             for bm in models.Bookmark.objects.filter(
                     user=request.user,
                     book=book,
+                    deleted=False
             )
         })
 
             )
         })
 
@@ -54,7 +56,10 @@ def bookmark(request, uuid):
 
 
 def bookmark_delete(request, uuid):
 
 
 def bookmark_delete(request, uuid):
-    models.Bookmark.objects.filter(user=request.user, uuid=uuid).delete()
+    models.Bookmark.objects.filter(user=request.user, uuid=uuid).update(
+        deleted=True,
+        updated_at=now()
+    )
     return JsonResponse({})
 
 
     return JsonResponse({})
 
 
@@ -62,6 +67,8 @@ def bookmark_delete(request, uuid):
 
 @cache.never_cache
 def quotes(request):
 
 @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:
     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='*')
 
 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')
 
     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 = [
     class Meta:
         model = Book
         fields = [
-            'full_sort_key', 'title',
+            'slug', 'title', 'full_sort_key',
             'href', 'url', 'language',
             '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',
             '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')
 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']
 
 
         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()
 
 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 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'
          ),
          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>/',
          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..3449339 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 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.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
 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 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
 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)
 
 
         return Response({}, status=status.HTTP_201_CREATED)
 
 
+class BookFilter(dfilters.FilterSet):
+    sort = dfilters.OrderingFilter(
+        fields=(
+            ('sort_key_author', 'alpha'),
+            ('popularity__count', '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
 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()
 
     def get_queryset(self):
         books = Book.objects.all()
@@ -196,6 +227,30 @@ class BookList2(ListAPIView):
         return books
 
 
         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()
 @vary_on_auth  # Because of 'liked'.
 class BookDetail(RetrieveAPIView):
     queryset = Book.objects.all()
@@ -209,6 +264,18 @@ class BookDetail2(RetrieveAPIView):
     serializer_class = serializers.BookSerializer2
 
 
     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
 @vary_on_auth  # Because of embargo links.
 class EbookList(BookList):
     serializer_class = serializers.EbookSerializer
@@ -328,6 +395,51 @@ class TagCategoryView(LegacyListAPIView):
 
         return tags
 
 
         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]
 
 class TagView(RetrieveAPIView):
     permission_classes = [DjangoModelPermissionsOrAnonReadOnly]
@@ -421,3 +533,56 @@ class FragmentView(RetrieveAPIView):
             book__slug=self.kwargs['book'],
             anchor=self.kwargs['anchor']
         )
             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))
 
 
         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'
 class CoverField(EbookField):
     ext = 'jpg'
     directory = 'cover'
index 919ff3a..c9c3edc 100644 (file)
@@ -20,6 +20,8 @@ class BookImportForm(forms.Form):
     logo = forms.CharField(required=False)
     logo_mono = forms.CharField(required=False)
     logo_alt = forms.CharField(required=False)
     logo = forms.CharField(required=False)
     logo_mono = forms.CharField(required=False)
     logo_alt = forms.CharField(required=False)
+    can_sell = forms.BooleanField(required=False)
+    isbn_mp3 = forms.CharField(required=False)
 
     def clean(self):
         from django.core.files.base import ContentFile
 
     def clean(self):
         from django.core.files.base import ContentFile
@@ -40,7 +42,9 @@ class BookImportForm(forms.Form):
                                   logo=self.cleaned_data['logo'],
                                   logo_mono=self.cleaned_data['logo_mono'],
                                   logo_alt=self.cleaned_data['logo_alt'],
                                   logo=self.cleaned_data['logo'],
                                   logo_mono=self.cleaned_data['logo_mono'],
                                   logo_alt=self.cleaned_data['logo_alt'],
-                                  **kwargs)
+                                  can_sell=self.cleaned_data['can_sell'], 
+                                  isbn_mp3=self.cleaned_data['isbn_mp3'],
+                                 **kwargs)
 
 
 FORMATS = [(f, f.upper()) for f in Book.ebook_formats]
 
 
 FORMATS = [(f, f.upper()) for f in Book.ebook_formats]
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'),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0055_book_can_sell.py b/src/catalogue/migrations/0055_book_can_sell.py
new file mode 100644 (file)
index 0000000..3f3df55
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2026-03-06 13:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0054_alter_book_sort_key_alter_book_sort_key_author_and_more'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='can_sell',
+            field=models.BooleanField(default=True, verbose_name='do sprzedaży'),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0056_book_isbn_mp3.py b/src/catalogue/migrations/0056_book_isbn_mp3.py
new file mode 100644 (file)
index 0000000..bb24a7e
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2026-04-24 10:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0055_book_can_sell'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='isbn_mp3',
+            field=models.CharField(blank=True, max_length=32, verbose_name='ISBN audiobooka'),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0057_book_can_sell_mp3.py b/src/catalogue/migrations/0057_book_can_sell_mp3.py
new file mode 100644 (file)
index 0000000..900053d
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2026-04-24 11:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0056_book_isbn_mp3'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='book',
+            name='can_sell_mp3',
+            field=models.BooleanField(default=True, verbose_name='do sprzedaży mp3'),
+        ),
+    ]
diff --git a/src/catalogue/migrations/0058_alter_book_can_sell_mp3.py b/src/catalogue/migrations/0058_alter_book_can_sell_mp3.py
new file mode 100644 (file)
index 0000000..bd32041
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2026-04-29 11:49
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('catalogue', '0057_book_can_sell_mp3'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='book',
+            name='can_sell_mp3',
+            field=models.BooleanField(default=False, verbose_name='do sprzedaży mp3'),
+        ),
+    ]
index 29e3754..7ed0b4c 100644 (file)
@@ -7,6 +7,9 @@ from datetime import date, timedelta
 from random import randint
 import os.path
 import re
 from random import randint
 import os.path
 import re
+import requests
+from slugify import slugify
+from sortify import sortify
 from urllib.request import urlretrieve
 from django.apps import apps
 from django.conf import settings
 from urllib.request import urlretrieve
 from django.apps import apps
 from django.conf import settings
@@ -36,13 +39,14 @@ bofh_storage = BofhFileSystemStorage()
 class Book(models.Model):
     """Represents a book imported from WL-XML."""
     title = models.CharField('tytuł', max_length=32767)
 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(
     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)
     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)
     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)
@@ -58,10 +62,14 @@ class Book(models.Model):
     preview_until = models.DateField('prapremiera do', blank=True, null=True)
     preview_key = models.CharField(max_length=32, blank=True, null=True)
     findable = models.BooleanField('wyszukiwalna', default=True, db_index=True)
     preview_until = models.DateField('prapremiera do', blank=True, null=True)
     preview_key = models.CharField(max_length=32, blank=True, null=True)
     findable = models.BooleanField('wyszukiwalna', default=True, db_index=True)
+    can_sell = models.BooleanField('do sprzedaży', default=True)
+    can_sell_mp3 = models.BooleanField('do sprzedaży mp3', default=False)
+    isbn_mp3 = models.CharField('ISBN audiobooka', max_length=32, blank=True)
 
     # files generated during publication
     xml_file = fields.XmlField(storage=bofh_storage, with_etag=False)
     html_file = fields.HtmlField(storage=bofh_storage)
 
     # 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)
     fb2_file = fields.Fb2Field(storage=bofh_storage)
     txt_file = fields.TxtField(storage=bofh_storage)
     epub_file = fields.EpubField(storage=bofh_storage)
@@ -79,7 +87,7 @@ class Book(models.Model):
         'okładka dla Ebookpoint')
 
     ebook_formats = constants.EBOOK_FORMATS
         '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)
 
     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 +98,13 @@ class Book(models.Model):
     objects = models.Manager()
     tagged = managers.ModelTaggedItemManager(Tag)
     tags = managers.TagDescriptor(Tag)
     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)
     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()
 
     html_built = django.dispatch.Signal()
     published = django.dispatch.Signal()
 
@@ -181,6 +193,10 @@ class Book(models.Model):
     def isbn_mobi(self):
         return self.get_extra_info_json().get('isbn_mobi')
 
     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
     def is_accessible_to(self, user):
         if not self.preview:
             return True
@@ -264,17 +280,6 @@ class Book(models.Model):
             return sibling.get_first_text()
         return self.parent.get_next_text(inside=False)
 
             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 []
     def get_siblings(self):
         if not self.parent:
             return []
@@ -335,9 +340,6 @@ class Book(models.Model):
         else:
             return self.media.filter(type=type_).exists()
 
         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:
     def get_media(self, type_):
         if self.has_media(type_):
             if type_ in Book.formats:
@@ -375,6 +377,9 @@ class Book(models.Model):
     def html_url(self):
         return self.media_url('html')
 
     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')
 
     def pdf_url(self):
         return self.media_url('pdf')
 
@@ -416,7 +421,69 @@ class Book(models.Model):
     def has_sync_file(self):
         return settings.FEATURE_SYNCHRO and self.has_media("sync")
 
     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):
     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])
         with self.get_media('sync').first().file.open('r') as f:
             sync = f.read().split('\n')
         offset = float(sync[0])
@@ -426,8 +493,22 @@ class Book(models.Model):
                 continue
             start, end, elid = line.split()
             items.append([elid, float(start) + offset])
                 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")
 
     def has_audio_epub_file(self):
         return self.has_media("audio.epub")
 
@@ -439,7 +520,7 @@ class Book(models.Model):
     def media_audio_epub(self):
         return self.get_media('audio.epub')
 
     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
         ogg_files = {}
         for m in self.media.filter(type='ogg').order_by().iterator():
             ogg_files[m.name] = m
@@ -465,13 +546,27 @@ class Book(models.Model):
                 media['ogg'] = ogg
             audiobooks.append(media)
 
                 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
 
         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
     def wldocument(self, parse_dublincore=True, inherit=True):
         from catalogue.import_utils import ORMDocProvider
         from librarian.parser import WLDocument
@@ -592,7 +687,7 @@ class Book(models.Model):
 
     @classmethod
     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
 
     @classmethod
     def from_text_and_meta(cls, raw_file, book_info, overwrite=False, dont_build=None, search_index=True,
-                           remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None, logo_alt=None):
+                           remote_gallery_url=None, days=0, findable=True, logo=None, logo_mono=None, logo_alt=None, can_sell=None, isbn_mp3=None):
         from catalogue import tasks
 
         if dont_build is None:
         from catalogue import tasks
 
         if dont_build is None:
@@ -635,6 +730,7 @@ class Book(models.Model):
         book.findable = findable
         book.language = book_info.language
         book.title = book_info.title
         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:
         if book_info.variant_of:
             book.common_slug = book_info.variant_of.slug
         else:
@@ -646,11 +742,17 @@ class Book(models.Model):
             extra['logo_mono'] = logo_mono
         if logo_alt:
             extra['logo_alt'] = logo_alt
             extra['logo_mono'] = logo_mono
         if logo_alt:
             extra['logo_alt'] = logo_alt
+        if can_sell is not None:
+            book.can_sell = can_sell
+        if isbn_mp3 is not None:
+            book.isbn_mp3 = isbn_mp3
         book.extra_info = json.dumps(extra)
         book.load_abstract()
         book.load_toc()
         book.save()
 
         book.extra_info = json.dumps(extra)
         book.load_abstract()
         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]
         meta_tags = Tag.tags_from_info(book_info)
 
         just_tags = [t for (t, rel) in meta_tags if not rel]
@@ -704,6 +806,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()
         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)
 
         if not settings.NO_SEARCH_INDEX and search_index and findable:
             tasks.index_book.delay(book.id)
@@ -717,6 +820,16 @@ class Book(models.Model):
         cls.published.send(sender=cls, instance=book)
         return book
 
         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()
     def update_references(self):
         Entity = apps.get_model('references', 'Entity')
         doc = self.wldocument2()
@@ -762,6 +875,55 @@ class Book(models.Model):
     def references(self):
         return self.reference_set.all().select_related('entity')
 
     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)
+
+    def update_can_sell_mp3(self):
+        ret = True
+        for child in self.get_children():
+            child.update_can_sell_mp3()
+            if not child.can_sell_mp3:
+                ret = False
+        if self.has_mp3_file():
+            audio_items = requests.get(f'https://audio.wolnelektury.pl/archive/book/{self.slug}.json').json()['items']
+            if not all(x['project']['can_sell'] for x in audio_items):
+                ret = False
+        self.can_sell_mp3 = ret
+        self.save(update_fields=['can_sell_mp3'])
+
     @classmethod
     @transaction.atomic
     def repopulate_ancestors(cls):
     @classmethod
     @transaction.atomic
     def repopulate_ancestors(cls):
@@ -878,6 +1040,12 @@ class Book(models.Model):
         elif isinstance(publisher, list):
             return ', '.join(publisher)
 
         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`.
     @classmethod
     def tagged_top_level(cls, tags):
         """ Returns top-level books tagged with `tags`.
@@ -986,7 +1154,7 @@ class Book(models.Model):
             return None
 
     def update_popularity(self):
             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
         try:
             pop = self.popularity
             pop.count = count
@@ -997,16 +1165,23 @@ class Book(models.Model):
     def ridero_link(self):
         return 'https://ridero.eu/%s/books/wl_%s/' % (get_language(), self.slug.replace('-', '_'))
 
     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)))
 
     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):
         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:
         from catalogue.utils import ExistingFile, remove_zip
 
         if not parts_count:
@@ -87,7 +89,7 @@ class BookMedia(models.Model):
         except BookMedia.DoesNotExist:
             old = None
 
         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:
         
         # 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()
         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:
 
     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)
     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)
     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):
 
     @staticmethod
     def get_tag_list(tag_str):
+        from social.models import UserList
+
         if not tag_str:
             return []
         tags = []
         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_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]
                 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
 
                     meta_tags.append((tag, relationship))
         return meta_tags
 
+#    def get_books(self):
+#        """ Only useful for sets. """
+#        return 
+
+
 
 TagRelation.tag_model = Tag
 
 
 TagRelation.tag_model = Tag
 
index 2ff4262..37613bf 100644 (file)
     }
 
     $("#paginator").on('click', 'a', function() {
     }
 
     $("#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;
     });
 
         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());
 
                 $(".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]) {
                 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;
                     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;
 
                 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;
                     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;
                     pdu += parseFloat($(this).data('duration'));
                 });
                 totalDurationBefore = pdu;
-                console.log('sm 3', du, pdu);
 
                 return player;
             };
 
                 return player;
             };
                     // TODO: if snap then roll
                     locator.removeClass('up').removeClass('down');
                     if (locator.hasClass('snap')) {
                     // TODO: if snap then roll
                     locator.removeClass('up').removeClass('down');
                     if (locator.hasClass('snap')) {
-                        console.log('SCROLL!');
                         scrollTo();
                     } else {
                         if (y < miny) {
                         scrollTo();
                     } else {
                         if (y < miny) {
                 });
 
                 $('.play-next', $root).click(function() {
                 });
 
                 $('.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() {
                     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']);
                     if (p.length) {
                         setMedia(p).jPlayer("play");
                         _paq.push(['trackEvent', 'audiobook', 'prev']);
                     _paq.push(['trackEvent', 'audiobook', 'chapter']);
                 });
 
                     _paq.push(['trackEvent', 'audiobook', 'chapter']);
                 });
 
-                console.log('READY 3!');
                 var initialElem = $('.jp-playlist li', $root).first();
                 var initialTime = 0;
                 var initialElem = $('.jp-playlist li', $root).first();
                 var initialTime = 0;
-                console.log('READY 4!');
                 if (true || Modernizr.localstorage) {
                     try {
                         let speedStr = localStorage['audiobook-speed'];
                 if (true || Modernizr.localstorage) {
                     try {
                         let speedStr = localStorage['audiobook-speed'];
                         initialTime = last[2];
                     }
                 }
                         initialTime = last[2];
                     }
                 }
-                console.log('READY 5!', initialElem, initialTime);
                 setMedia($(initialElem), initialTime);
                 setMedia($(initialElem), initialTime);
-                console.log('READY 6!');
             },
 
             timeupdate: function(event) {
             },
 
             timeupdate: function(event) {
 
 
             ended: function(event) {
 
 
             ended: function(event) {
-                let p = $currentMedia.next();
+                let p = $currentMedia.nextAll('li');
                 if (p.length) {
                     setMedia(p).jPlayer("play");
                 }
                 if (p.length) {
                     setMedia(p).jPlayer("play");
                 }
index eaa63a5..a674f31 100644 (file)
@@ -1,7 +1,8 @@
-Ten audiobook pochodzi z darmowej biblioteki internetowej Wolne Lektury. Znajdziesz w niej tysiące ebooków i audiobooków.
-Wolne Lektury to projekt prowadzony przez fundację Wolne Lektury. Jesteśmy organizacją pozarządową. Działamy dzięki wsparciu darczyńców takich jak Ty. 
-Wspólnie możemy udostępnić kolejne książki. Wspieraj Wolne Lektury stałą comiesięczną kwotą. Dzięki temu wcześniej uzyskasz dostęp do wyjątkowych publikacji! Możesz też wesprzeć nas jednorazowo. 
-Wejdź na https://wolnelektury.pl/pomagam/ i dorzuć się do nowych nagrań.
+Ten audiobook pochodzi z darmowej biblioteki internetowej Wolne Lektury. Znajdziesz w niej tysiące książek i audiobooków.
+
+Wolne Lektury nie mają stałego finansowania.
+Aby stabilnie działać, potrzebujemy wsparcia darczyńców takich jak Ty.
+Wejdź na https://wolnelektury.pl/pomagam/ by wesprzeć naszą działalność.
 
 Audiobooki Wolnych Lektur są udostępniane na wolnych licencjach, co oznacza, że możesz je swobodnie wykorzystywać, udostępniać i publikować pod warunkiem zachowania warunków licencji i zgodnie z Zasadami wykorzystania Wolnych Lektur: https://wolnelektury.pl/info/zasady-wykorzystania/ 
 
 
 Audiobooki Wolnych Lektur są udostępniane na wolnych licencjach, co oznacza, że możesz je swobodnie wykorzystywać, udostępniać i publikować pod warunkiem zachowania warunków licencji i zgodnie z Zasadami wykorzystania Wolnych Lektur: https://wolnelektury.pl/info/zasady-wykorzystania/ 
 
@@ -9,9 +10,7 @@ Audiobooki Wolnych Lektur są udostępniane na wolnych licencjach, co oznacza, 
 
 {% endif %}{% if meta.cover_by %}Okładka na podstawie: {{ meta.cover_by|safe }}{% if meta.cover_source %}, {{ meta.cover_source }}{% endif %}.
 
 
 {% endif %}{% if meta.cover_by %}Okładka na podstawie: {{ meta.cover_by|safe }}{% if meta.cover_source %}, {{ meta.cover_source }}{% endif %}.
 
-{% endif %}Przekaż 1,5%, by wszystkie dzieciaki w Polsce miały dostęp do darmowych książek KRS 0000070056: https://wolnelektury.pl/info/wesprzyj-nas/
-
-Zostań Przyjacielem Wolnych Lektur: https://wolnelektury.pl/pomagam/
+{% endif %}Przekaż 1,5% na Wolne Lektury: KRS 0000070056, https://wolnelektury.pl/info/wesprzyj-nas/
 
 Słuchaj nas na YouTube: https://wolnelektury.pl/re/WolneLekturyYT/
 
 
 Słuchaj nas na YouTube: https://wolnelektury.pl/re/WolneLekturyYT/
 
@@ -19,12 +18,10 @@ Wolne Lektury
 ul. Marszałkowska 84/92 lok. 125
 00-514 Warszawa
 tel./fax: +48 22 621 30 17
 ul. Marszałkowska 84/92 lok. 125
 00-514 Warszawa
 tel./fax: +48 22 621 30 17
-e-mail:fundacja@wolnelektury.pl
-www.fundacja.wolnelektury.pl
+e-mail: fundacja@wolnelektury.pl
+https://fundacja.wolnelektury.pl
 
 Organizacja Pożytku Publicznego
 Sąd Rejestrowy dla M.St. Warszawy w Warszawie,
 XII Wydział Gospodarczy
 
 Organizacja Pożytku Publicznego
 Sąd Rejestrowy dla M.St. Warszawy w Warszawie,
 XII Wydział Gospodarczy
-KRS 0000070056, NIP 952-18-77-087 
-
-Zapraszamy do wysłuchania audiobooków Wolnych Lektur: https://wolnelektury.pl/katalog/audiobooki/
+KRS 0000070056, NIP 952-18-77-087
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.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 %}
       <span class="icon icon-audio" title="{% trans 'audiobook' %}"></span>
     {% endif %}
     {% if book.is_picture %}
index eb1ab8a..4852c9a 100644 (file)
@@ -5,9 +5,14 @@
 {% load choose_cites from social_tags %}
 {% load catalogue_tags %}
 {% load likes_book from social_tags %}
 {% load choose_cites from social_tags %}
 {% load catalogue_tags %}
 {% load likes_book from social_tags %}
+{% load annoy %}
 
 
 {% block global-content %}
 
 
 {% 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="l-container">
       <div class="l-breadcrumb">
         <a href="/"><span>{% trans "Strona główna" %}</span></a>
                       </div>
                       <div class="c-media__actions lay-col lay-l-row">
                         <div class="c-media__btn">
                       </div>
                       <div class="c-media__actions lay-col lay-l-row">
                         <div class="c-media__btn">
-                          {% if book.has_mp3_file %}
+                          {% if book.has_audio %}
                             <button class="l-button l-button--media" id="audiobook"><i class="icon icon-audio"></i> {% trans "pobierz audiobook" %}</button>
                           {% endif %}
                         </div>
                         <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>
                             <button class="l-button l-button--media" id="audiobook"><i class="icon icon-audio"></i> {% trans "pobierz audiobook" %}</button>
                           {% endif %}
                         </div>
                         <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 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>
 
 
                   </div>
 
 
-
-                  {% if book.has_mp3_file %}
+                  {% if book.has_audio %}
                     {% include 'catalogue/snippets/jplayer.html' %}
                     {% 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 %}
 
 
                   {% endif %}
 
 
-
-
                   <div class="c-media__popup" data-popup="ebook">
                     <div class="c-media__popup__box">
                       <div class="c-media__popup__box__lead">
                   <div class="c-media__popup" data-popup="ebook">
                     <div class="c-media__popup__box">
                       <div class="c-media__popup__box__lead">
                       </button>
                     </div>
                   </div>
                       </button>
                     </div>
                   </div>
-                  {% if book.has_mp3_file %}
+                  {% if book.has_audio %}
                     <div class="c-media__popup" data-popup="audiobook">
                       <div class="c-media__popup__box">
                         <div class="c-media__popup__box__lead">
                     <div class="c-media__popup" data-popup="audiobook">
                       <div class="c-media__popup__box">
                         <div class="c-media__popup__box__lead">
                               <a href="{% url 'download_zip_mp3' book.slug %}" class="l-button l-button--media l-button--media--full">.mp3</a>
                             </div>
                           </div>
                               <a href="{% url 'download_zip_mp3' book.slug %}" class="l-button l-button--media l-button--media--full">.mp3</a>
                             </div>
                           </div>
-                          {% if book.has_ogg_file %}
-                            <div class="c-media__popup__box__item">
-                              <div>
-                                <h3>OggVorbis</h3>
-                                <p>{% trans "Otwarty format plików audio, oferujący wysokiej jakości nagranie." %}</p>
-                              </div>
-                              <div>
-                                <a href="{% url 'download_zip_ogg' book.slug %}" class="l-button l-button--media l-button--media--full">.ogg</a>
-                              </div>
+                          <div class="c-media__popup__box__item">
+                            <div>
+                              <h3>OggVorbis</h3>
+                              <p>{% trans "Otwarty format plików audio, oferujący wysokiej jakości nagranie." %}</p>
                             </div>
                             </div>
-                          {% endif %}
+                            <div>
+                              <a href="{% url 'download_zip_ogg' book.slug %}" class="l-button l-button--media l-button--media--full">.ogg</a>
+                            </div>
+                          </div>
                           {% if book.has_daisy_file %}
                             <div class="c-media__popup__box__item">
                               <div>
                           {% if book.has_daisy_file %}
                             <div class="c-media__popup__box__item">
                               <div>
                 </div>
               {% endif %}
 
                 </div>
               {% endif %}
 
-              <div class="l-article__overlay" data-max-height="327">
+              <div class="l-article__overlay abstract" data-max-height="327">
                 {% content_warning book %}
                 {{ book.abstract|safe }}
 
                 {% content_warning book %}
                 {{ book.abstract|safe }}
 
               <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 %}
               <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 %}
           </div>
         {% endwith %}
             {% endif %}
 
 
             {% 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">
             {% for rel in related_books %}
               <article class="l-books__item">
                 <figure class="l-books__item__img">
index c57bdd3..2a3db14 100644 (file)
     </div>
   {% endif %}
 
     </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">
   {% 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 %}>
 
 
   <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 %}
       {% 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 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="annoy-stubs">
         {% annoy_banners 'book-text-intermission' %}
 
     <div id="player-bar">
       {% include 'catalogue/snippets/jplayer_reader.html' %}
     </div>
     <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 %}
 
 
   {% endif %}
 
 
index e0893fa..2399a09 100644 (file)
@@ -1,6 +1,6 @@
 {% load i18n l10n catalogue_tags %}
 
 {% 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>
   <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 %}
           <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 }}'
               <li
                   data-mp3='{{ i.mp3.file.url }}'
                   data-ogg='{{ i.ogg.file.url }}'
@@ -67,6 +70,7 @@
                   </span>
                {% endwith %}
               </li>
                   </span>
                {% endwith %}
               </li>
+              {% endif %}
             {% endfor %}
             {% endlocalize %}
           </ul>
             {% 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 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()
 
 
 register = template.Library()
 
@@ -73,7 +74,10 @@ def nice_title_from_tags(tags, related_tags):
     def split_tags(tags):
         result = {}
         for tag in 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)
         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
 
 
 @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
 
 
 @register.simple_tag
@@ -420,16 +416,8 @@ def status(book, user):
 
 @register.inclusion_tag('catalogue/snippets/content_warning.html')
 def content_warning(book):
 
 @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 {
     return {
-        "warnings": warnings
+        "warnings": book.content_warnings(),
     }
 
 
     }
 
 
index 91c66a2..37b298d 100644 (file)
@@ -50,8 +50,8 @@ urlpatterns = [
     path('zip/pdf.zip', views.download_zip, {'file_format': 'pdf', 'slug': None}, 'download_zip_pdf'),
     path('zip/epub.zip', views.download_zip, {'file_format': 'epub', 'slug': None}, 'download_zip_epub'),
     path('zip/mobi.zip', views.download_zip, {'file_format': 'mobi', 'slug': None}, 'download_zip_mobi'),
     path('zip/pdf.zip', views.download_zip, {'file_format': 'pdf', 'slug': None}, 'download_zip_pdf'),
     path('zip/epub.zip', views.download_zip, {'file_format': 'epub', 'slug': None}, 'download_zip_epub'),
     path('zip/mobi.zip', views.download_zip, {'file_format': 'mobi', 'slug': None}, 'download_zip_mobi'),
-    path('zip/mp3/<slug:slug>.zip', views.download_zip, {'media_format': 'mp3'}, 'download_zip_mp3'),
-    path('zip/ogg/<slug:slug>.zip', views.download_zip, {'media_format': 'ogg'}, 'download_zip_ogg'),
+    path('zip/<slug:slug>_mp3.zip', views.stream_zip, {'media_format': 'mp3'}, 'download_zip_mp3'),
+    path('zip/<slug:slug>_ogg.zip', views.stream_zip, {'media_format': 'ogg'}, 'download_zip_ogg'),
 
     # Public interface. Do not change this URLs.
     path('lektura/<slug:slug>.html', views.book_text, name='book_text'),
 
     # Public interface. Do not change this URLs.
     path('lektura/<slug:slug>.html', views.book_text, name='book_text'),
index 3c4a55f..8d32e64 100644 (file)
@@ -5,11 +5,13 @@ from collections import OrderedDict
 import random
 import re
 from urllib.parse import quote_plus
 import random
 import re
 from urllib.parse import quote_plus
+from slugify import slugify
+from zipstream import ZipStream
 
 from django.conf import settings
 from django.template.loader import render_to_string
 from django.shortcuts import get_object_or_404, render, redirect
 
 from django.conf import settings
 from django.template.loader import render_to_string
 from django.shortcuts import get_object_or_404, render, redirect
-from django.http import HttpResponse, HttpResponseRedirect, Http404, HttpResponsePermanentRedirect
+from django.http import HttpResponse, HttpResponseRedirect, Http404, HttpResponsePermanentRedirect, StreamingHttpResponse
 from django.urls import reverse
 from django.db.models import Q, QuerySet
 from django.contrib.auth.decorators import login_required, user_passes_test
 from django.urls import reverse
 from django.db.models import Q, QuerySet
 from django.contrib.auth.decorators import login_required, user_passes_test
@@ -23,6 +25,7 @@ from club.forms import DonationStep1Form
 from club.models import Club
 from annoy.models import DynamicTextInsert
 from pdcounter import views as pdcounter_views
 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
 from wolnelektury.utils import is_ajax
 from catalogue import constants
 from catalogue import forms
@@ -211,25 +214,45 @@ class AudiobooksView(LiteratureView):
 class TaggedObjectList(BookList):
     def analyse(self):
         super().analyse()
 class TaggedObjectList(BookList):
     def analyse(self):
         super().analyse()
+
         self.ctx['tags'] = analyse_tags(self.request, self.kwargs['tags'])
         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]
         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']
         ]
         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['translation_list'] = self.ctx['main_tag'].book_set.all()
+            self.ctx['narrated'] = self.ctx['main_tag'].narrated.all()
 
     def get_queryset(self):
 
     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'])
         qs = qs.exclude(ancestor__in=qs)
         if self.is_themed:
             fqs = Fragment.tagged.with_all(self.ctx['fragment_tags'])
@@ -241,6 +264,9 @@ class TaggedObjectList(BookList):
         return qs
 
     def get_suggested_tags(self, queryset):
         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 = []
         tag_ids = [t.id for t in self.ctx['tags']]
         if self.is_themed:
             related_tags = []
@@ -252,6 +278,7 @@ class TaggedObjectList(BookList):
                     containing_books,
                 ).exclude(category='set').exclude(pk__in=tag_ids)
             ))
                     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(
             if self.request.user.is_authenticated:
                 related_tags.extend(list(
                     Tag.objects.usage_for_queryset(
@@ -292,6 +319,7 @@ def object_list(request, objects, list_type='books'):
             Tag.objects.usage_for_queryset(
                 objects, counts=True
             ).exclude(category='set'))
             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(
         if request.user.is_authenticated:
             related_tag_lists.append(
                 Tag.objects.usage_for_queryset(
@@ -309,7 +337,7 @@ def object_list(request, objects, list_type='books'):
             .only('name', 'sort_key', 'category', 'slug'))
         if isinstance(objects, QuerySet):
             objects = prefetch_relations(objects, 'author')
             .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']:
     categories = split_tags(*related_tag_lists)
     suggest = []
     for c in ['set', 'author', 'epoch', 'kind', 'genre']:
@@ -324,7 +352,7 @@ def object_list(request, objects, list_type='books'):
     }
 
     template = 'catalogue/author_detail.html'
     }
 
     template = 'catalogue/author_detail.html'
-        
+
     return render(
         request, template, result,
     )
     return render(
         request, template, result,
     )
@@ -492,6 +520,49 @@ def download_zip(request, file_format=None, media_format=None, slug=None):
     return HttpResponseRedirect(quote_plus(settings.MEDIA_URL + url, safe='/?='))
 
 
     return HttpResponseRedirect(quote_plus(settings.MEDIA_URL + url, safe='/?='))
 
 
+def stream_zip(request, media_format=None, slug=None):
+    book = get_object_or_404(Book, slug=slug)
+    def iterate_audiobooks(book, names):
+        for bm in book.media.filter(type=media_format).order_by('index'):
+            yield (
+                bm.file.path,
+                bm.get_extra_info_json().get('license'),
+                names + (slugify(bm.part_name),) if bm.part_name else names
+            )
+        for child in book.get_children():
+            yield from iterate_audiobooks(child, names + (slugify(child.title),))
+
+    zs = ZipStream()
+
+    audiobook_list = list(iterate_audiobooks(book, ()))
+    
+    licenses = set()
+    for i, (file_path, lic, names) in enumerate(audiobook_list):
+        index = i + 1
+        part_name = '_'.join(names)
+        if part_name:
+            part_name = '_' + part_name
+        ext = file_path.rsplit('.', 1)[-1]
+        if len(audiobook_list) > 1:
+            zip_name = f'{book.slug}_{index:03d}{part_name}'[:240] + '.' + ext
+        else:
+            zip_name = book.slug[:240] + '.' + ext
+        zs.add_path(file_path, zip_name)
+
+        lic_name = constants.LICENSES.get(lic, {}).get('locative')
+        if lic_name:
+            licenses.add(lic_name)
+
+    readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
+        'licenses': licenses,
+    })
+    zs.add(readme, 'informacje.txt')
+
+    response = StreamingHttpResponse(zs, content_type='application/zip')
+    response['Content-Disposition'] = f'attachment; filename={slug}_{media_format}.zip'
+    return response
+
+
 class CustomPDFFormView(AjaxableFormView):
     form_class = forms.CustomPDFForm
     title = gettext_lazy('Stwórz własny PDF')
 class CustomPDFFormView(AjaxableFormView):
     form_class = forms.CustomPDFForm
     title = gettext_lazy('Stwórz własny PDF')
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 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
 
 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
 
 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',
     search_fields = ['email', 'source']
     list_filter = [
         'is_cancelled', 'monthly', 'yearly', 'method',
+        'consent', OptOutFilter,
         PayedFilter, ActiveFilter, ExpiredFilter,
         SourceFilter, CrisisFilter
     ]
         PayedFilter, ActiveFilter, ExpiredFilter,
         SourceFilter, CrisisFilter
     ]
index 20b287a..dffeaa7 100644 (file)
@@ -27,12 +27,12 @@ class CiviCRM:
         d = response.json()
         return d
 
         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.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):
         return contact_id
 
     def get_contact_id(self, email):
@@ -49,7 +49,7 @@ class CiviCRM:
         if result:
             return result[0]['id']
 
         if result:
             return result[0]['id']
 
-    def create_contact(self, email, key=None):
+    def create_contact(self, email, fields):
         data = {
             'values': {},
             'chain': {
         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']
         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',
             {
         return self.request(
             'Contact',
             'update',
             {
-                'values': {
-                    'WL.TPWL_key': key,
-                },
+                'values': fields,
                 'where': [
                     ['id', '=', contact_id]
                 ]
                 'where': [
                     ['id', '=', contact_id]
                 ]
@@ -89,7 +92,8 @@ class CiviCRM:
         if not self.enabled:
             return
 
         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:
 
         activity_id = self.get_activity_id(key)
         if activity_id is None:
index b75877d..0ee00dc 100644 (file)
@@ -35,6 +35,8 @@ class DonationStep1Form(forms.ModelForm):
         self.referer = referer
         super().__init__(*args, **kwargs)
         club = models.Club.objects.first()
         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
 
         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):
         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)
 
 
         return super().save(*args, **kwargs)
 
 
@@ -59,9 +62,7 @@ class DonationStep2Form(forms.ModelForm, NewsletterForm):
         model = models.Schedule
         fields = [
             'first_name', 'last_name',
         model = models.Schedule
         fields = [
             'first_name', 'last_name',
-            'email', 'phone',
-            'postal',
-            'postal_code', 'postal_town', 'postal_country',
+            'email',
             ]
         widgets = {
             'amount': forms.HiddenInput,
             ]
         widgets = {
             'amount': forms.HiddenInput,
@@ -71,16 +72,14 @@ class DonationStep2Form(forms.ModelForm, NewsletterForm):
     def __init__(self, **kwargs):
         super().__init__(**kwargs)
 
     def __init__(self, **kwargs):
         super().__init__(**kwargs)
 
-        self.fields['first_name'].required = True
-        self.fields['last_name'].required = True
-        
         self.consent = []
         for c in models.Consent.objects.filter(active=True).order_by('order'):
             key = f'consent{c.id}'
         self.consent = []
         for c in models.Consent.objects.filter(active=True).order_by('order'):
             key = f'consent{c.id}'
-            self.fields[key] = forms.BooleanField(
-                label=c.text,
-                required=c.required
-            )
+            if not c.required:
+                self.fields[key] = forms.BooleanField(
+                    label=c.text,
+                    required=c.required
+                )
             self.consent.append((
                 c, key, (lambda k: lambda: self[k])(key)
             ))
             self.consent.append((
                 c, key, (lambda k: lambda: self[k])(key)
             ))
@@ -91,7 +90,7 @@ class DonationStep2Form(forms.ModelForm, NewsletterForm):
 
         consents = []
         for consent, key, consent_field in self.consent:
 
         consents = []
         for consent, key, consent_field in self.consent:
-            if self.cleaned_data[key]:
+            if consent.required or self.cleaned_data[key]:
                 instance.consent.add(consent)
 
         return instance
                 instance.consent.add(consent)
 
         return instance
index c09769a..eb14390 100644 (file)
@@ -161,6 +161,15 @@ class Schedule(models.Model):
         club = Club.objects.first()
         return club.get_description_for_amount(self.amount, self.monthly)
 
         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)
 
     def initiate_payment(self, request):
         return self.get_payment_method().initiate(request, self)
 
@@ -192,6 +201,20 @@ class Schedule(models.Model):
     def is_recurring(self):
         return self.monthly or self.yearly
 
     def is_recurring(self):
         return self.monthly or self.yearly
 
+    def n_paypal_payments(self, since, until):
+        # TODO: pull BA payments.
+        t = self.payed_at
+        if t is None: return 0
+        c = 0
+        until = min(until, now())
+        t += timedelta(days=1)
+        while t < until:
+            if t >= since:
+                c += 1
+            m = datetime(t.year, t.month, 1)
+            t += ((m + timedelta(days=31)).replace(day=1)) - m
+        return c
+
     def set_payed(self):
         since = self.expires_at
         n = now()
     def set_payed(self):
         since = self.expires_at
         n = now()
@@ -417,7 +440,8 @@ class PayUOrder(payu_models.Order):
         )            
 
     @classmethod
         )            
 
     @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')
         Contact = apps.get_model('messaging', 'Contact')
         Funding = apps.get_model('funding', 'Funding')
         BillingAgreement = apps.get_model('paypal', 'BillingAgreement')
@@ -485,11 +509,8 @@ class PayUOrder(payu_models.Order):
         ctx = {
             "email": email,
             "year": year,
         ctx = {
             "email": email,
             "year": year,
-            "next_year": year + 1,
             "total": sum(x['amount'] for x in payments),
             "payments": payments,
             "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()
         }
         temp = tempfile.NamedTemporaryFile(prefix='receipt-', suffix='.pdf', delete=False)
         temp.close()
@@ -497,15 +518,34 @@ class PayUOrder(payu_models.Order):
             "wl.eps": os.path.join(settings.STATIC_ROOT, "img/wl.eps"),
             })
 
             "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]
             )
         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()
         message.send()
-        os.unlink(f.name)
 
 
 class PayUCardToken(payu_models.CardToken):
 
 
 class PayUCardToken(payu_models.CardToken):
index 1b6de08..3d9cd55 100644 (file)
@@ -2,6 +2,7 @@
 {% load club %}
 
 {% block content %}
 {% load club %}
 
 {% block content %}
+<div style="display: flex; gap:50px;">
   <table class="table">
     <tr>
       <td>Aktywne miesięczne wpłaty cykliczne:</td>
   <table class="table">
     <tr>
       <td>Aktywne miesięczne wpłaty cykliczne:</td>
       <td>{% club_active_30day_sum %} zł.</td>
     </tr>
   </table>
       <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 %}
   {{ 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 c9f5ed2..4c7292f 100644 (file)
@@ -1,5 +1,23 @@
 {% extends 'club/donation_step_base.html' %}
 {% extends 'club/donation_step_base.html' %}
+{% load annoy %}
+{% load chunks %}
+
+{% block donation-form-header %}
+{% seasonal_overlay_exists as e %}
+{% if e %}
+{% annoy_checkout_header %}
+{% else %}
+{{ block.super }}
+{% endif %}
+{% endblock %}
 
 {% block donation-step-content %}
   {% include "club/donation_step1_form.html" %}
 {% endblock %}
 
 {% block donation-step-content %}
   {% include "club/donation_step1_form.html" %}
 {% endblock %}
+
+{% block donate-bottom %}
+<a name="wiecej"></a>
+<div class="donation-more">
+  {% chunk "donate-bottom" %}
+</div>
+{% endblock %}
index 033b0ea..4980555 100644 (file)
@@ -1,7 +1,7 @@
 {% load static %}
 {% load i18n %}
 
 {% 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 %}>
   {% 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 %}
   {% 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">
           <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>
         </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 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">
         <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 %}
         </div>
       </div>
     {% endfor %}
@@ -58,7 +50,7 @@
 
   <div class="l-checkout__amount">
     <div class="l-checkout__input">
 
   <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>
       {{ form.custom_amount }}
     </div>
     <button>{% trans "Dalej" %}</button>
index 3864ad5..0a4cba5 100644 (file)
@@ -8,91 +8,68 @@
 
 {% block donation-step-content %}
 
 
 {% 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ł
     <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="">
         </h3>
         <img src="{% static '2022/images/checkout-img-3.jpg' %}" alt="">
+       {% if schedule.get_description %}
         <p>{{ schedule.get_description }}</p>
         <p>{{ schedule.get_description }}</p>
+       {% endif %}
       </div>
     </div>
     <div class="l-checkout__col">
 
       </div>
     </div>
     <div class="l-checkout__col">
 
+      {% include "club/donation_infobox.html" %}
+      
       <form method='post'>
         {% csrf_token %}
         {{ form.errors }}
       <form method='post'>
         {% csrf_token %}
         {{ form.errors }}
-        {{ form.amount }}
-        {{ form.monthly }}
         <div class="l-checkout__form">
         <div class="l-checkout__form">
-          <div class="l-checkout__form__row">
-            <div class="l-checkout__input">
-              <label for="id_first_name"><span>*</span> {% trans "Imię" %}</label>
-              {{ form.first_name }}
-              {{ form.first_name.errors }}
-            </div>
-            <div class="l-checkout__input">
-              <label for="id_last_name"><span>*</span> {% trans "Nazwisko" %}</label>
-              {{ form.last_name }}
-              {{ form.last_name.errors }}
-            </div>
-          </div>
-          <div class="l-checkout__form__row">
-            <div class="l-checkout__input">
+          <div class="l-checkout__form__row full">
+            <div class="l-checkout__input" lang="pl">
               <label for="id_email"><span>*</span> {% trans "E-mail" %}</label>
               {{ form.email }}
               {{ form.email.errors }}
             </div>
               <label for="id_email"><span>*</span> {% trans "E-mail" %}</label>
               {{ form.email }}
               {{ form.email.errors }}
             </div>
-            <div class="l-checkout__input">
-              <label for="id_phone">{% trans "Telefon" %}</label>
-              {{ form.phone }}
-              {{ form.phone.errors }}
-            </div>
-          </div>
-          <div class="l-checkout__form__row full">
-            <div class="l-checkout__input">
-              <label for="id_postal">{% trans "Adres pocztowy" %}</label>
-              {{ form.postal }}
-              {{ form.postal.errors }}
-            </div>
           </div>
           <div class="l-checkout__form__row">
             <div class="l-checkout__input">
           </div>
           <div class="l-checkout__form__row">
             <div class="l-checkout__input">
-              <label for="id_postal_code">{% trans "Kod pocztowy" %}</label>
-              {{ form.postal_code }}
-              {{ form.postal_code.errors }}
-            </div>
-            <div class="l-checkout__input">
-              <label for="id_postal_town">{% trans "Miejscowość" %}</label>
-              {{ form.postal_town }}
-              {{ form.postal_town.errors }}
+              <label for="id_first_name">{% trans "Imię / pseudonim (opcjonalnie)" %}</label>
+              {{ form.first_name }}
+              {{ form.first_name.errors }}
             </div>
             </div>
-          </div>
-          <div class="l-checkout__form__row full">
             <div class="l-checkout__input">
             <div class="l-checkout__input">
-              <label for="id_postal_country">{% trans "Kraj" %}</label>
-              {{ form.postal_country }}
-              {{ form.postal_country.errors }}
+              <label for="id_last_name">{% trans "Nazwisko (opcjonalnie)" %}</label>
+              {{ form.last_name }}
+              {{ form.last_name.errors }}
             </div>
           </div>
             </div>
           </div>
+          
           <div class="l-checkout__form__row full">
             {% for consent, key, field in form.consent %}
           <div class="l-checkout__form__row full">
             {% for consent, key, field in form.consent %}
-              {{ field.errors }}
+            {% if not consent.required %}
+            {{ field.errors }}
+            {% endif %}
               <div class="c-checkbox">
               <div class="c-checkbox">
+                {% if consent.required %}
+                <label><p class="scroll">{{ consent.text|safe }}</p></label>
+                {% else %}
                 {{ field }}
                 <label for="id_{{ key }}">
                 {{ field }}
                 <label for="id_{{ key }}">
-                  <p>{% if field.field.required %}<span>*</span> {% endif %}{{ field.label }}</p>
+                  <p>{% if field.field.required %}<span>*</span> {% endif %}{{ field.label|safe }}</p>
                 </label>
                 </label>
+                {% endif %}
               </div>
             {% endfor %}
             <div class="c-checkbox">
               {{ form.agree_newsletter }}
               <label for="id_agree_newsletter">
               </div>
             {% endfor %}
             <div class="c-checkbox">
               {{ form.agree_newsletter }}
               <label for="id_agree_newsletter">
-                <p>{% trans "Zapisuję się na newsletter." %}</p>
+                <p>{% trans "Chcę dostawać newsletter o działalności Wolnych Lektur" %}</p>
               </label>
             </div>
           </div>
               </label>
             </div>
           </div>
index baeee9f..999c708 100644 (file)
@@ -8,21 +8,33 @@
 
 
 {% block donation-step-content %}
 
 
 {% 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 %}
     <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">
           {% 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="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 %}
             {% for method in schedule.get_payment_methods %}
               {% invite_payment method schedule %}
             {% endfor %}
index cede1b4..546b399 100644 (file)
 
     <div class="l-checkout__box">
       <div class="l-checkout__box__header">
 
     <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">
+        {% block donation-form-header %}
+        <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">
         <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>
+        {% endblock %}
       </div>
       </div>
-      <div class="l-checkout__steps">
+      <div class="l-checkout__steps" id="form-start">
 
         {% if view.step > 1 and view.step != 4 %}
           <a href="{% url 'donation_step1' schedule.key %}">
         {% endif %}
         <div class="{% if view.step == 1 %}is-current{% else %}is-completed{% endif %}">
           <span>1</span>
 
         {% if view.step > 1 and view.step != 4 %}
           <a href="{% url 'donation_step1' schedule.key %}">
         {% 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>
         </div>
         {% if view.step > 1 and view.step != 4 %}
           </a>
@@ -84,6 +86,9 @@
 
     <div class="l-checkout__footer">
       <div class="l-checkout__footer__content">
 
     <div class="l-checkout__footer">
       <div class="l-checkout__footer__content">
+        {% block donate-bottom %}{% endblock %}
+
+        <a name="informacje"></a>
         <div class="l-checkout__footer__content__item">
           <h3>{% trans "Transparentność jest dla nas bardzo ważna." %}</h3>
           <div>
         <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 %}
 {% 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">
 <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?
 
 
 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.
 
 
 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!
 
 
 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
 
 
 w imieniu całego zespołu Wolnych Lektur
 
 
diff --git a/src/club/templates/club/stats.html b/src/club/templates/club/stats.html
new file mode 100644 (file)
index 0000000..9a13139
--- /dev/null
@@ -0,0 +1,36 @@
+{% extends "base.html" %}
+
+{% block main %}
+
+
+<table style="" border="1">
+  <thead>
+    <tr>
+      <th style="width: 200px;"></th>
+      <th style="width: 200px;">PayU</th>
+      <th style="width: 200px;">PayU cykliczne</th>
+      <th style="width: 200px;">PayPal</th>
+    </tr>
+  </thead>
+  <tbody>
+  {% for day, acq in days %}
+  <tr>
+    <td>{{ day }}</td>
+    <td style="position: relative">
+      <span style="z-index: 0; background: #92BD39; position: absolute;left:0; top:0; bottom: 0; width: {{ acq.payu.1|floatformat:"3u" }}%;"></span>
+      <span style="z-index: 1; position: relative;">{{ acq.payu.0 }}</span>
+    </td>
+    <td style="position: relative">
+      <span style="z-index: 0; background: #92BD39; position: absolute;left:0; top:0; bottom: 0; width: {{ acq.payu_re.1|floatformat:"3u" }}%;"></span>
+      <span style="z-index: 1; position: relative;">{{ acq.payu_re.0 }}</span>
+    </td>
+    <td style="position: relative">
+      <span style="z-index: 0; background: #92BD39; position: absolute;left:0; top:0; bottom: 0; width: {{ acq.paypal.1|floatformat:"3u" }}%;"></span>
+      <span style="z-index: 1; position: relative;">{{ acq.paypal.0 }}</span>
+    </td>
+  </tr>
+  {% endfor %}
+  </tbody>
+</table>
+
+{% endblock %}
index 13cf53b..be61a53 100644 (file)
@@ -1,5 +1,5 @@
 {% load i18n %}
 {% 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 %}
 <div class="iframe">
   <form id="theform" method='POST'  action='{% url "club_payu_rec_payment" schedule.key %}'>
     {% csrf_token %}
index 0267580..95c51e0 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>/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'),
 
     path('przylacz/<key>/', views.claim, name='club_claim'),
     path('anuluj/<key>/', views.cancel, name='club_cancel'),
@@ -28,4 +29,7 @@ urlpatterns = [
     path('notify/<int:pk>/', views.PayUNotifyView.as_view(), name='club_payu_notify'),
 
     path('weryfikacja/', views.member_verify, name='club_member_verify'),
     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'),
+    path('stats/', views.stats),
 ]
 ]
index c6136b6..3f283af 100644 (file)
@@ -1,10 +1,11 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 # 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 date, timedelta
 from django.conf import settings
 from django.contrib.auth.decorators import login_required, permission_required
 from django.db.models import Sum
 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
 from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
 from django.utils.decorators import method_decorator
@@ -30,6 +31,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
 class DonationStep1(UpdateView):
     queryset = models.Schedule.objects.filter(payed_at=None)
     form_class = forms.DonationStep1Form
@@ -46,6 +48,7 @@ class DonationStep1(UpdateView):
         return reverse('donation_step2', args=[self.object.key])
 
 
         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
 class DonationStep2(UpdateView):
     queryset = models.Schedule.objects.filter(payed_at=None)
     form_class = forms.DonationStep2Form
@@ -59,6 +62,17 @@ class DonationStep2(UpdateView):
         return c
 
 
         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'
 class JoinView(CreateView):
     form_class = forms.DonationStep1Form
     template_name = 'club/donation_step1.html'
@@ -249,3 +263,60 @@ def member_verify(request):
             'result': rows
         }
     )
             '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"',
+        }
+    )
+
+
+@permission_required('club.schedule_view')
+def stats(request):
+    maxes = {}
+    acq = {}
+    today = date.today()
+    start = today - timedelta(365)
+    for schedule in models.Schedule.objects.filter(
+        payed_at__gte=start,
+    ):
+        d = schedule.payed_at.date()
+        m = schedule.method.replace('-', '_')
+        acq.setdefault(d, {})
+        acq[d].setdefault(m, 0)
+        acq[d][m] += schedule.amount
+
+    for a in acq.values():
+        for m, v in a.items():
+            maxes.setdefault(m, 0)
+            if v > maxes[m]:
+                maxes[m] = v
+
+    days = []
+    d = today
+    while d >= start:
+        a = acq.get(d, {})
+        for k, v in a.items():
+            a[k] = (v, 100 * v/(maxes[k] or 1))
+        days.append((d.isoformat(), a))
+        d -= timedelta(1)
+
+    return render(request, 'club/stats.html',
+                  {'days': days})
+        
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()
 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)
     fn_type = models.CharField(max_length=10, db_index=True)
     qualifiers = models.ManyToManyField(Qualifier)
     language = models.CharField(max_length=10, db_index=True)
index 8f11ac8..5413445 100644 (file)
@@ -1,9 +1,10 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 # 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.contrib.auth.decorators import login_required
 from django.urls import path
 from dictionary.views import NotesView
 
 urlpatterns = [
 from django.urls import path
 from dictionary.views import NotesView
 
 urlpatterns = [
-    path('', NotesView.as_view(), name='dictionary_notes'),
+    path('', login_required(NotesView.as_view()), name='dictionary_notes'),
 ]
 ]
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 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):
 
 
 class Poem(models.Model):
@@ -138,17 +139,17 @@ class Continuations(models.Model):
                       conts)
 
     @classmethod
                       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}
         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:
         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)
         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')
 
             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 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]
 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,
 
     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
 @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)
     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()
 
     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 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
 
 import operator
 import logging
@@ -318,7 +319,7 @@ class UserFeed(Feed):
         return "Półki użytkownika %s" % user.username
 
     def items(self, user):
         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
 
     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 "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):
 
     def items(self, tag):
-        return Book.tagged.with_any([tag])
+        return tag.get_books()
 
 
 @piwik_track
 
 
 @piwik_track
diff --git a/src/partners/__init__.py b/src/partners/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/partners/admin.py b/src/partners/admin.py
new file mode 100644 (file)
index 0000000..5083f60
--- /dev/null
@@ -0,0 +1,19 @@
+from django.contrib import admin
+from . import models
+
+
+class PriceLevelInline(admin.TabularInline):
+    model = models.PriceLevel
+    extra = 0
+
+class AudioPriceLevelInline(admin.TabularInline):
+    model = models.AudioPriceLevel
+    extra = 0
+
+@admin.register(models.Partner)
+class PartnerAdmin(admin.ModelAdmin):
+    inlines = [
+        PriceLevelInline,
+        AudioPriceLevelInline,
+    ]
+
diff --git a/src/partners/api/urls.py b/src/partners/api/urls.py
new file mode 100644 (file)
index 0000000..f88eedf
--- /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('<slug:key>/books/',
+        views.PartnerBooksView.as_view()),
+    path('<slug:key>/audiobooks/',
+        views.PartnerAudiobooksView.as_view()),
+]
diff --git a/src/partners/api/views.py b/src/partners/api/views.py
new file mode 100644 (file)
index 0000000..5309380
--- /dev/null
@@ -0,0 +1,111 @@
+from django.utils.decorators import method_decorator
+from django.views.decorators.cache import never_cache
+from rest_framework.generics import (ListAPIView, get_object_or_404)
+from rest_framework import serializers
+from api.fields import AbsoluteURLField
+from catalogue.models import Book
+from catalogue.api.fields import EmbargoURLField
+from catalogue.api.serializers import BookSerializer2
+from partners import models
+
+
+
+
+class PartnerBookSerializer(BookSerializer2):
+    price = serializers.SerializerMethodField()
+
+    class Meta:
+        model = Book
+        fields = [
+            'slug', 'title', 'full_sort_key',
+            'href', 'url', 'language',
+            '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',
+            'price',
+        ]
+
+    def get_price(self, obj):
+        if obj.pages is None:
+            return None
+        return self.context['partner'].get_price(obj.pages)
+
+
+class PartnerAudiobookSerializer(BookSerializer2):
+    price = serializers.SerializerMethodField()
+    duration = serializers.SerializerMethodField()
+    files = serializers.SerializerMethodField()
+
+    class Meta:
+        model = Book
+        fields = [
+            'slug', 'title',
+            'href', 'url', 'language',
+            'authors', 'translators',
+            'epochs', 'genres', 'kinds',
+            'files',
+            'cover',
+            'isbn_mp3',
+            'abstract',
+            'content_warnings', 'audiences',
+            'changed_at', 'duration',
+            'price',
+        ]
+
+    def get_duration(self, obj):
+        return obj.get_audiobooks(True)[2]
+
+    def get_files(self, obj):
+        def get_for_single(b):
+            fs = []
+            for m in b.media.filter(type='mp3'):
+                fs.append({
+                    "name": m.name,
+                    "part_name": m.part_name,
+                    "url": 'https://wolnelektury.pl' + m.file.url,
+                })
+            for c in b.get_children():
+                fs.extend(get_for_single(c))
+            return fs
+        return get_for_single(obj)
+
+    def get_price(self, obj):
+        duration = obj.get_audiobooks(True, True)[2]
+        if not duration:
+            return None
+        duration /= 60
+        return self.context['partner'].get_audio_price(obj.pages)
+
+
+@method_decorator(never_cache, name='dispatch')
+class PartnerBooksView(ListAPIView):
+    serializer_class = PartnerBookSerializer
+
+    def get_serializer_context(self):
+        ctx = super().get_serializer_context()
+        ctx['partner'] = get_object_or_404(models.Partner, key=self.kwargs['key'])
+        return ctx
+
+    def get_queryset(self):
+        return Book.objects.filter(parent=None).filter(can_sell=True).exclude(pages=None)
+
+
+@method_decorator(never_cache, name='dispatch')
+class PartnerAudiobooksView(ListAPIView):
+    serializer_class = PartnerAudiobookSerializer
+
+    def get_serializer_context(self):
+        ctx = super().get_serializer_context()
+        ctx['partner'] = get_object_or_404(models.Partner, key=self.kwargs['key'])
+        return ctx
+
+    def get_queryset(self):
+        return Book.objects.filter(parent=None, can_sell_mp3=True).exclude(isbn_mp3='')
diff --git a/src/partners/apps.py b/src/partners/apps.py
new file mode 100644 (file)
index 0000000..07969c7
--- /dev/null
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class PartnersConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'partners'
diff --git a/src/partners/migrations/0001_initial.py b/src/partners/migrations/0001_initial.py
new file mode 100644 (file)
index 0000000..10d88f7
--- /dev/null
@@ -0,0 +1,35 @@
+# Generated by Django 4.0.8 on 2026-03-06 13:32
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Partner',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('key', models.CharField(max_length=255)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='PriceLevel',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('min_pages', models.IntegerField(blank=True, null=True)),
+                ('price', models.IntegerField()),
+                ('partner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partners.partner')),
+            ],
+            options={
+                'ordering': ('price',),
+            },
+        ),
+    ]
diff --git a/src/partners/migrations/0002_alter_pricelevel_price.py b/src/partners/migrations/0002_alter_pricelevel_price.py
new file mode 100644 (file)
index 0000000..c7a4055
--- /dev/null
@@ -0,0 +1,18 @@
+# Generated by Django 4.0.8 on 2026-03-06 14:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('partners', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='pricelevel',
+            name='price',
+            field=models.DecimalField(decimal_places=2, max_digits=10),
+        ),
+    ]
diff --git a/src/partners/migrations/0003_audiopricelevel.py b/src/partners/migrations/0003_audiopricelevel.py
new file mode 100644 (file)
index 0000000..9bd1f21
--- /dev/null
@@ -0,0 +1,26 @@
+# Generated by Django 4.0.8 on 2026-04-24 10:48
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('partners', '0002_alter_pricelevel_price'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AudioPriceLevel',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('min_minutes', models.IntegerField(blank=True, null=True)),
+                ('price', models.DecimalField(decimal_places=2, max_digits=10)),
+                ('partner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='partners.partner')),
+            ],
+            options={
+                'ordering': ('price',),
+            },
+        ),
+    ]
diff --git a/src/partners/migrations/__init__.py b/src/partners/migrations/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/partners/models.py b/src/partners/models.py
new file mode 100644 (file)
index 0000000..583c34c
--- /dev/null
@@ -0,0 +1,43 @@
+from django.db import models
+
+
+class Partner(models.Model):
+    name = models.CharField(max_length=255)
+    key = models.CharField(max_length=255)
+
+    def __str__(self):
+        return self.name
+    
+    def get_price(self, pages):
+        price_obj = self.pricelevel_set.exclude(
+            min_pages__gt=pages
+        ).order_by('-price').first()
+        if price_obj is None:
+            return None
+        return price_obj.price
+
+    def get_audio_price(self, minutes):
+        price_obj = self.audiopricelevel_set.exclude(
+            min_minutes__gt=minutes
+        ).order_by('-price').first()
+        if price_obj is None:
+            return None
+        return price_obj.price
+        
+
+class PriceLevel(models.Model):
+    partner = models.ForeignKey(Partner, models.CASCADE)
+    min_pages = models.IntegerField(null=True, blank=True)
+    price = models.DecimalField(max_digits=10, decimal_places=2)
+
+    class Meta:
+        ordering = ('price',)
+
+
+class AudioPriceLevel(models.Model):
+    partner = models.ForeignKey(Partner, models.CASCADE)
+    min_minutes = models.IntegerField(null=True, blank=True)
+    price = models.DecimalField(max_digits=10, decimal_places=2)
+
+    class Meta:
+        ordering = ('price',)
diff --git a/src/partners/tests.py b/src/partners/tests.py
new file mode 100644 (file)
index 0000000..7ce503c
--- /dev/null
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/src/partners/views.py b/src/partners/views.py
new file mode 100644 (file)
index 0000000..91ea44a
--- /dev/null
@@ -0,0 +1,3 @@
+from django.shortcuts import render
+
+# Create your views here.
diff --git a/src/paypal/migrations/0005_donation.py b/src/paypal/migrations/0005_donation.py
new file mode 100644 (file)
index 0000000..3777186
--- /dev/null
@@ -0,0 +1,24 @@
+# Generated by Django 4.0.8 on 2026-04-01 14:25
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('paypal', '0004_auto_20210622_0945'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Donation',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('transaction_id', models.CharField(db_index=True, max_length=255)),
+                ('timestamp', models.DateTimeField()),
+                ('amount', models.DecimalField(decimal_places=2, max_digits=10)),
+                ('billing_agreement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='paypal.billingagreement')),
+            ],
+        ),
+    ]
index 6d45bcd..69d2f00 100644 (file)
@@ -25,3 +25,21 @@ class BillingAgreement(models.Model):
     def get_donations(self, year):
         from .rest import get_donations
         return get_donations(self.agreement_id, year)
     def get_donations(self, year):
         from .rest import get_donations
         return get_donations(self.agreement_id, year)
+
+    def update_donations(self, year):
+        from .rest import get_donations
+        for donation in get_donations(self.agreement_id, year):
+            Donation.objects.get_or_create(
+                transaction_id=donation['transaction_id'],
+                defaults={
+                    'timestamp': donation['timestamp'],
+                    'amount': donation['amount'],
+                }
+            )
+
+
+class Donation(models.Model):
+    billing_agreement = models.ForeignKey(BillingAgreement, models.CASCADE)
+    transaction_id = models.CharField(max_length=255, db_index=True)
+    timestamp = models.DateTimeField()
+    amount = models.DecimalField(decimal_places=2, max_digits=10)
index f1546b4..a5e7ebe 100644 (file)
@@ -136,6 +136,7 @@ def get_donations(agreement_id, year):
             continue
         assert transaction['amount']['currency'] == 'PLN'
         transactions.append({
             continue
         assert transaction['amount']['currency'] == 'PLN'
         transactions.append({
+            'transaction_id': transaction['transaction_id'],
             'timestamp': dt,
             'amount': Decimal(transaction['amount']['value'])
         })
             'timestamp': dt,
             'amount': Decimal(transaction['amount']['value'])
         })
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)
 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)
     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." %}
     {% 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>
           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>
           </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>
               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>
               {% 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." %}
                   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>
                   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)
 
     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()
 
     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 ''
         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)
 
         ]
         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,
         
     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 catalogue.models
 import infopages.models
+import social.models
 from .forms import SearchFilters
 import re
 import json
 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)
 
 
     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(
     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 '',
                 '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)]
         ])
             }
             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([
             {
         data.extend([
             {
-                'type': 'set',
+                'type': 'userlist',
                 'label': tag.name,
                 'url': tag.get_absolute_url(),
                 'label': tag.name,
                 'url': tag.get_absolute_url(),
+                'slug': tag.slug,
             }
             for tag in tags[:limit - len(data)]
         ])
             }
             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(),
                 'type': tag.category,
                 'label': tag.name,
                 'url': tag.get_absolute_url(),
+                'slug': tag.slug,
+                'id': tag.id,
             }
             for tag in tags[:limit - len(data)]
         ])
             }
             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(),
                 'type': 'collection',
                 'label': collection.title,
                 'url': collection.get_absolute_url(),
+                'slug': collection.slug,
             }
             for collection in collections[:limit - len(data)]
         ])
             }
             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 '',
                     '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:
                 }
             )
     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(),
                 'type': 'info',
                 'label': info.title,
                 'url': info.get_absolute_url(),
+                'slug': info.slug,
             }
             for info in infos[:limit - len(data)]
         ])
             }
             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 = [
 
     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 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:
 
 class CiteForm(ModelForm):
     class Meta:
@@ -79,3 +79,5 @@ class CarouselAdmin(admin.ModelAdmin):
 
 admin.site.register(Carousel, CarouselAdmin)
 
 
 admin.site.register(Carousel, CarouselAdmin)
 
+
+admin.site.register(models.UserList)
diff --git a/src/social/api/serializers.py b/src/social/api/serializers.py
new file mode 100644 (file)
index 0000000..155c0e6
--- /dev/null
@@ -0,0 +1,255 @@
+# 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 import serializers
+import bookmarks.api.views
+import bookmarks.models
+import catalogue.api.serializers
+import catalogue.models
+from social import models
+
+
+class SettingsSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = models.UserProfile
+        fields = ['notifications']
+
+
+class UserListBooksField(serializers.Field):
+    def to_representation(self, value):
+        return list(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 UserListSerializerV2(serializers.ModelSerializer):
+    client_id = serializers.CharField(write_only=True, required=False)
+    books = UserListBooksField(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(UserListSerializerV2):
+    class Meta:
+        model = models.UserList
+        fields = ['books']
+
+
+class UserListItemSerializer(serializers.ModelSerializer):
+    client_id = serializers.CharField(write_only=True, required=False)
+    favorites = serializers.BooleanField(read_only=True)
+    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=catalogue.models.Book.objects.all(),
+        source='book',
+        slug_field='slug',
+        required=False,
+        allow_null=True
+    )
+    bookmark = serializers.SlugRelatedField(
+        queryset=bookmarks.models.Bookmark.objects.all(),
+        slug_field='uuid',
+        required=False,
+        allow_null=True
+    )
+    quote = serializers.SlugRelatedField(
+        queryset=bookmarks.models.Quote.objects.all(),
+        slug_field='uuid',
+        required=False,
+        allow_null=True
+    )
+
+    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
+            }
+        }
+
+
+class UserListItemReadSerializerV3(UserListItemSerializer):
+    book = catalogue.api.serializers.BookSerializer2()
+    bookmark = bookmarks.api.views.BookmarkSerializerV3()
+    class Meta:
+        model = models.UserListItem
+        fields = [
+            'client_id',
+            'uuid',
+            'order',
+            'list_slug',
+            'timestamp',
+            'favorites',
+            'deleted',
+
+            'book',
+            'fragment',
+            'quote',
+            'bookmark',
+            'note',
+        ]
+
+class UserListItemReadSerializer(UserListItemSerializer):
+    book = catalogue.api.serializers.BookSerializer2()
+    bookmark = bookmarks.api.views.BookmarkSerializer()
+    class Meta:
+        model = models.UserListItem
+        fields = [
+            'client_id',
+            'uuid',
+            'order',
+            'list_slug',
+            'timestamp',
+            'favorites',
+            'deleted',
+
+            'book',
+            'fragment',
+            'quote',
+            'bookmark',
+            'note',
+        ]
+
+
+class UserListSerializerV3(serializers.ModelSerializer):
+    client_id = serializers.CharField(write_only=True, required=False)
+    timestamp = serializers.IntegerField(required=False)
+
+    class Meta:
+        model = models.UserList
+        fields = [
+            'timestamp',
+            'client_id',
+            'name',
+            'slug',
+            'favorites',
+            'deleted',
+        ]
+        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
+        )
+        return instance
+
+
+class ProgressSerializer(serializers.ModelSerializer):
+    book = serializers.HyperlinkedRelatedField(
+        read_only=True,
+        view_name='catalogue_api_book',
+        lookup_field='slug'
+    )
+    book_slug = serializers.SlugRelatedField(
+        queryset=catalogue.models.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']
diff --git a/src/social/api/urls2.py b/src/social/api/urls2.py
new file mode 100644 (file)
index 0000000..8f1dc15
--- /dev/null
@@ -0,0 +1,37 @@
+# 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>/items/', views.ListItemListViewV3.as_view()),
+    path('list-items/', views.ListItemsView.as_view()),
+    path('list-items/book/<slug:book>/', views.ListItemsForBook.as_view()),
+    path('list-items/<uuid:uuid>/', views.ListItemViewV3.as_view()),
+    path('lists/<slug:slug>/<slug:book>/', views.ListItemViewV2.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..35af9a8 100644 (file)
@@ -1,20 +1,39 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 # 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 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.exceptions import MethodNotAllowed
+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.views import APIView
 from api.models import BookUserData
 from rest_framework.response import Response
 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
 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.utils import likes
+from social import models
+import bookmarks.models
+from . import serializers
+from bookmarks.api.views import BookmarkSerializer
 
 
 
 
-@vary_on_auth
+
+class SettingsView(RetrieveUpdateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.SettingsSerializer
+
+    def get_object(self):
+        return models.UserProfile.get_for(self.request.user)
+
+
+@never_cache
 class LikeView(APIView):
     permission_classes = [IsAuthenticated]
 
 class LikeView(APIView):
     permission_classes = [IsAuthenticated]
 
@@ -26,12 +45,227 @@ class LikeView(APIView):
         book = get_object_or_404(Book, slug=slug)
         action = request.query_params.get('action', 'like')
         if action == 'like':
         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':
         elif action == 'unlike':
-            book.unlike(request.user)
+            models.UserList.unlike(request.user, book)
         return Response({})
 
 
         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)
+        )
+
+
+@never_cache
+class ListsView(ListCreateAPIView):
+    permission_classes = [IsAuthenticated]
+    #pagination_class = None
+
+    def get_serializer_class(self):
+        if self.request.version == 'v2':
+            return serializers.UserListSerializerV2
+        return serializers.UserListSerializerV3
+
+    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)
+
+
+def get_userlist(slug, request):
+    if request.method in SAFE_METHODS:
+        q = Q(deleted=False)
+        if request.user.is_authenticated:
+            q |= Q(user=request.user)
+        return get_object_or_404(
+            models.UserList,
+            q,
+            slug=slug,
+        )
+    else:
+        return get_object_or_404(
+            models.UserList.all_objects.all(),
+            slug=slug,
+            user=request.user
+        )
+
+
+@never_cache
+class ListView(RetrieveUpdateDestroyAPIView):
+    # TODO: check if can modify
+    permission_classes = [IsAuthenticatedOrReadOnly]
+
+    def get_serializer_class(self):
+        if self.request.version == 'v2':
+            return serializers.UserListSerializerV2
+        return serializers.UserListSerializerV3
+
+    def get_object(self):
+        return get_userlist(self.kwargs['slug'], self.request)
+
+    def perform_update(self, serializer):
+        serializer.save(user=self.request.user)
+
+    def post(self, request, slug):
+        if request.version == 'v2':
+            # Accept posting a list of books here.
+            serializer = serializers.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)
+        else:
+            raise MethodNotAllowed(method=request.method)
+
+    def perform_destroy(self, instance):
+        instance.deleted = True
+        instance.updated_at = now()
+        instance.save()
+
+
+@never_cache
+class ListItemViewV2(APIView):
+    """v2 only"""
+    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(serializers.UserListSerializerV2(instance).data)
+
+
+@never_cache
+class ListItemListViewV3(ListCreateAPIView):
+    permission_classes = [IsAuthenticatedOrReadOnly]
+
+    def get_queryset(self):
+        lst = get_userlist(self.kwargs['slug'], self.request)
+        return lst.userlistitem_set.all().order_by('order')
+
+    def get_serializer_class(self):
+        if self.request.method == 'GET':
+            if self.request.version < 'v4':
+                return serializers.UserListItemReadSerializerV3
+            else:
+                return serializers.UserListItemReadSerializer
+        else:
+            return serializers.UserListItemSerializer
+    
+    def get_serializer(self, *args, **kwargs):
+        serializer_class = self.get_serializer_class()
+        kwargs.setdefault('context', self.get_serializer_context())
+
+        if isinstance(self.request.data, list):
+            kwargs['many'] = True
+
+        return serializer_class(*args, **kwargs)
+
+    def perform_create(self, serializer):
+        lst = get_userlist(self.kwargs['slug'], self.request)
+        serializer.save(list=lst)
+
+
+@never_cache
+class ListItemsForBook(ListAPIView):
+    permission_classes = [IsAuthenticated]
+
+    def get_serializer_class(self):
+        if self.request.version < 'v4':
+            return serializers.UserListItemReadSerializerV3
+        else:
+            return serializers.UserListItemReadSerializer
+
+    def get_queryset(self):
+        book = get_object_or_404(catalogue.models.Book, slug=self.kwargs['book'])
+        return models.UserListItem.objects.filter(
+            list__user=self.request.user,
+            book=book,
+        )
+
+
+class ListItemsView(APIView):
+    permission_classes = [IsAuthenticated]
+
+    def delete(self, request):
+        if not isinstance(self.request.data, list):
+            return Response({"error": "no data"}, status=400)
+        models.UserListItem.objects.filter(
+            list__user=self.request.user,
+            uuid__in=self.request.data
+        ).delete()
+        return Response({})
+
+
+@never_cache
+class ListItemViewV3(RetrieveUpdateDestroyAPIView):
+    permission_classes = [IsAuthenticated]
+    lookup_field = 'uuid'
+
+    def get_queryset(self):
+        return models.UserListItem.objects.filter(
+            list__user=self.request.user
+        )
+
+    def get_serializer_class(self):
+        if self.request.method == 'GET':
+            if self.request.version < 'v4':
+                return serializers.UserListItemReadSerializerV3
+            else:
+                return serializers.UserListItemReadSerializer
+        else:
+            return serializers.UserListItemSerializer
+
+
 @vary_on_auth
 class ShelfView(ListAPIView):
     permission_classes = [IsAuthenticated]
 @vary_on_auth
 class ShelfView(ListAPIView):
     permission_classes = [IsAuthenticated]
@@ -46,7 +280,7 @@ class ShelfView(ListAPIView):
         after = self.request.query_params.get('after')
         count = int(self.request.query_params.get('count', 50))
         if state == 'likes':
         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)
         else:
             ids = BookUserData.objects.filter(user=self.request.user, complete=state == 'complete')\
                 .values_list('book_id', flat=True)
@@ -59,3 +293,162 @@ class ShelfView(ListAPIView):
 
         return books
 
 
         return books
 
+
+@never_cache
+class ProgressListView(ListAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.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 = serializers.ProgressSerializer
+
+
+@never_cache
+class TextProgressView(ProgressMixin, RetrieveUpdateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.TextProgressSerializer
+
+    def perform_update(self, serializer):
+        serializer.instance.reported_timestamp = now()
+        serializer.instance.last_mode = 'text'
+        serializer.save()
+
+
+@never_cache
+class AudioProgressView(ProgressMixin, RetrieveUpdateAPIView):
+    permission_classes = [IsAuthenticated]
+    serializer_class = serializers.AudioProgressSerializer
+
+    def perform_update(self, serializer):
+        serializer.instance.reported_timestamp = now()
+        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 = serializers.ProgressSerializer
+    
+    sync_id_field = 'book__slug'
+    sync_id_serializer_field = 'book_slug'
+
+
+class UserListSyncView(SyncView):
+    model = models.UserList
+
+    def get_serializer_class(self):
+        if self.request.version == 'v2':
+            return serializers.UserListSerializerV2
+        return serializers.UserListSerializerV3
+
+
+class UserListItemSyncView(SyncView):
+    model = models.UserListItem
+    serializer_class = serializers.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 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):
 
 
 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
     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:
         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
 
         except:
             pass
 
-        return book, tag
+        return book, ul
 
 
 class RemoveSetForm(forms.Form):
 
 
 class RemoveSetForm(forms.Form):
@@ -43,8 +35,8 @@ class RemoveSetForm(forms.Form):
     def save(self, user):
         slug = self.cleaned_data['slug']
         try:
     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'])
             return
         try:
             book = Book.objects.get(id=self.cleaned_data['book'])
@@ -52,8 +44,8 @@ class RemoveSetForm(forms.Form):
             return
 
         try:
             return
 
         try:
-            book.tag_relations.filter(tag=tag).delete()
+            ul.userlistitem_set.filter(book=book).delete()
         except:
             pass
 
         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)),
+            ],
+        ),
+    ]
diff --git a/src/social/migrations/0027_alter_userlistitem_order_and_more.py b/src/social/migrations/0027_alter_userlistitem_order_and_more.py
new file mode 100644 (file)
index 0000000..08a4413
--- /dev/null
@@ -0,0 +1,24 @@
+# Generated by Django 4.0.8 on 2026-03-17 11:18
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('social', '0026_userprofile'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='userlistitem',
+            name='order',
+            field=models.IntegerField(default=0),
+        ),
+        migrations.AlterField(
+            model_name='userlistitem',
+            name='reported_timestamp',
+            field=models.DateTimeField(default=django.utils.timezone.now),
+        ),
+    ]
index cb1326b..45ee0f4 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.
 #
 # 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 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.exceptions import ValidationError
+from django.core.mail import send_mail
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.timezone import now, utc
 from catalogue.models import Book
 from catalogue.models import Book
+from catalogue.utils import get_random_hash
 from wolnelektury.utils import cached_render, clear_cached_renders
 from wolnelektury.utils import cached_render, clear_cached_renders
+from .syncable import Syncable
 
 
 class BannerGroup(models.Model):
 
 
 class BannerGroup(models.Model):
@@ -170,3 +178,281 @@ class CarouselItem(models.Model):
 
     def get_banner(self):
         return self.banner or self.banner_group.get_banner()
 
     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(default=0)
+    deleted = models.BooleanField(default=False)
+    created_at = models.DateTimeField(auto_now_add=True)
+    updated_at = models.DateTimeField(auto_now=True)
+    reported_timestamp = models.DateTimeField(default=now)
+
+    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()
+
+    def save(self, *args, **kwargs):
+        if not self.order:
+            self.order = self.list.userlistitem_set.all().count() + 1
+        super().save(*args, **kwargs)
+
+    @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 %}
 {% endblock %}
 
 {% block main %}
+{% for list in favs %}
   <div class="l-section">
     <div class="l-author__header">
   <div class="l-section">
     <div class="l-author__header">
-      <h1>{% trans "Półka" %}</h1>
+      <h1>{% trans "Ulubione" %}</h1>
     </div>
   </div>
 
     </div>
   </div>
 
     <div class="l-books__header">
       <div class="l-books__input">
         <i class="icon icon-filter"></i>
     <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>
     </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 %}
       {% endif %}
+      {% endwith %}
+      {% endfor %}
     </div>
   </div>
     </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-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 %}
     </div>
   </div>
+{% endfor %}
 
 {% endblock %}
 
 {% 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
 #
 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
 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]
 
 
         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()
 @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 = [
 
 
 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'),
     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 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.models import Cite
+from social import models
 
 
 def likes(user, work, request=None):
 
 
 def likes(user, work, request=None):
@@ -18,7 +17,7 @@ def likes(user, work, request=None):
         return False
 
     if request is 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
 
     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()
             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)()
 
 
                 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))
 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 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
 
 
 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):
 def like_book(request, slug):
-    if not request.user.is_authenticated:
-        return HttpResponseForbidden('Login required.')
     book = get_object_or_404(Book, slug=slug)
 
     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})
 
     if is_ajax(request):
         return JsonResponse({"success": True, "msg": "ok", "like": True})
@@ -50,13 +50,14 @@ class RemoveSetView(AddSetView):
     form_class = forms.RemoveSetForm
 
 
     form_class = forms.RemoveSetForm
 
 
-@require_POST
+@login_required
 def unlike_book(request, slug):
 def unlike_book(request, slug):
-    if not request.user.is_authenticated:
-        return HttpResponseForbidden('Login required.')
     book = get_object_or_404(Book, slug=slug)
 
     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})
 
     if is_ajax(request):
         return JsonResponse({"success": True, "msg": "ok", "like": False})
@@ -64,36 +65,38 @@ def unlike_book(request, slug):
         return redirect(book)
 
 
         return redirect(book)
 
 
+@never_cache
 @login_required
 def my_shelf(request):
     template_name = 'social/my_shelf.html'
 @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, {
         
     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 = {}
     })
 
 
 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:
     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
     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', '')
 @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:
     if term:
-        tags = tags.filter(name__icontains=term)
+        ulists = tags.filter(name__icontains=term)
     return JsonResponse(
         [
     return JsonResponse(
         [
-            t.name for t in tags
+            ul.name for ul in ulists
         ], safe=False
     )
         ], 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 __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
     def description(self):
         if len(self._description):
             return self._description
index d4faa8b..c90152c 100644 (file)
@@ -41,6 +41,7 @@ INSTALLED_APPS_OUR = [
     'push',
     'club',
     'redirects',
     'push',
     'club',
     'redirects',
+    'partners',
 ]
 
 INSTALLED_APPS_CONTRIB = [
 ]
 
 INSTALLED_APPS_CONTRIB = [
@@ -59,6 +60,7 @@ INSTALLED_APPS_CONTRIB = [
     'django.contrib.postgres',
     'admin_ordering',
     'rest_framework',
     'django.contrib.postgres',
     'admin_ordering',
     'rest_framework',
+    'django_filters',
     'fnp_django_pagination',
     'pipeline',
     'sorl.thumbnail',
     '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 = [
 # 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_EMAIL_CONFIRMATION_EXPIRE_DAYS = 2
+ACCOUNT_AUTHENTICATION_METHOD = "username_email"
+FEATURE_CONFIRM_USER = False
+
 LOGIN_URL = '/uzytkownik/login/'
 
 LOGIN_REDIRECT_URL = '/'
 LOGIN_URL = '/uzytkownik/login/'
 
 LOGIN_REDIRECT_URL = '/'
index aa50736..84503ed 100644 (file)
@@ -1,10 +1,9 @@
 # This file is part of Wolne Lektury, licensed under GNU Affero GPLv3 or later.
 # Copyright © Fundacja Wolne Lektury. See NOTICE for more information.
 #
 # 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
+DEBUG = os.environ.get('DEBUG', '')
 
 ADMINS = [
     # ('Your Name', 'your_email@domain.com'),
 
 ADMINS = [
     # ('Your Name', 'your_email@domain.com'),
@@ -14,15 +13,52 @@ MANAGERS = ADMINS
 
 CONTACT_EMAIL = 'fundacja@wolnelektury.pl'
 
 
 CONTACT_EMAIL = 'fundacja@wolnelektury.pl'
 
+ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '').split()
+
+AUTH_PASSWORD_VALIDATORS = [
+    {
+        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+    },
+    {
+        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+        "OPTIONS": {
+            "min_length": 6,
+        },
+    },
+    {
+        "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+    },
+    {
+        "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+    },
+]
+
 CACHE_MIDDLEWARE_SECONDS = 3 * 60
 
 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': {
 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.
 #
 # 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']
 CELERY_TASK_EAGER_PROPAGATES = True
 CELERY_SEND_TASK_ERROR_EMAILS = True
 CELERY_ACCEPT_CONTENT = ['pickle']
index e5b2f60..1e74dbf 100644 (file)
@@ -24,7 +24,7 @@ PAYPAL_ENABLED = True
 REST_FRAMEWORK = {
     "DEFAULT_RENDERER_CLASSES": (
         'rest_framework.renderers.JSONRenderer',
 REST_FRAMEWORK = {
     "DEFAULT_RENDERER_CLASSES": (
         'rest_framework.renderers.JSONRenderer',
-        'rest_framework.renderers.BrowsableAPIRenderer',
+        #'rest_framework.renderers.BrowsableAPIRenderer',
         'api.renderers.LegacyXMLRenderer',
     ),
     'DEFAULT_AUTHENTICATION_CLASSES': (
         'api.renderers.LegacyXMLRenderer',
     ),
     'DEFAULT_AUTHENTICATION_CLASSES': (
@@ -33,7 +33,7 @@ REST_FRAMEWORK = {
         'rest_framework.authentication.SessionAuthentication',
     ),
     'DEFAULT_PAGINATION_CLASS': 'api.pagination.WLLimitOffsetPagination',
         'rest_framework.authentication.SessionAuthentication',
     ),
     'DEFAULT_PAGINATION_CLASS': 'api.pagination.WLLimitOffsetPagination',
-    'PAGE_SIZE': 10,
+    'PAGE_SIZE': 20,
     'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning',
 }
 
     '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
 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"
 
 
 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
 
 
 CATALOGUE_MIN_INITIALS = 60
 
@@ -55,8 +58,6 @@ NEWSLETTER_PHPLIST_SUBSCRIBE_URL = None
 VARIANTS = {
 }
 
 VARIANTS = {
 }
 
-EPUB_FUNDRAISING = []
-
 CIVICRM_BASE = None
 CIVICRM_KEY = None
 
 CIVICRM_BASE = None
 CIVICRM_KEY = None
 
@@ -76,3 +77,5 @@ SEARCH_CONFIG_SIMPLE = 'simple'
 SEARCH_USE_UNACCENT = False
 
 FEATURE_SYNCHRO = False
 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;
 
 .c-media__actions {
     display: flex;
-    column-gap: 38px;
+    column-gap: 20px;
     row-gap: 10px;
 }
 
     row-gap: 10px;
 }
 
index 52729f9..d1fb096 100644 (file)
           display: inline;
       }
     }
           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..5301cba 100644 (file)
@@ -51,7 +51,7 @@
     .annoy-banner-off {
         font-size: 1.2rem !important;
     }
     .annoy-banner-off {
         font-size: 1.2rem !important;
     }
-    
+
     @include rwd($break-flow) {
         .annoy-banner-inner {
             padding: 0;
     @include rwd($break-flow) {
         .annoy-banner-inner {
             padding: 0;
 
         }
         .annoy-banner-off {
 
         }
         .annoy-banner-off {
-            
+
         }
     }
 }
 
 
         }
     }
 }
 
 
-.annoy-banner_crisis-container {
+.annoy-banner_crisis-container,
+.annoy-banner_top-container {
     position: sticky;
     top: 0;
     position: sticky;
     top: 0;
-    height: 160px;
     z-index: 10;
     box-shadow: 0 0 10px black;
     display: flex;
     z-index: 10;
     box-shadow: 0 0 10px black;
     display: flex;
     align-items:center;
     cursor: pointer;
 
     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%;
        position: sticky;
        top: 0;
        width: 100%;
            .image-box {
                position: relative;
                img {
            .image-box {
                position: relative;
                img {
-                   height: 159px;
+                   max-height: 159px;
                    display: block;
 
                    @media screen and (max-width: 700px) {
                    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;
                }
                .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;
                }
                a {
                    color: #c32721;
                }
                display: block;
                transition: background-color .2s;
 
                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;
                &: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 {
        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;
+       }
+    }
+}
+
+
+
+
+
+.annoy-banner_seasonal {
+    background-color: #ffd430;
+    color: #083F4D;
+    border-radius: 10px;
+    padding: 15px 20px;
+    margin-top: 20px;
+    cursor: pointer;
+
+    .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;
+           }
+       }
+        a.action { 
+            color: #fff;
+            background: #c92834;
+            padding: 9px 20px;
+            font-weight: 600;
+            border-radius: 15px;
+           border-width: 0;
+           &:hover {
+               border-bottom-width: 0;
+            }
+        }
+
+    }
+        .state-box {
+            margin-top: 15px;
+        }
+        .bar-status-box {
+            display: flex;
+            justify-content: space-between;
+        }
+}
+
+
+.t-relative {
+    position: relative;
+}
+.annoy-banner_seasonal-overlay {
+    display: none;
+    
+    background-color: #ffd430;
+    color: #083F4D;
+    border-radius: 0 0 10px 10px;
+    position: absolute;
+    z-index: 14;
+    left: 16px;
+    right: 16px;
+    padding: 15px 20px;
+    margin-top: -10px;
+
+    &:before {
+        background: rgba(0,0,0,0);
+        height: 10px;
+        display: block;
+        content: "";
+        position: absolute;
+        bottom: 0;
+        left: 0;
+        right: 0;
+        border-radius: 0 0 10px 10px;
+        box-shadow: 0px 10px 10px black;
+        z-index: 999;
+    }  
+    
+    .annoy-banner-inner {
+       display: flex;
+       flex-direction: column;
+       gap: 20px;
+       align-items: flex-start;
+       justify-content: space-between;
+
+        @include rwd($break-flow) {
+            flex-direction: row;
+        }
+        
+        .annoy-banner-off {
+            background: none;
+            border-color: transparent;
+
+            position: absolute;
+            top: 16px;
+            right: 16px;
+            padding: 12px 4px;
+        }
+        
+        video {
+            height: 240px;
+            width: 100%;
+            @include rwd($break-flow) {
+                height: 360px;
+                width: auto;
+            }
+        }
+
+        .text {
+            margin-right: 60px;
+            h1 {
+                font-size: 1.5em;
+                margin: 0 0 1em;
+                font-weight: bold;
+            }
+        
+           p {
+               margin: 0 0 1em 0;
+           }
+            .l-button {
+                display: inline-block;
+                margin-top: 1em;
+                padding: 12px 20px;
+            }
+        }
+        
+    }
+}
index ba7e145..c4b1024 100644 (file)
     list-style: none;
     margin-top: 0.25rem;
     margin-bottom: 0;
     list-style: none;
     margin-top: 0.25rem;
     margin-bottom: 0;
-    li {
+    margin-left: 1em;
       font-size: 18px;
       font-size: 18px;
-      line-height: 150%;
       font-weight: normal;
       font-weight: normal;
+    li {
+      line-height: 150%;
 
     }
 
 
     }
 
         margin-bottom: .7em;
     }
 }
         margin-bottom: .7em;
     }
 }
+
+
+.abstract {
+    h2 {
+        font-size: 1.1em;
+        margin-top: 1.5em;
+        margin-bottom: 0;
+        font-weight: bold;
+    }
+    em.author-emphasis {
+        font-weight: bold;
+        font-style: normal;
+    }
+}
index c983b38..e14adbf 100644 (file)
 
   img {
       width: 100%;
 
   img {
       width: 100%;
-      @include rwd($break3) {
-          width: 173px;
-      }
   }
 }
 
   }
 }
 
index 78b33bb..b1844d8 100644 (file)
     @include rwd($break-flow) {
         @include font-size(16px);
         line-height: 20px;
     @include rwd($break-flow) {
         @include font-size(16px);
         line-height: 20px;
-        padding: 19px 20px;
+        padding: 19px 0;
     }
     
     .icon {
       font-size: 21px;
     }
     
     .icon {
       font-size: 21px;
-      margin-right: 15px;
+      margin-right: 10px;
+    }
+
+    img {
+        height: 21px;
     }
 
     &:hover {
     }
 
     &:hover {
index 593794b..a1ea7b4 100644 (file)
 }
 
 .l-checkout__box__header {
 }
 
 .l-checkout__box__header {
-  display: flex;
+    display: flex;
+    flex-direction: column;
   background: #083F4D;
 
   background: #083F4D;
 
-  img {
+  @include rwd($break-flow) {
+      flex-direction: row;
+  }
+
+  .l-checkout__box__header__img {
       display: none;
       display: none;
+      background: black;
+      background-position: center;
+      background-size: cover;
       @include rwd($break-flow) {
           display: block;
       @include rwd($break-flow) {
           display: block;
+          width: 50%;
       }
       }
-  }
-}
 
 
-.l-checkout__box__header__content h1 {
-  font-style: italic;
-  font-weight: 300;
-  font-size: 48px;
-  line-height: 55px;
-  letter-spacing: -0.02em;
-  color: #92BD39;
-  margin: 0;
+      &.l-checkout__box__header__real {
+          display: block;
+          width: auto;
+
+          video {
+              height: 200px;
+              width: 100%;
+              display: block;
+              @include rwd($break-flow) {
+                  height: 360px;
+                  width: auto;
+              }
+          }
+      }
+  }
 }
 
 }
 
-.l-checkout__box__header__content p:first-of-type {
-  font-style: normal;
-  font-weight: 600;
-  font-size: 21.5px;
-  line-height: 140%;
-  letter-spacing: -0.01em;
-  color: #FFFFFF;
-  margin-top: 23px;
-}
+.l-checkout__box__header__content {
+    color: white;
+    padding: 32px 45px;
+
+    h1 {
+        font-style: italic;
+        font-weight: 300;
+        font-size: 48px;
+        line-height: 55px;
+        letter-spacing: -0.02em;
+        color: #92BD39;
+        margin: 0;
+        margin-bottom: 23px;
+    }
 
 
-.l-checkout__box__header__content p {
-  font-weight: normal;
-  font-size: 18px;
-  line-height: 150%;
-  color: #FFFFFF;
-  margin-top: 8px;
-  max-width: 410px;
+    p {
+        font-weight: normal;
+        font-size: 18px;
+        line-height: 150%;
+        margin-top: 0;
+        margin-bottom: 8px;
+
+        &:first-of-type {
+            font-weight: 600;
+            font-size: 21.5px;
+            line-height: 140%;
+            letter-spacing: -0.01em;
+        }
+    }
 }
 
 }
 
-.l-checkout__box__header__content {
-  padding: 32px 45px;
-}
 
 //Steps
 .l-checkout__steps {
 
 //Steps
 .l-checkout__steps {
     }
   }
   .l-checkout__payments__box__btn-wrp {
     }
   }
   .l-checkout__payments__box__btn-wrp {
-    padding: 0 20px 20px 20px;
+      padding: 20px;
     margin-bottom: 0;
     margin-top: auto;
     margin-bottom: 0;
     margin-top: auto;
-    @include rwd($break-flow) {
-        padding-top: 20px;
-    }
   }
   p {
     margin-top: 0;
   }
   p {
     margin-top: 0;
     }
   }
   button {
     }
   }
   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;
     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;
     text-align: center;
     color: #083F4D;
-    transition: background $ease-out 250ms;
 
 
+    flex-direction: column;
     @include rwd($break-flow) {
     @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 {
       &:hover {
         background: #83AD2B;
       }
       &:hover {
         background: #83AD2B;
       }
+      span {
+         color: white;
+      }
     }
   }
 
     }
   }
 
       flex-direction: row;
   }
   
       flex-direction: row;
   }
   
-  &:nth-child(4) {
-    .l-checkout__input {
-      &:nth-child(1) { width: 172px; }
-      &:nth-child(2) { width: calc(100% - 172px); }
-    }
-  }
-
   &.full {
     flex-direction: column;
     @include rwd($break-flow) {
   &.full {
     flex-direction: column;
     @include rwd($break-flow) {
         }
     }
 }
         }
     }
 }
+
+
+.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;
+}
+
+
+.donation-more {
+    margin-bottom:40px;
+}
index c4d4f7a..a47875b 100644 (file)
@@ -161,6 +161,15 @@ body {
               color: white;
           }
       }
               color: white;
           }
       }
+
+      .menubar-donate {
+         color: #fff;
+         background: #c92834;
+         padding: 9px 20px 11px;
+         font-weight: 600;
+         margin-right: 20px;
+         border-radius: 15px;
+      }
   }
 
     .user {
   }
 
     .user {
@@ -267,6 +276,10 @@ body {
               display: block;
               left: 16px;
           }
               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;
 h3, .subtitle2 {
     font-size: 1.5em;
     margin: 1.5em 0 1em 0;
+    padding-right: 48px;
     font-weight: normal;
     line-height: 1.5em;
 }
     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 {
 
 
 #menu {
-    padding-bottom: 50px;
     * {
         box-sizing: content-box;
     }
     * {
         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 9794f35..f8ee6a6 100644 (file)
Binary files a/src/wolnelektury/static/img/favicon.png and b/src/wolnelektury/static/img/favicon.png differ
diff --git a/src/wolnelektury/static/img/favicon.svg b/src/wolnelektury/static/img/favicon.svg
new file mode 100644 (file)
index 0000000..9178ed6
--- /dev/null
@@ -0,0 +1,126 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Generator: Adobe Illustrator 12.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 51448)  -->
+
+<svg
+   version="1.1"
+   id="Layer_1"
+   width="130.35504"
+   height="130.36163"
+   viewBox="0 0 130.35504 130.36164"
+   overflow="visible"
+   enable-background="new 0 0 541.021 364.465"
+   xml:space="preserve"
+   inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
+   sodipodi:docname="favicon.svg"
+   style="overflow:visible"
+   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
+   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
+   xmlns="http://www.w3.org/2000/svg"
+   xmlns:svg="http://www.w3.org/2000/svg"
+   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+   xmlns:cc="http://creativecommons.org/ns#"
+   xmlns:dc="http://purl.org/dc/elements/1.1/"><metadata
+   id="metadata181"><rdf:RDF><cc:Work
+       rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
+         rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
+   id="defs179">
+       <foreignObject
+   id="foreignObject5"
+   height="1"
+   width="1"
+   y="0"
+   x="0"
+   requiredExtensions="http://ns.adobe.com/AdobeIllustrator/10.0/">
+               
+       </foreignObject>
+       
+
+               
+               
+               
+               
+               
+               
+               
+               
+               
+               
+               
+               
+               
+               
+               
+               
+               
+               
+       </defs><sodipodi:namedview
+   pagecolor="#ffffff"
+   bordercolor="#666666"
+   borderopacity="1"
+   objecttolerance="10"
+   gridtolerance="10"
+   guidetolerance="10"
+   inkscape:pageopacity="0"
+   inkscape:pageshadow="2"
+   inkscape:window-width="1920"
+   inkscape:window-height="1129"
+   id="namedview177"
+   showgrid="false"
+   fit-margin-top="0"
+   fit-margin-left="0"
+   fit-margin-right="0"
+   fit-margin-bottom="0"
+   inkscape:zoom="6.4182511"
+   inkscape:cx="40.976895"
+   inkscape:cy="68.320792"
+   inkscape:window-x="0"
+   inkscape:window-y="32"
+   inkscape:window-maximized="1"
+   inkscape:current-layer="Layer_1"
+   inkscape:showpageshadow="2"
+   inkscape:pagecheckerboard="0"
+   inkscape:deskcolor="#d1d1d1" />
+<rect
+   style="fill:#ffffff;stroke-width:6.56219"
+   id="rect420"
+   width="130.355"
+   height="130.362"
+   x="0"
+   y="-2.3678889e-07" /><g
+   transform="translate(-45.962818,4.5124199e-4)"
+   id="g9">
+                       <g
+   id="g11">
+                               <path
+   style="fill:#ecbe00"
+   inkscape:connector-curvature="0"
+   id="path13"
+   d="m 161.889,40.548 c -4.561,1.024 -8.983,3.101 -13.466,4.375 -5.697,1.618 -11.178,3.993 -16.629,6.283 -4.439,1.865 -17.03,6.779 -12.194,14.136 3.222,4.901 12.195,2.042 16.64,1.055 4.9,-1.089 9.723,-2.889 14.542,-4.305 4.377,-1.287 9.529,-4.458 14.232,-3.914 3.419,0.395 3.915,-4.949 0.503,-5.307 -5.48,-0.572 -11.253,2.692 -16.245,4.491 -6.785,2.446 -14.014,4.673 -21.16,5.683 -1.042,0.147 -2.071,0.161 -3.119,0.104 -3.35,-0.183 0.713,-3.859 1.885,-4.496 3.885,-2.11 8.247,-3.601 12.353,-5.222 4.493,-1.774 8.997,-3.283 13.649,-4.587 3.897,-1.093 7.652,-3.055 11.639,-3.676 1.181,-0.185 1.86,-1.144 2.069,-2.241 1.203,-6.314 3.686,-12.275 5.17,-18.521 1.491,-6.269 2.491,-12.617 4.43,-18.777 0.581,-1.846 -0.863,-3.696 -2.83,-3.391 -3.959,0.614 -7.776,2.038 -11.705,2.876 -3.609,0.77 -7.204,1.568 -10.717,2.709 -3.871,1.258 -7.667,2.705 -11.464,4.167 -2.438,0.938 -5.848,3.382 -8.47,3.173 -0.94,-0.075 -2.122,0.495 -2.369,1.488 -1.516,6.081 -4.167,11.729 -6.375,17.572 -2.128,5.629 -4.853,11.899 -5.779,17.844 -0.323,2.069 3.008,2.839 3.583,0.818 v 0 c 3.235,-11.361 9.237,-21.891 12.492,-33.247 l 0.048,0.012 c 1.496,-0.249 2.914,-0.869 4.334,-1.493 3.652,-1.604 7.38,-2.975 11.138,-4.32 4.176,-1.496 8.413,-2.592 12.75,-3.495 3.125,-0.651 6.249,-1.587 9.382,-2.312 l -0.009,0.071 c -1.159,4.454 -2.013,8.979 -2.962,13.488 -1.353,6.43 -3.815,12.539 -5.26,18.94 z" />
+                       </g>
+                       <g
+   id="g15">
+                               <path
+   style="fill:#9bbb2b"
+   inkscape:connector-curvature="0"
+   id="path17"
+   d="M 65.606,43.706 C 65.355,42.216 64.736,40.801 64.115,39.385 62.512,35.734 61.14,32.007 59.794,28.25 58.296,24.072 57.202,19.837 56.298,15.499 55.651,12.387 54.72,9.276 53.996,6.155 53.994,6.144 54.068,6.1 54.098,6.134 c 4.44,1.156 8.951,2.008 13.446,2.952 6.433,1.352 12.542,3.819 18.946,5.263 0,0 3.104,9.021 4.391,13.551 1.618,5.698 3.993,11.18 6.284,16.633 1.865,4.438 6.777,17.027 14.134,12.191 4.9,-3.221 2.041,-12.196 1.056,-16.642 -1.086,-4.9 -2.891,-9.722 -4.305,-14.541 -1.285,-4.379 -4.458,-9.528 -3.915,-14.233 0.395,-3.419 -4.949,-3.914 -5.306,-0.502 -0.573,5.481 2.693,11.251 4.492,16.245 2.445,6.788 4.668,14.013 5.68,21.16 0.148,1.043 0.161,2.071 0.104,3.119 -0.182,3.347 -3.859,-0.711 -4.496,-1.884 -2.108,-3.887 -3.6,-8.246 -5.221,-12.353 C 97.614,32.6 96.105,28.094 94.801,23.441 93.708,19.543 91.742,15.791 91.121,11.803 90.937,10.621 89.978,9.943 88.88,9.734 82.565,8.534 76.604,6.049 70.36,4.561 64.095,3.068 57.748,2.075 51.59,0.13 c -1.847,-0.583 -3.694,0.864 -3.391,2.83 0.61,3.96 2.035,7.778 2.872,11.709 0.769,3.608 1.565,7.203 2.708,10.716 1.258,3.871 2.707,7.668 4.17,11.466 0.938,2.436 3.383,5.816 3.172,8.468 -0.075,0.94 0.495,2.122 1.489,2.369 6.081,1.517 11.728,4.168 17.57,6.377 5.63,2.13 11.9,4.854 17.845,5.782 2.069,0.323 2.838,-3.008 0.818,-3.584 v 0 C 87.489,53.024 76.962,47.028 65.609,43.771 Z" />
+                       </g>
+                       <g
+   id="g19">
+                               <path
+   style="fill:#bf001a"
+   inkscape:connector-curvature="0"
+   id="path21"
+   d="m 89.662,110.716 c -1.49,0.252 -2.903,0.869 -4.318,1.49 -3.653,1.604 -7.38,2.976 -11.137,4.321 -4.178,1.497 -8.412,2.594 -12.751,3.495 -3.112,0.646 -6.22,1.576 -9.339,2.3 L 52.08,122.27 c 1.161,-4.455 2.015,-8.983 2.966,-13.494 1.345,-6.381 3.778,-12.446 5.226,-18.796 0.015,-0.063 0.029,-0.127 0.043,-0.19 l 0.052,0.032 c 4.57,-1.021 9,-3.105 13.491,-4.38 5.699,-1.617 11.179,-3.994 16.631,-6.286 4.438,-1.865 17.027,-6.78 12.193,-14.135 -3.22,-4.898 -12.198,-2.037 -16.641,-1.053 -4.901,1.086 -9.722,2.891 -14.542,4.303 -4.38,1.283 -9.522,4.454 -14.227,3.917 -3.42,-0.39 -3.916,4.954 -0.503,5.307 5.484,0.564 11.243,-2.699 16.24,-4.494 6.792,-2.44 14.012,-4.668 21.162,-5.679 1.042,-0.147 2.071,-0.161 3.12,-0.104 3.347,0.181 -0.717,3.856 -1.888,4.491 -3.886,2.109 -8.244,3.603 -12.349,5.225 -4.494,1.775 -8.999,3.283 -13.652,4.586 -3.898,1.092 -7.651,3.059 -11.639,3.677 -1.181,0.184 -1.86,1.144 -2.069,2.241 -1.202,6.314 -3.686,12.274 -5.168,18.521 -1.488,6.269 -2.493,12.617 -4.433,18.775 -0.582,1.846 0.863,3.695 2.83,3.391 3.96,-0.613 7.776,-2.038 11.707,-2.874 3.608,-0.769 7.203,-1.563 10.714,-2.706 3.873,-1.26 7.67,-2.708 11.47,-4.171 2.438,-0.938 5.813,-3.382 8.466,-3.174 0.941,0.074 2.122,-0.494 2.37,-1.488 1.516,-6.08 4.167,-11.728 6.375,-17.569 2.128,-5.629 4.855,-11.899 5.779,-17.844 0.322,-2.068 -3.009,-2.839 -3.583,-0.818 v 0 C 98.992,88.832 92.996,99.35 89.739,110.694 Z" />
+                       </g>
+                       <g
+   id="g23">
+                               <path
+   style="fill:#3e626f"
+   inkscape:connector-curvature="0"
+   id="path25"
+   d="m 156.67,86.647 c 0.251,1.495 0.87,2.913 1.493,4.334 1.603,3.652 2.974,7.379 4.322,11.135 1.498,4.178 2.593,8.412 3.496,12.75 0.651,3.131 1.589,6.26 2.314,9.399 l 0.083,0.016 c -4.503,-1.182 -9.081,-2.045 -13.644,-3.002 -6.425,-1.349 -12.528,-3.812 -18.924,-5.257 l -0.047,-0.134 c -1.024,-4.547 -3.097,-8.953 -4.364,-13.421 -1.618,-5.698 -3.992,-11.179 -6.284,-16.631 -1.866,-4.439 -6.777,-17.028 -14.135,-12.192 -4.897,3.218 -2.04,12.197 -1.055,16.639 1.086,4.9 2.889,9.723 4.304,14.542 1.285,4.376 4.459,9.526 3.917,14.229 -0.395,3.419 4.949,3.915 5.306,0.502 0.572,-5.479 -2.696,-11.249 -4.493,-16.241 -2.443,-6.789 -4.668,-14.011 -5.677,-21.158 -0.147,-1.043 -0.161,-2.071 -0.104,-3.12 0.181,-3.346 3.856,0.711 4.492,1.884 2.107,3.89 3.602,8.247 5.224,12.354 1.774,4.493 3.281,8.997 4.584,13.649 1.091,3.897 3.06,7.65 3.676,11.638 0.183,1.182 1.144,1.861 2.241,2.069 6.316,1.2 12.277,3.686 18.522,5.172 6.268,1.491 12.615,2.487 18.774,4.428 1.846,0.582 3.695,-0.863 3.391,-2.83 -0.613,-3.959 -2.037,-7.775 -2.873,-11.706 -0.768,-3.608 -1.564,-7.205 -2.707,-10.718 -1.259,-3.87 -2.708,-7.665 -4.171,-11.461 -0.939,-2.438 -3.381,-5.817 -3.173,-8.471 0.074,-0.94 -0.495,-2.122 -1.489,-2.369 -6.08,-1.515 -11.727,-4.165 -17.568,-6.375 -5.63,-2.131 -11.9,-4.852 -17.846,-5.78 -2.069,-0.323 -2.838,3.008 -0.818,3.584 v 0 c 11.358,3.239 21.888,9.238 33.245,12.492 z" />
+                       </g>
+               </g>
+
+</svg>
index eff64a0..dde96ed 100644 (file)
@@ -94,33 +94,85 @@ $("#menu a").each(function() {
 $("#menu-other").show();
 
 
 $("#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();
 
 /* 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) {
     $("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-waiter").hide();
-            $("#other-text-body").show();
-            loaded_text($("#other-text-body"));
+            loaded_text($(".other"));
         }
     });
     _paq.push(['trackEvent', 'html', 'other-text']);
 });
 
 
         }
     });
     _paq.push(['trackEvent', 'html', 'other-text']);
 });
 
 
+
+    
+
 /* Remove other version of text. */
 $(".other-text-close").click(function(e) {
     release_menu();
     e.preventDefault();
 /* 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');
     $("body").removeClass('with-other-text');
-    $("#other-text-body").html("");
     _paq.push(['trackEvent', 'html', 'other-text-close']);
 });
 
     _paq.push(['trackEvent', 'html', 'other-text-close']);
 });
 
index ae5b14a..723e03e 100644 (file)
@@ -4,6 +4,8 @@
 
     function upd_t() {
         $text = $('#main-text #book-text');
 
     function upd_t() {
         $text = $('#main-text #book-text');
+        if (!$text.length) return;
+
         texttop = $text.offset().top;
 
         $footnotes = $('#footnotes', $text);
         texttop = $text.offset().top;
 
         $footnotes = $('#footnotes', $text);
index fa4a35a..394ec3f 100644 (file)
     }
 
     var map_enabled = false;
     }
 
     var map_enabled = false;
-    var marker = L.circleMarker([0,0]);
+    var marker = null;
     var map = null;
 
     function enable_map() {
     var map = null;
 
     function enable_map() {
+        if (!$("#reference-map").length) return;
+
         $("#reference-map").show('slow');
 
         if (map_enabled) 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);
         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;
     }
 
         map_enabled = true;
     }
     $('a.anchor').on('click', function(e) {
         // Workaround for bad TOC markers.
         if ($(this).closest('#toc').length) return;
     $('a.anchor').on('click', function(e) {
         // Workaround for bad TOC markers.
         if ($(this).closest('#toc').length) return;
+        if ($(this).closest('.toc').length) return;
         if ($(this).closest('#wltoc').length) return;
         e.preventDefault();
 
         if ($(this).closest('#wltoc').length) return;
         e.preventDefault();
 
                         history.pushState({}, '', anchor);
                     },
                 });
                         history.pushState({}, '', anchor);
                     },
                 });
+                return true;
             }
         }
     }
     scrollToAnchor(window.location.hash)
             }
         }
     }
     scrollToAnchor(window.location.hash)
-    $('#toc, #themes, #book-text, #annotation').on('click', 'a', function(event) {
-        event.preventDefault();
-        scrollToAnchor($(this).attr('href'));
+    $('#toc, #themes, #book-text, #annotation, .scroll').on('click', 'a', function(event) {
+        if (scrollToAnchor($(this).attr('href'))) {
+            event.preventDefault();
+        }
     });
     });
-
     
 })})(jQuery);
     
 })})(jQuery);
index 842f31b..0ab3569 100644 (file)
  * Search terms: "jQuery Switch" and "Zepto Switch"
  */
 
  * 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.
 (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),
                        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,
                                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 */
 /*! 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..68d35fa 100644 (file)
         $('input', container).val($(this).val());
         $('.is-active', container).removeClass('is-active');
         $(this).closest('.l-checkout__payments__box').addClass('is-active');
         $('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()},
             $.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);
                 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()},
                 }
             })
         } else {
             $.post({
                 url: '/ludzie/lektura/' + $(this).attr('data-book-slug') + '/lubie/',
                 data: {'csrfmiddlewaretoken': $('[name=csrfmiddlewaretoken]').val()},
+                dataType: 'json',
                 success: function() {
                 success: function() {
-                    state.liked[$btn.attr('data-book')] = [];
+                    state.liked[$btn.attr('data-book')] = [{'favorites': true}];
                     updateLiked($btn);
                 },
                     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');
 
     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);
         let $bookContainer = $('.book-container-' + bookId);
-        $bookContainer.toggleClass('book-liked', liked);
         let $sets = $(".sets", $bookContainer);
         $sets.empty();
         $.each(state.liked[bookId], (i,e) => {
         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);
     }
     
 })();
     }
     
 })();
         $(".c-media__settings").toggleClass('active');
     });
 
         $(".c-media__settings").toggleClass('active');
     });
 
-    const crisis = document.querySelector(".annoy-banner_crisis-container");
-    const crisisLink = document.querySelector('.annoy-banner_crisis-container a.action');
-    if (crisis) {
-       crisis.addEventListener("click", function() {
-           crisisLink.click();
-       });
-    }
+    $(".annoy-banner-clickable").each(function() {
+        let btn = $("a.action", this)[0];
+        $(this).click((e) => {
+            if (e.target !== btn) {
+                btn.click();
+            }
+        });
+    });
 
 })();
 
 })();
index 98751b1..a679664 100644 (file)
@@ -6,4 +6,5 @@ Disallow: /lesmianator/wiersz/$
 Disallow: /szukaj/
 Disallow: /uzytkownik/
 Disallow: /zegar/
 Disallow: /szukaj/
 Disallow: /uzytkownik/
 Disallow: /zegar/
-
+Disallow: /katalog/custompdf/
+Disallow: /przypisy/
index 40c1de9..093fcb2 100644 (file)
@@ -1,7 +1,7 @@
 {% extends "base_simple.html" %}
 
 {% block body %}
 {% extends "base_simple.html" %}
 
 {% block body %}
-  <div class="white-box normal-text">
+  <div class="white-box">
     {% block content %}{% endblock %}
   </div>
 {% endblock %}
     {% block content %}{% endblock %}
   </div>
 {% endblock %}
index b9574d9..db33084 100644 (file)
@@ -4,7 +4,7 @@
   <form action="{% url 'import_book' %}" method="post" enctype="multipart/form-data">
     {% csrf_token %}
     <p>
   <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>
       <input type="submit" value="Import książki" />
     </p>
   </form>
index f1a7bf6..2f7b99f 100644 (file)
@@ -6,21 +6,22 @@
 {% load funding_tags %}
 {% load piwik_tags %}
 {% load title %}
 {% load funding_tags %}
 {% load piwik_tags %}
 {% load title %}
+{% load annoy %}
 
 {% block settings %}
 {% endblock %}
 
 
 {% block settings %}
 {% endblock %}
 
-<html class="no-js">
+<html class="no-js" lang="{{ LANGUAGE_CODE }}">
   <head>
     <meta charset="utf-8">
     <meta name="description" content="">
     <meta name="viewport" content="width=device-width,minimum-scale=1">
     <title>{% if title %}{{ title }} | {% endif %}WolneLektury.pl</title>
   <head>
     <meta charset="utf-8">
     <meta name="description" content="">
     <meta name="viewport" content="width=device-width,minimum-scale=1">
     <title>{% if title %}{{ title }} | {% endif %}WolneLektury.pl</title>
-    <link rel="icon" href="{% static 'img/favicon.png' %}" type="image/png"/>
+    <link rel="icon" href="{% static 'img/favicon.svg' %}" type="image/svg+xml"/>
     <link rel="search" type="application/opensearchdescription+xml" title="Wolne Lektury"
           href="{% static 'opensearch.xml' %}"/>
     <link rel="manifest" href="/manifest.json">
     <link rel="search" type="application/opensearchdescription+xml" title="Wolne Lektury"
           href="{% static 'opensearch.xml' %}"/>
     <link rel="manifest" href="/manifest.json">
-    <link rel="apple-touch-icon" href="apple-touch-icon.png">
+    <link rel="apple-touch-icon" href="{% static 'img/favicon.png' %}">
     {% stylesheet 'main' %}
       {% tracking_code %}
       {% block extrahead %}{% endblock %}
     {% stylesheet 'main' %}
       {% tracking_code %}
       {% block extrahead %}{% endblock %}
@@ -38,6 +39,9 @@
       </div>
     {% endif %}
 
       </div>
     {% endif %}
 
+    {% annoy_banner_seasonal %}
+    {% annoy_banner_seasonal_overlay %}
+      
     {% block global-content %}
       <div class="l-container l-breadcrumb-container">
         <div class="l-breadcrumb">
     {% block global-content %}
       <div class="l-container l-breadcrumb-container">
         <div class="l-breadcrumb">
index 17e2379..3cc4851 100644 (file)
@@ -7,6 +7,7 @@
 {% load preview_ad from catalogue_tags %}
 
 {% annoy_banner_crisis %}
 {% load preview_ad from catalogue_tags %}
 
 {% annoy_banner_crisis %}
+{% annoy_banner_top %}
 
 {% annoy_banner_blackout %}
 
 
 {% annoy_banner_blackout %}
 
index dcee6ff..fd41103 100644 (file)
@@ -5,7 +5,7 @@
 {% block head_title %}{% trans "Połączone konta" %}{% endblock %}
 
 {% block body %}
 {% block head_title %}{% trans "Połączone konta" %}{% endblock %}
 
 {% block body %}
-  <div class="normal-text white-box">
+  <div class="white-box">
 
     <h1>{% trans "Połączone konta" %}</h1>
 
 
     <h1>{% trans "Połączone konta" %}</h1>
 
index 3e95773..98da130 100644 (file)
@@ -5,7 +5,7 @@
 {% block head_title %}{% trans "Logowanie anulowane" %}{% endblock %}
 
 {% block body %}
 {% block head_title %}{% trans "Logowanie anulowane" %}{% endblock %}
 
 {% block body %}
-  <div class="normal-text white-box">
+  <div class="white-box">
 
     <h1>{% trans "Logowanie anulowane" %}</h1>
 
 
     <h1>{% trans "Logowanie anulowane" %}</h1>
 
index 36bb978..42078df 100644 (file)
@@ -11,7 +11,7 @@
 {% block titleextra %}{% trans "Użytkownik" %}{% endblock %}
 
 {% block body %}
 {% block titleextra %}{% trans "Użytkownik" %}{% endblock %}
 
 {% block body %}
-  <div class="normal-text white-box">
+  <div class="white-box">
     <h1>{% trans "Użytkownik" %}</h1>
 
     <p><a href="{% url 'account_set_password' %}">{% trans "Hasło" %}</a></p>
     <h1>{% trans "Użytkownik" %}</h1>
 
     <p><a href="{% url 'account_set_password' %}">{% trans "Hasło" %}</a></p>
@@ -20,5 +20,6 @@
     {% if request.user.membership %}
       <p><a href="{% url 'club_join' %}">{% trans "Wspierasz Wolne Lektury, dziękujemy!" %}</a></p>
     {% endif %}
     {% if request.user.membership %}
       <p><a href="{% url 'club_join' %}">{% trans "Wspierasz Wolne Lektury, dziękujemy!" %}</a></p>
     {% endif %}
+    <p><a href="{% url 'logout' %}">{% trans "Wyloguj się" %}</a>
   </div>
 {% endblock %}
   </div>
 {% endblock %}
index 7b9ea2d..1a804f1 100644 (file)
     </ul>
   </div>
   <a href="{% url 'user_settings' %}" class="user">
     </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 %}
       <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>
 {% 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='{% url 'register' %}?next={{ request.path }}'>{% trans "Załóż konto" %}</a>
   </div>
 {% endif %}
+
+<a href="/pomagam/?pk_campaign=menubar" class="menubar-donate">Wspieram!</a>
+