diora-web/books/models.py

125 lines
5.8 KiB
Python
Raw Permalink Normal View History

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)
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht) - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
# 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
Bibliothek neu: Ordner als Filter-Chips überall, Cover-Raster, Cover verschlüsselt und generiert (noch nicht veröffentlicht) - Ordner wie Tags (docs/library.md): folders.js zeichnet die Chip-Reihe, das Blatt „In Ordner …“ (Häkchen, Neuer Ordner) und „Ordner verwalten“ (umbenennen = zusammenlegen, löschen, Reihenfolge). E-Books: `folders` im verschlüsselten Meta, Reihenfolge in verschlüsselten Bibliotheks-Einstellungen (/books/prefs/). Podcasts/Hörbücher: PodcastFeed.folders (Migration 0021, übernimmt `folder`), /podcasts/folders/ (+rename, +delete). Blogs: Reihenfolge (/feeds/folders/order/). - Bibliothek (53-library.js): Kopf mit Suche, Sortierung, Raster/Liste und +; Chips Angefangen · Ungelesen · Gelesen · Ordner; eine „Weiterlesen“-Karte; Mehrfachauswahl (lange drücken). E-Ink und diora Reader beginnen als Liste. - Cover Ende-zu-Ende verschlüsselt (covers.js, /books/<id>/cover/): aus dem E-Book (EPUB-Coverbild, PDF Seite 1), 300 px, mit dem Nutzerschlüssel verschlüsselt, einmal hochgeladen; sparsam im Leerlauf, nie beim Lesen, nie auf E-Ink. - Bücher ohne Cover bekommen eins (gencover.js): Stichwörter aus Titel, Autor, Regal und Ordnern wählen ein Gestaltungskonzept (Konstruktivismus, Geometrie, Psyche, Wellen, Karte, Kosmos, Ornament, Botanik, Berge, Zellen, Raster, Schatten, Typo, sonst Bauhaus); ein Hash aus Titel und Autor Palette, Aufbau und Details – einzigartig, überall gleich, nichts gespeichert. - Hörbücher: Ordner-Chips statt Kacheln, „In Ordner …“ mit mehreren Ordnern. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:23 +02:00
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}"