fix
[wolnelektury.git] / src / catalogue / models / book.py
index 9116a46..2371103 100644 (file)
@@ -8,15 +8,17 @@ from random import randint
 import os.path
 import re
 from urllib.request import urlretrieve
 import os.path
 import re
 from urllib.request import urlretrieve
+from django.apps import apps
 from django.conf import settings
 from django.db import connection, models, transaction
 import django.dispatch
 from django.contrib.contenttypes.fields import GenericRelation
 from django.conf import settings
 from django.db import connection, models, transaction
 import django.dispatch
 from django.contrib.contenttypes.fields import GenericRelation
+from django.template.loader import render_to_string
 from django.urls import reverse
 from django.utils.translation import ugettext_lazy as _, get_language
 from django.utils.deconstruct import deconstructible
 from fnpdjango.storage import BofhFileSystemStorage
 from django.urls import reverse
 from django.utils.translation import ugettext_lazy as _, get_language
 from django.utils.deconstruct import deconstructible
 from fnpdjango.storage import BofhFileSystemStorage
-
+from lxml import html
 from librarian.cover import WLCover
 from librarian.html import transform_abstrakt
 from newtagging import managers
 from librarian.cover import WLCover
 from librarian.html import transform_abstrakt
 from newtagging import managers
@@ -42,9 +44,11 @@ class UploadToPath(object):
 
 
 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
 
 
 _cover_upload_to = UploadToPath('book/cover/%s.jpg')
+_cover_clean_upload_to = UploadToPath('book/cover_clean/%s.jpg')
 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
 _cover_thumb_upload_to = UploadToPath('book/cover_thumb/%s.jpg')
 _cover_api_thumb_upload_to = UploadToPath('book/cover_api_thumb/%s.jpg')
 _simple_cover_upload_to = UploadToPath('book/cover_simple/%s.jpg')
+_cover_ebookpoint_upload_to = UploadToPath('book/cover_ebookpoint/%s.jpg')
 
 
 def _ebook_upload_to(upload_path):
 
 
 def _ebook_upload_to(upload_path):
@@ -62,6 +66,7 @@ class Book(models.Model):
     language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
     description = models.TextField(_('description'), blank=True)
     abstract = models.TextField(_('abstract'), blank=True)
     language = models.CharField(_('language code'), max_length=3, db_index=True, default=app_settings.DEFAULT_LANGUAGE)
     description = models.TextField(_('description'), blank=True)
     abstract = models.TextField(_('abstract'), blank=True)
+    toc = models.TextField(_('toc'), blank=True)
     created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
     changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
     parent_number = models.IntegerField(_('parent number'), default=0)
     created_at = models.DateTimeField(_('creation date'), auto_now_add=True, db_index=True)
     changed_at = models.DateTimeField(_('change date'), auto_now=True, db_index=True)
     parent_number = models.IntegerField(_('parent number'), default=0)
@@ -74,6 +79,7 @@ class Book(models.Model):
     preview = models.BooleanField(_('preview'), default=False)
     preview_until = models.DateField(_('preview until'), blank=True, null=True)
     preview_key = models.CharField(max_length=32, blank=True, null=True)
     preview = models.BooleanField(_('preview'), default=False)
     preview_until = models.DateField(_('preview until'), blank=True, null=True)
     preview_key = models.CharField(max_length=32, blank=True, null=True)
+    findable = models.BooleanField(_('findable'), default=True, db_index=True)
 
     # files generated during publication
     cover = EbookField(
 
     # files generated during publication
     cover = EbookField(
@@ -81,22 +87,39 @@ class Book(models.Model):
         null=True, blank=True,
         upload_to=_cover_upload_to,
         storage=bofh_storage, max_length=255)
         null=True, blank=True,
         upload_to=_cover_upload_to,
         storage=bofh_storage, max_length=255)
+    cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
     # Cleaner version of cover for thumbs
     # Cleaner version of cover for thumbs
+    cover_clean = EbookField(
+        'cover_clean', _('clean cover'),
+        null=True, blank=True,
+        upload_to=_cover_clean_upload_to,
+        max_length=255
+    )
+    cover_clean_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
     cover_thumb = EbookField(
         'cover_thumb', _('cover thumbnail'),
         null=True, blank=True,
         upload_to=_cover_thumb_upload_to,
         max_length=255)
     cover_thumb = EbookField(
         'cover_thumb', _('cover thumbnail'),
         null=True, blank=True,
         upload_to=_cover_thumb_upload_to,
         max_length=255)
+    cover_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
     cover_api_thumb = EbookField(
         'cover_api_thumb', _('cover thumbnail for mobile app'),
         null=True, blank=True,
         upload_to=_cover_api_thumb_upload_to,
         max_length=255)
     cover_api_thumb = EbookField(
         'cover_api_thumb', _('cover thumbnail for mobile app'),
         null=True, blank=True,
         upload_to=_cover_api_thumb_upload_to,
         max_length=255)
+    cover_api_thumb_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
     simple_cover = EbookField(
         'simple_cover', _('cover for mobile app'),
         null=True, blank=True,
         upload_to=_simple_cover_upload_to,
         max_length=255)
     simple_cover = EbookField(
         'simple_cover', _('cover for mobile app'),
         null=True, blank=True,
         upload_to=_simple_cover_upload_to,
         max_length=255)
+    simple_cover_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
+    cover_ebookpoint = EbookField(
+        'cover_ebookpoint', _('cover for Ebookpoint'),
+        null=True, blank=True,
+        upload_to=_cover_ebookpoint_upload_to,
+        max_length=255)
+    cover_ebookpoint_etag = models.CharField(max_length=255, editable=False, default='', db_index=True)
     ebook_formats = constants.EBOOK_FORMATS
     formats = ebook_formats + ['html', 'xml']
 
     ebook_formats = constants.EBOOK_FORMATS
     formats = ebook_formats + ['html', 'xml']
 
@@ -171,6 +194,12 @@ class Book(models.Model):
     def genre_unicode(self):
         return self.tag_unicode('genre')
 
     def genre_unicode(self):
         return self.tag_unicode('genre')
 
+    def translators(self):
+        translators = self.get_extra_info_json().get('translators') or []
+        return [
+            '\xa0'.join(reversed(translator.split(', ', 1))) for translator in translators
+        ]
+
     def translator(self):
         translators = self.get_extra_info_json().get('translators')
         if not translators:
     def translator(self):
         translators = self.get_extra_info_json().get('translators')
         if not translators:
@@ -185,6 +214,31 @@ class Book(models.Model):
     def cover_source(self):
         return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
 
     def cover_source(self):
         return self.get_extra_info_json().get('cover_source', self.parent.cover_source() if self.parent else '')
 
+    @property
+    def isbn_pdf(self):
+        return self.get_extra_info_json().get('isbn_pdf')
+
+    @property
+    def isbn_epub(self):
+        return self.get_extra_info_json().get('isbn_epub')
+
+    @property
+    def isbn_mobi(self):
+        return self.get_extra_info_json().get('isbn_mobi')
+
+    def is_accessible_to(self, user):
+        if not self.preview:
+            return True
+        if not user.is_authenticated:
+            return False
+        Membership = apps.get_model('club', 'Membership')
+        if Membership.is_active_for(user):
+            return True
+        Funding = apps.get_model('funding', 'Funding')
+        if Funding.objects.filter(user=user, offer__book=self):
+            return True
+        return False
+
     def save(self, force_insert=False, force_update=False, **kwargs):
         from sortify import sortify
 
     def save(self, force_insert=False, force_update=False, **kwargs):
         from sortify import sortify
 
@@ -216,6 +270,63 @@ class Book(models.Model):
     def gallery_url(self):
         return gallery_url(self.slug)
 
     def gallery_url(self):
         return gallery_url(self.slug)
 
+    def get_first_text(self):
+        if self.html_file:
+            return self
+        child = self.children.all().order_by('parent_number').first()
+        if child is not None:
+            return child.get_first_text()
+
+    def get_last_text(self):
+        if self.html_file:
+            return self
+        child = self.children.all().order_by('parent_number').last()
+        if child is not None:
+            return child.get_last_text()
+
+    def get_prev_text(self):
+        if not self.parent:
+            return None
+        sibling = self.parent.children.filter(parent_number__lt=self.parent_number).order_by('-parent_number').first()
+        if sibling is not None:
+            return sibling.get_last_text()
+
+        if self.parent.html_file:
+            return self.parent
+        
+        return self.parent.get_prev_text()
+
+    def get_next_text(self):
+        child = self.children.order_by('parent_number').first()
+        if child is not None:
+            return child.get_first_text()
+
+        if not self.parent:
+            return None
+        sibling = self.parent.children.filter(parent_number__gt=self.parent_number).order_by('parent_number').first()
+        if sibling is not None:
+            return sibling.get_first_text()
+        return self.parent.get_next_text()
+
+    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.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 []
+        return self.parent.children.all().order_by('parent_number')
+
+    def get_children(self):
+        return self.children.all().order_by('parent_number')
+    
     @property
     def name(self):
         return self.title
     @property
     def name(self):
         return self.title
@@ -237,6 +348,12 @@ class Book(models.Model):
 
     @staticmethod
     def format_audio_length(seconds):
 
     @staticmethod
     def format_audio_length(seconds):
+        """
+        >>> Book.format_audio_length(1)
+        '0:01'
+        >>> Book.format_audio_length(3661)
+        '1:01:01'
+        """
         if seconds < 60*60:
             minutes = seconds // 60
             seconds = seconds % 60
         if seconds < 60*60:
             minutes = seconds // 60
             seconds = seconds % 60
@@ -334,6 +451,10 @@ class Book(models.Model):
     has_daisy_file.short_description = 'DAISY'
     has_daisy_file.boolean = True
 
     has_daisy_file.short_description = 'DAISY'
     has_daisy_file.boolean = True
 
+    @property
+    def media_daisy(self):
+        return self.get_media('daisy')
+    
     def get_audiobooks(self):
         ogg_files = {}
         for m in self.media.filter(type='ogg').order_by().iterator():
     def get_audiobooks(self):
         ogg_files = {}
         for m in self.media.filter(type='ogg').order_by().iterator():
@@ -341,6 +462,7 @@ class Book(models.Model):
 
         audiobooks = []
         projects = set()
 
         audiobooks = []
         projects = set()
+        total_duration = 0
         for mp3 in self.media.filter(type='mp3').iterator():
             # ogg files are always from the same project
             meta = mp3.get_extra_info_json()
         for mp3 in self.media.filter(type='mp3').iterator():
             # ogg files are always from the same project
             meta = mp3.get_extra_info_json()
@@ -350,6 +472,7 @@ class Book(models.Model):
                 project = 'CzytamySłuchając'
 
             projects.add((project, meta.get('funded_by', '')))
                 project = 'CzytamySłuchając'
 
             projects.add((project, meta.get('funded_by', '')))
+            total_duration += mp3.duration or 0
 
             media = {'mp3': mp3}
 
 
             media = {'mp3': mp3}
 
@@ -359,7 +482,11 @@ class Book(models.Model):
             audiobooks.append(media)
 
         projects = sorted(projects)
             audiobooks.append(media)
 
         projects = sorted(projects)
-        return audiobooks, projects
+        total_duration = '%d:%02d' % (
+            total_duration // 60,
+            total_duration % 60
+        )
+        return audiobooks, projects, total_duration
 
     def wldocument(self, parse_dublincore=True, inherit=True):
         from catalogue.import_utils import ORMDocProvider
 
     def wldocument(self, parse_dublincore=True, inherit=True):
         from catalogue.import_utils import ORMDocProvider
@@ -376,6 +503,17 @@ class Book(models.Model):
             parse_dublincore=parse_dublincore,
             meta_fallbacks=meta_fallbacks)
 
             parse_dublincore=parse_dublincore,
             meta_fallbacks=meta_fallbacks)
 
+    def wldocument2(self):
+        from catalogue.import_utils import ORMDocProvider
+        from librarian.document import WLDocument
+        doc = WLDocument(
+            self.xml_file.path,
+            provider=ORMDocProvider(self)
+        )
+        doc.meta.update(self.cover_info())
+        return doc
+
+
     @staticmethod
     def zip_format(format_):
         def pretty_file_name(book):
     @staticmethod
     def zip_format(format_):
         def pretty_file_name(book):
@@ -385,16 +523,28 @@ class Book(models.Model):
                 format_)
 
         field_name = "%s_file" % format_
                 format_)
 
         field_name = "%s_file" % format_
-        books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True)
+        books = Book.objects.filter(parent=None).exclude(**{field_name: ""}).exclude(preview=True).exclude(findable=False)
         paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
         return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
 
     def zip_audiobooks(self, format_):
         bm = BookMedia.objects.filter(book=self, type=format_)
         paths = [(pretty_file_name(b), getattr(b, field_name).path) for b in books.iterator()]
         return create_zip(paths, app_settings.FORMAT_ZIPS[format_])
 
     def zip_audiobooks(self, format_):
         bm = BookMedia.objects.filter(book=self, type=format_)
-        paths = map(lambda bm: (None, bm.file.path), bm)
-        return create_zip(paths, "%s_%s" % (self.slug, format_))
+        paths = map(lambda bm: (bm.get_nice_filename(), bm.file.path), bm)
+        licenses = set()
+        for m in bm:
+            license = constants.LICENSES.get(
+                m.get_extra_info_json().get('license'), {}
+            ).get('locative')
+            if license:
+                licenses.add(license)
+        readme = render_to_string('catalogue/audiobook_zip_readme.txt', {
+            'licenses': licenses,
+        })
+        return create_zip(paths, "%s_%s" % (self.slug, format_), {'informacje.txt': readme})
 
     def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
 
     def search_index(self, book_info=None, index=None, index_tags=True, commit=True):
+        if not self.findable:
+            return
         if index is None:
             from search.index import Index
             index = Index()
         if index is None:
             from search.index import Index
             index = Index()
@@ -431,6 +581,20 @@ class Book(models.Model):
         else:
             self.abstract = ''
 
         else:
             self.abstract = ''
 
+    def load_toc(self):
+        self.toc = ''
+        if self.html_file:
+            parser = html.HTMLParser(encoding='utf-8')
+            tree = html.parse(self.html_file.path, parser=parser)
+            toc = tree.find('//div[@id="toc"]/ol')
+            if toc is None or not len(toc):
+                return
+            html_link = reverse('book_text', args=[self.slug])
+            for a in toc.findall('.//a'):
+                a.attrib['href'] = html_link + a.attrib['href']
+            self.toc = html.tostring(toc, encoding='unicode')
+            # div#toc
+            
     @classmethod
     def from_xml_file(cls, xml_file, **kwargs):
         from django.core.files import File
     @classmethod
     def from_xml_file(cls, xml_file, **kwargs):
         from django.core.files import File
@@ -449,7 +613,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,
-                           search_index_tags=True, remote_gallery_url=None, days=0):
+                           search_index_tags=True, remote_gallery_url=None, days=0, findable=True):
         if dont_build is None:
             dont_build = set()
         dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
         if dont_build is None:
             dont_build = set()
         dont_build = set.union(set(dont_build), set(app_settings.DONT_BUILD))
@@ -487,6 +651,7 @@ class Book(models.Model):
         if book.preview:
             book.xml_file.set_readable(False)
 
         if book.preview:
             book.xml_file.set_readable(False)
 
+        book.findable = findable
         book.language = book_info.language
         book.title = book_info.title
         if book_info.variant_of:
         book.language = book_info.language
         book.title = book_info.title
         if book_info.variant_of:
@@ -495,6 +660,7 @@ class Book(models.Model):
             book.common_slug = book.slug
         book.extra_info = json.dumps(book_info.to_dict())
         book.load_abstract()
             book.common_slug = book.slug
         book.extra_info = json.dumps(book_info.to_dict())
         book.load_abstract()
+        book.load_toc()
         book.save()
 
         meta_tags = Tag.tags_from_info(book_info)
         book.save()
 
         meta_tags = Tag.tags_from_info(book_info)
@@ -537,9 +703,11 @@ class Book(models.Model):
         # Build cover.
         if 'cover' not in dont_build:
             book.cover.build_delay()
         # Build cover.
         if 'cover' not in dont_build:
             book.cover.build_delay()
+            book.cover_clean.build_delay()
             book.cover_thumb.build_delay()
             book.cover_api_thumb.build_delay()
             book.simple_cover.build_delay()
             book.cover_thumb.build_delay()
             book.cover_api_thumb.build_delay()
             book.simple_cover.build_delay()
+            book.cover_ebookpoint.build_delay()
 
         # Build HTML and ebooks.
         book.html_file.build_delay()
 
         # Build HTML and ebooks.
         book.html_file.build_delay()
@@ -551,16 +719,62 @@ class Book(models.Model):
             if format_ not in dont_build:
                 getattr(book, '%s_file' % format_).build_delay()
 
             if format_ not in dont_build:
                 getattr(book, '%s_file' % format_).build_delay()
 
-        if not settings.NO_SEARCH_INDEX and search_index:
+        if not settings.NO_SEARCH_INDEX and search_index and findable:
             tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
 
         for child in notify_cover_changed:
             child.parent_cover_changed()
 
         book.update_popularity()
             tasks.index_book.delay(book.id, book_info=book_info, index_tags=search_index_tags)
 
         for child in notify_cover_changed:
             child.parent_cover_changed()
 
         book.update_popularity()
+        tasks.update_references.delay(book.id)
+
         cls.published.send(sender=cls, instance=book)
         return book
 
         cls.published.send(sender=cls, instance=book)
         return book
 
+    def get_master(self):
+        master_tags = [
+            'opowiadanie',
+            'powiesc',
+            'dramat_wierszowany_l',
+            'dramat_wierszowany_lp',
+            'dramat_wspolczesny', 'liryka_l', 'liryka_lp',
+            'wywiad',
+        ]
+        from librarian.parser import WLDocument
+        wld = WLDocument.from_file(self.xml_file.path, parse_dublincore=False)
+        root = wld.edoc.getroot()
+        for master in root.iter():
+            if master.tag in master_tags:
+                return master
+    
+    def update_references(self):
+        from references.models import Entity, Reference
+        master = self.get_master()
+        if master is None:
+            master = []
+        found = set()
+        for i, sec in enumerate(master):
+            for ref in sec.findall('.//ref'):
+                href = ref.attrib.get('href', '')
+                if not href or href in found:
+                    continue
+                found.add(href)
+                entity, created = Entity.objects.get_or_create(
+                    uri=href
+                )
+                ref, created = Reference.objects.get_or_create(
+                    book=self,
+                    entity=entity
+                )
+                ref.first_section = 'sec%d' % (i + 1)
+                entity.populate()
+                entity.save()
+        Reference.objects.filter(book=self).exclude(entity__uri__in=found).delete()
+    
+    @property
+    def references(self):
+        return self.reference_set.all().select_related('entity')
+
     @classmethod
     @transaction.atomic
     def repopulate_ancestors(cls):
     @classmethod
     @transaction.atomic
     def repopulate_ancestors(cls):
@@ -594,6 +808,15 @@ class Book(models.Model):
                     b.ancestor.add(parent)
                     parent = parent.parent
 
                     b.ancestor.add(parent)
                     parent = parent.parent
 
+    @property
+    def ancestors(self):
+        if self.parent:
+            for anc in self.parent.ancestors:
+                yield anc
+            yield self.parent
+        else:
+            return []
+                    
     def clear_cache(self):
         clear_cached_renders(self.mini_box)
         clear_cached_renders(self.mini_box_nolink)
     def clear_cache(self):
         clear_cached_renders(self.mini_box)
         clear_cached_renders(self.mini_box_nolink)
@@ -620,16 +843,18 @@ class Book(models.Model):
     def related_themes(self):
         return Tag.objects.usage_for_queryset(
             Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
     def related_themes(self):
         return Tag.objects.usage_for_queryset(
             Fragment.objects.filter(models.Q(book=self) | models.Q(book__ancestor=self)),
-            counts=True).filter(category='theme')
+            counts=True).filter(category='theme').order_by('-count')
 
     def parent_cover_changed(self):
         """Called when parent book's cover image is changed."""
         if not self.cover_info(inherit=False):
             if 'cover' not in app_settings.DONT_BUILD:
                 self.cover.build_delay()
 
     def parent_cover_changed(self):
         """Called when parent book's cover image is changed."""
         if not self.cover_info(inherit=False):
             if 'cover' not in app_settings.DONT_BUILD:
                 self.cover.build_delay()
+                self.cover_clean.build_delay()
                 self.cover_thumb.build_delay()
                 self.cover_api_thumb.build_delay()
                 self.simple_cover.build_delay()
                 self.cover_thumb.build_delay()
                 self.cover_api_thumb.build_delay()
                 self.simple_cover.build_delay()
+                self.cover_ebookpoint.build_delay()
             for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
                 if format_ not in app_settings.DONT_BUILD:
                     getattr(self, '%s_file' % format_).build_delay()
             for format_ in constants.EBOOK_FORMATS_WITH_COVERS:
                 if format_ not in app_settings.DONT_BUILD:
                     getattr(self, '%s_file' % format_).build_delay()
@@ -638,7 +863,7 @@ class Book(models.Model):
 
     def other_versions(self):
         """Find other versions (i.e. in other languages) of the book."""
 
     def other_versions(self):
         """Find other versions (i.e. in other languages) of the book."""
-        return type(self).objects.filter(common_slug=self.common_slug).exclude(pk=self.pk)
+        return type(self).objects.filter(common_slug=self.common_slug, findable=True).exclude(pk=self.pk)
 
     def parents(self):
         books = []
 
     def parents(self):
         books = []
@@ -675,7 +900,7 @@ class Book(models.Model):
 
         """
         objects = cls.tagged.with_all(tags)
 
         """
         objects = cls.tagged.with_all(tags)
-        return objects.exclude(ancestor__in=objects)
+        return objects.filter(findable=True).exclude(ancestor__in=objects)
 
     @classmethod
     def book_list(cls, book_filter=None):
 
     @classmethod
     def book_list(cls, book_filter=None):
@@ -686,7 +911,7 @@ class Book(models.Model):
         """
 
         books_by_parent = {}
         """
 
         books_by_parent = {}
-        books = cls.objects.order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
+        books = cls.objects.filter(findable=True).order_by('parent_number', 'sort_key').only('title', 'parent', 'slug', 'extra_info')
         if book_filter:
             books = books.filter(book_filter).distinct()
 
         if book_filter:
             books = books.filter(book_filter).distinct()
 
@@ -739,19 +964,30 @@ class Book(models.Model):
         else:
             return None, None
 
         else:
             return None, None
 
-    def choose_fragment(self):
+    def choose_fragments(self, number):
         fragments = self.fragments.order_by()
         fragments_count = fragments.count()
         if not fragments_count and self.children.exists():
             fragments = Fragment.objects.filter(book__ancestor=self).order_by()
             fragments_count = fragments.count()
         if fragments_count:
         fragments = self.fragments.order_by()
         fragments_count = fragments.count()
         if not fragments_count and self.children.exists():
             fragments = Fragment.objects.filter(book__ancestor=self).order_by()
             fragments_count = fragments.count()
         if fragments_count:
-            return fragments[randint(0, fragments_count - 1)]
+            if fragments_count > number:
+                offset = randint(0, fragments_count - number)
+            else:
+                offset = 0
+            return fragments[offset : offset + number]
         elif self.parent:
         elif self.parent:
-            return self.parent.choose_fragment()
+            return self.parent.choose_fragments(number)
         else:
         else:
-            return None
+            return []
 
 
+    def choose_fragment(self):
+        fragments = self.choose_fragments(1)
+        if fragments:
+            return fragments[0]
+        else:
+            return None
+        
     def fragment_data(self):
         fragment = self.choose_fragment()
         if fragment:
     def fragment_data(self):
         fragment = self.choose_fragment()
         if fragment:
@@ -820,6 +1056,8 @@ def add_file_fields():
             blank=True,
             default=''
         ).contribute_to_class(Book, field_name)
             blank=True,
             default=''
         ).contribute_to_class(Book, field_name)
+        if format_ != 'xml':
+            models.CharField(max_length=255, editable=False, default='', db_index=True).contribute_to_class(Book, f'{field_name}_etag')
 
 
 add_file_fields()
 
 
 add_file_fields()