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 `
${_libCover(b, 'lib-thumb')} ${_libEsc(b.title)} - ${_libEsc(b.author || '')} + ${_libEsc(by)} ${_libEsc(_libState(b))}
`; } @@ -136,7 +196,7 @@ function _libCard(b) { ${_libEsc(b.title)} - ${_libEsc(b.author || '')}`; + ${_libEsc(by)}`; } function _libContinueBook(books) { @@ -156,6 +216,56 @@ function _libContinue(b) { `; } +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 `
${_libCover(next, 'lib-thumb')} + ${_libEsc(g.name)}${meta} + ›
`; + } + return `
+ ${_libCover(next, 'lib-cover')} + + ${_libEsc(g.name)} + ${meta}
`; +} + +// 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(); @@ -197,13 +307,18 @@ function _libRender(allBooks) { 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 (_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 += `
${shown.map(_libCard).join('')}
`; + else h += `
${stack ? _libStacked(shown, pool) : shown.map(_libCard).join('')}
`; } const q = listEl.querySelector('.lib-q'); const focused = q && document.activeElement === q; @@ -294,6 +409,97 @@ function _libManage() { }); } +// ---- series: assign, manage ------------------------------------------------------------------------ + +function _libSheetBox(title, body) { + const wrap = document.createElement('div'); + wrap.className = 'dfo-scrim'; + wrap.innerHTML = ``; + 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 => ``).join(''); + const wrap = _libSheetBox(`Serie für „${b.title}“`, `
+
${radios || '

Noch keine Serien.

'} +
+ +
+
`); + 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) => `
${i + 1}. ${_libEsc(b.title)} + +
`).join(''); + const wrap = _libSheetBox(`Serie „${g.name}“`, `
${draw()}
+
+ +
`); + const list = wrap.querySelector('.lib-series-order'); + const write = async fn => { + wrap.remove(); + try { + for (let i = 0; i < order.length; i++) await _updateBookMeta(order[i].id, fn(order[i], i)); + } catch (e) { customAlert('Das ließ sich nicht speichern: ' + e.message); } + renderBookList(_lastBookListData); + }; + wrap.addEventListener('click', async e => { + const t = e.target.closest('button'); + if (!t) return; + if (t.dataset.up != null || t.dataset.down != null) { + const i = Number(t.dataset.up != null ? t.dataset.up : t.dataset.down), j = t.dataset.up != null ? i - 1 : i + 1; + const x = order[i]; order[i] = order[j]; order[j] = x; + list.innerHTML = draw(); + } else if (t.dataset.a === 'save') { + write((b, i) => ({series: g.name, seriesIndex: i + 1})); + } else if (t.dataset.a === 'rename') { + const n = await customPrompt('Neuer Name der Serie:', g.name); + const name = n == null ? '' : String(n).trim().replace(/\s+/g, ' ').slice(0, 120); + if (!name || name === g.name) return; + _lib.series = name.toLowerCase(); + write(b => ({series: name, seriesIndex: (_libSeries(b) || {}).index == null ? null : _libSeries(b).index})); + } else if (t.dataset.a === 'dissolve') { + if (!(await customConfirm(`Serie „${g.name}“ auflösen? Die Bücher bleiben.`))) return; + _lib.series = null; + write(() => ({series: '', seriesIndex: null})); + } + }); +} + // ---- a book's actions (the apps may bring their own sheet: __dioraBookSheet) ------------------------- function _libSheet(b) { @@ -304,6 +510,7 @@ function _libSheet(b) { ['In Ordner …', () => _libAssign([b.id])], [_isMagazine(b) ? 'Als Buch führen' : 'Als Zeitschrift führen', () => toggleBookMagazine(b.id)], [b.cover === 'generated' ? 'Eigenes Cover verwenden' : 'Generiertes Cover verwenden', () => toggleBookCover(b.id)], + ['Serie …', () => _libSeriesSheet(b)], [b.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren', () => toggleBookRead(b.id, !!b.is_read)], b.has_highlights && ['Markierungen exportieren', () => downloadBookAnnotationsFromList(b.id, null)], ['Regal-Kategorie abrufen', () => lookupBookMetadata(b.id)], @@ -338,7 +545,8 @@ function _libSheet(b) { const more = t.closest('[data-lib-more]'); const act = t.closest('[data-lib]'); const card = t.closest('[data-lib-book]'); - if (!more && !act && !card) return; + 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) { @@ -356,6 +564,14 @@ function _libSheet(b) { } else if (a === 'add') _libAddMenu(); else if (a === 'sel-done') { _lib.selecting = null; renderBookList(_lastBookListData); } else if (a === 'sel-folders') _libAssign(Array.from(_lib.selecting || [])); + else if (a === 'series-up') { _lib.series = null; renderBookList(_lastBookListData); } + else if (a === 'series-manage') _libSeriesManage(); + return; + } + if (stack && !card) { + _lib.series = stack.dataset.libSeries; + renderBookList(_lastBookListData); + window.scrollTo(0, 0); return; } const id = Number(card.dataset.libBook); @@ -401,6 +617,7 @@ function _libSheet(b) { window.__dioraBack = function () { const shown = $('tab-books') && $('tab-books').style.display !== 'none'; if (shown && _lib.selecting) { _lib.selecting = null; renderBookList(_lastBookListData); return true; } + if (shown && _lib.series) { _lib.series = null; renderBookList(_lastBookListData); return true; } if (shown && (_lib.q || _lib.searching)) { _lib.q = ''; _lib.searching = false; renderBookList(_lastBookListData); return true; } return back ? back() : false; }; @@ -516,6 +733,15 @@ function _libAddMenu() { '#book-list .lib-empty { color: var(--muted, #9a9a9a); font-size: 14px; }', 'body.eink #book-list .lib-add, body.eink #book-list .lib-play { background: #000; color: #fff; }', '#tab-books.lib-drop { outline: 2px dashed var(--accent, #e63946); outline-offset: -6px; }', + '#book-list .lib-stackbox::before, #book-list .lib-stackbox::after { content: ""; position: absolute; left: 7%; right: 7%; height: 100%; border-radius: 4px; border: 1px solid var(--border-strong, #444); background: var(--panel, #161616); }', + '#book-list .lib-stackbox::before { top: -9px; left: 14%; right: 14%; }', + '#book-list .lib-stackbox::after { top: -5px; }', + '#book-list .lib-stackbox .lib-cover { position: relative; z-index: 1; }', + '#book-list .lib-stack { margin-top: 9px; }', + '#book-list .lib-place { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; }', + '#book-list .lib-place-name { flex: 1; min-width: 0; font-size: 16px; }', + '.lib-series-ix { display: flex; align-items: center; gap: 8px; margin: 10px 0 0; }', + '.lib-series-form .dfo-check .dfo-input { margin-left: 6px; }', '#book-upload-status { display: block; margin: 0 0 10px; color: var(--muted, #9a9a9a); font-size: 13px; }', '.cloud-chosen { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 6px 0; }', '.cloud-chosen:empty { display: none; }', diff --git a/static/js/sw.js b/static/js/sw.js index 098dcc6..80ad434 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v121'; +const CACHE = 'diora-v122'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',