Bibliothek: Ausgaben – PDF und EPUB (oder zwei Übersetzungen) als ein Werk, in der Liste einmal mit ihren Formaten
Some checks failed
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 9m16s
Test / e2e (push) Failing after 2m42s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-19 14:27:14 +02:00
parent 2327eb06d5
commit 7f9d6fc22a
5 changed files with 199 additions and 7 deletions

View file

@ -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 returns `{cover_ct, cover_iv}`. Clients cache the decrypted image locally. The
server never sees a cover. 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 ### Generated covers
A book without a cover (or one set to `cover: 'generated'` in its meta) gets one A book without a cover (or one set to `cover: 'generated'` in its meta) gets one

View file

@ -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)); 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/`); 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/`);
});

View file

@ -50,8 +50,8 @@ async function loadBookList() {
try { try {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf)); 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}; 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, 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}); 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) { } catch (e) {
bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; 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}); 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); const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress);
if (cachedBooks.length) { if (cachedBooks.length) {
cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || '')); 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'); const uploadArea = $('book-upload-area');
if (uploadArea) uploadArea.style.display = 'none'; if (uploadArea) uploadArea.style.display = 'none';
renderBookList(cachedBooks); renderBookList(cachedBooks);
@ -159,6 +159,7 @@ function repairBook(bookId) {
cover: prevMeta.cover, cover: prevMeta.cover,
series: prevMeta.series, series: prevMeta.series,
seriesIndex: prevMeta.seriesIndex, seriesIndex: prevMeta.seriesIndex,
work: prevMeta.work,
// A new file: its articles are read anew; what was read of them stays. // A new file: its articles are read anew; what was read of them stays.
issueRead: prevMeta.issueRead, issueRead: prevMeta.issueRead,
issueAt: prevMeta.issueAt, issueAt: prevMeta.issueAt,
@ -445,6 +446,8 @@ async function _updateBookMeta(bookId, overrides) {
cover: cached.cover !== undefined ? cached.cover : book.cover, cover: cached.cover !== undefined ? cached.cover : book.cover,
series: cached.series !== undefined ? cached.series : book.series, series: cached.series !== undefined ? cached.series : book.series,
seriesIndex: cached.seriesIndex !== undefined ? cached.seriesIndex : book.seriesIndex, 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). // An issue's articles and what was read of them (54-issues.js).
issue: cached.issue !== undefined ? cached.issue : book.issue, issue: cached.issue !== undefined ? cached.issue : book.issue,
issueRead: cached.issueRead !== undefined ? cached.issueRead : book.issueRead, issueRead: cached.issueRead !== undefined ? cached.issueRead : book.issueRead,

View file

@ -127,6 +127,124 @@ function _libAllSeries(books) {
return map; 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 --------------------------------------------------------------- // ---- which books, in which order ---------------------------------------------------------------
function _libShown(books) { function _libShown(books) {
@ -180,7 +298,10 @@ function _libState(b) {
function _libCard(b, label) { function _libCard(b, label) {
const sel = _lib.selecting && _lib.selecting.has(b.id); 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 ? `<span class="lib-ed" title="${eds.length} Ausgaben">${_libEsc(_libFormats(eds))}</span>` : '';
const pct = _libPct(b); const pct = _libPct(b);
// An issue with its articles (54-issues.js): read of them instead of a percentage. // 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; const iss = typeof _issueSummary === 'function' && _isMagazine(b) ? _issueSummary(b) : null;
@ -208,7 +329,7 @@ function _libCard(b, label) {
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></div>`; <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}"> return `<div class="lib-card${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
<span class="lib-coverbox">${_libCover(b, 'lib-cover')} <span class="lib-coverbox">${_libCover(b, 'lib-cover')}${edBadge}
${b.is_read ? '<span class="lib-badge">✓</span>' : ''} ${b.is_read ? '<span class="lib-badge">✓</span>' : ''}
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></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-bar"><span style="width:${pct}%"></span></span>
@ -339,7 +460,7 @@ function _libRender(allBooks) {
const listEl = $('book-list'); const listEl = $('book-list');
if (!listEl) return; if (!listEl) return;
_lastBookListData = allBooks; _lastBookListData = allBooks;
const books = _bookSectionFilter(allBooks); const books = _libCollapse(_bookSectionFilter(allBooks));
const magazines = window.DioraNav && _bookSection === 'magazines'; const magazines = window.DioraNav && _bookSection === 'magazines';
const shown = _libShown(books); const shown = _libShown(books);
// An issue opened (54-issues.js): its contents instead of the list, without the list's search and chips. // 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) { if (_lib.selecting) {
h += `<div class="lib-selbar"><span>${_lib.selecting.size} ausgewählt</span> 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-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>`; <button type="button" class="lib-btn" data-lib="sel-done">Fertig</button></div>`;
} }
if (!allBooks.length) { if (!allBooks.length) {
@ -571,6 +693,7 @@ function _libSheet(b) {
[_isMagazine(b) ? 'Als Buch führen' : 'Als Zeitschrift führen', () => toggleBookMagazine(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)], [b.cover === 'generated' ? 'Eigenes Cover verwenden' : 'Generiertes Cover verwenden', () => toggleBookCover(b.id)],
['Serie …', () => _libSeriesSheet(b)], ['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.is_read ? 'Als ungelesen markieren' : 'Als gelesen markieren', () => toggleBookRead(b.id, !!b.is_read)],
b.has_highlights && ['Markierungen exportieren', () => downloadBookAnnotationsFromList(b.id, null)], b.has_highlights && ['Markierungen exportieren', () => downloadBookAnnotationsFromList(b.id, null)],
['Regal-Kategorie abrufen', () => lookupBookMetadata(b.id)], ['Regal-Kategorie abrufen', () => lookupBookMetadata(b.id)],
@ -624,6 +747,11 @@ function _libSheet(b) {
} else if (a === 'add') _libAddMenu(); } else if (a === 'add') _libAddMenu();
else if (a === 'sel-done') { _lib.selecting = null; renderBookList(_lastBookListData); } else if (a === 'sel-done') { _lib.selecting = null; renderBookList(_lastBookListData); }
else if (a === 'sel-folders') _libAssign(Array.from(_lib.selecting || [])); 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-up') { _lib.series = null; renderBookList(_lastBookListData); }
else if (a === 'series-manage') _libSeriesManage(); else if (a === 'series-manage') _libSeriesManage();
return; return;
@ -794,6 +922,13 @@ function _libAddMenu() {
'#book-list .lib-thumb.lib-nocover span { display: none; }', '#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.lib-nocover b { font-size: 15px; align-self: center; margin: auto; }',
'#book-list .lib-thumb-l { width: 52px; height: 78px; }', '#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-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 .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; }', '#book-list .lib-card:hover .lib-more, #book-list .lib-card:focus-within .lib-more { opacity: 1; }',

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v130'; const CACHE = 'diora-v131';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',