Books: Notizen-Sidebar auto-öffnen, Notizen-Download + Gelesen-Status in Übersicht (SW v29)
All checks were successful
Build and push Docker image / build (push) Successful in 17s
Test / test (push) Successful in 16s

- EBook.is_read (Migration) + neuer /books/<id>/read/-Endpoint zum Togglen
- book_list liefert is_read und has_highlights (Existenz einer Highlights-Zeile)
- Reader öffnet die Notizen-Sidebar automatisch, wenn das Buch bereits
  Markierungen/Notizen hat, statt sie hinter dem manuellen Toggle zu verstecken
- Buch-Übersicht: Download-Button für Notizen/Markierungen direkt aus der Liste
  (ohne den Reader zu öffnen), "Als gelesen markieren" mit Bestätigungsdialog,
  Filter "Gelesene Bücher anzeigen" (standardmäßig aus)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
marwin 2026-08-04 18:10:33 +02:00
parent f9573d3d62
commit b805d62b11
8 changed files with 173 additions and 6 deletions

View file

@ -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),
),
]

View file

@ -9,6 +9,7 @@ class EBook(models.Model):
data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes data_ct = models.TextField() # base64 AES-GCM ciphertext of raw EPUB bytes
data_iv = models.CharField(max_length=32) # hex IV for EPUB data data_iv = models.CharField(max_length=32) # hex IV for EPUB data
uploaded_at = models.DateTimeField(auto_now_add=True) uploaded_at = models.DateTimeField(auto_now_add=True)
is_read = models.BooleanField(default=False)
class Meta: class Meta:
ordering = ['uploaded_at'] ordering = ['uploaded_at']

View file

@ -6,6 +6,7 @@ urlpatterns = [
path('upload/', views.upload_book, name='upload_book'), path('upload/', views.upload_book, name='upload_book'),
path('<int:pk>/data/', views.get_book_data, name='get_book_data'), path('<int:pk>/data/', views.get_book_data, name='get_book_data'),
path('<int:pk>/delete/', views.delete_book, name='delete_book'), path('<int:pk>/delete/', views.delete_book, name='delete_book'),
path('<int:pk>/read/', views.set_book_read, name='set_book_read'),
path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'), path('<int:pk>/replace-data/', views.replace_book_data, name='replace_book_data'),
path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'), path('<int:pk>/rekey/', views.rekey_book, name='rekey_book'),
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'), path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),

View file

@ -46,7 +46,7 @@ def book_list(request):
if err: if err:
return err return err
books = list( 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: for b in books:
b['uploaded_at'] = b['uploaded_at'].isoformat() 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) p.book_id: (p.scroll_fraction, p.updated_at, p.position_anchor)
for p in EBookProgress.objects.filter(user=request.user) 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: for b in books:
prog = progress_map.get(b['id']) prog = progress_map.get(b['id'])
b['scroll_fraction'] = prog[0] if prog else 0.0 b['scroll_fraction'] = prog[0] if prog else 0.0
b['last_read'] = prog[1].isoformat() if prog else None b['last_read'] = prog[1].isoformat() if prog else None
b['position_anchor'] = prog[2] if prog else '' b['position_anchor'] = prog[2] if prog else ''
b['has_highlights'] = b['id'] in highlighted_ids
return JsonResponse(books, safe=False) 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 @csrf_exempt
@require_http_methods(['POST']) @require_http_methods(['POST'])
def upload_book(request): def upload_book(request):

View file

@ -1579,6 +1579,19 @@ body.dnd-mode .timer-display {
gap: 6px; gap: 6px;
flex-shrink: 0; 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 pages --- */
.pdf-page-wrapper { .pdf-page-wrapper {

View file

@ -3206,10 +3206,10 @@ async function loadBookList() {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf)); const meta = JSON.parse(new TextDecoder().decode(metaBuf));
bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub'}; 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) { } catch (e) {
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'}; 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. // 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(); 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) { function renderBookList(books) {
const listEl = $('book-list'); const listEl = $('book-list');
if (!listEl) return; if (!listEl) return;
_lastBookListData = books;
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
if (!visible.length) {
listEl.innerHTML = books.length
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
: '';
return;
}
let html = ''; let html = '';
for (const b of books) { for (const b of visible) {
const pct = Math.round((b.scroll_fraction || 0) * 100); const pct = Math.round((b.scroll_fraction || 0) * 100);
const keyWarning = b.keyOk === false ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠ wrong key</span>' : ''; const keyWarning = b.keyOk === false ? '<span title="Wrong encryption key — import the correct key to open this book" style="color:var(--accent,#e63946);margin-left:4px;">⚠ wrong key</span>' : '';
const broken = _brokenBooks.has(b.id); const broken = _brokenBooks.has(b.id);
html += `<div class="book-item" data-book-id="${b.id}"> html += `<div class="book-item" data-book-id="${b.id}">
<div class="book-item-info"> <div class="book-item-info">
<strong class="book-title">${escapeHtml(b.title)}${keyWarning}</strong> <strong class="book-title">${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</strong>
<span class="muted book-author">${escapeHtml(b.author)}</span> <span class="muted book-author">${escapeHtml(b.author)}</span>
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''} ${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
</div> </div>
<div class="book-item-actions"> <div class="book-item-actions">
${broken ? `<button class="btn btn-sm btn-danger book-broken-btn" title="Buch konnte nicht geöffnet werden — Datei erneut hochladen" onclick="repairBook(${b.id})">!</button>` : ''} ${broken ? `<button class="btn btn-sm btn-danger book-broken-btn" title="Buch konnte nicht geöffnet werden — Datei erneut hochladen" onclick="repairBook(${b.id})">!</button>` : ''}
${b.has_highlights ? `<button class="btn btn-sm" title="Markierungen &amp; Notizen herunterladen" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳</button>` : ''}
<button class="btn btn-sm" title="${b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren'}" onclick="toggleBookRead(${b.id}, ${!!b.is_read})">${b.is_read ? '↺' : '✓'}</button>
<button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button> <button class="btn btn-sm" onclick="openBook(${b.id})"${b.keyOk === false ? ' disabled title="Import the correct encryption key first"' : ''}>Open</button>
<button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button> <button class="btn btn-sm btn-danger" onclick="deleteBook(${b.id})">Delete</button>
</div> </div>
@ -3761,6 +3800,12 @@ async function openBook(bookId) {
// Apply reader settings (theme, font size, etc.) // Apply reader settings (theme, font size, etc.)
applyReaderSettings(isPdfBook); 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 // Touch: swipe (paginated) + pinch-to-zoom
contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true}); contentEl.addEventListener('touchstart', _pdfTouchStart, {passive: true});
contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false}); contentEl.addEventListener('touchmove', _pdfTouchMove, {passive: false});
@ -5578,6 +5623,63 @@ function editNoteInlineInMargin(h) {
textarea.select(); 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 // Export annotations — walks all highlights/notes of the current book in
// reading order and offers them as a downloadable plain-text file. // reading order and offers them as a downloadable plain-text file.
@ -5887,6 +5989,8 @@ function openRadioSidebar() {
// Init book drop zone // Init book drop zone
initBookDropZone(); initBookDropZone();
const showReadToggle = $('book-show-read-toggle');
if (showReadToggle) showReadToggle.checked = _bookShowRead;
// Restore active tab: a URL hash (shared/bookmarked link, or browser // Restore active tab: a URL hash (shared/bookmarked link, or browser
// back/forward) wins over the last-used tab remembered in localStorage — // back/forward) wins over the last-used tab remembered in localStorage —

View file

@ -2,7 +2,7 @@
* diora service worker caches the app shell for offline use. * 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 PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',

View file

@ -320,6 +320,10 @@
<span id="book-upload-status" class="muted"></span> <span id="book-upload-status" class="muted"></span>
</div> </div>
</div> </div>
<label class="book-list-filter">
<input type="checkbox" id="book-show-read-toggle" onchange="_onBookShowReadToggle(this.checked)">
Gelesene Bücher anzeigen
</label>
<div id="book-list" class="book-list"></div> <div id="book-list" class="book-list"></div>
{% else %} {% else %}
<p class="auth-prompt"> <p class="auth-prompt">