diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index 5ee4876..38ba91b 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -50,8 +50,8 @@ async function loadBookList() { try { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); - bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover}; - decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, has_cover: !!b.has_cover, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex}; + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, has_cover: !!b.has_cover, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); @@ -68,7 +68,7 @@ async function loadBookList() { const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress); if (cachedBooks.length) { cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || '')); - for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', folders: _foldersOfMeta(b), isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine, cover: b.cover}; + for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', folders: _foldersOfMeta(b), isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine, cover: b.cover, series: b.series, seriesIndex: b.seriesIndex}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; renderBookList(cachedBooks); @@ -157,6 +157,8 @@ function repairBook(bookId) { shelfTag: prevMeta.shelfTag || '', magazine: prevMeta.magazine, cover: prevMeta.cover, + series: prevMeta.series, + seriesIndex: prevMeta.seriesIndex, })); const [metaEnc, dataEnc] = await Promise.all([ encryptBytes(key, metaJson), @@ -438,6 +440,8 @@ async function _updateBookMeta(bookId, overrides) { pages: cached.pages || book.pages || 0, magazine: cached.magazine !== undefined ? cached.magazine : book.magazine, cover: cached.cover !== undefined ? cached.cover : book.cover, + series: cached.series !== undefined ? cached.series : book.series, + seriesIndex: cached.seriesIndex !== undefined ? cached.seriesIndex : book.seriesIndex, ...overrides, }; // The list decides; `folder` follows it (the first), for older clients. @@ -607,6 +611,27 @@ async function _importMany(items, statusEl) { return done; } +// The series an EPUB names itself: calibre's with +// calibre:series_index, or EPUB 3's belongs-to-collection with group-position. Or null. +function _seriesFromOpf(opf) { + const metas = Array.from(opf.getElementsByTagName('meta')); + const named = n => metas.filter(m => m.getAttribute('name') === n)[0]; + const cs = named('calibre:series'); + if (cs && cs.getAttribute('content')) { + const ix = named('calibre:series_index'); + const n = ix ? parseFloat(ix.getAttribute('content')) : NaN; + return {name: cs.getAttribute('content').trim(), index: isFinite(n) ? n : null}; + } + const coll = metas.filter(m => m.getAttribute('property') === 'belongs-to-collection')[0]; + if (coll && coll.textContent.trim()) { + const id = coll.getAttribute('id'); + const pos = id ? metas.filter(m => m.getAttribute('refines') === '#' + id && m.getAttribute('property') === 'group-position')[0] : null; + const n = pos ? parseFloat(pos.textContent) : NaN; + return {name: coll.textContent.trim(), index: isFinite(n) ? n : null}; + } + return null; +} + // Is a book with this title and author in the library already? function _knownBook(title, author) { const n = s => String(s || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/\s+/g, ' ').trim(); @@ -698,6 +723,7 @@ async function _importEbookBuffer(buf, filename, statusEl, opts) { let title = filename.replace(/\.(epub|pdf)$/i, ''); let author = ''; let isbn = ''; + let series, seriesIndex; const type = isPdf ? 'pdf' : 'epub'; if (isPdf) { @@ -719,13 +745,17 @@ async function _importEbookBuffer(buf, filename, statusEl, opts) { title = opfDoc.querySelector('metadata > title, metadata > *|title')?.textContent?.trim() || title; author = opfDoc.querySelector('metadata > creator, metadata > *|creator')?.textContent?.trim() || ''; isbn = _extractIsbnFromOpf(opfDoc); + const sr = _seriesFromOpf(opfDoc); + if (sr) { series = sr.name; seriesIndex = sr.index; } } } catch (e) { /* use filename as title */ } } if (opts.skipKnown && _knownBook(title, author)) return 'dup'; const key = await getOrCreateEncKey(); - const metaJson = new TextEncoder().encode(JSON.stringify({title, author, filename, type, isbn, folder: '', shelfTag: ''})); + const meta = {title, author, filename, type, isbn, folder: '', shelfTag: ''}; + if (series) { meta.series = series; if (seriesIndex != null) meta.seriesIndex = seriesIndex; } + const metaJson = new TextEncoder().encode(JSON.stringify(meta)); const [metaEnc, dataEnc] = await Promise.all([ encryptBytes(key, metaJson), encryptBytes(key, buf), diff --git a/static/js/app/53-library.js b/static/js/app/53-library.js index 46b7a8a..ce52f4a 100644 --- a/static/js/app/53-library.js +++ b/static/js/app/53-library.js @@ -23,6 +23,7 @@ const _lib = { 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'); @@ -68,6 +69,64 @@ function _libFolderList(books) { .map(n => ({name: n, count: counts.get(n)})); } +// ---- series (docs/library.md): set per book in its encrypted meta (series, seriesIndex), +// read from the EPUB on upload, else guessed from the title ("Der Zauberberg. Erster Band"). +// series '' means: none, on purpose. + +const _LIB_ORD = ['erst', 'zweit', 'dritt', 'viert', 'fünft', 'sechst', 'siebt', 'acht', 'neunt', 'zehnt', 'elft', 'zwölft']; +const _LIB_ORD_EN = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth']; +const _LIB_ROMAN = {i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6, vii: 7, viii: 8, ix: 9, x: 10}; + +function _libVolumeNumber(text) { + const t = String(text || '').toLowerCase(); + const d = /(\d+(?:[.,]\d+)?)/.exec(t); + if (d) return parseFloat(d[1].replace(',', '.')); + for (const w of t.split(/[^a-zäöüß]+/)) { + if (_LIB_ROMAN[w]) return _LIB_ROMAN[w]; + for (let i = 0; i < _LIB_ORD.length; i++) if (w.indexOf(_LIB_ORD[i]) === 0) return i + 1; + for (let i = 0; i < _LIB_ORD_EN.length; i++) if (w === _LIB_ORD_EN[i]) return i + 1; + } + return null; +} + +function _libSeriesGuess(title) { + const t = String(title || ''); + const m = /^(.+?)\s+[–—-]\s+(.+)$/.exec(t) || /^(.+?):\s+(.+)$/.exec(t) || /^(.+?)[.,]\s+(.+)$/.exec(t); + if (!m || !/\b(band|bd\.|teil|buch|folge|staffel|vol\.?|volume|part|book)\b/i.test(m[2]) || /bände|bundle/i.test(m[2])) return null; + return {name: m[1].trim(), index: _libVolumeNumber(m[2]), auto: true}; +} + +// A book's series: {name, index, auto} or null. +function _libSeries(b) { + if (b.series === '') return null; + if (b.series) { + const ix = typeof b.seriesIndex === 'number' ? b.seriesIndex : (_libSeriesGuess(b.title) || {}).index; + return {name: b.series, index: ix == null ? null : ix, auto: false}; + } + return _libSeriesGuess(b.title); +} +const _libSeriesKey = s => s.name.toLowerCase().replace(/\s+/g, ' ').trim(); + +function _libVolumeSort(a, b) { + const sa = _libSeries(a), sb = _libSeries(b); + const ia = sa && sa.index != null ? sa.index : 1e6, ib = sb && sb.index != null ? sb.index : 1e6; + return ia - ib || a.title.localeCompare(b.title, 'de', {numeric: true}); +} + +// Every series in the library: key -> {name, books (in order)}. +function _libAllSeries(books) { + const map = new Map(); + books.forEach(b => { + const s = _libSeries(b); + if (!s) return; + const k = _libSeriesKey(s); + if (!map.has(k)) map.set(k, {key: k, name: s.name, books: []}); + map.get(k).books.push(b); + }); + map.forEach(g => g.books.sort(_libVolumeSort)); + return map; +} + // ---- which books, in which order --------------------------------------------------------------- function _libShown(books) { @@ -119,14 +178,15 @@ function _libState(b) { return p > 0 ? p + ' %' : 'neu'; } -function _libCard(b) { +function _libCard(b, label) { const sel = _lib.selecting && _lib.selecting.has(b.id); + const by = (label ? label + (b.author ? ' · ' : '') : '') + (b.author || ''); // "Band 2 · Autor" in a series const pct = _libPct(b); if (_libLayout() === 'list') { return `
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 (_lib.series && !magazines) { + h += _libSeriesView(books); } else { const going = _libContinueBook(books); + const pool = shown.slice(); h += _libContinue(going); if (going) shown.splice(shown.indexOf(going), 1); // once, on top + // Series as stacks, unless searching, filtering by state or choosing several. + const stack = !magazines && !_lib.q && _lib.status === 'all' && !_lib.selecting; if (!shown.length && !going) h += 'Nichts gefunden.
'; else if (magazines && _lib.sort === 'recent' && !_lib.q) h += _libMagazines(shown); - else h += `