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
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
58003057e3
commit
6e8e5a0f7b
5 changed files with 90 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue