Bibliothek: nach Länge sortieren (kürzeste/längste zuerst); Seitenzahl beim Hochladen, für ältere Bücher einmal auf dem Gerät ermittelt; sw v150
Some checks failed
Test / e2e (push) Waiting to run
Test / test (push) Waiting to run
Build and push Docker image / build (push) Has been cancelled

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-25 12:28:41 +01:00
parent 58003057e3
commit 6e8e5a0f7b
5 changed files with 90 additions and 4 deletions

View file

@ -54,7 +54,13 @@ Each area keeps its own storage and calls these for the look and the dialogs.
## The library (Bücher, Zeitschriften)
- **Header**: title, search (title, author), sort (zuletzt gelesen · Titel ·
Autor · hinzugefügt), ⊞/☰ grid or list, `+` (Buch hinzufügen, Aus Cloud).
Autor · hinzugefügt · kürzeste / längste zuerst), ⊞/☰ grid or list, `+` (Buch hinzufügen, Aus Cloud).
- **Length** (since 2026-10-25): the pages in the encrypted meta (`pages`) — a PDF's pages, an
EPUB's text by 1800 characters a page (`bookPagesOfFile`, 40-epub.js; the reader's own
estimate). Set on upload and when a book is opened; for older books the device works it out
once when a length sort is chosen ("Länge wird ermittelt … 3 von 80": file from the offline
copy or the server, decrypted on the device). Unknown lengths come last; the cards show
"· 320 S." while sorted by length.
- **Filter chips**: Alle · Angefangen · Ungelesen · Gelesen — then the folders.
- **Weiterlesen**: one card on top (cover, title, "Seite 132 von 186 · gestern",
▶), not repeated in the list below.

View file

@ -102,6 +102,35 @@ async function parseEpub(arrayBuffer) {
return {title, author, chapters, toc, imageMap};
}
// A book's length in printed pages, from its file without drawing it: a PDF's pages, an EPUB's
// spine text by CHARS_PER_BOOK_PAGE (as the reader estimates it). The library's length sorts.
async function bookPagesOfFile(buf, type) {
if (type === 'pdf') {
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(buf)}).promise;
const n = pdf.numPages;
pdf.destroy();
return n;
}
const zip = await JSZip.loadAsync(buf);
const containerXml = await zip.file('META-INF/container.xml').async('text');
const opfPath = new DOMParser().parseFromString(containerXml, 'application/xml').querySelector('rootfile')?.getAttribute('full-path');
if (!opfPath) return 0;
const opfDir = opfPath.includes('/') ? opfPath.substring(0, opfPath.lastIndexOf('/') + 1) : '';
const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml');
const hrefs = {};
opfDoc.querySelectorAll('manifest > item').forEach(item => { hrefs[item.getAttribute('id')] = opfDir + item.getAttribute('href'); });
let chars = 0;
for (const ref of opfDoc.querySelectorAll('spine > itemref')) {
const f = zip.file(hrefs[ref.getAttribute('idref')] || '');
if (!f) continue;
const html = (await f.async('text').catch(() => ''))
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '').replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
const body = new DOMParser().parseFromString(html, 'text/html').body;
chars += body ? body.textContent.length : 0;
}
return chars ? Math.max(1, Math.round(chars / CHARS_PER_BOOK_PAGE)) : 0;
}
async function _parseEpubToc(zip, opfDoc, manifest) {
// Try EPUB3 nav document
const navItem = Object.values(manifest).find(m => m.properties.includes('nav'));

View file

@ -804,6 +804,7 @@ async function _importEbookBuffer(buf, filename, statusEl, opts) {
if (opts.skipKnown && _knownBook(title, author)) return 'dup';
const key = await getOrCreateEncKey();
const meta = {title, author, filename, type, isbn, folder: '', shelfTag: ''};
try { meta.pages = await bookPagesOfFile(buf.slice(0), type) || undefined; } catch (e) { /* the reader fills it in */ }
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([

View file

@ -247,6 +247,47 @@ window.bookEditionsOf = id => { const b = (_lastBookListData || []).find(x => x.
// ---- which books, in which order ---------------------------------------------------------------
const _libByLength = () => _lib.sort === 'short' || _lib.sort === 'long';
// A book's length is in its meta once it was opened (62-reader.js) or uploaded (since 2026-10-25).
// For the others this device works it out once, when a length sort is chosen: the file from the
// offline copy or the server, decrypted here, its text counted (bookPagesOfFile), the pages into
// the encrypted meta. One book after the other; a book that fails isn't tried again this session.
let _libLengthRun = null;
const _libLengthTried = new Set();
function _libFillLengths() {
if (_libLengthRun || !_libByLength()) return;
const todo = (_lastBookListData || []).filter(b => !b.pages && b.keyOk !== false && !_libLengthTried.has(b.id));
if (!todo.length) return;
_libLengthRun = {done: 0, of: todo.length};
(async () => {
let shown = Date.now();
try {
const key = await getOrCreateEncKey();
for (const b of todo) {
if (!_libByLength()) break;
_libLengthTried.add(b.id);
try {
let file = await _getCachedBook(b.id);
if (!file) {
const res = await fetch(`/books/${b.id}/data/`);
if (!res.ok) throw new Error(res.status);
file = await res.json();
}
const pages = await bookPagesOfFile(await decryptBytes(key, file.data_iv, file.data_ct), b.type);
if (pages > 0) await _updateBookMeta(b.id, {pages});
} catch (e) { /* stays at the end */ }
_libLengthRun.done++;
if (Date.now() - shown > 4000) { shown = Date.now(); renderBookList(_lastBookListData); }
}
} finally {
_libLengthRun = null;
renderBookList(_lastBookListData);
}
})();
}
function _libShown(books) {
const q = _libNorm(_lib.q).trim();
let list = books.filter(b =>
@ -258,6 +299,9 @@ function _libShown(books) {
title: (a, b) => a.title.localeCompare(b.title, 'de', {numeric: true}),
author: (a, b) => (a.author || '~').localeCompare(b.author || '~', 'de') || a.title.localeCompare(b.title, 'de', {numeric: true}),
added: (a, b) => String(b.uploaded_at || '').localeCompare(String(a.uploaded_at || '')),
// By length (pages in the meta); a book whose length isn't known yet comes last.
short: (a, b) => ((a.pages || Infinity) - (b.pages || Infinity)) || a.title.localeCompare(b.title, 'de', {numeric: true}),
long: (a, b) => ((b.pages || 0) - (a.pages || 0)) || a.title.localeCompare(b.title, 'de', {numeric: true}),
};
return list.slice().sort(by[_lib.sort] || by.recent);
}
@ -300,7 +344,8 @@ function _libCard(b, label) {
const sel = _lib.selecting && _lib.selecting.has(b.id);
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) : '');
(eds && _libLayout() === 'list' ? ' · ' + _libFormats(eds) : '') +
(_libByLength() && b.pages ? (b.author || label ? ' · ' : '') + b.pages + ' S.' : '');
const edBadge = eds ? `<span class="lib-ed" title="${eds.length} Ausgaben">${_libEsc(_libFormats(eds))}</span>` : '';
const pct = _libPct(b);
// An issue with its articles (54-issues.js): read of them instead of a percentage.
@ -467,7 +512,9 @@ function _libRender(allBooks) {
const issueOpen = magazines && _lib.issue != null && typeof _issueView === 'function' && books.some(b => b.id === _lib.issue);
listEl.classList.toggle('iss-open', !!issueOpen);
const narrow = window.matchMedia && matchMedia('(max-width: 720px)').matches;
const sorts = [['recent', 'Zuletzt'], ['title', 'Titel'], ['author', 'Autor'], ['added', 'Neu hinzugefügt']];
const sorts = [['recent', 'Zuletzt'], ['title', 'Titel'], ['author', 'Autor'], ['added', 'Neu hinzugefügt'],
['short', 'Kürzeste zuerst'], ['long', 'Längste zuerst']];
_libFillLengths();
let h = `<div class="lib-head"><h2 class="lib-h">${magazines ? 'Zeitschriften' : 'Bücher'}</h2>
${!narrow || _lib.searching || _lib.q ? `<input class="lib-q" type="search" placeholder="Titel, Autor …" value="${_libEsc(_lib.q)}">` :
'<button type="button" class="lib-icon" data-lib="search" aria-label="Suchen">⌕</button>'}
@ -475,6 +522,9 @@ function _libRender(allBooks) {
<button type="button" class="lib-icon" data-lib="layout" aria-label="${_libLayout() === 'grid' ? 'Als Liste' : 'Als Raster'}">${_libLayout() === 'grid' ? '☰' : '⊞'}</button>
<button type="button" class="lib-icon lib-add" data-lib="add" aria-label="Hinzufügen">+</button></div>
<div class="lib-chips"></div>`;
if (_libLengthRun && _libByLength()) {
h += `<p class="lib-length-note">Länge wird ermittelt … ${_libLengthRun.done} von ${_libLengthRun.of} Büchern</p>`;
}
if (_lib.selecting) {
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>

View file

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