diora-web/books/models.py
mrwnslz c8e05bdb0a
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 9m15s
Test / e2e (push) Failing after 1m19s
Bücherliste liest nie mehr an den Dateien vorbei: Datum, gelesen, Cover aus abdeckenden Indizes (37 s → schnell; die Desktop-App gab nach 10 s auf)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 15:37:11 +02:00

103 lines
5 KiB
Python

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}"