diff --git a/docs/library.md b/docs/library.md index 85efa35..7d07190 100644 --- a/docs/library.md +++ b/docs/library.md @@ -60,6 +60,21 @@ PDF: page 1), scales it to 240 px wide JPEG, **encrypts it with the user's key** returns `{cover_ct, cover_iv}`. Clients cache the decrypted image locally. The server never sees a cover. +### Editions + +Two or more books can be **editions of one work** (the PDF scan and the EPUB, two +translations …): the same random key in each one's encrypted meta, `work: "w…"` +(`''` or missing = none). The list shows a work once — the edition read last, else an EPUB, +else the first added — with its formats on the cover ("EPUB · PDF", in the list view after +the author). Each edition keeps its own file, position and highlights. + +- Joining: choose several books → "Als Ausgaben verbinden", or a book's ⋮ → "Als Ausgabe + verbinden …" (other books listed, similar titles first, with a search). +- ⋮ → "Ausgaben (n) …" lists the editions: open one, "Lösen" sets one apart (the last one + left is set apart with it), "Weitere Ausgabe verbinden …". +- For the apps' own book sheets: `window.bookEditions(id)` opens that sheet, + `window.bookEditionsOf(id)` returns the editions (the book alone if it has none). + ### Generated covers A book without a cover (or one set to `cover: 'generated'` in its meta) gets one diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js index 9e675cc..3d0a7ee 100644 --- a/e2e/navigation.spec.js +++ b/e2e/navigation.spec.js @@ -104,3 +104,42 @@ test('several books at once; one already there is left out', async ({ page }) => const books = await page.evaluate(() => _lastBookListData.filter(b => /^Mehrfach/.test(b.title)).map(b => b.id)); for (const id of books) await page.request.post(`/books/${id}/delete/`); }); + +test('editions: two books joined show once with their formats, set apart show twice', async ({ page }) => { + await page.setViewportSize({ width: 1200, height: 800 }); + const files = ['Eins', 'Zwei'].map(n => path.join(__dirname, '.auth', `many-${n}.epub`)); + await page.goto('/#books'); + await page.waitForFunction(() => typeof _importMany === 'function' && Array.isArray(_lastBookListData)); + await page.locator('#book-file-input').setInputFiles(files); + await page.waitForFunction(() => _lastBookListData.filter(b => /^Mehrfach/.test(b.title)).length === 2, null, { timeout: 30000 }); + const ids = await page.evaluate(() => _lastBookListData.filter(b => /^Mehrfach/.test(b.title)).map(b => b.id)); + const list = page.locator('#book-list'); + // New books: as cards under "Ungelesen" (not in the "Zuletzt gelesen" area on top). + const unread = () => page.evaluate(() => { _lib.status = 'unread'; renderBookList(_lastBookListData); }); + await unread(); + const cards = () => list.locator('.lib-grid [data-lib-book]').filter({ hasText: 'Mehrfach' }); + await expect(cards()).toHaveCount(2); + // Choose both, join them. + await page.evaluate(ids => { _lib.selecting = new Set(ids); renderBookList(_lastBookListData); }, ids); + await list.locator('[data-lib="sel-editions"]').click(); + await page.waitForFunction(ids => { + const w = ids.map(id => _lastBookListData.find(b => b.id === id).work); + return w[0] && w[0] === w[1]; + }, ids); + await expect(cards()).toHaveCount(1); + await expect(cards().locator('.lib-ed')).toHaveText('EPUB'); + // The work survives a reload (it is in the encrypted meta). + await page.reload(); + await page.waitForFunction(() => Array.isArray(_lastBookListData) && _lastBookListData.length); + await unread(); + await expect(cards()).toHaveCount(1); + // Ausgaben: both listed; one set apart, and the other is on its own again. + await cards().locator('.lib-more').click({ force: true }); + await page.locator('.dfo-sheet .lib-sheet-item', { hasText: 'Ausgaben (2)' }).click(); + await expect(page.locator('.dfo-sheet .lib-ed-row')).toHaveCount(2); + await page.locator('.dfo-sheet [data-unlink]').first().click(); + await page.waitForFunction(ids => ids.every(id => !_lastBookListData.find(b => b.id === id).work), ids); + await expect(cards()).toHaveCount(2); + await page.evaluate(() => { _lib.status = 'all'; }); + for (const id of ids) await page.request.post(`/books/${id}/delete/`); +}); diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index 700b7d6..a4dde37 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, series: meta.series, seriesIndex: meta.seriesIndex, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt}; - 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, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, 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, work: meta.work, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt}; + 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, work: meta.work, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, 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, series: b.series, seriesIndex: b.seriesIndex, issue: b.issue, issueRead: b.issueRead, issueAt: b.issueAt}; + 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, work: b.work, issue: b.issue, issueRead: b.issueRead, issueAt: b.issueAt}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; renderBookList(cachedBooks); @@ -159,6 +159,7 @@ function repairBook(bookId) { cover: prevMeta.cover, series: prevMeta.series, seriesIndex: prevMeta.seriesIndex, + work: prevMeta.work, // A new file: its articles are read anew; what was read of them stays. issueRead: prevMeta.issueRead, issueAt: prevMeta.issueAt, @@ -445,6 +446,8 @@ async function _updateBookMeta(bookId, overrides) { cover: cached.cover !== undefined ? cached.cover : book.cover, series: cached.series !== undefined ? cached.series : book.series, seriesIndex: cached.seriesIndex !== undefined ? cached.seriesIndex : book.seriesIndex, + // Editions of one work (53-library.js): the same key on each. + work: cached.work !== undefined ? cached.work : book.work, // An issue's articles and what was read of them (54-issues.js). issue: cached.issue !== undefined ? cached.issue : book.issue, issueRead: cached.issueRead !== undefined ? cached.issueRead : book.issueRead, diff --git a/static/js/app/53-library.js b/static/js/app/53-library.js index abcf879..6048801 100644 --- a/static/js/app/53-library.js +++ b/static/js/app/53-library.js @@ -127,6 +127,124 @@ function _libAllSeries(books) { 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 --------------------------------------------------------------- function _libShown(books) { @@ -180,7 +298,10 @@ function _libState(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 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) : ''); + 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; @@ -208,7 +329,7 @@ function _libCard(b, label) { `; } return `
- ${_libCover(b, 'lib-cover')} + ${_libCover(b, 'lib-cover')}${edBadge} ${b.is_read ? '✓' : ''} @@ -339,7 +460,7 @@ function _libRender(allBooks) { const listEl = $('book-list'); if (!listEl) return; _lastBookListData = allBooks; - const books = _bookSectionFilter(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. @@ -357,6 +478,7 @@ function _libRender(allBooks) { if (_lib.selecting) { h += `
${_lib.selecting.size} ausgewählt +
`; } if (!allBooks.length) { @@ -571,6 +693,7 @@ function _libSheet(b) { [_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)], b.has_highlights && ['Markierungen exportieren', () => downloadBookAnnotationsFromList(b.id, null)], ['Regal-Kategorie abrufen', () => lookupBookMetadata(b.id)], @@ -624,6 +747,11 @@ 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 === '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; @@ -794,6 +922,13 @@ function _libAddMenu() { '#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; }', diff --git a/static/js/sw.js b/static/js/sw.js index b0b38d5..7c317ce 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-v130'; +const CACHE = 'diora-v131'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',