diff --git a/books/migrations/0004_ebook_is_read.py b/books/migrations/0004_ebook_is_read.py new file mode 100644 index 0000000..e1d87b5 --- /dev/null +++ b/books/migrations/0004_ebook_is_read.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-08-04 16:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0003_ebookprogress_position_anchor'), + ] + + operations = [ + migrations.AddField( + model_name='ebook', + name='is_read', + field=models.BooleanField(default=False), + ), + ] diff --git a/books/models.py b/books/models.py index 33b48a1..9bc06c7 100644 --- a/books/models.py +++ b/books/models.py @@ -9,6 +9,7 @@ class EBook(models.Model): data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes data_iv = models.CharField(max_length=32) # hex IV for EPUB data uploaded_at = models.DateTimeField(auto_now_add=True) + is_read = models.BooleanField(default=False) class Meta: ordering = ['uploaded_at'] diff --git a/books/urls.py b/books/urls.py index b2a9de2..ddcb20a 100644 --- a/books/urls.py +++ b/books/urls.py @@ -6,6 +6,7 @@ urlpatterns = [ path('upload/', views.upload_book, name='upload_book'), path('/data/', views.get_book_data, name='get_book_data'), path('/delete/', views.delete_book, name='delete_book'), + path('/read/', views.set_book_read, name='set_book_read'), path('/replace-data/', views.replace_book_data, name='replace_book_data'), path('/rekey/', views.rekey_book, name='rekey_book'), path('/progress/', views.save_progress, name='save_book_progress'), diff --git a/books/views.py b/books/views.py index 3a7ba80..c9e21f2 100644 --- a/books/views.py +++ b/books/views.py @@ -46,7 +46,7 @@ def book_list(request): if err: return err books = list( - request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at') + request.user.ebooks.values('id', 'meta_ct', 'meta_iv', 'uploaded_at', 'is_read') ) for b in books: b['uploaded_at'] = b['uploaded_at'].isoformat() @@ -55,14 +55,40 @@ def book_list(request): p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor) for p in EBookProgress.objects.filter(user=request.user) } + highlighted_ids = set( + EBookHighlights.objects.filter(user=request.user).values_list('book_id', flat=True) + ) for b in books: prog = progress_map.get(b['id']) b['scroll_fraction'] = prog[0] if prog else 0.0 b['last_read'] = prog[1].isoformat() if prog else None b['position_anchor'] = prog[2] if prog else '' + b['has_highlights'] = b['id'] in highlighted_ids return JsonResponse(books, safe=False) +@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}) + + @csrf_exempt @require_http_methods(['POST']) def upload_book(request): diff --git a/static/css/app.css b/static/css/app.css index 5dcb5ac..da3ed2e 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1579,6 +1579,19 @@ body.dnd-mode .timer-display { gap: 6px; flex-shrink: 0; } +.book-read-badge { + font-size: 12px; + font-weight: normal; +} +.book-list-filter { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--muted, #888); + margin-top: 10px; + cursor: pointer; +} /* --- PDF pages --- */ .pdf-page-wrapper { diff --git a/static/js/app.js b/static/js/app.js index b74fd8d..97c1309 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3206,10 +3206,10 @@ async function loadBookList() { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'}; - decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true}); + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'}; - decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false}); + decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } } // If local cache is further ahead than the server, push it and use it for display. @@ -3342,22 +3342,61 @@ function repairBook(bookId) { input.click(); } +let _lastBookListData = []; +const _bookShowReadKey = 'diora_book_show_read'; +let _bookShowRead = localStorage.getItem(_bookShowReadKey) === '1'; + +function _onBookShowReadToggle(checked) { + _bookShowRead = checked; + localStorage.setItem(_bookShowReadKey, checked ? '1' : '0'); + renderBookList(_lastBookListData); +} + +async function toggleBookRead(bookId, currentlyRead) { + const verb = currentlyRead ? 'als ungelesen markieren' : 'als gelesen markieren'; + if (!await customConfirm(`Dieses Buch ${verb}?`)) return; + try { + const res = await fetch(`/books/${bookId}/read/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({is_read: !currentlyRead}), + }); + const data = await res.json(); + if (!data.ok) throw new Error('Server error'); + const book = _lastBookListData.find(b => b.id === bookId); + if (book) book.is_read = data.is_read; + renderBookList(_lastBookListData); + } catch (e) { + await customAlert('Konnte Lesestatus nicht ändern: ' + e.message); + } +} + function renderBookList(books) { const listEl = $('book-list'); if (!listEl) return; + _lastBookListData = books; + const visible = _bookShowRead ? books : books.filter(b => !b.is_read); + if (!visible.length) { + listEl.innerHTML = books.length + ? '

Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.

' + : ''; + return; + } let html = ''; - for (const b of books) { + for (const b of visible) { const pct = Math.round((b.scroll_fraction || 0) * 100); const keyWarning = b.keyOk === false ? '⚠ wrong key' : ''; const broken = _brokenBooks.has(b.id); html += `
- ${escapeHtml(b.title)}${keyWarning} + ${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' ✓ gelesen' : ''} ${escapeHtml(b.author)} ${pct > 0 ? `${pct}% read` : ''}
${broken ? `` : ''} + ${b.has_highlights ? `` : ''} +
@@ -3761,6 +3800,12 @@ async function openBook(bookId) { // Apply reader settings (theme, font size, etc.) applyReaderSettings(isPdfBook); + // If this book already has notes/highlights, surface them right away + // instead of making the reader hide them behind a manual toggle. + if (!isPdfBook && currentHighlights.length && !readerAnnotationsMarginOpen) { + toggleAnnotationsMargin(); + } + // Touch: swipe (paginated) + pinch-to-zoom contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true}); contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false}); @@ -5578,6 +5623,63 @@ function editNoteInlineInMargin(h) { textarea.select(); } +// Same plain-text export as exportAnnotations(), but callable straight from the +// book list (no open reader, so no live DOM/TOC to resolve percent position or +// chapter titles against — falls back to the raw chapterSrc path and orders by +// the anchor's block index, which is already a global reading-order index). +async function downloadBookAnnotationsFromList(bookId, btnEl) { + if (btnEl) btnEl.disabled = true; + try { + const res = await fetch(`/books/${bookId}/highlights/`); + const {ct, iv} = await res.json(); + if (!ct) { await customAlert('No highlights or notes in this book yet.'); return; } + const key = await getOrCreateEncKey(); + const plain = await decryptBytes(key, iv, ct); + const highlights = JSON.parse(new TextDecoder().decode(plain)); + if (!highlights.length) { await customAlert('No highlights or notes in this book yet.'); return; } + + highlights.sort((a, b) => { + const ab = a.anchor?.startBlockIndex ?? 0, bb = b.anchor?.startBlockIndex ?? 0; + if (ab !== bb) return ab - bb; + return (a.anchor?.startChar ?? 0) - (b.anchor?.startChar ?? 0); + }); + + const bookTitle = bookMetaCache[bookId]?.title || `Book #${bookId}`; + const lines = [`${bookTitle} — Annotations`, `Exported: ${new Date().toISOString().slice(0, 10)}`, '']; + + let lastChapter = null; + for (const h of highlights) { + const chapterTitle = h.anchor?.chapterSrc || ''; + if (chapterTitle !== lastChapter) { + lines.push(`── ${chapterTitle || 'Untitled section'} ──`); + lastChapter = chapterTitle; + } + if (h.type === 'note') { + lines.push(`[Note] near: "${(h.anchor?.quote || '').trim()}"`); + if (h.note) lines.push(` ${h.note}`); + } else { + lines.push(`[Highlight · ${h.color || 'yellow'}] "${(h.anchor?.quote || '').trim()}"`); + if (h.note) lines.push(` Note: ${h.note}`); + } + lines.push(''); + } + + const blob = new Blob([lines.join('\n')], {type: 'text/plain;charset=utf-8'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${bookTitle.replace(/[^\w\-]+/g, '_')}-annotations.txt`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (e) { + await customAlert('Export failed: ' + e.message); + } finally { + if (btnEl) btnEl.disabled = false; + } +} + // --------------------------------------------------------------------------- // Export annotations — walks all highlights/notes of the current book in // reading order and offers them as a downloadable plain-text file. @@ -5887,6 +5989,8 @@ function openRadioSidebar() { // Init book drop zone initBookDropZone(); + const showReadToggle = $('book-show-read-toggle'); + if (showReadToggle) showReadToggle.checked = _bookShowRead; // Restore active tab: a URL hash (shared/bookmarked link, or browser // back/forward) wins over the last-used tab remembered in localStorage — diff --git a/static/js/sw.js b/static/js/sw.js index 08b05a4..4770fef 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v28'; +const CACHE = 'diora-v29'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css', diff --git a/templates/radio/player.html b/templates/radio/player.html index a675e93..2f36e86 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -320,6 +320,10 @@
+
{% else %}