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>/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'),
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
158
static/js/app.js
158
static/js/app.js
|
|
@ -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 & Notizen herunterladen" onclick="downloadBookAnnotationsFromList(${b.id}, this)">⭳</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" 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;
|
||||||
|
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.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) {
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue