from django.db import models from django.contrib.auth.models import User class EBookManager(models.Manager): def create(self, data_ct=None, data_iv=None, **kwargs): """A book, and with data_ct/data_iv its file (EBookFile) in the same go.""" book = super().create(**kwargs) if data_ct is not None: EBookFile.objects.create(book=book, data_ct=data_ct, data_iv=data_iv or '') return book class EBook(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebooks') meta_ct = models.TextField() # base64 AES-GCM ciphertext of {title, author, filename} meta_iv = models.CharField(max_length=32) # hex IV for metadata # The file itself is in EBookFile (book.file), not here (docs/database.md). uploaded_at = models.DateTimeField(auto_now_add=True) is_read = models.BooleanField(default=False) # The cover, end-to-end encrypted like the rest (docs/library.md): a client that has # the file extracts it, encrypts it with the user's key and sends it once. cover_ct = models.TextField(blank=True, default='') cover_iv = models.CharField(max_length=32, blank=True, default='') objects = EBookManager() class Meta: ordering = ['uploaded_at'] # The list reads these from indexes (book_rows()); they made it fast while the file # was still in this row (docs/database.md), and cost nothing now. indexes = [ models.Index(fields=['user', 'uploaded_at', 'is_read'], name='ebook_list_idx'), models.Index(fields=['user', 'cover_iv'], name='ebook_cover_idx'), ] def __str__(self): return f"EBook #{self.pk} (user={self.user_id})" class EBookFile(models.Model): """A book's file, encrypted like everything else: base64 AES-GCM of the EPUB/PDF bytes. In a table of its own (docs/database.md): with the file in the book's row, every change of a title, a folder or "gelesen" rewrote megabytes, and the list read past every file.""" book = models.OneToOneField(EBook, on_delete=models.CASCADE, primary_key=True, related_name='file') data_ct = models.TextField() data_iv = models.CharField(max_length=32) def __str__(self): return f"EBookFile of #{self.book_id}" def book_rows(user): """Every book of the user as {id, meta_ct, meta_iv, uploaded_at (ISO), is_read, has_cover}, oldest first — read without passing a book's file: meta_ct and meta_iv stand before it in the row, the rest comes from the covering indexes ebook_list_idx and ebook_cover_idx.""" books = {b['id']: b for b in EBook.objects.filter(user=user).order_by().values('id', 'meta_ct', 'meta_iv')} for book_id, at, read in EBook.objects.filter(user=user).order_by().values_list('id', 'uploaded_at', 'is_read'): if book_id in books: books[book_id].update(uploaded_at=at, is_read=read) covered = set(EBook.objects.filter(user=user).exclude(cover_iv='').order_by().values_list('id', flat=True)) out = sorted((b for b in books.values() if 'uploaded_at' in b), key=lambda b: (b['uploaded_at'], b['id'])) for b in out: b['uploaded_at'] = b['uploaded_at'].isoformat() b['has_cover'] = b['id'] in covered return out class LibraryPrefs(models.Model): """The library's own settings, encrypted with the user's key (the folder order: folder names are private, docs/library.md). One blob, replaced as a whole.""" user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='library_prefs') prefs_ct = models.TextField(blank=True, default='') prefs_iv = models.CharField(max_length=32, blank=True, default='') updated_at = models.DateTimeField(auto_now=True) class EBookProgress(models.Model): """Where to continue a book: the place it was last read, on any device (docs/progress-sync.md), plus the furthest place ever reached.""" user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_progress') book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='progress') scroll_fraction = models.FloatField(default=0.0) position_anchor = models.CharField(max_length=30, blank=True, default='') # When the reader was at that place (ms since epoch, server clock); null before 2026-10-12. read_at = models.BigIntegerField(null=True, blank=True) device = models.CharField(max_length=40, blank=True, default='') furthest_fraction = models.FloatField(default=0.0) furthest_anchor = models.CharField(max_length=30, blank=True, default='') updated_at = models.DateTimeField(auto_now=True) class Meta: unique_together = ('user', 'book') def __str__(self): return f"Progress book={self.book_id} user={self.user_id} {self.scroll_fraction:.2f}" class EBookHighlights(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_highlights') book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='highlights') ct = models.TextField() # base64 AES-GCM ciphertext of JSON array iv = models.CharField(max_length=32) # hex IV updated_at = models.DateTimeField(auto_now=True) class Meta: unique_together = ('user', 'book') def __str__(self): return f"Highlights book={self.book_id} user={self.user_id}" class EBookBookmarks(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='ebook_bookmarks') book = models.ForeignKey(EBook, on_delete=models.CASCADE, related_name='bookmarks') ct = models.TextField() iv = models.CharField(max_length=32) updated_at = models.DateTimeField(auto_now=True) class Meta: unique_together = ('user', 'book') def __str__(self): return f"Bookmarks book={self.book_id} user={self.user_id}"