124 lines
5.8 KiB
Python
124 lines
5.8 KiB
Python
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}"
|