Bücher: Ordner (eine Ebene) + "Zuletzt gelesen"-Leiste (SW v36)
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:
parent
30a6d29ca8
commit
7d99e58286
5 changed files with 250 additions and 23 deletions
|
|
@ -7,6 +7,7 @@ urlpatterns = [
|
|||
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>/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>/rekey/', views.rekey_book, name='rekey_book'),
|
||||
path('<int:pk>/progress/', views.save_progress, name='save_book_progress'),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
152
static/js/app.js
152
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
|
||||
? '<p class="muted">Keine ungelesenen Bücher. „Gelesene Bücher anzeigen“ aktivieren, um alle zu sehen.</p>'
|
||||
: '';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
for (const b of visible) {
|
||||
// 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 ? '<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}">
|
||||
return `<div class="book-item" data-book-id="${b.id}">
|
||||
<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>
|
||||
<span class="muted book-author">${escapeHtml(b.author)}</span>
|
||||
|
|
@ -3440,13 +3440,127 @@ function renderBookList(books) {
|
|||
<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="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" 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>
|
||||
</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;
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue