diora-web/static/js/app/53-library.js
mrwnslz f3b0080790
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Has been cancelled
Serien für Bücher: erkannt, als Stapel, selbst zuweisen und verwalten
- Beim Hochladen liest diora die Serie aus dem E-Book (calibre:series und
  series_index, EPUB 3 belongs-to-collection mit group-position); vorhandene
  Bücher werden am Titel erkannt („Der Zauberberg. Erster Band“, „…: Teil 3“).
- In der Bibliothek ist eine Serie ab zwei Bänden ein Stapel („2 Bände · bei
  Band 1“), darin die Bände in Reihenfolge; beim Suchen und Filtern einzeln.
- ⋮ → „Serie …“: vorhandene oder neue Serie, Band-Nummer, oder „Keine Serie“.
  In der Serie: umbenennen, Reihenfolge, auflösen. Alles im verschlüsselten
  Meta (series, seriesIndex; '' = bewusst keine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:47:18 +02:00

752 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// ---------------------------------------------------------------------------
// The library (docs/library.md): Bücher and Zeitschriften as one page — a header with
// search, sort, grid/list and +, one row of chips (Angefangen · Ungelesen · Gelesen,
// then the folders, docs/library.md: tags, any number per book), one "Weiterlesen"
// card, then the books as covers (grid) or rows (list; e-ink and diora Reader start
// with it). Folders live in each book's encrypted meta (`folders`), their order in the
// encrypted library prefs (/books/prefs/). Covers come from covers.js.
// Takes over renderBookList; loading, decrypting and the book actions stay in
// 52-book-list.js.
// ---------------------------------------------------------------------------
const _LIB_EINK = () => document.documentElement.classList.contains('nui-reader-app') ||
(document.body && document.body.classList.contains('eink'));
const _lib = {
q: '',
searching: false,
sort: (() => { try { return localStorage.getItem('diora_lib_sort') || 'recent'; } catch (e) { return 'recent'; } })(),
layout: (() => { try { return localStorage.getItem('diora_lib_layout') || ''; } catch (e) { return ''; } })(),
status: 'all', // all | started | unread | read
folder: null, // null: all, '': without a folder, else a name
selecting: null, // a Set of book ids while choosing several
order: null, // the folders' order (decrypted prefs)
series: null, // the series shown (its key), or null
};
window.__dioraLibraryV2 = true;
document.documentElement.classList.add('lib-v2');
const _libLayout = () => _lib.layout || (_LIB_EINK() ? 'list' : 'grid');
const _libFolders = b => (typeof _foldersOfMeta === 'function' ? _foldersOfMeta(b) : (b.folder ? [b.folder] : []));
const _libNorm = s => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const _libStarted = b => !b.is_read && (b.scroll_fraction || 0) > 0;
const _libUnread = b => !b.is_read && !(b.scroll_fraction > 0);
// ---- the folders' order: encrypted prefs ------------------------------------------------
async function _libLoadPrefs() {
try { _lib.order = JSON.parse(localStorage.getItem('diora_lib_order') || 'null'); } catch (e) {}
try {
const r = await fetch('/books/prefs/', {cache: 'no-store'});
const d = r.ok ? await r.json() : null;
if (d && d.prefs_ct) {
const plain = await decryptBytes(await getOrCreateEncKey(), d.prefs_iv, d.prefs_ct);
const prefs = JSON.parse(new TextDecoder().decode(plain));
_lib.order = Array.isArray(prefs.folder_order) ? prefs.folder_order : [];
try { localStorage.setItem('diora_lib_order', JSON.stringify(_lib.order)); } catch (e) {}
if (_lastBookListData && _lastBookListData.length) renderBookList(_lastBookListData);
}
} catch (e) { /* offline: the order kept here */ }
}
async function _libSaveOrder(order) {
_lib.order = order;
try { localStorage.setItem('diora_lib_order', JSON.stringify(order)); } catch (e) {}
const enc = await encryptBytes(await getOrCreateEncKey(), new TextEncoder().encode(JSON.stringify({folder_order: order})));
await fetch('/books/prefs/', {method: 'POST', headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
body: JSON.stringify({prefs_ct: enc.ciphertext, prefs_iv: enc.iv})});
}
// Every folder of the books shown (this section), in the user's order.
function _libFolderList(books) {
const counts = new Map();
books.forEach(b => _libFolders(b).forEach(f => counts.set(f, (counts.get(f) || 0) + 1)));
const order = _lib.order || [];
const rank = n => { const i = order.indexOf(n); return i < 0 ? 1e6 : i; };
return Array.from(counts.keys()).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b, 'de'))
.map(n => ({name: n, count: counts.get(n)}));
}
// ---- series (docs/library.md): set per book in its encrypted meta (series, seriesIndex),
// read from the EPUB on upload, else guessed from the title ("Der Zauberberg. Erster Band").
// series '' means: none, on purpose.
const _LIB_ORD = ['erst', 'zweit', 'dritt', 'viert', 'fünft', 'sechst', 'siebt', 'acht', 'neunt', 'zehnt', 'elft', 'zwölft'];
const _LIB_ORD_EN = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth'];
const _LIB_ROMAN = {i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6, vii: 7, viii: 8, ix: 9, x: 10};
function _libVolumeNumber(text) {
const t = String(text || '').toLowerCase();
const d = /(\d+(?:[.,]\d+)?)/.exec(t);
if (d) return parseFloat(d[1].replace(',', '.'));
for (const w of t.split(/[^a-zäöüß]+/)) {
if (_LIB_ROMAN[w]) return _LIB_ROMAN[w];
for (let i = 0; i < _LIB_ORD.length; i++) if (w.indexOf(_LIB_ORD[i]) === 0) return i + 1;
for (let i = 0; i < _LIB_ORD_EN.length; i++) if (w === _LIB_ORD_EN[i]) return i + 1;
}
return null;
}
function _libSeriesGuess(title) {
const t = String(title || '');
const m = /^(.+?)\s+[–—-]\s+(.+)$/.exec(t) || /^(.+?):\s+(.+)$/.exec(t) || /^(.+?)[.,]\s+(.+)$/.exec(t);
if (!m || !/\b(band|bd\.|teil|buch|folge|staffel|vol\.?|volume|part|book)\b/i.test(m[2]) || /bände|bundle/i.test(m[2])) return null;
return {name: m[1].trim(), index: _libVolumeNumber(m[2]), auto: true};
}
// A book's series: {name, index, auto} or null.
function _libSeries(b) {
if (b.series === '') return null;
if (b.series) {
const ix = typeof b.seriesIndex === 'number' ? b.seriesIndex : (_libSeriesGuess(b.title) || {}).index;
return {name: b.series, index: ix == null ? null : ix, auto: false};
}
return _libSeriesGuess(b.title);
}
const _libSeriesKey = s => s.name.toLowerCase().replace(/\s+/g, ' ').trim();
function _libVolumeSort(a, b) {
const sa = _libSeries(a), sb = _libSeries(b);
const ia = sa && sa.index != null ? sa.index : 1e6, ib = sb && sb.index != null ? sb.index : 1e6;
return ia - ib || a.title.localeCompare(b.title, 'de', {numeric: true});
}
// Every series in the library: key -> {name, books (in order)}.
function _libAllSeries(books) {
const map = new Map();
books.forEach(b => {
const s = _libSeries(b);
if (!s) return;
const k = _libSeriesKey(s);
if (!map.has(k)) map.set(k, {key: k, name: s.name, books: []});
map.get(k).books.push(b);
});
map.forEach(g => g.books.sort(_libVolumeSort));
return map;
}
// ---- which books, in which order ---------------------------------------------------------------
function _libShown(books) {
const q = _libNorm(_lib.q).trim();
let list = books.filter(b =>
(_lib.status === 'all' || (_lib.status === 'started' ? _libStarted(b) : _lib.status === 'unread' ? _libUnread(b) : b.is_read)) &&
(_lib.folder == null || (_lib.folder === '' ? !_libFolders(b).length : _libFolders(b).indexOf(_lib.folder) >= 0)) &&
(!q || _libNorm(b.title + ' ' + b.author).indexOf(q) >= 0));
const by = {
recent: (a, b) => (_bookRecency(b) - _bookRecency(a)) || String(b.uploaded_at || '').localeCompare(String(a.uploaded_at || '')),
title: (a, b) => a.title.localeCompare(b.title, 'de', {numeric: true}),
author: (a, b) => (a.author || '~').localeCompare(b.author || '~', 'de') || a.title.localeCompare(b.title, 'de', {numeric: true}),
added: (a, b) => String(b.uploaded_at || '').localeCompare(String(a.uploaded_at || '')),
};
return list.slice().sort(by[_lib.sort] || by.recent);
}
// ---- drawing -------------------------------------------------------------------------------------
function _libEsc(s) {
return typeof escapeHtml === 'function' ? escapeHtml(s) : String(s == null ? '' : s);
}
function _libHue(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
return h;
}
function _libCover(b, cls) {
const url = window.DioraCovers && b.cover !== 'generated' ? DioraCovers.url(b) : null;
if (url) return `<img class="${cls}" src="${url}" alt="" data-cover-id="${b.id}">`;
// None (yet): one drawn from the book's title, author, category and folders (gencover.js).
if (window.DioraGenCover) {
return `<img class="${cls}" src="${DioraGenCover.url(b, {gray: _LIB_EINK()})}" alt="" data-cover-id="${b.id}">`;
}
const initials = (b.title || '?').trim().slice(0, 1).toUpperCase();
return `<span class="${cls} lib-nocover" data-cover-id="${b.id}" style="--h:${_libHue(b.title || '')}"><span>${_libEsc(b.title || '')}</span><b>${_libEsc(initials)}</b></span>`;
}
function _libPct(b) {
return b.is_read ? 100 : Math.round((b.scroll_fraction || 0) * 100);
}
function _libState(b) {
if (b.keyOk === false) return '⚠ falscher Schlüssel';
if (b.is_read) return '✓ gelesen';
const p = _libPct(b);
return p > 0 ? p + ' %' : 'neu';
}
function _libCard(b, label) {
const sel = _lib.selecting && _lib.selecting.has(b.id);
const by = (label ? label + (b.author ? ' · ' : '') : '') + (b.author || ''); // "Band 2 · Autor" in a series
const pct = _libPct(b);
if (_libLayout() === 'list') {
return `<div class="lib-row${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
${_libCover(b, 'lib-thumb')}
<span class="lib-text"><span class="lib-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${_libEsc(by)}</span></span>
<span class="lib-state${b.is_read ? ' lib-done' : ''}">${_libEsc(_libState(b))}</span>
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></div>`;
}
return `<div class="lib-card${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
<span class="lib-coverbox">${_libCover(b, 'lib-cover')}
${b.is_read ? '<span class="lib-badge">✓</span>' : ''}
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></span>
<span class="lib-bar"><span style="width:${pct}%"></span></span>
<span class="lib-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${_libEsc(by)}</span></div>`;
}
function _libContinueBook(books) {
if (_lib.status !== 'all' || _lib.folder != null || _lib.q || _lib.selecting) return null;
return _recentlyReadBooks(books).filter(x => !x.is_read && x.keyOk !== false)[0] || null;
}
function _libContinue(b) {
if (!b) return '';
const pages = b.pages ? ` · Seite ${Math.max(1, Math.round(b.pages * (b.scroll_fraction || 0)))} von ${b.pages}` : '';
return `<div class="lib-continue" data-lib-book="${b.id}">${_libCover(b, 'lib-thumb lib-thumb-l')}
<span class="lib-text"><span class="lib-kicker">Weiterlesen</span>
<span class="lib-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${_libEsc(b.author || '')}</span>
<span class="lib-bar"><span style="width:${_libPct(b)}%"></span></span>
<span class="lib-meta">${_libPct(b)} %${pages}</span></span>
<span class="lib-play" aria-hidden="true">▶</span></div>`;
}
function _libVolumeLabel(b) {
const s = _libSeries(b);
return s && s.index != null ? 'Band ' + String(s.index).replace('.', ',') : '';
}
function _libStackCard(g) {
const next = g.books.filter(b => !b.is_read)[0] || g.books[0];
const read = g.books.filter(b => b.is_read).length;
const going = g.books.filter(_libStarted)[0];
const meta = g.books.length + ' Bände' + (going ? ' · bei ' + (_libVolumeLabel(going) || _libEsc(going.title))
: read === g.books.length ? ' · gelesen' : read ? ' · ' + read + ' gelesen' : '');
const pct = Math.round(100 * g.books.reduce((n, b) => n + _libPct(b), 0) / (100 * g.books.length) * 100) / 100;
if (_libLayout() === 'list') {
return `<div class="lib-row lib-stackrow" data-lib-series="${_libEsc(g.key)}">${_libCover(next, 'lib-thumb')}
<span class="lib-text"><span class="lib-title">${_libEsc(g.name)}</span><span class="lib-meta">${meta}</span></span>
<span class="lib-state">›</span></div>`;
}
return `<div class="lib-card lib-stack" data-lib-series="${_libEsc(g.key)}">
<span class="lib-coverbox lib-stackbox">${_libCover(next, 'lib-cover')}</span>
<span class="lib-bar"><span style="width:${pct}%"></span></span>
<span class="lib-title">${_libEsc(g.name)}</span>
<span class="lib-meta">${meta}</span></div>`;
}
// The books shown as cards, a series of two or more as one stack (in its last-read place).
function _libStacked(list, pool) {
const groups = _libAllSeries(pool); // the volumes in what is shown (a folder, all)
const out = [];
const placed = new Set();
list.forEach(b => {
const s = _libSeries(b);
const g = s ? groups.get(_libSeriesKey(s)) : null;
if (!g || g.books.length < 2) { out.push(_libCard(b)); return; }
if (placed.has(g.key)) return;
placed.add(g.key);
out.push(_libStackCard(g));
});
return out.join('');
}
function _libSeriesView(books) {
const g = _libAllSeries(books).get(_lib.series);
if (!g) { _lib.series = null; return ''; }
const cards = g.books.map(b => _libCard(b, _libVolumeLabel(b))).join('');
return `<div class="lib-place"><button type="button" class="lib-btn" data-lib="series-up">‹ Bücher</button>
<strong class="lib-place-name">${_libEsc(g.name)}</strong>
<button type="button" class="lib-icon" data-lib="series-manage" aria-label="Serie verwalten">⋯</button></div>
<div class="${_libLayout() === 'list' ? 'lib-list' : 'lib-grid'}">${cards}</div>`;
}
// Zeitschriften: the issues under their periodical (automatic, not a folder).
function _libMagazines(list) {
const groups = new Map();
list.forEach(b => {
const k = typeof _periodicalOf === 'function' ? _periodicalOf(b) : '';
if (!groups.has(k)) groups.set(k, []);
groups.get(k).push(b);
});
const names = Array.from(groups.keys()).sort((a, b) => a.localeCompare(b, 'de'));
return names.map(n => {
const issues = groups.get(n).sort((a, b) => b.title.localeCompare(a.title, 'de', {numeric: true}));
return `<h3 class="lib-group">${_libEsc(n)} <span class="lib-count">${issues.length}</span></h3>` +
`<div class="${_libLayout() === 'list' ? 'lib-list' : 'lib-grid'}">${issues.map(_libCard).join('')}</div>`;
}).join('');
}
function _libRender(allBooks) {
const listEl = $('book-list');
if (!listEl) return;
_lastBookListData = allBooks;
const books = _bookSectionFilter(allBooks);
const magazines = window.DioraNav && _bookSection === 'magazines';
const shown = _libShown(books);
const narrow = window.matchMedia && matchMedia('(max-width: 720px)').matches;
const sorts = [['recent', 'Zuletzt'], ['title', 'Titel'], ['author', 'Autor'], ['added', 'Neu hinzugefügt']];
let h = `<div class="lib-head"><h2 class="lib-h">${magazines ? 'Zeitschriften' : 'Bücher'}</h2>
${!narrow || _lib.searching || _lib.q ? `<input class="lib-q" type="search" placeholder="Titel, Autor …" value="${_libEsc(_lib.q)}">` :
'<button type="button" class="lib-icon" data-lib="search" aria-label="Suchen">⌕</button>'}
<select class="lib-sort" aria-label="Sortieren">${sorts.map(s => `<option value="${s[0]}"${_lib.sort === s[0] ? ' selected' : ''}>${s[1]}</option>`).join('')}</select>
<button type="button" class="lib-icon" data-lib="layout" aria-label="${_libLayout() === 'grid' ? 'Als Liste' : 'Als Raster'}">${_libLayout() === 'grid' ? '☰' : '⊞'}</button>
<button type="button" class="lib-icon lib-add" data-lib="add" aria-label="Hinzufügen">+</button></div>
<div class="lib-chips"></div>`;
if (_lib.selecting) {
h += `<div class="lib-selbar"><span>${_lib.selecting.size} ausgewählt</span>
<button type="button" class="lib-btn" data-lib="sel-folders"${_lib.selecting.size ? '' : ' disabled'}>In Ordner …</button>
<button type="button" class="lib-btn" data-lib="sel-done">Fertig</button></div>`;
}
if (!allBooks.length) {
h += '<p class="lib-empty">Noch keine Bücher. Mit + eine .epub- oder .pdf-Datei hinzufügen.</p>';
} else if (!books.length) {
h += `<p class="lib-empty">${magazines ? 'Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.' : 'Alle Bücher stehen bei den Zeitschriften.'}</p>`;
} else if (_lib.series && !magazines) {
h += _libSeriesView(books);
} else {
const going = _libContinueBook(books);
const pool = shown.slice();
h += _libContinue(going);
if (going) shown.splice(shown.indexOf(going), 1); // once, on top
// Series as stacks, unless searching, filtering by state or choosing several.
const stack = !magazines && !_lib.q && _lib.status === 'all' && !_lib.selecting;
if (!shown.length && !going) h += '<p class="lib-empty">Nichts gefunden.</p>';
else if (magazines && _lib.sort === 'recent' && !_lib.q) h += _libMagazines(shown);
else h += `<div class="${_libLayout() === 'list' ? 'lib-list' : 'lib-grid'}">${stack ? _libStacked(shown, pool) : shown.map(_libCard).join('')}</div>`;
}
const q = listEl.querySelector('.lib-q');
const focused = q && document.activeElement === q;
listEl.innerHTML = h;
const status = document.getElementById('book-upload-status');
if (status && status.textContent.trim()) listEl.querySelector('.lib-head').after(status);
_libChips(books);
if (focused || (_lib.searching && !_lib.q)) {
const n = listEl.querySelector('.lib-q');
if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); }
}
}
function _libChips(books) {
const el = document.querySelector('#book-list .lib-chips');
if (!el || !window.DioraFolders) return;
const folders = _libFolderList(books);
const none = books.filter(b => !_libFolders(b).length).length;
DioraFolders.chips(el, {
lead: [
{id: 'started', label: 'Angefangen', count: books.filter(_libStarted).length, on: _lib.status === 'started'},
{id: 'unread', label: 'Ungelesen', count: books.filter(_libUnread).length, on: _lib.status === 'unread'},
{id: 'read', label: 'Gelesen', count: books.filter(b => b.is_read).length, on: _lib.status === 'read'},
],
folders, active: _lib.folder, none: folders.length ? none : 0,
onPick(p) {
if (p && p.lead) _lib.status = _lib.status === p.lead ? 'all' : p.lead;
else _lib.folder = p;
renderBookList(_lastBookListData);
},
onManage: folders.length ? _libManage : null,
});
}
window.renderBookList = _libRender;
// ---- folders ---------------------------------------------------------------------------------------
// Into folders: one book (its folders as they are), or several (the folders they share).
function _libAssign(ids) {
const books = ids.map(id => _lastBookListData.find(b => b.id === id)).filter(Boolean);
if (!books.length || !window.DioraFolders) return;
const common = _libFolders(books[0]).filter(f => books.every(b => _libFolders(b).indexOf(f) >= 0));
DioraFolders.assign({
title: books.length === 1 ? `Ordner für „${books[0].title}“` : `Ordner für ${books.length} Bücher`,
folders: _libFolderList(_lastBookListData).map(f => f.name),
selected: common,
async onDone(picked) {
try {
for (const b of books) {
const kept = _libFolders(b).filter(f => common.indexOf(f) < 0 || picked.indexOf(f) >= 0);
const next = kept.concat(picked.filter(f => kept.indexOf(f) < 0));
if (next.join('\u0000') !== _libFolders(b).join('\u0000')) await _updateBookMeta(b.id, {folders: next});
}
} catch (e) {
customAlert('Das ließ sich nicht speichern: ' + e.message);
}
_lib.selecting = null;
renderBookList(_lastBookListData);
},
});
}
window.assignBookFolder = id => _libAssign([Number(id)]);
function _libManage() {
if (!window.DioraFolders) return;
const rewrite = async fn => {
for (const b of _lastBookListData) {
const was = _libFolders(b);
const next = fn(was).filter((f, i, all) => f && all.indexOf(f) === i);
if (next.join('\u0000') !== was.join('\u0000')) await _updateBookMeta(b.id, {folders: next});
}
renderBookList(_lastBookListData);
};
DioraFolders.manage({
title: 'Ordner der Bücher',
folders: _libFolderList(_lastBookListData).map(f => f.name),
onRename: (old, n) => {
if (_lib.folder === old) _lib.folder = n;
return rewrite(fs => fs.map(f => (f === old ? n : f))).then(() =>
_libSaveOrder((_lib.order || []).map(f => (f === old ? n : f)).filter((f, i, a) => a.indexOf(f) === i)));
},
onDelete: name => {
if (_lib.folder === name) _lib.folder = null;
return rewrite(fs => fs.filter(f => f !== name)).then(() => _libSaveOrder((_lib.order || []).filter(f => f !== name)));
},
onOrder: order => _libSaveOrder(order).then(() => renderBookList(_lastBookListData)).catch(() => {}),
});
}
// ---- series: assign, manage ------------------------------------------------------------------------
function _libSheetBox(title, body) {
const wrap = document.createElement('div');
wrap.className = 'dfo-scrim';
wrap.innerHTML = `<div class="dfo-sheet" role="dialog"><div class="dfo-head"><strong>${_libEsc(title)}</strong>
<button type="button" class="dfo-x" data-x="1" aria-label="Schließen">✕</button></div>${body}</div>`;
wrap.addEventListener('click', e => { if (e.target === wrap || e.target.closest('[data-x]')) wrap.remove(); });
document.body.appendChild(wrap);
return wrap;
}
// A book into a series (an existing one, or a new name) with its number, or out of any.
function _libSeriesSheet(b) {
const now = _libSeries(b);
const names = Array.from(_libAllSeries(_lastBookListData).values()).map(g => g.name).sort((x, y) => x.localeCompare(y, 'de'));
const radios = names.map(n => `<label class="dfo-check"><input type="radio" name="sr" value="${_libEsc(n)}"${now && now.name === n ? ' checked' : ''}> ${_libEsc(n)}</label>`).join('');
const wrap = _libSheetBox(`Serie für „${b.title}“`, `<form class="lib-series-form">
<div class="dfo-list">${radios || '<p class="dfo-note">Noch keine Serien.</p>'}
<label class="dfo-check"><input type="radio" name="sr" value="__new"${now ? '' : ' checked'}> Neue Serie:
<input class="dfo-input" name="newname" maxlength="120" placeholder="Name der Serie" value="${now ? '' : _libEsc((_libSeriesGuess(b.title) || {}).name || '')}"></label></div>
<label class="lib-series-ix">Band <input class="dfo-input" name="ix" inputmode="decimal" size="5" value="${now && now.index != null ? String(now.index).replace('.', ',') : ''}"></label>
<div class="dfo-actions"><button type="button" class="dfo-btn" data-none="1">Keine Serie</button>
<button type="submit" class="dfo-btn dfo-primary">Übernehmen</button></div></form>`);
const form = wrap.querySelector('form');
form.newname.addEventListener('focus', () => { form.querySelector('input[value="__new"]').checked = true; });
const save = async (series, ix) => {
wrap.remove();
try {
await _updateBookMeta(b.id, {series, seriesIndex: ix});
renderBookList(_lastBookListData);
} catch (e) { customAlert('Das ließ sich nicht speichern: ' + e.message); }
};
wrap.querySelector('[data-none]').addEventListener('click', () => save('', null));
form.addEventListener('submit', e => {
e.preventDefault();
const pick = (form.querySelector('input[name="sr"]:checked') || {}).value;
const name = pick === '__new' ? form.newname.value.trim().replace(/\s+/g, ' ') : pick;
if (!name) { form.newname.focus(); return; }
const n = parseFloat(String(form.ix.value).replace(',', '.'));
save(name.slice(0, 120), isFinite(n) ? n : null);
});
}
window.assignBookSeries = id => {
const b = _lastBookListData.find(x => x.id === Number(id));
if (b) _libSeriesSheet(b);
};
// The series shown: rename (every volume), order (numbers 1 … n), dissolve.
function _libSeriesManage() {
const g = _libAllSeries(_lastBookListData).get(_lib.series);
if (!g) return;
let order = g.books.slice();
const draw = () => order.map((b, i) => `<div class="dfo-mrow"><span class="dfo-mname">${i + 1}. ${_libEsc(b.title)}</span>
<button type="button" class="dfo-icon" data-up="${i}"${i ? '' : ' disabled'} aria-label="Nach oben">↑</button>
<button type="button" class="dfo-icon" data-down="${i}"${i < order.length - 1 ? '' : ' disabled'} aria-label="Nach unten">↓</button></div>`).join('');
const wrap = _libSheetBox(`Serie „${g.name}“`, `<div class="dfo-list lib-series-order">${draw()}</div>
<div class="dfo-actions"><button type="button" class="dfo-btn" data-a="rename">Umbenennen …</button>
<button type="button" class="dfo-btn" data-a="dissolve">Auflösen</button>
<button type="button" class="dfo-btn dfo-primary" data-a="save">Reihenfolge übernehmen</button></div>`);
const list = wrap.querySelector('.lib-series-order');
const write = async fn => {
wrap.remove();
try {
for (let i = 0; i < order.length; i++) await _updateBookMeta(order[i].id, fn(order[i], i));
} catch (e) { customAlert('Das ließ sich nicht speichern: ' + e.message); }
renderBookList(_lastBookListData);
};
wrap.addEventListener('click', async e => {
const t = e.target.closest('button');
if (!t) return;
if (t.dataset.up != null || t.dataset.down != null) {
const i = Number(t.dataset.up != null ? t.dataset.up : t.dataset.down), j = t.dataset.up != null ? i - 1 : i + 1;
const x = order[i]; order[i] = order[j]; order[j] = x;
list.innerHTML = draw();
} else if (t.dataset.a === 'save') {
write((b, i) => ({series: g.name, seriesIndex: i + 1}));
} else if (t.dataset.a === 'rename') {
const n = await customPrompt('Neuer Name der Serie:', g.name);
const name = n == null ? '' : String(n).trim().replace(/\s+/g, ' ').slice(0, 120);
if (!name || name === g.name) return;
_lib.series = name.toLowerCase();
write(b => ({series: name, seriesIndex: (_libSeries(b) || {}).index == null ? null : _libSeries(b).index}));
} else if (t.dataset.a === 'dissolve') {
if (!(await customConfirm(`Serie „${g.name}“ auflösen? Die Bücher bleiben.`))) return;
_lib.series = null;
write(() => ({series: '', seriesIndex: null}));
}
});
}
// ---- a book's actions (the apps may bring their own sheet: __dioraBookSheet) -------------------------
function _libSheet(b) {
if (window.__dioraBookSheet && window.__dioraBookSheet(b) !== false) return;
const broken = typeof _brokenBooks !== 'undefined' && _brokenBooks.has(b.id);
const items = [
b.keyOk !== false && ['Lesen', () => openBook(b.id)],
['In Ordner …', () => _libAssign([b.id])],
[_isMagazine(b) ? 'Als Buch führen' : 'Als Zeitschrift führen', () => toggleBookMagazine(b.id)],
[b.cover === 'generated' ? 'Eigenes Cover verwenden' : 'Generiertes Cover verwenden', () => toggleBookCover(b.id)],
['Serie …', () => _libSeriesSheet(b)],
[b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren', () => toggleBookRead(b.id, !!b.is_read)],
b.has_highlights && ['Markierungen exportieren', () => downloadBookAnnotationsFromList(b.id, null)],
['Regal-Kategorie abrufen', () => lookupBookMetadata(b.id)],
['Mehrere auswählen', () => { _lib.selecting = new Set([b.id]); renderBookList(_lastBookListData); }],
broken && ['Reparieren', () => repairBook(b.id)],
['Löschen', () => deleteBook(b.id), true],
].filter(Boolean);
const wrap = document.createElement('div');
wrap.className = 'dfo-scrim';
wrap.innerHTML = `<div class="dfo-sheet" role="dialog"><div class="dfo-head"><strong>${_libEsc(b.title)}</strong>
<button type="button" class="dfo-x" data-x="1" aria-label="Schließen">✕</button></div>
${items.map((it, i) => `<button type="button" class="lib-sheet-item${it[2] ? ' lib-danger' : ''}" data-i="${i}">${it[0]}</button>`).join('')}</div>`;
const close = () => wrap.remove();
wrap.addEventListener('click', e => {
const t = e.target.closest('[data-i]');
if (e.target === wrap || e.target.closest('[data-x]')) { close(); return; }
if (t) { close(); items[Number(t.dataset.i)][1](); }
});
document.body.appendChild(wrap);
}
// ---- events ------------------------------------------------------------------------------------------
(function () {
const listEl = $('book-list');
if (!listEl) return;
let pressTimer = 0;
let pressed = null;
// First on the list: the apps' own card clicks (native-ui.js) don't run for these cards.
listEl.addEventListener('click', e => {
const t = e.target;
const more = t.closest('[data-lib-more]');
const act = t.closest('[data-lib]');
const card = t.closest('[data-lib-book]');
const stack = t.closest('[data-lib-series]');
if (!more && !act && !card && !stack) return;
e.stopImmediatePropagation();
if (pressed) { pressed = null; return; } // the long press already chose
if (more) {
const b = _lastBookListData.find(x => x.id === Number(more.dataset.libMore));
if (b) _libSheet(b);
return;
}
if (act) {
const a = act.dataset.lib;
if (a === 'search') { _lib.searching = true; renderBookList(_lastBookListData); }
else if (a === 'layout') {
_lib.layout = _libLayout() === 'grid' ? 'list' : 'grid';
try { localStorage.setItem('diora_lib_layout', _lib.layout); } catch (err) {}
renderBookList(_lastBookListData);
} else if (a === 'add') _libAddMenu();
else if (a === 'sel-done') { _lib.selecting = null; renderBookList(_lastBookListData); }
else if (a === 'sel-folders') _libAssign(Array.from(_lib.selecting || []));
else if (a === 'series-up') { _lib.series = null; renderBookList(_lastBookListData); }
else if (a === 'series-manage') _libSeriesManage();
return;
}
if (stack && !card) {
_lib.series = stack.dataset.libSeries;
renderBookList(_lastBookListData);
window.scrollTo(0, 0);
return;
}
const id = Number(card.dataset.libBook);
if (_lib.selecting) {
if (_lib.selecting.has(id)) _lib.selecting.delete(id); else _lib.selecting.add(id);
renderBookList(_lastBookListData);
return;
}
const b = _lastBookListData.find(x => x.id === id);
if (b && b.keyOk === false) {
customAlert('Dieses Buch lässt sich mit deinem Schlüssel nicht entschlüsseln. Über ⋮ → „Schlüssel einfügen …“ den richtigen Schlüssel eintragen.');
return;
}
openBook(id);
}, true);
// A long press on a book: choosing several.
listEl.addEventListener('pointerdown', e => {
const card = e.target.closest('[data-lib-book]');
if (!card || e.target.closest('button')) return;
clearTimeout(pressTimer);
pressTimer = setTimeout(() => {
const id = Number(card.dataset.libBook);
_lib.selecting = _lib.selecting || new Set();
_lib.selecting.add(id);
pressed = id;
renderBookList(_lastBookListData);
}, 550);
});
['pointerup', 'pointercancel', 'pointerleave'].forEach(n => listEl.addEventListener(n, () => clearTimeout(pressTimer)));
listEl.addEventListener('input', e => {
if (!e.target.classList.contains('lib-q')) return;
_lib.q = e.target.value;
renderBookList(_lastBookListData);
});
listEl.addEventListener('change', e => {
if (!e.target.classList.contains('lib-sort')) return;
_lib.sort = e.target.value;
try { localStorage.setItem('diora_lib_sort', _lib.sort); } catch (err) {}
renderBookList(_lastBookListData);
});
// Back (Android): out of choosing, out of a search.
const back = window.__dioraBack;
window.__dioraBack = function () {
const shown = $('tab-books') && $('tab-books').style.display !== 'none';
if (shown && _lib.selecting) { _lib.selecting = null; renderBookList(_lastBookListData); return true; }
if (shown && _lib.series) { _lib.series = null; renderBookList(_lastBookListData); return true; }
if (shown && (_lib.q || _lib.searching)) { _lib.q = ''; _lib.searching = false; renderBookList(_lastBookListData); return true; }
return back ? back() : false;
};
if (window.DioraCovers) {
DioraCovers.onReady(id => {
const b = (_lastBookListData || []).find(x => x.id === id);
if (b && b.cover === 'generated') return;
const url = DioraCovers.url({id});
document.querySelectorAll(`#book-list [data-cover-id="${id}"]`).forEach(el => {
if (el.tagName === 'IMG') { el.src = url; return; }
const img = document.createElement('img');
img.className = el.className.replace('lib-nocover', '').trim();
img.alt = '';
img.dataset.coverId = id;
img.src = url;
el.replaceWith(img);
});
});
}
window.addEventListener('resize', () => { if (_lastBookListData && _lastBookListData.length) renderBookList(_lastBookListData); });
// Files dropped anywhere on the library (the old drop zone is hidden): one or several.
const tabEl = $('tab-books');
if (tabEl) {
tabEl.addEventListener('dragover', e => {
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).indexOf('Files') >= 0) { e.preventDefault(); tabEl.classList.add('lib-drop'); }
});
tabEl.addEventListener('dragleave', e => { if (e.target === tabEl) tabEl.classList.remove('lib-drop'); });
tabEl.addEventListener('drop', e => {
tabEl.classList.remove('lib-drop');
const files = Array.from((e.dataTransfer && e.dataTransfer.files) || []);
if (!files.length) return;
e.preventDefault();
if (files.length === 1) uploadEbook(files[0]);
else _importMany(files.map(f => ({name: f.name, size: f.size, buf: () => f.arrayBuffer()})));
});
}
_libLoadPrefs();
})();
function _libAddMenu() {
const wrap = document.createElement('div');
wrap.className = 'dfo-scrim';
const cloud = !(window.DioraNative && DioraNative.isReaderApp && DioraNative.isReaderApp());
wrap.innerHTML = `<div class="dfo-sheet" role="dialog"><div class="dfo-head"><strong>Hinzufügen</strong>
<button type="button" class="dfo-x" data-x="1" aria-label="Schließen">✕</button></div>
<button type="button" class="lib-sheet-item" data-a="file">E-Book-Datei (.epub, .pdf) …</button>
${cloud ? '<button type="button" class="lib-sheet-item" data-a="cloud">Aus der Cloud (WebDAV, Nextcloud) …</button>' : ''}
<button type="button" class="lib-sheet-item" data-a="select">Mehrere Bücher auswählen</button></div>`;
wrap.addEventListener('click', e => {
const a = e.target.closest('[data-a]');
if (e.target === wrap || e.target.closest('[data-x]')) { wrap.remove(); return; }
if (!a) return;
wrap.remove();
if (a.dataset.a === 'file') { const f = $('book-file-input'); if (f) f.click(); }
else if (a.dataset.a === 'cloud') toggleCloudImport();
else { _lib.selecting = new Set(); renderBookList(_lastBookListData); }
});
document.body.appendChild(wrap);
}
// ---- styles ---------------------------------------------------------------------------------------------
(function () {
const style = document.createElement('style');
style.textContent = [
'html.lib-v2 #tab-books .book-drop-zone, html.lib-v2 #tab-books .book-list-filter,',
'html.lib-v2 #book-upload-area > button.btn, html.lib-v2 #book-upload-area > .nui-add-row { display: none !important; }',
'#book-list .lib-head { display: flex; align-items: center; gap: 6px; margin: 4px 0 8px; }',
'#book-list .lib-h { margin: 0 auto 0 0; font-size: 18px; }',
'#book-list .lib-q { flex: 0 1 260px; min-width: 0; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }',
'#book-list .lib-sort { padding: 7px 6px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; font-size: 13px; max-width: 140px; }',
'#book-list .lib-icon { min-width: 38px; min-height: 38px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 17px; cursor: pointer; }',
'#book-list .lib-add { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 20px; }',
'#book-list .lib-chips { margin: 0 0 10px; }',
'#book-list .lib-selbar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; margin: 0 0 10px; border-radius: 8px; background: var(--raised, #222); }',
'#book-list .lib-selbar span { flex: 1; }',
'#book-list .lib-btn, .lib-sheet-item { min-height: 36px; padding: 0 14px; border-radius: 18px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }',
'.lib-sheet-item { display: block; width: 100%; text-align: left; border: none; border-radius: 0; border-bottom: 1px solid var(--border, #2a2a2a); min-height: 46px; }',
'.lib-danger { color: var(--accent, #e63946); }',
'#book-list .lib-continue { display: flex; align-items: center; gap: 12px; padding: 10px; margin: 0 0 16px; border: 1px solid var(--border, #2a2a2a); border-radius: 10px; background: var(--card, #0b0b0b); cursor: pointer; }',
'#book-list .lib-kicker { color: var(--accent, #e63946); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }',
'#book-list .lib-play { flex: none; width: 46px; height: 46px; border-radius: 50%; background: var(--accent, #e63946); color: var(--on-accent, #fff); display: flex; align-items: center; justify-content: center; font-size: 17px; }',
'#book-list .lib-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 18px 14px; }',
'@media (max-width: 480px) { #book-list .lib-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px 10px; } }',
'#book-list .lib-card { display: flex; flex-direction: column; gap: 3px; min-width: 0; cursor: pointer; }',
'#book-list .lib-coverbox { position: relative; display: block; }',
'#book-list .lib-cover { display: block; width: 100%; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 4px; background: var(--panel, #161616); box-shadow: 0 1px 3px rgba(0,0,0,.35); }',
'#book-list .lib-nocover { display: flex; flex-direction: column; justify-content: space-between; padding: 8px; box-sizing: border-box; overflow: hidden; background: hsl(var(--h), 28%, 24%); color: hsl(var(--h), 40%, 88%); }',
'#book-list .lib-nocover span { font-size: 12px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }',
'#book-list .lib-nocover b { font-size: 26px; opacity: .5; align-self: flex-end; }',
'#book-list .lib-thumb { width: 34px; height: 51px; flex: none; border-radius: 3px; object-fit: cover; }',
'#book-list .lib-thumb.lib-nocover { padding: 2px; }',
'#book-list .lib-thumb.lib-nocover span { display: none; }',
'#book-list .lib-thumb.lib-nocover b { font-size: 15px; align-self: center; margin: auto; }',
'#book-list .lib-thumb-l { width: 52px; height: 78px; }',
'#book-list .lib-badge { position: absolute; left: 6px; bottom: 6px; padding: 1px 6px; border-radius: 9px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 11px; }',
'#book-list .lib-card .lib-more { position: absolute; right: 2px; top: 2px; width: 30px; height: 30px; border-radius: 15px; border: none; background: rgba(0,0,0,.45); color: #fff; font-size: 16px; cursor: pointer; opacity: 0; }',
'#book-list .lib-card:hover .lib-more, #book-list .lib-card:focus-within .lib-more { opacity: 1; }',
'@media (hover: none) { #book-list .lib-card .lib-more { opacity: .85; } }',
'#book-list .lib-bar { display: block; height: 3px; border-radius: 2px; background: var(--border, #2a2a2a); overflow: hidden; margin-top: 4px; }',
'#book-list .lib-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }',
'#book-list .lib-title { font-size: 13px; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }',
'#book-list .lib-meta { font-size: 12px; color: var(--muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#book-list .lib-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
'#book-list .lib-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border, #2a2a2a); cursor: pointer; }',
'#book-list .lib-row .lib-more { background: none; border: none; color: inherit; font-size: 18px; min-width: 36px; min-height: 36px; cursor: pointer; }',
'#book-list .lib-state { font-size: 12px; color: var(--muted, #9a9a9a); white-space: nowrap; }',
'#book-list .lib-done { color: var(--accent, #e63946); }',
'#book-list .lib-sel .lib-cover, #book-list .lib-sel .lib-thumb { outline: 3px solid var(--accent, #e63946); outline-offset: 2px; }',
'#book-list .lib-row.lib-sel { background: var(--hover, rgba(255,255,255,.05)); }',
'#book-list .lib-group { margin: 18px 0 8px; font-size: 13px; font-weight: 400; color: var(--muted, #9a9a9a); }',
'#book-list .lib-count { opacity: .7; }',
'#book-list .lib-empty { color: var(--muted, #9a9a9a); font-size: 14px; }',
'body.eink #book-list .lib-add, body.eink #book-list .lib-play { background: #000; color: #fff; }',
'#tab-books.lib-drop { outline: 2px dashed var(--accent, #e63946); outline-offset: -6px; }',
'#book-list .lib-stackbox::before, #book-list .lib-stackbox::after { content: ""; position: absolute; left: 7%; right: 7%; height: 100%; border-radius: 4px; border: 1px solid var(--border-strong, #444); background: var(--panel, #161616); }',
'#book-list .lib-stackbox::before { top: -9px; left: 14%; right: 14%; }',
'#book-list .lib-stackbox::after { top: -5px; }',
'#book-list .lib-stackbox .lib-cover { position: relative; z-index: 1; }',
'#book-list .lib-stack { margin-top: 9px; }',
'#book-list .lib-place { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; }',
'#book-list .lib-place-name { flex: 1; min-width: 0; font-size: 16px; }',
'.lib-series-ix { display: flex; align-items: center; gap: 8px; margin: 10px 0 0; }',
'.lib-series-form .dfo-check .dfo-input { margin-left: 6px; }',
'#book-upload-status { display: block; margin: 0 0 10px; color: var(--muted, #9a9a9a); font-size: 13px; }',
'.cloud-chosen { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 6px 0; }',
'.cloud-chosen:empty { display: none; }',
'.cloud-entry .cloud-check { width: 18px; height: 18px; margin: 0 8px 0 2px; flex: none; }',
'body.eink #book-list .lib-cover { box-shadow: none; border: 1px solid #000; }',
].join('\n');
document.head.appendChild(style);
})();