Books: Notizen-Sidebar auto-öffnen, Notizen-Download + Gelesen-Status in Übersicht (SW v29)
- 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:
parent
f9573d3d62
commit
b805d62b11
8 changed files with 173 additions and 6 deletions
18
books/migrations/0004_ebook_is_read.py
Normal file
18
books/migrations/0004_ebook_is_read.py
Normal 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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ urlpatterns = [
|
|||
path('upload/', views.upload_book, name='upload_book'),
|
||||
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>/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>/rekey/', views.rekey_book, name='rekey_book'),
|
||||
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
112
static/js/app.js
112
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
|
||||
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
|
||||
: '';
|
||||
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 ? '<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);
|
||||
html += `<div class="book-item" data-book-id="${b.id}">
|
||||
<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>
|
||||
${pct > 0 ? `<span class="muted book-progress">${pct}% read</span>` : ''}
|
||||
</div>
|
||||
<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>` : ''}
|
||||
${b.has_highlights ? `<button class="btn btn-sm" title="Markierungen & 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 btn-danger" onclick="deleteBook(${b.id})">Delete</button>
|
||||
</div>
|
||||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -320,6 +320,10 @@
|
|||
<span id="book-upload-status" class="muted"></span>
|
||||
</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>
|
||||
{% else %}
|
||||
<p class="auth-prompt">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue