Bücher: Ordner (eine Ebene) + "Zuletzt gelesen"-Leiste (SW v36)
All checks were successful
Build and push Docker image / build (push) Successful in 22s
Test / test (push) Successful in 34s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011j4LcoeddwFt6Rw8Wyaknj
This commit is contained in:
marwin 2026-08-17 10:58:53 +02:00
parent 30a6d29ca8
commit 7d99e58286
5 changed files with 250 additions and 23 deletions

View file

@ -7,6 +7,7 @@ urlpatterns = [
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>/read/', views.set_book_read, name='set_book_read'),
path('<int:pk>/meta/', views.update_book_meta, name='update_book_meta'),
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

@ -89,6 +89,35 @@ def set_book_read(request, pk):
return JsonResponse({'ok': True, 'is_read': book.is_read}) 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 @csrf_exempt
@require_http_methods(['POST']) @require_http_methods(['POST'])
def upload_book(request): def upload_book(request):

View file

@ -1602,6 +1602,89 @@ body.dnd-mode .timer-display {
cursor: pointer; 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 pages --- */
.pdf-page-wrapper { .pdf-page-wrapper {
margin: 0 auto 1rem; margin: 0 auto 1rem;

View file

@ -3249,11 +3249,11 @@ async function loadBookList() {
try { try {
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', filename: meta.filename || '', folder: meta.folder || ''};
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}); 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) { } catch (e) {
bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub'}; bookMetaCache[b.id] = {title: `Book #${b.id}`, author: '', type: 'epub', filename: '', folder: ''};
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}); 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. // 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; if (b.last_read) return 1;
return (b.uploaded_at || '').localeCompare(a.uploaded_at || ''); 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'); const uploadArea = $('book-upload-area');
if (uploadArea) uploadArea.style.display = 'none'; if (uploadArea) uploadArea.style.display = 'none';
renderBookList(cachedBooks); renderBookList(cachedBooks);
@ -3415,23 +3415,23 @@ async function toggleBookRead(bookId, currentlyRead) {
} }
} }
function renderBookList(books) { // Folder assigned to a book lives inside its encrypted meta blob (like title/author), so the
const listEl = $('book-list'); // server never sees plaintext folder names — no schema change needed, and it rides along for
if (!listEl) return; // free in /api/sync/. One level only: folders don't nest.
_lastBookListData = books; let _currentBookFolder = null; // null = root view (folder tiles + unfiled books)
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
if (!visible.length) { function _recentlyReadBooks(books) {
listEl.innerHTML = books.length return books
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>' .filter(b => b.last_read)
: ''; .sort((a, b) => b.last_read.localeCompare(a.last_read))
return; .slice(0, 7);
} }
let html = '';
for (const b of visible) { function _renderBookItemHtml(b) {
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}"> return `<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}${b.is_read ? ' <span class="muted book-read-badge">✓ gelesen</span>' : ''}</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>
@ -3440,13 +3440,127 @@ function renderBookList(books) {
<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>` : ''} ${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="Ordner zuweisen" onclick="assignBookFolder(${b.id})">📁</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" 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>
</div>`; </div>`;
}
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 += '<div class="book-recent-shelf">'
+ '<h3 class="book-recent-title">Zuletzt gelesen</h3>'
+ '<div class="book-recent-row">'
+ recent.map(b => `<button class="book-recent-item" onclick="openBook(${b.id})" title="${escapeHtml(b.title)}">
<span class="book-recent-item-title">${escapeHtml(b.title)}</span>
${b.author ? `<span class="book-recent-item-author">${escapeHtml(b.author)}</span>` : ''}
</button>`).join('')
+ '</div></div>';
}
const visible = _bookShowRead ? books : books.filter(b => !b.is_read);
if (!visible.length) {
html += books.length
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
: '';
listEl.innerHTML = html; 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 += '<div class="book-folder-grid">'
+ folderNames.map(name => `<button class="book-folder-tile" data-folder="${escapeHtml(name)}">
<span class="book-folder-tile-icon">📁</span>
<span class="book-folder-tile-name">${escapeHtml(name)}</span>
<span class="book-folder-tile-count">${grouped.get(name).length}</span>
</button>`).join('')
+ '</div>';
}
html += (grouped.get('') || []).map(_renderBookItemHtml).join('');
} else {
html += `<div class="book-folder-header">
<button class="btn btn-sm" onclick="_openBookFolder(null)"> Alle Ordner</button>
<strong>${escapeHtml(_currentBookFolder)}</strong>
</div>`;
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) { function bookFileSelected(input) {

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-v35'; const CACHE = 'diora-v36';
const PODCAST_CACHE = 'diora-podcast-v1'; const PODCAST_CACHE = 'diora-podcast-v1';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',