From 6e8e5a0f7b2a0dd71a56e2bf2984fd5dbed342f4 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sun, 25 Oct 2026 12:28:41 +0100 Subject: [PATCH] =?UTF-8?q?Bibliothek:=20nach=20L=C3=A4nge=20sortieren=20(?= =?UTF-8?q?k=C3=BCrzeste/l=C3=A4ngste=20zuerst);=20Seitenzahl=20beim=20Hoc?= =?UTF-8?q?hladen,=20f=C3=BCr=20=C3=A4ltere=20B=C3=BCcher=20einmal=20auf?= =?UTF-8?q?=20dem=20Ger=C3=A4t=20ermittelt;=20sw=20v150?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/library.md | 8 +++++- static/js/app/40-epub.js | 29 +++++++++++++++++++ static/js/app/52-book-list.js | 1 + static/js/app/53-library.js | 54 +++++++++++++++++++++++++++++++++-- static/js/sw.js | 2 +- 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/docs/library.md b/docs/library.md index b28a882..e839a36 100644 --- a/docs/library.md +++ b/docs/library.md @@ -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. diff --git a/static/js/app/40-epub.js b/static/js/app/40-epub.js index 6e83b48..6c2f4bf 100644 --- a/static/js/app/40-epub.js +++ b/static/js/app/40-epub.js @@ -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(/]*>[\s\S]*?<\/style>/gi, '').replace(/]*>[\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')); diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index 0fabf7e..b5a55ee 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -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([ diff --git a/static/js/app/53-library.js b/static/js/app/53-library.js index 4fb228b..8be3df6 100644 --- a/static/js/app/53-library.js +++ b/static/js/app/53-library.js @@ -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 ? `${_libEsc(_libFormats(eds))}` : ''; 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 = `

${magazines ? 'Zeitschriften' : 'Bücher'}

${!narrow || _lib.searching || _lib.q ? `` : ''} @@ -475,6 +522,9 @@ function _libRender(allBooks) {
`; + if (_libLengthRun && _libByLength()) { + h += `

Länge wird ermittelt … ${_libLengthRun.done} von ${_libLengthRun.of} Büchern

`; + } if (_lib.selecting) { h += `
${_lib.selecting.size} ausgewählt diff --git a/static/js/sw.js b/static/js/sw.js index d3b7dcc..3ff1519 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-v149'; +const CACHE = 'diora-v150'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js',