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)
|
## The library (Bücher, Zeitschriften)
|
||||||
|
|
||||||
- **Header**: title, search (title, author), sort (zuletzt gelesen · Titel ·
|
- **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.
|
- **Filter chips**: Alle · Angefangen · Ungelesen · Gelesen — then the folders.
|
||||||
- **Weiterlesen**: one card on top (cover, title, "Seite 132 von 186 · gestern",
|
- **Weiterlesen**: one card on top (cover, title, "Seite 132 von 186 · gestern",
|
||||||
▶), not repeated in the list below.
|
▶), not repeated in the list below.
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,35 @@ async function parseEpub(arrayBuffer) {
|
||||||
return {title, author, chapters, toc, imageMap};
|
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) {
|
async function _parseEpubToc(zip, opfDoc, manifest) {
|
||||||
// Try EPUB3 nav document
|
// Try EPUB3 nav document
|
||||||
const navItem = Object.values(manifest).find(m => m.properties.includes('nav'));
|
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';
|
if (opts.skipKnown && _knownBook(title, author)) return 'dup';
|
||||||
const key = await getOrCreateEncKey();
|
const key = await getOrCreateEncKey();
|
||||||
const meta = {title, author, filename, type, isbn, folder: '', shelfTag: ''};
|
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; }
|
if (series) { meta.series = series; if (seriesIndex != null) meta.seriesIndex = seriesIndex; }
|
||||||
const metaJson = new TextEncoder().encode(JSON.stringify(meta));
|
const metaJson = new TextEncoder().encode(JSON.stringify(meta));
|
||||||
const [metaEnc, dataEnc] = await Promise.all([
|
const [metaEnc, dataEnc] = await Promise.all([
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,47 @@ window.bookEditionsOf = id => { const b = (_lastBookListData || []).find(x => x.
|
||||||
|
|
||||||
// ---- which books, in which order ---------------------------------------------------------------
|
// ---- 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) {
|
function _libShown(books) {
|
||||||
const q = _libNorm(_lib.q).trim();
|
const q = _libNorm(_lib.q).trim();
|
||||||
let list = books.filter(b =>
|
let list = books.filter(b =>
|
||||||
|
|
@ -258,6 +299,9 @@ function _libShown(books) {
|
||||||
title: (a, b) => a.title.localeCompare(b.title, 'de', {numeric: true}),
|
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}),
|
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 || '')),
|
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);
|
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 sel = _lib.selecting && _lib.selecting.has(b.id);
|
||||||
const eds = _lib.editions && _lib.editions.get(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
|
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 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.
|
||||||
|
|
@ -467,7 +512,9 @@ function _libRender(allBooks) {
|
||||||
const issueOpen = magazines && _lib.issue != null && typeof _issueView === 'function' && books.some(b => b.id === _lib.issue);
|
const issueOpen = magazines && _lib.issue != null && typeof _issueView === 'function' && books.some(b => b.id === _lib.issue);
|
||||||
listEl.classList.toggle('iss-open', !!issueOpen);
|
listEl.classList.toggle('iss-open', !!issueOpen);
|
||||||
const narrow = window.matchMedia && matchMedia('(max-width: 720px)').matches;
|
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>
|
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)}">` :
|
${!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>'}
|
'<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" 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>
|
<button type="button" class="lib-icon lib-add" data-lib="add" aria-label="Hinzufügen">+</button></div>
|
||||||
<div class="lib-chips"></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) {
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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-v149';
|
const CACHE = 'diora-v150';
|
||||||
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