From 7d99e58286b9098debe04f0cd28874d90a3d7564 Mon Sep 17 00:00:00 2001 From: marwin Date: Mon, 17 Aug 2026 10:58:53 +0200 Subject: [PATCH] =?UTF-8?q?B=C3=BCcher:=20Ordner=20(eine=20Ebene)=20+=20"Z?= =?UTF-8?q?uletzt=20gelesen"-Leiste=20(SW=20v36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordnername lebt im bereits verschlüsselten meta-Blob (wie Titel/Autor), daher keine Schema-Änderung nötig — nur ein schlanker Endpoint zum Aktualisieren von meta_ct/meta_iv ohne die Buchdaten neu hochzuladen. Die Bücheransicht zeigt jetzt immer oben die letzten 7 gelesenen Titel als Shortcut-Leiste, darunter Ordner-Kacheln (root) bzw. den Inhalt eines geöffneten Ordners (kein Verschachteln). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj --- books/urls.py | 1 + books/views.py | 29 +++++++++ static/css/app.css | 83 ++++++++++++++++++++++++ static/js/app.js | 158 ++++++++++++++++++++++++++++++++++++++------- static/js/sw.js | 2 +- 5 files changed, 250 insertions(+), 23 deletions(-) diff --git a/books/urls.py b/books/urls.py index ddcb20a..0d3b782 100644 --- a/books/urls.py +++ b/books/urls.py @@ -7,6 +7,7 @@ urlpatterns = [ 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('/meta/', views.update_book_meta, name='update_book_meta'), 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 c9e21f2..15d1445 100644 --- a/books/views.py +++ b/books/views.py @@ -89,6 +89,35 @@ def set_book_read(request, pk): return JsonResponse({'ok': True, 'is_read': book.is_read}) +@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}) + + @csrf_exempt @require_http_methods(['POST']) def upload_book(request): diff --git a/static/css/app.css b/static/css/app.css index 3f953dc..3ed7abd 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -1602,6 +1602,89 @@ body.dnd-mode .timer-display { cursor: pointer; } +/* --- Recently-read shelf (always pinned atop the books view) --- */ +.book-recent-shelf { + margin-bottom: 14px; +} +.book-recent-title { + font-size: 13px; + font-weight: 600; + margin: 0 0 6px; + color: var(--muted, #888); +} +.book-recent-row { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; +} +.book-recent-item { + flex: 0 0 auto; + display: flex; + flex-direction: column; + gap: 2px; + max-width: 160px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: none; + text-align: left; + cursor: pointer; +} +.book-recent-item-title { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.book-recent-item-author { + font-size: 11px; + color: var(--muted, #888); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* --- Book folders (single level) --- */ +.book-folder-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 8px; + margin-bottom: 10px; +} +.book-folder-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: none; + cursor: pointer; + text-align: center; +} +.book-folder-tile-icon { + font-size: 22px; +} +.book-folder-tile-name { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} +.book-folder-tile-count { + font-size: 11px; + color: var(--muted, #888); +} +.book-folder-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + /* --- PDF pages --- */ .pdf-page-wrapper { margin: 0 auto 1rem; diff --git a/static/js/app.js b/static/js/app.js index 3ab4dfb..b7157a7 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -3249,11 +3249,11 @@ async function loadBookList() { try { 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, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || ''}; + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', 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, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub', filename: '', folder: ''}; + decrypted.push({id: b.id, title: `Book #${b.id}`, author: '', type: 'epub', folder: '', 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. @@ -3292,7 +3292,7 @@ async function loadBookList() { if (b.last_read) return 1; return (b.uploaded_at || '').localeCompare(a.uploaded_at || ''); }); - for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub'}; + for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || ''}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; renderBookList(cachedBooks); @@ -3415,23 +3415,23 @@ async function toggleBookRead(bookId, currentlyRead) { } } -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 visible) { - const pct = Math.round((b.scroll_fraction || 0) * 100); - const keyWarning = b.keyOk === false ? '⚠ wrong key' : ''; - const broken = _brokenBooks.has(b.id); - html += `
+// Folder assigned to a book lives inside its encrypted meta blob (like title/author), so the +// server never sees plaintext folder names — no schema change needed, and it rides along for +// free in /api/sync/. One level only: folders don't nest. +let _currentBookFolder = null; // null = root view (folder tiles + unfiled books) + +function _recentlyReadBooks(books) { + return books + .filter(b => b.last_read) + .sort((a, b) => b.last_read.localeCompare(a.last_read)) + .slice(0, 7); +} + +function _renderBookItemHtml(b) { + const pct = Math.round((b.scroll_fraction || 0) * 100); + const keyWarning = b.keyOk === false ? '⚠ wrong key' : ''; + const broken = _brokenBooks.has(b.id); + return `
${escapeHtml(b.title)}${keyWarning}${b.is_read ? ' ✓ gelesen' : ''} ${escapeHtml(b.author)} @@ -3440,13 +3440,127 @@ function renderBookList(books) {
${broken ? `` : ''} ${b.has_highlights ? `` : ''} +
`; +} + +function _openBookFolder(name) { + _currentBookFolder = name; + renderBookList(_lastBookListData); +} + +function renderBookList(books) { + const listEl = $('book-list'); + if (!listEl) return; + _lastBookListData = books; + + if (!books.length) { + listEl.innerHTML = ''; + return; } + + let html = ''; + + // Always-visible "recently read" shelf, independent of folder navigation and the + // read/unread filter below — it's a shortcut back into whatever was open last. + const recent = _recentlyReadBooks(books); + if (recent.length) { + html += '
' + + '

Zuletzt gelesen

' + + '
' + + recent.map(b => ``).join('') + + '
'; + } + + const visible = _bookShowRead ? books : books.filter(b => !b.is_read); + if (!visible.length) { + html += books.length + ? '

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

' + : ''; + listEl.innerHTML = html; + return; + } + + const grouped = new Map(); + for (const b of visible) { + const key = b.folder || ''; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key).push(b); + } + // Folder emptied out (last book moved/deleted, or read-filter hid it) — fall back to root. + if (_currentBookFolder !== null && !grouped.has(_currentBookFolder)) { + _currentBookFolder = null; + } + + if (_currentBookFolder === null) { + const folderNames = [...grouped.keys()].filter(k => k !== '').sort((a, b) => a.localeCompare(b, 'de')); + if (folderNames.length) { + html += '
' + + folderNames.map(name => ``).join('') + + '
'; + } + html += (grouped.get('') || []).map(_renderBookItemHtml).join(''); + } else { + html += `
+ + ${escapeHtml(_currentBookFolder)} +
`; + html += grouped.get(_currentBookFolder).map(_renderBookItemHtml).join(''); + } + listEl.innerHTML = html; + listEl.querySelectorAll('.book-folder-tile').forEach(tile => { + tile.addEventListener('click', () => _openBookFolder(tile.dataset.folder)); + }); +} + +async function assignBookFolder(bookId) { + const book = _lastBookListData.find(b => b.id === bookId); + if (!book) return; + const folders = [...new Set(_lastBookListData.map(b => b.folder).filter(Boolean))].sort((a, b) => a.localeCompare(b, 'de')); + const hint = folders.length ? ` Vorhandene Ordner: ${folders.join(', ')}.` : ''; + const result = await customPrompt(`Ordner für „${book.title}“ (leer = kein Ordner).${hint}`, book.folder || ''); + if (result === null) return; + const folder = result.trim(); + if (folder === (book.folder || '')) return; + + try { + const key = await getOrCreateEncKey(); + const cached = bookMetaCache[bookId] || {}; + const metaJson = new TextEncoder().encode(JSON.stringify({ + title: cached.title || book.title, + author: cached.author || book.author, + filename: cached.filename || '', + type: cached.type || book.type || 'epub', + folder, + })); + const metaEnc = await encryptBytes(key, metaJson); + const res = await fetch(`/books/${bookId}/meta/`, { + method: 'POST', + headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()}, + body: JSON.stringify({meta_ct: metaEnc.ciphertext, meta_iv: metaEnc.iv}), + }); + const data = await res.json(); + if (!data.ok) throw new Error('Server error'); + book.folder = folder; + bookMetaCache[bookId] = {...cached, folder}; + _saveBookMeta(_lastBookListData); + if (folder) _currentBookFolder = folder; + renderBookList(_lastBookListData); + } catch (e) { + await customAlert('Konnte Ordner nicht ändern: ' + e.message); + } } function bookFileSelected(input) { diff --git a/static/js/sw.js b/static/js/sw.js index 900716b..31d3d26 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-v35'; +const CACHE = 'diora-v36'; const PODCAST_CACHE = 'diora-podcast-v1'; const SHELL = [ '/static/css/app.css',