'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 => `
`).join('');
const wrap = _libSheetBox(`Ausgaben von „${b.title}“`, `
${rows}
`);
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 => ``;
const wrap = _libSheetBox(`Als Ausgabe von „${b.title}“`, `
${others.slice(0, 30).map(item).join('')}
`);
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 ``;
// None (yet): one drawn from the book's title, author, category and folders (gencover.js).
if (window.DioraGenCover) {
return ``;
}
const initials = (b.title || '?').trim().slice(0, 1).toUpperCase();
return `${_libEsc(b.title || '')}${_libEsc(initials)}`;
}
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 ? `${_libEsc(_libFormats(eds))}` : '';
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 `
`;
}
// 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 `
${_libEsc(g.name)}
${cards}
`;
}
// 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 `
${_libEsc(n)} ${issues.length}
` +
`
${issues.map(_libCard).join('')}
`;
}).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 = `
${magazines ? 'Zeitschriften' : 'Bücher'}
${!narrow || _lib.searching || _lib.q ? `` :
''}
`;
if (_libLengthRun && _libByLength()) {
h += `
Länge wird ermittelt … ${_libLengthRun.done} von ${_libLengthRun.of} Büchern
`;
}
if (_lib.selecting) {
h += `
${_lib.selecting.size} ausgewählt
`;
}
if (!allBooks.length) {
h += '
Noch keine Bücher. Mit + eine .epub- oder .pdf-Datei hinzufügen.
';
} else if (!books.length) {
h += `
${magazines ? 'Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.' : 'Alle Bücher stehen bei den Zeitschriften.'}
`;
} 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 += '
Nichts gefunden.
';
else if (magazines && _lib.sort === 'recent' && !_lib.q) h += _libMagazines(shown);
else h += `