Bibliothek: Ausgaben – PDF und EPUB (oder zwei Übersetzungen) als ein Werk, in der Liste einmal mit ihren Formaten
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2327eb06d5
commit
7f9d6fc22a
5 changed files with 199 additions and 7 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/`);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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; }',
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue