1030 lines
58 KiB
JavaScript
1030 lines
58 KiB
JavaScript
'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; }',
|
||
'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);
|
||
})();
|