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
|
|
|
|
|
|
|
|
|
|
from .models import EBook, EBookProgress, EBookHighlights, EBookBookmarks
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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()
|
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})
|
|
|
|
|
|
|
|
|
|
|
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-04-04 21:01:32 +02:00
|
|
|
return JsonResponse({'error': 'file too large (max 50 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})
|