diora-web/static/js/app/53-library.js
mrwnslz 841ac5bed5
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 10m44s
Test / e2e (push) Has been cancelled
Bibliothek: Hinweis „Länge wird ermittelt“ gedämpft gesetzt; sw v151
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:28:52 +01:00

1031 lines
58 KiB
JavaScript
Raw Permalink 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;
}
// ---- editions: one work as PDF and EPUB (or two translations …), shown once ----------------------
// Books with the same `work` key in their meta are editions of one work. The list shows one of
// them: the one read last, else an EPUB, else the first added; the others are behind "Ausgaben".
const _libWork = b => (b && typeof b.work === 'string' && b.work) || '';
function _libEditionGroups(books) {
const map = new Map();
books.forEach(b => {
const w = _libWork(b);
if (!w) return;
if (!map.has(w)) map.set(w, []);
map.get(w).push(b);
});
map.forEach((list, w) => { if (list.length < 2) map.delete(w); });
return map;
}
function _libEditionLead(list) {
return list.slice().sort((a, b) => (_bookRecency(b) - _bookRecency(a)) ||
((a.type === 'epub' ? 0 : 1) - (b.type === 'epub' ? 0 : 1)) || a.id - b.id)[0];
}
// The books with every work once; _lib.editions: the shown book's id -> all its editions.
function _libCollapse(books) {
const groups = _libEditionGroups(books);
_lib.editions = new Map();
if (!groups.size) return books;
const hide = new Set();
groups.forEach(list => {
const lead = _libEditionLead(list);
_lib.editions.set(lead.id, list);
list.forEach(b => { if (b !== lead) hide.add(b.id); });
});
return books.filter(b => !hide.has(b.id));
}
function _libEditionsOf(b) {
const w = _libWork(b);
return w ? (_lastBookListData || []).filter(x => _libWork(x) === w) : [b];
}
const _libFormat = b => (b.type || 'epub').toUpperCase();
function _libFormats(list) {
return Array.from(new Set(list.map(_libFormat))).join(' · ');
}
async function _libLinkEditions(ids) {
const books = ids.map(id => _lastBookListData.find(b => b.id === id)).filter(Boolean);
if (books.length < 2) return;
const key = books.map(_libWork).filter(Boolean)[0] ||
'w' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
try {
for (const b of books) if (_libWork(b) !== key) await _updateBookMeta(b.id, {work: key});
renderBookList(_lastBookListData);
} catch (e) { customAlert('Das ließ sich nicht speichern: ' + e.message); }
}
async function _libUnlinkEdition(b) {
const rest = _libEditionsOf(b).filter(x => x !== b);
try {
await _updateBookMeta(b.id, {work: ''});
// One left on its own is no longer an edition of anything.
if (rest.length === 1) await _updateBookMeta(rest[0].id, {work: ''});
renderBookList(_lastBookListData);
} catch (e) { customAlert('Das ließ sich nicht speichern: ' + e.message); }
}
// "Ausgaben": the editions of a book, each to open or to set apart; or, for a book with none,
// another book to join it with (the ones with a similar title first).
function _libEditionsSheet(b) {
const eds = _libEditionsOf(b);
if (eds.length > 1) {
const rows = eds.slice().sort((x, y) => x.id - y.id).map(x => `<div class="lib-ed-row">
<button type="button" class="lib-sheet-item" data-open="${x.id}"><b>${_libEsc(_libFormat(x))}</b> · ${_libEsc(_libState(x))}
<span class="lib-meta">${_libEsc(x.title)}${x.last_read ? ' · ' + _libEsc(_libAgo(x.last_read)) : ''}</span></button>
<button type="button" class="lib-btn" data-unlink="${x.id}">Lösen</button></div>`).join('');
const wrap = _libSheetBox(`Ausgaben von „${b.title}“`, `<div class="dfo-list">${rows}</div>
<div class="dfo-actions"><button type="button" class="dfo-btn" data-add="1">Weitere Ausgabe verbinden …</button></div>`);
wrap.addEventListener('click', e => {
const o = e.target.closest('[data-open]'), u = e.target.closest('[data-unlink]'), a = e.target.closest('[data-add]');
if (o) { wrap.remove(); openBook(Number(o.dataset.open)); }
else if (u) { wrap.remove(); _libUnlinkEdition(eds.find(x => x.id === Number(u.dataset.unlink))); }
else if (a) { wrap.remove(); _libPickEdition(b); }
});
return;
}
_libPickEdition(b);
}
function _libPickEdition(b) {
const mine = new Set(_libEditionsOf(b).map(x => x.id));
const words = t => _libNorm(t).split(/[^a-z0-9äöüß]+/).filter(w => w.length > 3);
const own = new Set(words(b.title).concat(words(b.author)));
const score = x => words(x.title + ' ' + x.author).filter(w => own.has(w)).length;
const others = _lastBookListData.filter(x => !mine.has(x.id) && x.keyOk !== false)
.map(x => [score(x), x]).sort((p, q) => q[0] - p[0] || p[1].title.localeCompare(q[1].title, 'de')).map(p => p[1]);
const item = x => `<button type="button" class="lib-sheet-item" data-pick="${x.id}"><b>${_libEsc(_libFormat(x))}</b> · ${_libEsc(x.title)}
<span class="lib-meta">${_libEsc(x.author || '')}</span></button>`;
const wrap = _libSheetBox(`Als Ausgabe von „${b.title}“`, `<input class="dfo-input lib-ed-q" type="search" placeholder="Titel, Autor …">
<div class="dfo-list lib-ed-list">${others.slice(0, 30).map(item).join('')}</div>`);
const q = wrap.querySelector('.lib-ed-q');
q.addEventListener('input', () => {
const t = _libNorm(q.value).trim();
wrap.querySelector('.lib-ed-list').innerHTML = others.filter(x => !t || _libNorm(x.title + ' ' + x.author).indexOf(t) >= 0)
.slice(0, 30).map(item).join('');
});
wrap.addEventListener('click', e => {
const p = e.target.closest('[data-pick]');
if (!p) return;
wrap.remove();
_libLinkEditions([b.id, Number(p.dataset.pick)]);
});
}
window.bookEditions = id => { const b = (_lastBookListData || []).find(x => x.id === Number(id)); if (b) _libEditionsSheet(b); };
window.bookEditionsOf = id => { const b = (_lastBookListData || []).find(x => x.id === Number(id)); return b ? _libEditionsOf(b) : []; };
// ---- which books, in which order ---------------------------------------------------------------
const _libByLength = () => _lib.sort === 'short' || _lib.sort === 'long';
// A book's length is in its meta once it was opened (62-reader.js) or uploaded (since 2026-10-25).
// For the others this device works it out once, when a length sort is chosen: the file from the
// offline copy or the server, decrypted here, its text counted (bookPagesOfFile), the pages into
// the encrypted meta. One book after the other; a book that fails isn't tried again this session.
let _libLengthRun = null;
const _libLengthTried = new Set();
function _libFillLengths() {
if (_libLengthRun || !_libByLength()) return;
const todo = (_lastBookListData || []).filter(b => !b.pages && b.keyOk !== false && !_libLengthTried.has(b.id));
if (!todo.length) return;
_libLengthRun = {done: 0, of: todo.length};
(async () => {
let shown = Date.now();
try {
const key = await getOrCreateEncKey();
for (const b of todo) {
if (!_libByLength()) break;
_libLengthTried.add(b.id);
try {
let file = await _getCachedBook(b.id);
if (!file) {
const res = await fetch(`/books/${b.id}/data/`);
if (!res.ok) throw new Error(res.status);
file = await res.json();
}
const pages = await bookPagesOfFile(await decryptBytes(key, file.data_iv, file.data_ct), b.type);
if (pages > 0) await _updateBookMeta(b.id, {pages});
} catch (e) { /* stays at the end */ }
_libLengthRun.done++;
if (Date.now() - shown > 4000) { shown = Date.now(); renderBookList(_lastBookListData); }
}
} finally {
_libLengthRun = null;
renderBookList(_lastBookListData);
}
})();
}
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 || '')),
// By length (pages in the meta); a book whose length isn't known yet comes last.
short: (a, b) => ((a.pages || Infinity) - (b.pages || Infinity)) || a.title.localeCompare(b.title, 'de', {numeric: true}),
long: (a, b) => ((b.pages || 0) - (a.pages || 0)) || a.title.localeCompare(b.title, 'de', {numeric: true}),
};
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 eds = _lib.editions && _lib.editions.get(b.id);
const by = (label ? label + (b.author ? ' · ' : '') : '') + (b.author || '') + // "Band 2 · Autor" in a series
(eds && _libLayout() === 'list' ? ' · ' + _libFormats(eds) : '') +
(_libByLength() && b.pages ? (b.author || label ? ' · ' : '') + b.pages + ' S.' : '');
const edBadge = eds ? `<span class="lib-ed" title="${eds.length} Ausgaben">${_libEsc(_libFormats(eds))}</span>` : '';
const pct = _libPct(b);
// An issue with its articles (54-issues.js): read of them instead of a percentage.
const iss = typeof _issueSummary === 'function' && _isMagazine(b) ? _issueSummary(b) : null;
if (iss && _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(_issueCounts(iss))}</span>${_issueSegments(iss, -1, 'iss-seg-thin')}</span>
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></div>`;
}
if (iss) {
return `<div class="lib-card${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
<span class="lib-coverbox">${_libCover(b, 'lib-cover')}
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></span>
${_issueSegments(iss, -1, 'iss-seg-thin')}
<span class="lib-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${iss.read}/${iss.total} gelesen</span></div>`;
}
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')}${edBadge}
${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>`;
}
// ---- "Zuletzt gelesen": the book read last, large, and the ones read before it in a row --------
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;
}
// The others read lately (finished ones too), newest first, without the large one.
function _libRecentBooks(books, going) {
if (!going) return [];
return _recentlyReadBooks(books).filter(x => x !== going && x.keyOk !== false).slice(0, 6);
}
// "vor 5 Minuten", "gestern", "vor 3 Tagen", "am 4. Okt."
function _libAgo(iso) {
const t = Date.parse(iso || '');
if (!t) return '';
const min = Math.round((Date.now() - t) / 60000);
if (min < 2) return 'gerade eben';
if (min < 60) return `vor ${min} Minuten`;
const h = Math.round(min / 60);
const day = d => new Date(d).toDateString();
if (day(t) === day(Date.now())) return h === 1 ? 'vor einer Stunde' : `vor ${h} Stunden`;
if (day(t) === day(Date.now() - 86400000)) return 'gestern';
const days = Math.round((Date.now() - t) / 86400000);
if (days < 7) return `vor ${days} Tagen`;
return 'am ' + new Date(t).toLocaleDateString('de-DE', {day: 'numeric', month: 'short'});
}
function _libContinue(b, recent) {
if (!b) return '';
const iss = typeof _issueSummary === 'function' && _isMagazine(b) ? _issueSummary(b) : null;
const nx = iss && iss.next >= 0 ? iss.arts[iss.next] : null;
const pages = b.pages ? ` · Seite ${Math.max(1, Math.round(b.pages * (b.scroll_fraction || 0)))} von ${b.pages}` : '';
const ago = _libAgo(b.last_read);
const row = (recent || []).map(r => `<div class="lib-recent-item" data-lib-book="${r.id}">
<span class="lib-coverbox">${_libCover(r, 'lib-cover')}${r.is_read ? '<span class="lib-badge">✓</span>' : ''}</span>
${(() => { const ri = typeof _issueSummary === 'function' && _isMagazine(r) ? _issueSummary(r) : null;
return ri ? _issueSegments(ri, -1, 'iss-seg-thin') : `<span class="lib-bar"><span style="width:${_libPct(r)}%"></span></span>`; })()}
<span class="lib-title">${_libEsc(r.title)}</span></div>`).join('');
return `<section class="lib-recent" aria-label="Zuletzt gelesen">
<div class="lib-continue" data-lib-book="${b.id}">${_libCover(b, 'lib-hero-cover')}
<span class="lib-text"><span class="lib-kicker">${iss ? 'Im Heft weiterlesen' : 'Weiterlesen'}${ago ? ' · ' + ago : ''}</span>
<span class="lib-title lib-hero-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${nx ? _libEsc('Artikel ' + (iss.next + 1) + ' · ' + nx.t) : _libEsc(b.author || '')}</span>
${iss ? `<span class="lib-hero-progress">${_issueSegments(iss, -1)}<span class="lib-meta">${_libEsc(_issueCounts(iss))}</span></span>`
: `<span class="lib-hero-progress"><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">▶ Weiterlesen</span></span></div>
${row ? `<div class="lib-recent-head">Zuletzt gelesen</div><div class="lib-recent-row">${row}</div>` : ''}
</section>`;
}
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 = _libCollapse(_bookSectionFilter(allBooks));
const magazines = window.DioraNav && _bookSection === 'magazines';
const shown = _libShown(books);
// An issue opened (54-issues.js): its contents instead of the list, without the list's search and chips.
const issueOpen = magazines && _lib.issue != null && typeof _issueView === 'function' && books.some(b => b.id === _lib.issue);
listEl.classList.toggle('iss-open', !!issueOpen);
const narrow = window.matchMedia && matchMedia('(max-width: 720px)').matches;
const sorts = [['recent', 'Zuletzt'], ['title', 'Titel'], ['author', 'Autor'], ['added', 'Neu hinzugefügt'],
['short', 'Kürzeste zuerst'], ['long', 'Längste zuerst']];
_libFillLengths();
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 (_libLengthRun && _libByLength()) {
h += `<p class="lib-length-note">Länge wird ermittelt … ${_libLengthRun.done} von ${_libLengthRun.of} Büchern</p>`;
}
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-editions"${_lib.selecting.size > 1 ? '' : ' disabled'}>Als Ausgaben verbinden</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 (issueOpen) {
h += _issueView(books.find(b => b.id === _lib.issue));
} else if (_lib.series && !magazines) {
h += _libSeriesView(books);
} else {
const going = _libContinueBook(books);
const pool = shown.slice();
h += _libContinue(going, _libRecentBooks(books, 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);
// Issues still without their articles' index: built now and then, in the background.
if (magazines && typeof _issueIndexLater === 'function') _issueIndexLater(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 _libProjectsLabel(b) {
const inIt = window.DioraProjects ? DioraProjects.of('book', b.id) : [];
return 'Mit Projekt verknüpfen …' + (inIt.length ? ` <span class="muted">(${inIt.map(p => _libEsc(p.name)).join(', ')})</span>` : '');
}
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)],
[_libEditionsOf(b).length > 1 ? `Ausgaben (${_libEditionsOf(b).length}) …` : 'Als Ausgabe verbinden …', () => _libEditionsSheet(b)],
[b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren', () => toggleBookRead(b.id, !!b.is_read)],
// Projekte (projects.js): "Forschung › Mit Projekt verknüpfen", and where it is already.
window.DioraProjects && [_libProjectsLabel(b), () => DioraProjects.link('book', b.id, {title: b.title, author: b.author})],
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 === 'sel-editions') {
const ids = Array.from(_lib.selecting || []);
_lib.selecting = null;
_libLinkEditions(ids);
}
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;
}
if (b && _isMagazine(b) && typeof _issueOpen === 'function') {
if (card.classList.contains('lib-continue') && _issueSummary(b)) _issueContinue(id);
else _issueOpen(id);
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.issue != null) { _lib.issue = 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-kicker { color: var(--accent, #e63946); font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }',
// "Zuletzt gelesen": the last book large (cover, title, progress, a button), the others in a row.
'#book-list .lib-recent { margin: 0 0 22px; }',
'#book-list .lib-continue { display: flex; align-items: stretch; gap: 16px; padding: 14px; border: 1px solid var(--border, #2a2a2a); border-radius: 12px; background: var(--card, #0b0b0b); cursor: pointer; }',
'#book-list .lib-hero-cover { flex: none; width: 104px; height: 156px; border-radius: 5px; object-fit: cover; box-shadow: 0 2px 8px rgba(0,0,0,.35); }',
'#book-list .lib-hero-cover.lib-nocover { padding: 8px; }',
'#book-list .lib-continue .lib-text { justify-content: center; gap: 4px; }',
'#book-list .lib-continue .lib-hero-title { font-size: 18px; font-weight: 700; line-height: 1.25; -webkit-line-clamp: 3; }',
'#book-list .lib-hero-progress { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; }',
'#book-list .lib-hero-progress .lib-bar { height: 5px; margin-top: 0; }',
'#book-list .lib-play { align-self: flex-start; margin-top: 10px; padding: 9px 18px; border-radius: 20px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 14px; font-weight: 600; white-space: nowrap; }',
'@media (min-width: 721px) { #book-list .lib-hero-cover { width: 136px; height: 204px; } #book-list .lib-continue .lib-hero-title { font-size: 22px; } #book-list .lib-continue { padding: 18px; gap: 22px; } }',
'#book-list .lib-recent-head { margin: 16px 0 8px; font-size: 13px; color: var(--muted, #9a9a9a); }',
'#book-list .lib-recent-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; }',
'#book-list .lib-recent-row::-webkit-scrollbar { display: none; }',
'#book-list .lib-recent-item { flex: none; width: 92px; display: flex; flex-direction: column; gap: 3px; cursor: pointer; }',
'@media (min-width: 721px) { #book-list .lib-recent-item { width: 112px; } }',
'#book-list .lib-recent-item .lib-title { font-size: 12px; }',
'#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-ed { position: absolute; left: 4px; top: 4px; padding: 1px 6px; border-radius: 4px; background: rgba(0,0,0,.62); color: #fff; font-size: 10px; letter-spacing: .03em; }',
'body.eink #book-list .lib-ed { background: #fff; color: #000; border: 1px solid #000; }',
'.lib-ed-row { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border, #2a2a2a); }',
'.lib-ed-row .lib-sheet-item { border-bottom: none; flex: 1; }',
'.lib-sheet-item .lib-meta { display: block; font-size: 12px; color: var(--muted, #9a9a9a); }',
'.lib-ed-q { width: 100%; box-sizing: border-box; margin: 0 0 8px; }',
'.lib-ed-list { max-height: 55vh; overflow-y: auto; }',
'#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; }',
'#book-list .lib-length-note { color: var(--muted, #9a9a9a); font-size: 13px; margin: 4px 0 8px; }',
'body.eink #book-list .lib-add, body.eink #book-list .lib-play { background: #000; color: #fff; }',
'body.eink #book-list .lib-continue { border: 2px solid #000; background: #fff; }',
'body.eink #book-list .lib-hero-cover { box-shadow: none; border: 1px solid #000; }',
// Progress on e-ink: a light track, the part read in black (the border colour is black there).
'body.eink #book-list .lib-bar { background: #ccc; }',
'body.eink #book-list .lib-bar > span { background: #000; }',
'#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);
})();