Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
import base64
|
|
|
|
|
|
import json
|
2026-04-01 15:41:30 +02:00
|
|
|
|
import re
|
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.
- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
/books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
Stelle, Liste und Snapshot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00
|
|
|
|
import time
|
2026-08-17 11:27:16 +02:00
|
|
|
|
import xml.etree.ElementTree as ET
|
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.
- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
/books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
Stelle, Liste und Snapshot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00
|
|
|
|
from datetime import datetime, timezone as dt_timezone
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
|
2026-08-17 11:27:16 +02:00
|
|
|
|
import requests
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
from django.conf import settings
|
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.
- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
/books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
Stelle, Liste und Snapshot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00
|
|
|
|
from django.db import transaction
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
from django.http import JsonResponse
|
|
|
|
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
|
|
|
|
from django.views.decorators.http import require_http_methods
|
|
|
|
|
|
|
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
|
|
|
|
from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks, LibraryPrefs
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _require_auth(request):
|
|
|
|
|
|
if not request.user.is_authenticated:
|
|
|
|
|
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 11:27:16 +02:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Library-shelf metadata lookup (DNB, falling back to Open Library)
|
|
|
|
|
|
#
|
|
|
|
|
|
# Only ever called on explicit user request (the "Metadaten" book action), never
|
|
|
|
|
|
# automatically — the server briefly sees the plaintext ISBN for this one proxied
|
|
|
|
|
|
# request, which is a deliberate, narrow exception to the "server never sees book
|
|
|
|
|
|
# content" rule (see CLAUDE.md), made because the encryption's real purpose here is
|
|
|
|
|
|
# to keep the operator from being able to see what's on the platform (piracy
|
|
|
|
|
|
# liability), not strict user privacy — an ISBN lookup against public library
|
|
|
|
|
|
# catalogs doesn't undermine that. Nothing from this lookup is persisted server-side;
|
|
|
|
|
|
# the resulting label is stored only in the client's encrypted meta blob.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
# DNB "Sachgruppen der Deutschen Nationalbibliografie" / DDC divisions (hundreds -> tens),
|
|
|
|
|
|
# used to turn a raw DDC notation like "833.92" into a short shelf label.
|
|
|
|
|
|
_DDC_DIVISIONS = {
|
|
|
|
|
|
'000': 'Allgemeines, Informatik', '010': 'Bibliografien', '020': 'Bibliotheks- und Informationswissenschaft',
|
|
|
|
|
|
'030': 'Enzyklopädien', '050': 'Zeitschriften, fortlaufende Sammelwerke', '060': 'Organisationen, Museumswissenschaft',
|
|
|
|
|
|
'070': 'Nachrichtenmedien, Journalismus, Verlagswesen', '090': 'Handschriften, seltene Bücher',
|
|
|
|
|
|
'100': 'Philosophie', '130': 'Parapsychologie, Okkultismus', '150': 'Psychologie',
|
|
|
|
|
|
'200': 'Religion', '230': 'Christentum, Christliche Theologie', '290': 'Andere Religionen',
|
|
|
|
|
|
'300': 'Sozialwissenschaften, Soziologie', '310': 'Statistiken', '320': 'Politikwissenschaft',
|
|
|
|
|
|
'330': 'Wirtschaft', '340': 'Recht', '350': 'Öffentliche Verwaltung, Militärwissenschaft',
|
|
|
|
|
|
'360': 'Soziale Probleme, Sozialdienste, Versicherungen', '370': 'Erziehung, Schul- und Bildungswesen',
|
|
|
|
|
|
'380': 'Handel, Kommunikation, Verkehr', '390': 'Gebräuche, Etikette, Folklore',
|
|
|
|
|
|
'400': 'Sprache, Linguistik', '420': 'Englisch', '430': 'Deutsch, Germanische Sprachen',
|
|
|
|
|
|
'440': 'Französisch, Romanische Sprachen', '450': 'Italienisch, Rumänisch, Rätoromanisch',
|
|
|
|
|
|
'460': 'Spanisch, Portugiesisch', '470': 'Latein, Italische Sprachen', '480': 'Griechisch', '490': 'Andere Sprachen',
|
|
|
|
|
|
'500': 'Naturwissenschaften', '510': 'Mathematik', '520': 'Astronomie', '530': 'Physik', '540': 'Chemie',
|
|
|
|
|
|
'550': 'Geowissenschaften', '560': 'Paläontologie', '570': 'Biowissenschaften, Biologie',
|
|
|
|
|
|
'580': 'Pflanzen (Botanik)', '590': 'Tiere (Zoologie)',
|
|
|
|
|
|
'600': 'Technik', '610': 'Medizin, Gesundheit', '620': 'Ingenieurwissenschaften', '630': 'Landwirtschaft',
|
|
|
|
|
|
'640': 'Hauswirtschaft', '650': 'Management', '660': 'Chemische Technik', '670': 'Industrielle Fertigung',
|
|
|
|
|
|
'680': 'Fertigung für spezielle Zwecke', '690': 'Hausbau, Bauhandwerk',
|
|
|
|
|
|
'700': 'Künste', '710': 'Landschaftsgestaltung, Raumplanung', '720': 'Architektur',
|
|
|
|
|
|
'730': 'Plastik, Keramik, Metallkunst', '740': 'Zeichnung, angewandte Kunst', '750': 'Malerei',
|
|
|
|
|
|
'760': 'Grafik, Druckgrafik, Fotografie', '780': 'Musik', '790': 'Freizeit, Darstellende Kunst, Sport',
|
|
|
|
|
|
'800': 'Literatur', '810': 'Amerikanische Literatur', '820': 'Englische Literatur',
|
|
|
|
|
|
'830': 'Deutsche Literatur', '840': 'Französische Literatur', '850': 'Italienische Literatur',
|
|
|
|
|
|
'860': 'Spanische, Portugiesische Literatur', '870': 'Lateinische Literatur', '880': 'Griechische Literatur',
|
|
|
|
|
|
'890': 'Literaturen in anderen Sprachen',
|
|
|
|
|
|
'900': 'Geschichte', '910': 'Geografie, Reisen', '920': 'Biografie, Genealogie',
|
|
|
|
|
|
'930': 'Geschichte des Altertums', '940': 'Geschichte Europas', '950': 'Geschichte Asiens',
|
|
|
|
|
|
'960': 'Geschichte Afrikas', '970': 'Geschichte Nordamerikas', '980': 'Geschichte Südamerikas',
|
|
|
|
|
|
'990': 'Geschichte der übrigen Welt',
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ddc_label(ddc_raw):
|
|
|
|
|
|
"""Coarsen a DDC notation ('833.92') down to its nearest known division/class label."""
|
|
|
|
|
|
digits = re.sub(r'\D', '', ddc_raw or '')
|
|
|
|
|
|
if len(digits) < 3:
|
|
|
|
|
|
return None
|
|
|
|
|
|
tens = digits[:2] + '0'
|
|
|
|
|
|
if tens in _DDC_DIVISIONS:
|
|
|
|
|
|
return _DDC_DIVISIONS[tens]
|
|
|
|
|
|
return _DDC_DIVISIONS.get(digits[0] + '00')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_MARC_NS = '{http://www.loc.gov/MARC21/slim}'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lookup_dnb_shelf(isbn):
|
|
|
|
|
|
url = 'https://services.dnb.de/sru/dnb'
|
|
|
|
|
|
params = {
|
|
|
|
|
|
'version': '1.1',
|
|
|
|
|
|
'operation': 'searchRetrieve',
|
|
|
|
|
|
'query': f'dnb.num={isbn}',
|
|
|
|
|
|
'recordSchema': 'MARC21-xml',
|
|
|
|
|
|
'maximumRecords': '1',
|
|
|
|
|
|
}
|
|
|
|
|
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
root = ET.fromstring(resp.content)
|
|
|
|
|
|
for datafield in root.iter(f'{_MARC_NS}datafield'):
|
|
|
|
|
|
if datafield.get('tag') != '082':
|
|
|
|
|
|
continue
|
|
|
|
|
|
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
|
|
|
|
|
|
if subfield.get('code') == 'a' and subfield.text:
|
|
|
|
|
|
label = _ddc_label(subfield.text)
|
|
|
|
|
|
if label:
|
|
|
|
|
|
return label
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lookup_openlibrary_shelf(isbn):
|
|
|
|
|
|
url = 'https://openlibrary.org/api/books'
|
|
|
|
|
|
params = {'bibkeys': f'ISBN:{isbn}', 'jscmd': 'data', 'format': 'json'}
|
|
|
|
|
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
data = resp.json().get(f'ISBN:{isbn}', {})
|
|
|
|
|
|
ddc = (data.get('classifications') or {}).get('dewey_decimal_class') or []
|
|
|
|
|
|
if ddc:
|
|
|
|
|
|
label = _ddc_label(ddc[0])
|
|
|
|
|
|
if label:
|
|
|
|
|
|
return label
|
|
|
|
|
|
subjects = data.get('subjects') or []
|
|
|
|
|
|
if subjects:
|
|
|
|
|
|
return subjects[0].get('name')
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 12:33:54 +02:00
|
|
|
|
def _cql_phrase(value):
|
|
|
|
|
|
# CQL string literals are quoted; strip embedded quotes rather than escaping them
|
|
|
|
|
|
# (this only feeds a lookup heuristic, not a stored/displayed value).
|
|
|
|
|
|
return value.replace('"', ' ').strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lookup_dnb_shelf_by_title(title, author):
|
|
|
|
|
|
url = 'https://services.dnb.de/sru/dnb'
|
|
|
|
|
|
query = f'dnb.tit="{_cql_phrase(title)}"'
|
|
|
|
|
|
if author:
|
|
|
|
|
|
query += f' and dnb.per="{_cql_phrase(author)}"'
|
|
|
|
|
|
params = {
|
|
|
|
|
|
'version': '1.1',
|
|
|
|
|
|
'operation': 'searchRetrieve',
|
|
|
|
|
|
'query': query,
|
|
|
|
|
|
'recordSchema': 'MARC21-xml',
|
|
|
|
|
|
'maximumRecords': '1',
|
|
|
|
|
|
}
|
|
|
|
|
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
root = ET.fromstring(resp.content)
|
|
|
|
|
|
for datafield in root.iter(f'{_MARC_NS}datafield'):
|
|
|
|
|
|
if datafield.get('tag') != '082':
|
|
|
|
|
|
continue
|
|
|
|
|
|
for subfield in datafield.findall(f'{_MARC_NS}subfield'):
|
|
|
|
|
|
if subfield.get('code') == 'a' and subfield.text:
|
|
|
|
|
|
label = _ddc_label(subfield.text)
|
|
|
|
|
|
if label:
|
|
|
|
|
|
return label
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _lookup_openlibrary_shelf_by_title(title, author):
|
|
|
|
|
|
url = 'https://openlibrary.org/search.json'
|
|
|
|
|
|
params = {'title': title, 'limit': 1, 'fields': 'ddc,subject'}
|
|
|
|
|
|
if author:
|
|
|
|
|
|
params['author'] = author
|
|
|
|
|
|
resp = requests.get(url, params=params, timeout=getattr(settings, 'BOOK_METADATA_TIMEOUT', 6))
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
docs = resp.json().get('docs') or []
|
|
|
|
|
|
if not docs:
|
|
|
|
|
|
return None
|
|
|
|
|
|
doc = docs[0]
|
|
|
|
|
|
ddc = doc.get('ddc') or []
|
|
|
|
|
|
if ddc:
|
|
|
|
|
|
label = _ddc_label(ddc[0])
|
|
|
|
|
|
if label:
|
|
|
|
|
|
return label
|
|
|
|
|
|
subjects = doc.get('subject') or []
|
|
|
|
|
|
if subjects:
|
|
|
|
|
|
return subjects[0]
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 11:27:16 +02:00
|
|
|
|
@require_http_methods(['GET'])
|
|
|
|
|
|
def lookup_book_metadata(request):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
2026-08-17 12:33:54 +02:00
|
|
|
|
isbn_raw = request.GET.get('isbn', '').strip()
|
|
|
|
|
|
title = request.GET.get('title', '').strip()
|
|
|
|
|
|
author = request.GET.get('author', '').strip()
|
|
|
|
|
|
isbn = re.sub(r'[^0-9Xx]', '', isbn_raw)
|
|
|
|
|
|
|
|
|
|
|
|
if isbn_raw and len(isbn) not in (10, 13):
|
2026-08-17 11:27:16 +02:00
|
|
|
|
return JsonResponse({'error': 'invalid ISBN'}, status=400)
|
2026-08-17 12:33:54 +02:00
|
|
|
|
if not isbn and not title:
|
|
|
|
|
|
return JsonResponse({'error': 'isbn or title required'}, status=400)
|
2026-08-17 11:27:16 +02:00
|
|
|
|
|
|
|
|
|
|
label = None
|
|
|
|
|
|
source = None
|
2026-08-17 12:33:54 +02:00
|
|
|
|
|
|
|
|
|
|
# ISBN is the precise path — try it first when we have one.
|
|
|
|
|
|
if isbn:
|
2026-08-17 11:27:16 +02:00
|
|
|
|
try:
|
2026-08-17 12:33:54 +02:00
|
|
|
|
label = _lookup_dnb_shelf(isbn)
|
|
|
|
|
|
if label:
|
|
|
|
|
|
source = 'dnb'
|
2026-08-17 11:27:16 +02:00
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-08-17 12:33:54 +02:00
|
|
|
|
if not label:
|
|
|
|
|
|
try:
|
|
|
|
|
|
label = _lookup_openlibrary_shelf(isbn)
|
|
|
|
|
|
if label:
|
|
|
|
|
|
source = 'openlibrary'
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# No ISBN (or it drew a blank) — fall back to a title/author text search. Less
|
|
|
|
|
|
# precise (wrong edition/translation is possible), so the source is tagged
|
|
|
|
|
|
# distinctly for the client to hint at that if it wants to.
|
|
|
|
|
|
if not label and title:
|
|
|
|
|
|
try:
|
|
|
|
|
|
label = _lookup_dnb_shelf_by_title(title, author)
|
|
|
|
|
|
if label:
|
|
|
|
|
|
source = 'dnb-title'
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if not label:
|
|
|
|
|
|
try:
|
|
|
|
|
|
label = _lookup_openlibrary_shelf_by_title(title, author)
|
|
|
|
|
|
if label:
|
|
|
|
|
|
source = 'openlibrary-title'
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-08-17 11:27:16 +02:00
|
|
|
|
|
|
|
|
|
|
return JsonResponse({'label': label, 'source': source})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 20:52:27 +02:00
|
|
|
|
def _anchor_parts(anchor):
|
|
|
|
|
|
"""Split a position anchor 'blockIndex:innerFraction' into (block, inner).
|
|
|
|
|
|
|
|
|
|
|
|
Returns (-1, 0.0) for empty/invalid anchors (e.g. PDF progress)."""
|
|
|
|
|
|
if not isinstance(anchor, str) or ':' not in anchor:
|
|
|
|
|
|
return (-1, 0.0)
|
|
|
|
|
|
block, _, inner = anchor.partition(':')
|
|
|
|
|
|
try:
|
|
|
|
|
|
return (int(block), float(inner))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return (-1, 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _progress_is_further(new_anchor, new_frac, old_anchor, old_frac):
|
|
|
|
|
|
"""True if the new reading position is at least as far into the book as the
|
|
|
|
|
|
old one. Compares by anchor block index (precise, decoupled from layout);
|
|
|
|
|
|
falls back to scroll_fraction only when an anchor is missing (PDFs)."""
|
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.
- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
/books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
Stelle, Liste und Snapshot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00
|
|
|
|
return _cmp_progress(new_anchor, new_frac, old_anchor, old_frac) >= 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _cmp_progress(a_anchor, a_frac, b_anchor, b_frac):
|
|
|
|
|
|
"""> 0 if A is further into the book than B, < 0 if behind, 0 if level."""
|
|
|
|
|
|
ab, ai = _anchor_parts(a_anchor)
|
|
|
|
|
|
bb, bi = _anchor_parts(b_anchor)
|
|
|
|
|
|
if ab >= 0 and bb >= 0:
|
|
|
|
|
|
if ab != bb:
|
|
|
|
|
|
return ab - bb
|
|
|
|
|
|
return (ai > bi) - (ai < bi)
|
|
|
|
|
|
return (a_frac > b_frac) - (a_frac < b_frac)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _now_ms():
|
|
|
|
|
|
return int(time.time() * 1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def progress_state(p):
|
|
|
|
|
|
"""A book's reading state as every client sees it (docs/progress-sync.md);
|
|
|
|
|
|
p may be None (never opened)."""
|
|
|
|
|
|
if p is None:
|
|
|
|
|
|
return {'scroll_fraction': 0.0, 'position_anchor': '', 'read_at': None, 'device': '',
|
|
|
|
|
|
'furthest_fraction': 0.0, 'furthest_anchor': '', 'last_read': None}
|
|
|
|
|
|
read_at = p.read_at if p.read_at is not None else int(p.updated_at.timestamp() * 1000)
|
|
|
|
|
|
return {
|
|
|
|
|
|
'scroll_fraction': p.scroll_fraction,
|
|
|
|
|
|
'position_anchor': p.position_anchor,
|
|
|
|
|
|
'read_at': read_at,
|
|
|
|
|
|
'device': p.device,
|
|
|
|
|
|
'furthest_fraction': p.furthest_fraction,
|
|
|
|
|
|
'furthest_anchor': p.furthest_anchor,
|
|
|
|
|
|
'last_read': datetime.fromtimestamp(read_at / 1000, tz=dt_timezone.utc).isoformat(),
|
|
|
|
|
|
}
|
2026-07-10 20:52:27 +02:00
|
|
|
|
|
|
|
|
|
|
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
@require_http_methods(['GET'])
|
|
|
|
|
|
def book_list(request):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
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
|
|
|
|
with_cover = set(request.user.ebooks.exclude(cover_ct='').values_list('id', flat=True))
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
books = list(
|
2026-08-04 18:10:33 +02:00
|
|
|
|
request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read')
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
)
|
|
|
|
|
|
for b in books:
|
|
|
|
|
|
b['uploaded_at'] = b['uploaded_at'].isoformat()
|
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
|
|
|
|
b['has_cover'] = b['id'] in with_cover
|
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.
- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
/books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
Stelle, Liste und Snapshot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00
|
|
|
|
progress_map = {p.book_id: p for p in EBookProgress.objects.filter(user=request.user)}
|
2026-08-04 18:10:33 +02:00
|
|
|
|
highlighted_ids = set(
|
|
|
|
|
|
EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True)
|
|
|
|
|
|
)
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
for b in books:
|
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.
- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
/books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
Stelle, Liste und Snapshot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00
|
|
|
|
b.update(progress_state(progress_map.get(b['id'])))
|
2026-08-04 18:10:33 +02:00
|
|
|
|
b['has_highlights'] = b['id'] in highlighted_ids
|
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.
- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
/books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
Stelle, Liste und Snapshot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00
|
|
|
|
response = JsonResponse(books, safe=False)
|
|
|
|
|
|
# The list is a bare array (old clients expect that): the server clock goes in a header.
|
|
|
|
|
|
response['X-Server-Now'] = str(_now_ms())
|
|
|
|
|
|
return response
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-04 18:10:33 +02:00
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['POST'])
|
|
|
|
|
|
def set_book_read(request, pk):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
book = EBook.objects.get(pk=pk, user=request.user)
|
|
|
|
|
|
except EBook.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
book.is_read = bool(body.get('is_read', True))
|
|
|
|
|
|
book.save(update_fields=['is_read'])
|
|
|
|
|
|
return JsonResponse({'ok': True, 'is_read': book.is_read})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-17 10:58:53 +02:00
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['POST'])
|
|
|
|
|
|
def update_book_meta(request, pk):
|
|
|
|
|
|
"""Update only the encrypted metadata blob (e.g. to assign a folder) without touching book bytes."""
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
book = EBook.objects.get(pk=pk, user=request.user)
|
|
|
|
|
|
except EBook.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
meta_ct = body.get('meta_ct', '')
|
|
|
|
|
|
meta_iv = body.get('meta_iv', '')
|
|
|
|
|
|
if not meta_ct or not meta_iv:
|
|
|
|
|
|
return JsonResponse({'error': 'meta_ct, meta_iv required'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
book.meta_ct = meta_ct
|
|
|
|
|
|
book.meta_iv = meta_iv
|
|
|
|
|
|
book.save(update_fields=['meta_ct', 'meta_iv'])
|
|
|
|
|
|
return JsonResponse({'ok': True})
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
MAX_COVER = 200_000 # characters of base64: a 300×450 JPEG is far below
|
|
|
|
|
|
MAX_PREFS = 100_000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _hex_iv(value):
|
|
|
|
|
|
return isinstance(value, str) and 8 <= len(value) <= 32 and all(c in '0123456789abcdefABCDEF' for c in value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['GET', 'POST'])
|
|
|
|
|
|
def book_cover(request, pk):
|
|
|
|
|
|
"""A book's cover, encrypted on the device with the user's key (docs/library.md):
|
|
|
|
|
|
GET {cover_ct, cover_iv} (404 without one), POST the same to set it."""
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
book = EBook.objects.filter(pk=pk, user=request.user).only('id', 'cover_ct', 'cover_iv').first()
|
|
|
|
|
|
if book is None:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
if request.method == 'GET':
|
|
|
|
|
|
if not book.cover_ct:
|
|
|
|
|
|
return JsonResponse({'error': 'no cover'}, status=404)
|
|
|
|
|
|
return JsonResponse({'cover_ct': book.cover_ct, 'cover_iv': book.cover_iv})
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
ct, iv = body.get('cover_ct'), body.get('cover_iv')
|
|
|
|
|
|
if not isinstance(ct, str) or not ct or len(ct) > MAX_COVER or not _hex_iv(iv):
|
|
|
|
|
|
return JsonResponse({'error': 'cover_ct, cover_iv required (cover at most 150 KB)'}, status=400)
|
|
|
|
|
|
book.cover_ct, book.cover_iv = ct, iv
|
|
|
|
|
|
book.save(update_fields=['cover_ct', 'cover_iv'])
|
|
|
|
|
|
return JsonResponse({'ok': True})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['GET', 'POST'])
|
|
|
|
|
|
def library_prefs(request):
|
|
|
|
|
|
"""The library's settings, encrypted with the user's key ({folder_order: [...]}):
|
|
|
|
|
|
GET {prefs_ct, prefs_iv} ('' while there are none), POST the same, replacing them."""
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
if request.method == 'GET':
|
|
|
|
|
|
p = LibraryPrefs.objects.filter(user=request.user).first()
|
|
|
|
|
|
return JsonResponse({'prefs_ct': p.prefs_ct if p else '', 'prefs_iv': p.prefs_iv if p else ''})
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
ct, iv = body.get('prefs_ct'), body.get('prefs_iv')
|
|
|
|
|
|
if not isinstance(ct, str) or not ct or len(ct) > MAX_PREFS or not _hex_iv(iv):
|
|
|
|
|
|
return JsonResponse({'error': 'prefs_ct, prefs_iv required'}, status=400)
|
|
|
|
|
|
LibraryPrefs.objects.update_or_create(user=request.user, defaults={'prefs_ct': ct, 'prefs_iv': iv})
|
|
|
|
|
|
return JsonResponse({'ok': True})
|
|
|
|
|
|
|
|
|
|
|
|
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['POST'])
|
|
|
|
|
|
def upload_book(request):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
meta_ct = body.get('meta_ct', '')
|
|
|
|
|
|
meta_iv = body.get('meta_iv', '')
|
|
|
|
|
|
data_ct = body.get('data_ct', '')
|
|
|
|
|
|
data_iv = body.get('data_iv', '')
|
|
|
|
|
|
|
|
|
|
|
|
if not all([meta_ct, meta_iv, data_ct, data_iv]):
|
|
|
|
|
|
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
# Enforce size limit: ciphertext is plaintext + 16-byte GCM tag
|
|
|
|
|
|
max_bytes = getattr(settings, 'EBOOK_MAX_BYTES', 10 * 1024 * 1024) + 32
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw_size = len(base64.b64decode(data_ct))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return JsonResponse({'error': 'invalid base64 in data_ct'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
if raw_size > max_bytes:
|
2026-10-16 21:52:31 +02:00
|
|
|
|
return JsonResponse({'error': f'file too large (max {settings.EBOOK_MAX_BYTES // (1024 * 1024)} MB)'}, status=400)
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
|
|
|
|
|
|
book = EBook.objects.create(
|
|
|
|
|
|
user=request.user,
|
|
|
|
|
|
meta_ct=meta_ct,
|
|
|
|
|
|
meta_iv=meta_iv,
|
|
|
|
|
|
data_ct=data_ct,
|
|
|
|
|
|
data_iv=data_iv,
|
|
|
|
|
|
)
|
2026-04-04 21:01:32 +02:00
|
|
|
|
EBookProgress.objects.create(user=request.user, book=book)
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
return JsonResponse({'ok': True, 'id': book.id})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@require_http_methods(['GET'])
|
|
|
|
|
|
def get_book_data(request, pk):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
book = EBook.objects.get(pk=pk, user=request.user)
|
|
|
|
|
|
except EBook.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
|
|
|
|
|
|
return JsonResponse({'data_ct': book.data_ct, 'data_iv': book.data_iv})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 17:03:59 +02:00
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['POST'])
|
|
|
|
|
|
def replace_book_data(request, pk):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
book = EBook.objects.get(pk=pk, user=request.user)
|
|
|
|
|
|
except EBook.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
|
2026-05-25 17:06:52 +02:00
|
|
|
|
data_ct = body.get('data_ct', '')
|
|
|
|
|
|
data_iv = body.get('data_iv', '')
|
|
|
|
|
|
meta_ct = body.get('meta_ct', '')
|
|
|
|
|
|
meta_iv = body.get('meta_iv', '')
|
2026-05-25 17:03:59 +02:00
|
|
|
|
|
|
|
|
|
|
if not all([data_ct, data_iv]):
|
|
|
|
|
|
return JsonResponse({'error': 'data_ct and data_iv required'}, status=400)
|
|
|
|
|
|
|
2026-05-25 17:06:52 +02:00
|
|
|
|
update_fields = ['data_ct', 'data_iv']
|
2026-05-25 17:03:59 +02:00
|
|
|
|
book.data_ct = data_ct
|
|
|
|
|
|
book.data_iv = data_iv
|
2026-05-25 17:06:52 +02:00
|
|
|
|
if meta_ct and meta_iv:
|
|
|
|
|
|
book.meta_ct = meta_ct
|
|
|
|
|
|
book.meta_iv = meta_iv
|
|
|
|
|
|
update_fields += ['meta_ct', 'meta_iv']
|
|
|
|
|
|
book.save(update_fields=update_fields)
|
2026-05-25 17:03:59 +02:00
|
|
|
|
return JsonResponse({'ok': True})
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-25 16:37:41 +02:00
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['POST'])
|
|
|
|
|
|
def rekey_book(request, pk):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
book = EBook.objects.get(pk=pk, user=request.user)
|
|
|
|
|
|
except EBook.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
meta_ct = body.get('meta_ct', '')
|
|
|
|
|
|
meta_iv = body.get('meta_iv', '')
|
|
|
|
|
|
data_ct = body.get('data_ct', '')
|
|
|
|
|
|
data_iv = body.get('data_iv', '')
|
|
|
|
|
|
|
|
|
|
|
|
if not all([meta_ct, meta_iv, data_ct, data_iv]):
|
|
|
|
|
|
return JsonResponse({'error': 'meta_ct, meta_iv, data_ct, data_iv required'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
book.meta_ct = meta_ct
|
|
|
|
|
|
book.meta_iv = meta_iv
|
|
|
|
|
|
book.data_ct = data_ct
|
|
|
|
|
|
book.data_iv = data_iv
|
|
|
|
|
|
book.save(update_fields=['meta_ct', 'meta_iv', 'data_ct', 'data_iv'])
|
|
|
|
|
|
return JsonResponse({'ok': True})
|
|
|
|
|
|
|
|
|
|
|
|
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['POST'])
|
|
|
|
|
|
def delete_book(request, pk):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
book = EBook.objects.get(pk=pk, user=request.user)
|
|
|
|
|
|
except EBook.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
|
|
|
|
|
|
book.delete()
|
|
|
|
|
|
return JsonResponse({'ok': True})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['POST'])
|
|
|
|
|
|
def save_progress(request, pk):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
book = EBook.objects.get(pk=pk, user=request.user)
|
|
|
|
|
|
except EBook.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
scroll_fraction = float(body.get('scroll_fraction', 0.0))
|
|
|
|
|
|
scroll_fraction = max(0.0, min(1.0, scroll_fraction))
|
|
|
|
|
|
|
2026-04-01 15:41:30 +02:00
|
|
|
|
raw_anchor = body.get('position_anchor', '')
|
|
|
|
|
|
position_anchor = ''
|
|
|
|
|
|
if isinstance(raw_anchor, str) and re.fullmatch(r'\d{1,7}:\d(\.\d{1,6})?', raw_anchor):
|
|
|
|
|
|
position_anchor = raw_anchor
|
|
|
|
|
|
|
Lesestand: die zuletzt gelesene Stelle gewinnt, nicht mehr die weiteste; sw v100
docs/progress-sync.md ist der Vertrag für Server, Web, Android und Desktop.
- Server: read_at (Zeit, zu der dort gelesen wurde, Uhrfehler des Geräts per
sent_at herausgerechnet, nie nach "jetzt"), Gerät, weiteste Stelle. Ältere
Schreibvorgänge (offline nachgereicht, liegengelassener Tab) gewinnen nicht.
Apps ohne read_at: nur strikt vorwärts. Antwort = Zustand nach dem Schreiben.
/books/ mit X-Server-Now, /api/sync/ mit server_now.
- Web-Reader: Zeit einer Stelle ist der Moment, in dem man durch eigenes Tun
hinkam (Scrollen, Blättern, Tasten, Vorlesen); unverändert Liegendes
schickt nichts bzw. nur seine alte Zeit. Vor dem Wiederherstellen und
während eines Blicks (Fußnote, "Weit gescrollt") wird nicht gespeichert.
Eigene Stellen in einem Ausgang (localStorage) bis der Server sie hat.
Angebote "Auf <Gerät> weitergelesen" und "Weiteste Stelle".
- Tests: books/tests.py (Reihenfolge, Uhrfehler, Zukunft, alte Apps, weiteste
Stelle, Liste und Snapshot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:31:36 +02:00
|
|
|
|
force = body.get('force') is True
|
|
|
|
|
|
device = body.get('device') if isinstance(body.get('device'), str) else ''
|
|
|
|
|
|
now = _now_ms()
|
|
|
|
|
|
read_at = _client_ms(body.get('read_at'))
|
|
|
|
|
|
sent_at = _client_ms(body.get('sent_at'))
|
|
|
|
|
|
|
|
|
|
|
|
with transaction.atomic():
|
|
|
|
|
|
progress, created = EBookProgress.objects.select_for_update().get_or_create(user=request.user, book=book)
|
|
|
|
|
|
if created:
|
|
|
|
|
|
progress.read_at = None
|
|
|
|
|
|
if force:
|
|
|
|
|
|
when, store = now, True
|
|
|
|
|
|
elif read_at is not None:
|
|
|
|
|
|
# The device's clock may be off: move its time onto ours by the
|
|
|
|
|
|
# difference at sending, and nothing counts as later than now.
|
|
|
|
|
|
when = read_at + (now - sent_at) if sent_at is not None else read_at
|
|
|
|
|
|
when = min(when, now)
|
|
|
|
|
|
store = progress.read_at is None or when >= progress.read_at
|
|
|
|
|
|
else:
|
|
|
|
|
|
# An app from before 2026-10-12: only forward, as it used to be — but
|
|
|
|
|
|
# strictly, so re-saving the same place changes nothing.
|
|
|
|
|
|
when = now
|
|
|
|
|
|
store = created or _cmp_progress(position_anchor, scroll_fraction,
|
|
|
|
|
|
progress.position_anchor, progress.scroll_fraction) > 0
|
|
|
|
|
|
if store:
|
|
|
|
|
|
progress.scroll_fraction = scroll_fraction
|
|
|
|
|
|
progress.position_anchor = position_anchor
|
|
|
|
|
|
progress.read_at = when
|
|
|
|
|
|
progress.device = device[:40]
|
|
|
|
|
|
if _cmp_progress(position_anchor, scroll_fraction,
|
|
|
|
|
|
progress.furthest_anchor, progress.furthest_fraction) > 0 or created:
|
|
|
|
|
|
progress.furthest_fraction = scroll_fraction
|
|
|
|
|
|
progress.furthest_anchor = position_anchor
|
|
|
|
|
|
progress.save()
|
|
|
|
|
|
|
|
|
|
|
|
return JsonResponse({'ok': True, 'stored': store, 'server_now': now, **progress_state(progress)})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _client_ms(value):
|
|
|
|
|
|
"""A time from a client (ms since epoch) or None if it isn't a plausible one."""
|
|
|
|
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
|
|
|
|
return None
|
|
|
|
|
|
value = int(value)
|
|
|
|
|
|
return value if 10 ** 12 <= value < 10 ** 14 else None
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['GET', 'POST'])
|
|
|
|
|
|
def book_highlights(request, pk):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
book = EBook.objects.get(pk=pk, user=request.user)
|
|
|
|
|
|
except EBook.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
|
|
|
|
|
|
if request.method == 'GET':
|
|
|
|
|
|
try:
|
|
|
|
|
|
row = EBookHighlights.objects.get(user=request.user, book=book)
|
|
|
|
|
|
return JsonResponse({'ct': row.ct, 'iv': row.iv})
|
|
|
|
|
|
except EBookHighlights.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'ct': None, 'iv': None})
|
|
|
|
|
|
|
|
|
|
|
|
# POST — upsert
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
ct = body.get('ct', '')
|
|
|
|
|
|
iv = body.get('iv', '')
|
|
|
|
|
|
if not ct or not iv:
|
|
|
|
|
|
return JsonResponse({'error': 'ct and iv required'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
# Size guard: highlights ≤ 700 KB base64
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw_size = len(base64.b64decode(ct))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
|
2026-04-04 21:10:14 +02:00
|
|
|
|
if raw_size > getattr(settings, 'HIGHLIGHTS_MAX_BYTES', 700 * 1024):
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
return JsonResponse({'error': 'highlights data too large (max 700 KB)'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
row, _ = EBookHighlights.objects.get_or_create(user=request.user, book=book)
|
|
|
|
|
|
row.ct = ct
|
|
|
|
|
|
row.iv = iv
|
|
|
|
|
|
row.save(update_fields=['ct', 'iv', 'updated_at'])
|
|
|
|
|
|
return JsonResponse({'ok': True})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@csrf_exempt
|
|
|
|
|
|
@require_http_methods(['GET', 'POST'])
|
|
|
|
|
|
def book_bookmarks(request, pk):
|
|
|
|
|
|
err = _require_auth(request)
|
|
|
|
|
|
if err:
|
|
|
|
|
|
return err
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
book = EBook.objects.get(pk=pk, user=request.user)
|
|
|
|
|
|
except EBook.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'error': 'not found'}, status=404)
|
|
|
|
|
|
|
|
|
|
|
|
if request.method == 'GET':
|
|
|
|
|
|
try:
|
|
|
|
|
|
row = EBookBookmarks.objects.get(user=request.user, book=book)
|
|
|
|
|
|
return JsonResponse({'ct': row.ct, 'iv': row.iv})
|
|
|
|
|
|
except EBookBookmarks.DoesNotExist:
|
|
|
|
|
|
return JsonResponse({'ct': None, 'iv': None})
|
|
|
|
|
|
|
|
|
|
|
|
# POST — upsert
|
|
|
|
|
|
try:
|
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
|
except (json.JSONDecodeError, ValueError):
|
|
|
|
|
|
return JsonResponse({'error': 'invalid JSON'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
ct = body.get('ct', '')
|
|
|
|
|
|
iv = body.get('iv', '')
|
|
|
|
|
|
if not ct or not iv:
|
|
|
|
|
|
return JsonResponse({'error': 'ct and iv required'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
# Size guard: bookmarks ≤ 100 KB base64
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw_size = len(base64.b64decode(ct))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return JsonResponse({'error': 'invalid base64 in ct'}, status=400)
|
2026-04-04 21:10:14 +02:00
|
|
|
|
if raw_size > getattr(settings, 'BOOKMARKS_MAX_BYTES', 100 * 1024):
|
Add ebook reader features: highlights, bookmarks, search, settings, PDF paginated mode
- Backend: EBookHighlights and EBookBookmarks models with encrypted blob storage;
GET/POST views with size guards (700 KB / 100 KB); migration applied
- Reader header: search, settings, bookmark add/list buttons
- Font & layout settings panel (font size, line height, max width, themes for EPUB;
zoom, invert, paginated for PDF); persisted in localStorage
- Bookmarks: encrypted per-book blob, toast on add, sidebar with jump/delete
- Full-text search: EPUB TreeWalker mark injection, PDF span search; Ctrl+F / F3;
arrow key cycling; highlights re-applied on search clear
- PDF paginated mode: single-page view, tap-zone / swipe / arrow key navigation,
smart zoom (text bounding box → scale+translate canvas), auto-enable on mobile
- Progress tracking fixes: save before hiding overlay (was always writing 100%),
wait for EPUB images to load before restoring scroll, PDF paginated uses page
fraction, sendBeacon cache for unload/visibilitychange reliability
- PDF text layer disabled pending overlay rendering fix
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:08:42 +01:00
|
|
|
|
return JsonResponse({'error': 'bookmarks data too large (max 100 KB)'}, status=400)
|
|
|
|
|
|
|
|
|
|
|
|
row, _ = EBookBookmarks.objects.get_or_create(user=request.user, book=book)
|
|
|
|
|
|
row.ct = ct
|
|
|
|
|
|
row.iv = iv
|
|
|
|
|
|
row.save(update_fields=['ct', 'iv', 'updated_at'])
|
|
|
|
|
|
return JsonResponse({'ok': True})
|