# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
#
from django.contrib import admin
-from catalogue.models import Tag, Book, Fragment, BookMedia, Collection, Source
+from catalogue.models import Tag, Book, Fragment, BookMedia, Collection, Source, Snippet
from pz.admin import EmptyFieldListFilter
admin.site.register(Fragment, FragmentAdmin)
admin.site.register(Collection, CollectionAdmin)
admin.site.register(Source, SourceAdmin)
+
+
+admin.site.register(Snippet)
--- /dev/null
+# Generated by Django 4.0.8 on 2023-05-29 09:06
+
+import django.contrib.postgres.search
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('catalogue', '0043_alter_bookmedia_duration_alter_bookmedia_type'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Snippet',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('sec', models.IntegerField()),
+ ('text', models.TextField()),
+ ('search_vector', django.contrib.postgres.search.SearchVectorField()),
+ ('book', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='catalogue.book')),
+ ],
+ ),
+ ]
from catalogue.models.book import Book, BookPopularity
from catalogue.models.collection import Collection
from catalogue.models.source import Source
+from .snippet import Snippet
def get_5_books(self):
return self.get_books()[:5]
+ def example3(self):
+ return self.get_books()[:3]
+
@cached_render('catalogue/collection_box.html')
def box(self):
return {
--- /dev/null
+from django.db import models
+from django.contrib.postgres.search import SearchVector, SearchVectorField
+from search.utils import build_search_vector
+
+
+class Snippet(models.Model):
+ book = models.ForeignKey('Book', models.CASCADE)
+ sec = models.IntegerField()
+ # header_type ?
+ # header_span ?
+ text = models.TextField()
+ search_vector = SearchVectorField()
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+ if not self.search_vector:
+ self.update()
+
+ def update(self):
+ self.search_vector = build_search_vector('text', config='polish') # config=polish
+ self.save()
+
+ @classmethod
+ def update_all(cls):
+ cls.objects.all().update(search_vector = build_search_vector('text'))
--- /dev/null
+<div class="c-collectionbox">
+ <a href="{{ collection.get_absolute_url }}">
+ <div class="c-collectionbox-covers">
+ {% for c in collection.example3 %}
+ <img src="{{ c.cover_clean.url }}">
+ {% endfor %}
+ </div>
+ {{ collection.title }}
+ </a>
+</div>
#
from django import forms
from django.forms.utils import flatatt
+from django.forms.widgets import RadioSelect
from django.utils.encoding import smart_str
from django.utils.safestring import mark_safe
from json import dumps
kwargs['widget'] = JQueryAutoCompleteSearchWidget(options)
super(JQueryAutoCompleteSearchField, self).__init__(*args, **kwargs)
+
+
+
+class InlineRadioWidget(RadioSelect):
+ option_template_name = 'search/inline_radio_widget_option.html'
# This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
# Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
#
+from django.apps import apps
+from django.contrib.postgres.search import SearchHeadline, SearchRank, SearchQuery
from django import forms
from django.utils.translation import gettext_lazy as _
-from search.fields import JQueryAutoCompleteSearchField
+from .fields import JQueryAutoCompleteSearchField, InlineRadioWidget
+from .utils import build_search_query
class SearchForm(forms.Form):
self.fields['q'].widget.attrs['data-source'] = source
if 'q' not in self.data:
self.fields['q'].widget.attrs['placeholder'] = _('title, author, epoch, kind, genre, phrase')
+
+
+class SearchFilters(forms.Form):
+ q = forms.CharField(required=False, widget=forms.HiddenInput())
+ format = forms.ChoiceField(required=False, choices=[
+ ('', 'wszystkie'),
+ ('text', 'tekst'),
+ ('audio', 'audiobook'),
+ ('daisy', 'Daisy'),
+ ('art', 'obraz'),
+ #('theme', 'motywy'),
+ ], widget=InlineRadioWidget())
+ lang = forms.ChoiceField(required=False)
+ epoch = forms.ChoiceField(required=False)
+ genre = forms.ChoiceField(required=False)
+ category = forms.ChoiceField(required=False, choices=[
+ ('', 'wszystkie'),
+ ('author', 'autor'),
+ #('translator', 'tłumacz'),
+ ('theme', 'motyw'),
+ ('genre', 'gatunek'),
+ ('book', 'tytuł'),
+ ('art', 'obraz'),
+ ('collection', 'kolekcja'),
+ ('quote', 'cytat'),
+ ], widget=InlineRadioWidget())
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ from catalogue.models import Book, Tag
+
+ self.fields['lang'].choices = [('', 'wszystkie')] + [
+ (b, b)
+ for b in Book.objects.values_list(
+ 'language', flat=True
+ ).distinct().order_by()
+ ]
+ self.fields['epoch'].choices = [('', 'wszystkie')] + [
+ (b.slug, b.name)
+ for b in Tag.objects.filter(category='epoch')
+ ]
+ self.fields['genre'].choices = [('', 'wszystkie')] + [
+ (b.slug, b.name)
+ for b in Tag.objects.filter(category='genre')
+ ]
+
+ def get_querysets(self):
+ Tag = apps.get_model('catalogue', 'Tag')
+ Book = apps.get_model('catalogue', 'Book')
+ Picture = apps.get_model('picture', 'Picture')
+ Snippet = apps.get_model('catalogue', 'Snippet')
+ Collection = apps.get_model('catalogue', 'Collection')
+ qs = {
+ 'author': Tag.objects.filter(category='author'),
+ 'theme': Tag.objects.filter(category='theme'),
+ 'genre': Tag.objects.filter(category='genre'),
+ 'collection': Collection.objects.all(),
+ 'book': Book.objects.all(), #findable
+ 'snippet': Snippet.objects.all(),
+ 'art': Picture.objects.all(),
+ # art pieces
+ # pdbooks
+ # pdauthors
+ }
+ if self.cleaned_data['category']:
+ c = self.cleaned_data['category']
+ if c != 'author': qs['author'] = Tag.objects.none()
+ if c != 'theme': qs['theme'] = Tag.objects.none()
+ if c != 'genre': qs['genre'] = Tag.objects.none()
+ if c != 'collection': qs['collection'] = Collection.objects.none()
+ if c != 'book': qs['book'] = Book.objects.none()
+ if c != 'quote': qs['snippet'] = Snippet.objects.none()
+ if c != 'art': qs['art'] = Picture.objects.none()
+ qs['art'] = Picture.objects.none()
+
+ if self.cleaned_data['format']:
+ c = self.cleaned_data['format']
+ qs['author'] = Tag.objects.none()
+ qs['theme'] = Tag.objects.none()
+ qs['genre'] = Tag.objects.none()
+ qs['collection'] = Collection.objects.none()
+ if c == 'art':
+ qs['book'] = Book.objects.none()
+ qs['snippet'] = Snippet.objects.none()
+ if c in ('text', 'audio', 'daisy'):
+ qs['art'] = Picture.objects.none()
+ if c == 'audio':
+ qs['book'] = qs['book'].filter(media__type='mp3')
+ qs['snippet'] = qs['snippet'].filter(book__media__type='mp3')
+ elif c == 'daisy':
+ qs['book'] = qs['book'].filter(media__type='daisy')
+ qs['snippet'] = qs['snippet'].filter(book__media__type='daisy')
+
+ if self.cleaned_data['lang']:
+ qs['author'] = Tag.objects.none()
+ qs['theme'] = Tag.objects.none()
+ qs['genre'] = Tag.objects.none()
+ qs['art'] = Picture.objects.none()
+ qs['collection'] = Collection.objects.none()
+ qs['book'] = qs['book'].filter(language=self.cleaned_data['lang'])
+ qs['snippet'] = qs['snippet'].filter(book__language=self.cleaned_data['lang'])
+
+ for tag_cat in ('epoch', 'genre'):
+ c = self.cleaned_data[tag_cat]
+ if c:
+ # FIXME nonexistent
+ t = Tag.objects.get(category=tag_cat, slug=c)
+ qs['author'] = Tag.objects.none()
+ qs['theme'] = Tag.objects.none()
+ qs['genre'] = Tag.objects.none()
+ qs['collection'] = Collection.objects.none()
+ qs['book'] = qs['book'].filter(tag_relations__tag=t)
+ qs['snippet'] = qs['snippet'].filter(book__tag_relations__tag=t)
+ qs['art'] = qs['art'].filter(tag_relations__tag=t)
+
+ return qs
+
+ def results(self):
+ qs = self.get_querysets()
+ query = self.cleaned_data['q']
+ squery = build_search_query(query, config='polish')
+ query = SearchQuery(query, config='polish')
+ books = qs['book'].filter(title__search=query)
+ books = books.exclude(ancestor__in=books)
+ return {
+ 'author': qs['author'].filter(slug__search=query),
+ 'theme': qs['theme'].filter(slug__search=query),
+ 'genre': qs['genre'].filter(slug__search=query),
+ 'collection': qs['collection'].filter(title__search=query),
+ 'book': books[:100],
+ 'snippet': qs['snippet'].annotate(
+ rank=SearchRank('search_vector', squery)
+ ).filter(rank__gt=0).order_by('-rank').annotate(
+ headline=SearchHeadline(
+ 'text',
+ query,
+ config='polish',
+ start_sel='<strong>',
+ stop_sel='</strong>',
+ highlight_all=True
+ )
+ )[:100],
+ 'art': qs['art'].filter(title__search=query)[:100],
+ }
+
def __init__(self):
super(Index, self).__init__(mode='rw')
+ def remove_snippets(self, book):
+ book.snippet_set.all().delete()
+
+ def add_snippet(self, book, doc):
+ assert book.id == doc.pop('book_id')
+ # Fragments already exist and can be indexed where they live.
+ if 'fragment_anchor' in doc:
+ return
+
+ text = doc.pop('text')
+ header_index = doc.pop('header_index')
+ book.snippet_set.create(
+ sec=header_index,
+ text=text,
+ )
+
def delete_query(self, *queries):
"""
index.delete(queries=...) doesn't work, so let's reimplement it
doc['parent_id'] = int(book.parent.id)
return doc
- def remove_book(self, book_or_id, remove_snippets=True):
+ def remove_book(self, book, remove_snippets=True):
"""Removes a book from search index.
book - Book instance."""
- if isinstance(book_or_id, catalogue.models.Book):
- book_id = book_or_id.id
- else:
- book_id = book_or_id
-
- self.delete_query(self.index.Q(book_id=book_id))
+ self.delete_query(self.index.Q(book_id=book.id))
if remove_snippets:
- snippets = Snippets(book_id)
+ snippets = Snippets(book.id)
snippets.remove()
+ self.remove_snippets(book)
def index_book(self, book, book_info=None, overwrite=True):
"""
Creates a lucene document for extracted metadata
and calls self.index_content() to index the contents of the book.
"""
+ if not book.xml_file: return
+
if overwrite:
# we don't remove snippets, since they might be still needed by
# threads using not reopened index
fields = {}
if book_info is None:
- book_info = dcparser.parse(open(book.xml_file.path))
+ book_info = dcparser.parse(open(book.xml_file.path, 'rb'))
fields['slug'] = book.slug
fields['is_book'] = True
elif end is not None and footnote is not [] and end.tag in self.footnote_tags:
handle_text.pop()
doc = add_part(snippets, header_index=position, header_type=header.tag,
- text=''.join(footnote),
- is_footnote=True)
+ text=''.join(footnote))
+ self.add_snippet(book, doc)
self.index.add(doc)
footnote = []
fragment_anchor=fid,
text=fix_format(frag['text']),
themes=frag['themes'])
+ # Add searchable fragment
+ self.add_snippet(book, doc)
self.index.add(doc)
# Collect content.
doc = add_part(snippets, header_index=position,
header_type=header.tag, text=fix_format(content))
+ self.add_snippet(book, doc)
self.index.add(doc)
finally:
--- /dev/null
+{% if widget.wrap_label %}<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% endif %}{% include "django/forms/widgets/input.html" %}{% if widget.wrap_label %} <span>{{ widget.label }}</span></label>{% endif %}
--- /dev/null
+{% extends "2022/base.html" %}
+
+
+{% block main %}
+ <main class="l-main">
+ <div class="l-section">
+ <div class="l-author__header">
+ <h1><span>Wynik wyszukiwania dla:</span> {{ query }}</h1>
+ </div>
+ </div>
+
+ <form class="c-form j-form-auto">
+ <div class="c-form__inline-radio">
+ format: {{ filters.format }}
+ </div>
+ <div class="c-form__controls-row">
+ <label class="c-form__control">
+ <span>język:</span>
+ {{ filters.lang }}
+ </label>
+ <label class="c-form__control">
+ <span>epoka:</span>
+ {{ filters.epoch }}
+ </label>
+ <label class="c-form__control">
+ <span>gatunek</span>
+ {{ filters.genre }}
+ </label>
+ </div>
+ <div class="c-form__inline-radio">
+ kategoria:
+ {{ filters.category }}
+ </div>
+ {{ filters.q }}
+ <button type="submit" class="c-form__hidden-submit">wyślij</button>
+ </form>
+
+ {% if results.author %}
+ <div class="l-container">
+ <h2 class="header">Autorzy</h2>
+ <ul class="c-search-result c-search-result-author">
+ {% for tag in results.author %}
+ <li>
+ <a href="{{ tag.get_absolute_url }}">
+ {% if tag.photo %}
+ <figure>
+ <img src="{{ tag.photo.url }}">
+ </figure>
+ {% endif %}
+ {{ tag.name }}
+ </a>
+ </li>
+ {% endfor %}
+ </ul>
+ </div>
+ {% endif %}
+
+ {% if results.theme %}
+ <div class="l-container">
+ <h2 class="header">Motywy</h2>
+ <ul class="c-search-result">
+ {% for tag in results.theme %}
+ <li>
+ <a href="{{ tag.get_absolute_url }}">
+ {% if tag.photo %}
+ <figure>
+ <img src="{{ tag.photo.url }}">
+ </figure>
+ {% endif %}
+ {{ tag.name }}
+ </a>
+ </li>
+ {% endfor %}
+ </ul>
+ </div>
+ {% endif %}
+
+ {% if results.book %}
+ <div class="l-container">
+ <h2 class="header">Książki</h2>
+ </div>
+ <div class="l-section l-section--col">
+ <div class="l-books__grid">
+ {% for book in results.book %}
+ {% include 'catalogue/2022/book_box.html' %}
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
+
+ {% if results.art %}
+ <div class="l-container">
+ <h2 class="header">Obrazy</h2>
+ </div>
+ <div class="l-section l-section--col">
+ <div class="l-books__grid">
+ {% for book in results.art %}
+ {% include 'catalogue/2022/book_box.html' %}
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
+
+ {% if results.fragment or results.snippet %}
+ <div class="l-container">
+ <h2 class="header">W treści</h2>
+ {% for f in results.snippet %}
+ <div class="c-search-result-fragment">
+ {% for author in f.book.authors %}
+ <a class="c-search-result-fragment-author" href="{{ author.get_absolute_url }}">{{ author }}</a>
+ {% endfor %}
+ <a class="c-search-result-fragment-title" href="{{ f.book.get_absolute_url }}">
+ {{ f.book.title }}
+ </a>
+ <a class="c-search-result-fragment-text" href='{% url 'book_text' f.book.slug %}#sec{{ f.sec }}'>
+ {{ f.headline|safe }}
+ </a>
+ </div>
+ {% endfor %}
+ </div>
+ {% endif %}
+
+ {% if results.collection %}
+ <div class="l-container">
+ <h2 class="header">Kolekcje</h2>
+ <div class="c-search-result-collection">
+ {% for collection in results.collection %}
+ {% include 'catalogue/2022/collection_box.html' %}
+ {% include 'catalogue/2022/collection_box.html' %}
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
+
+ {% if pd_authors %}
+ <div class="l-container">
+ <div class="c-search-result-pd">
+ <h2>Domena publiczna?</h2>
+ <p>
+ Dzieła tych autorów przejdą do zasobów domeny publicznej i będą mogły
+ być publikowane bez żadnych ograniczeń.
+ Dowiedz się, dlaczego biblioteki internetowe nie mogą udostępniać dzieł tego autora.
+ </p>
+ <div>
+ {% for tag in pd_authors %}
+ <div><a href="{{ tag.get_absolute_url }}">
+ <strong>{{ tag }}</strong>
+ Dzieła tego autora będą mogły być publikowane bez ograniczeń w roku <em>{{ tag.goes_to_pd }}</em>.
+ </a></div>
+ {% endfor %}
+ </div>
+ </div>
+ </div>
+ {% endif %}
+ </main>
+{% endblock %}
'django.contrib.admin',
'django.contrib.admindocs',
'django.contrib.staticfiles',
+ 'django.contrib.postgres',
'admin_ordering',
'rest_framework',
'fnp_django_pagination',
clearTimeout(timer);
});
})();
+
+
+
+// Update search form filters.
+(function() {
+ $('.j-form-auto').each(function() {
+ let $form = $(this);
+ $('input', $form).change(function() {$form.submit()});
+ $('select', $form).change(function() {$form.submit()});
+ $('textarea', $form).change(function() {$form.submit()});
+ });
+})();
--- /dev/null
+.c-collectionbox {
+ border: 1px solid #D9D9D9;
+ border-radius: 10px;
+ padding: 21px;
+ width: 3*172px + 2*21px + 2px;
+ font-size: 18px;
+ line-height: 24px;
+
+ a {
+ display: block;
+ }
+ .c-collectionbox-covers {
+ display: flex;
+ margin-bottom: 15px;
+ img {
+ width: 172px;
+ @media screen and (max-width: 3*172px + 2*21px + 2px + 2*16px) {
+ width: calc((100vw - 2*16px - 2px - 2*21px) / 3);
+ }
+ }
+ }
+}
--- /dev/null
+.c-form {
+ padding: 0 16px;
+
+ .c-form__hidden-submit {
+ font-size: 0;
+ border: 0;
+ opacity: 0;
+ }
+
+ div.c-form__inline-radio, div.c-form__inline-radio > div {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+ padding: 10px 0;
+ label {
+ display: flex;
+ span {
+ display: block;
+ padding: 11px 14px;
+ }
+ input {
+ width: 0px;
+ opacity: 0;
+
+ &:checked + span {
+ background: #083F4D;
+ border-radius: 4px;
+ color: white;
+ font-weight: bold;
+ }
+ }
+ }
+ }
+
+ .c-form__controls-row {
+ display: flex;
+ gap: 20px;
+ flex-wrap: wrap;
+ }
+
+ .c-form__control {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+
+ select {
+ padding: 8px 10px;
+ background: white;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ }
+ }
+}
--- /dev/null
+h2.header {
+ margin: 0;
+ font-weight: 600;
+ font-size: 21.5px;
+ line-height: 140%;
+ border-bottom: 1px solid #D9D9D9;
+ padding-bottom: 15px;
+ padding-top: 5px;
+ letter-spacing: -0.01em;
+ color: #007880;
+ margin-top: 23px;
+}
@import "lang";
@import "avatar";
@import "read_more";
+@import "form";
+@import "search";
+@import "header";
+@import "collectionbox";
--- /dev/null
+.c-search-result-fragment {
+ display: block;
+ padding: 21px;
+ margin-top: 20px;
+ border: 1px solid #D9D9D9;
+ border-radius: 10px;
+
+ .c-search-result-fragment-title {
+ display: block;
+ font-size: 21.5px;
+ line-height: 1.4em;
+ color: #474747;
+ }
+
+ .c-search-result-fragment-author {
+ display: block;
+ font-size: 15px;
+ line-height: 1.2em;
+ color: #808080;
+ }
+
+ .c-search-result-fragment-text {
+ margin-top: 16px;
+ padding: 6px 12px;
+ display: block;
+ color: #474747;
+ background: #F2F2F2;
+ border-radius: 4px;
+ font-size: 18px;
+ line-height: 1.5em;
+
+ strong {
+ font-weight: normal;
+ background: #FFEA00;
+ }
+ }
+}
+
+
+.c-search-result {
+ margin: 20px 0;
+ padding: 0;
+ list-style: none;
+ font-size: 18px;
+ line-height: 27px;
+
+ &.c-search-result-author {
+ li {
+ padding-left: 52px;
+ figure {
+ font-size: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ margin-left: -52px;
+ margin-right: 12px;
+ overflow: hidden;
+ border-radius: 50%;
+ img {
+ width: 100%;
+ }
+ }
+ }
+ }
+
+ li {
+ margin-bottom: 5px;
+ a {
+ display: flex;
+ align-items: center;
+ }
+ }
+}
+
+
+.c-search-result-collection {
+ display: flex;
+ margin-top: 20px;
+ gap: 20px;
+ flex-wrap: wrap;
+}
+
+
+
+.c-search-result-pd {
+ margin-top: 64px;
+ padding: 34px;
+ font-size: 18px;
+ line-height: 24px;
+ background: #E1F1F2;
+ border-radius: 10px;
+
+ h2 {
+ color: #007880;
+ font-size: 21px;
+ line-height: 30px;
+ font-weight: bold;
+ margin: 0;
+ }
+
+ p {
+ font-size: 18px;
+ line-height: 27px;
+ }
+
+ > div {
+ display: flex;
+ gap: 20px;
+ margin-top: 26px;
+ > div {
+ background: white;
+ padding: 21px;
+ border-radius: 10px;
+ width: 343px;
+ a {
+ color: #474747;
+ line-height: 28px;
+ }
+
+ strong {
+ display: block;
+ margin-bottom: 10px;
+ color: #083F4D;
+ font-size: 25px;
+ line-height: 30px;
+ }
+
+ em {
+ font-style: normal;
+ font-weight: bold;
+ }
+ }
+ }
+}