from django.db import models from django.contrib.auth.models import User 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 data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes data_iv = models.CharField(max_length=32) # hex IV for EPUB data 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='') class Meta: ordering = ['uploaded_at'] # The list reads these from indexes, never from the row: the file (data_ct) stands # before them in it, and SQLite walks through all of a file to reach a column after it # — 37 s for a library of 400 books (docs/database.md). See book_rows(). 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})" 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}"