Serien für Bücher: erkannt, als Stapel, selbst zuweisen und verwalten
Some checks failed
Test / e2e (push) Waiting to run
Build and push Docker image / build (push) Successful in 18s
Test / test (push) Has been cancelled

- Beim Hochladen liest diora die Serie aus dem E-Book (calibre:series und
  series_index, EPUB 3 belongs-to-collection mit group-position); vorhandene
  Bücher werden am Titel erkannt („Der Zauberberg. Erster Band“, „…: Teil 3“).
- In der Bibliothek ist eine Serie ab zwei Bänden ein Stapel („2 Bände · bei
  Band 1“), darin die Bände in Reihenfolge; beim Suchen und Filtern einzeln.
- ⋮ → „Serie …“: vorhandene oder neue Serie, Band-Nummer, oder „Keine Serie“.
  In der Serie: umbenennen, Reihenfolge, auflösen. Alles im verschlüsselten
  Meta (series, seriesIndex; '' = bewusst keine).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-16 21:47:18 +02:00
parent 6d7fb4a849
commit f3b0080790
3 changed files with 266 additions and 10 deletions

View file

@ -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 <meta name="calibre:series" content> 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),

View file

@ -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 `<div class="lib-row${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
${_libCover(b, 'lib-thumb')}
<span class="lib-text"><span class="lib-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${_libEsc(b.author || '')}</span></span>
<span class="lib-meta">${_libEsc(by)}</span></span>
<span class="lib-state${b.is_read ? ' lib-done' : ''}">${_libEsc(_libState(b))}</span>
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></div>`;
}
@ -136,7 +196,7 @@ function _libCard(b) {
<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-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${_libEsc(b.author || '')}</span></div>`;
<span class="lib-meta">${_libEsc(by)}</span></div>`;
}
function _libContinueBook(books) {
@ -156,6 +216,56 @@ function _libContinue(b) {
<span class="lib-play" aria-hidden="true">▶</span></div>`;
}
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 `<div class="lib-row lib-stackrow" data-lib-series="${_libEsc(g.key)}">${_libCover(next, 'lib-thumb')}
<span class="lib-text"><span class="lib-title">${_libEsc(g.name)}</span><span class="lib-meta">${meta}</span></span>
<span class="lib-state">›</span></div>`;
}
return `<div class="lib-card lib-stack" data-lib-series="${_libEsc(g.key)}">
<span class="lib-coverbox lib-stackbox">${_libCover(next, 'lib-cover')}</span>
<span class="lib-bar"><span style="width:${pct}%"></span></span>
<span class="lib-title">${_libEsc(g.name)}</span>
<span class="lib-meta">${meta}</span></div>`;
}
// 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 `<div class="lib-place"><button type="button" class="lib-btn" data-lib="series-up">‹ Bücher</button>
<strong class="lib-place-name">${_libEsc(g.name)}</strong>
<button type="button" class="lib-icon" data-lib="series-manage" aria-label="Serie verwalten">⋯</button></div>
<div class="${_libLayout() === 'list' ? 'lib-list' : 'lib-grid'}">${cards}</div>`;
}
// 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 += '<p class="lib-empty">Noch keine Bücher. Mit + eine .epub- oder .pdf-Datei hinzufügen.</p>';
} else if (!books.length) {
h += `<p class="lib-empty">${magazines ? 'Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.' : 'Alle Bücher stehen bei den Zeitschriften.'}</p>`;
} 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 += '<p class="lib-empty">Nichts gefunden.</p>';
else if (magazines && _lib.sort === 'recent' && !_lib.q) h += _libMagazines(shown);
else h += `<div class="${_libLayout() === 'list' ? 'lib-list' : 'lib-grid'}">${shown.map(_libCard).join('')}</div>`;
else h += `<div class="${_libLayout() === 'list' ? 'lib-list' : 'lib-grid'}">${stack ? _libStacked(shown, pool) : shown.map(_libCard).join('')}</div>`;
}
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 = `<div class="dfo-sheet" role="dialog"><div class="dfo-head"><strong>${_libEsc(title)}</strong>
<button type="button" class="dfo-x" data-x="1" aria-label="Schließen">✕</button></div>${body}</div>`;
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 => `<label class="dfo-check"><input type="radio" name="sr" value="${_libEsc(n)}"${now && now.name === n ? ' checked' : ''}> ${_libEsc(n)}</label>`).join('');
const wrap = _libSheetBox(`Serie für „${b.title}“`, `<form class="lib-series-form">
<div class="dfo-list">${radios || '<p class="dfo-note">Noch keine Serien.</p>'}
<label class="dfo-check"><input type="radio" name="sr" value="__new"${now ? '' : ' checked'}> Neue Serie:
<input class="dfo-input" name="newname" maxlength="120" placeholder="Name der Serie" value="${now ? '' : _libEsc((_libSeriesGuess(b.title) || {}).name || '')}"></label></div>
<label class="lib-series-ix">Band <input class="dfo-input" name="ix" inputmode="decimal" size="5" value="${now && now.index != null ? String(now.index).replace('.', ',') : ''}"></label>
<div class="dfo-actions"><button type="button" class="dfo-btn" data-none="1">Keine Serie</button>
<button type="submit" class="dfo-btn dfo-primary">Übernehmen</button></div></form>`);
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) => `<div class="dfo-mrow"><span class="dfo-mname">${i + 1}. ${_libEsc(b.title)}</span>
<button type="button" class="dfo-icon" data-up="${i}"${i ? '' : ' disabled'} aria-label="Nach oben">↑</button>
<button type="button" class="dfo-icon" data-down="${i}"${i < order.length - 1 ? '' : ' disabled'} aria-label="Nach unten">↓</button></div>`).join('');
const wrap = _libSheetBox(`Serie „${g.name}“`, `<div class="dfo-list lib-series-order">${draw()}</div>
<div class="dfo-actions"><button type="button" class="dfo-btn" data-a="rename">Umbenennen …</button>
<button type="button" class="dfo-btn" data-a="dissolve">Auflösen</button>
<button type="button" class="dfo-btn dfo-primary" data-a="save">Reihenfolge übernehmen</button></div>`);
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; }',

View file

@ -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',