Zeitschriften: das Heft und seine Artikel
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 9m1s
Test / e2e (push) Successful in 2m1s

Das Heft bleibt das Dokument, der Artikel die Einheit, in der man sich
bewegt (docs/library.md):
- Artikel-Index auf dem Gerät aus dem Inhaltsverzeichnis des EPUB bzw. den
  Lesezeichen des PDF (Rubriken, Anreißer, Lesezeit), verschlüsselt im Meta.
- Stand pro Artikel (gelesen / übersprungen, wo man war), verschlüsselt im
  Meta, offline gepuffert.
- Bibliothek: Segmentbalken pro Heft, „7 von 23 gelesen“; ein Tipp öffnet den
  Heft-Inhalt mit Rubriken, Stand je Artikel, „Im Heft weiterlesen“, „Heft
  abschließen“.
- Reader: Kopfzeile „Artikel 8 von 23“, am Ende jedes Artikels eine Zäsur
  (gelesen, Weiter im Heft, Zum Inhalt, überspringen); bei PDF eine Leiste
  beim Weiterblättern. Durchgelesene Artikel zählen als gelesen, Sprünge nicht.
sw.js: diora-v127.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-17 11:14:28 +02:00
parent fa9dc073cb
commit ea7d62789f
7 changed files with 879 additions and 9 deletions

View file

@ -81,3 +81,42 @@ doorway), grusel (Horror), klassiker (patterned paper with a title plate),
sakral (Religion & Mythologie), spiel (Kinder & Jugend), code (Informatik), sakral (Religion & Mythologie), spiel (Kinder & Jugend), code (Informatik),
typo, wellen, botanik, berge, zellen — and bauhaus when nothing matches. A hash typo, wellen, botanik, berge, zellen — and bauhaus when nothing matches. A hash
of title and author picks palette, layout and every detail. of title and author picks palette, layout and every detail.
## Zeitschriften: das Heft und seine Artikel
An issue is a unit (the editors' selection and order, its date, its theme) and
so is each article in it. Neither is reduced to the other
(`static/js/app/54-issues.js`):
- **The issue stays the document.** It opens and reads through as before, in
the editors' order. The reader marks the article one is in: a running head
under the bar ("Schwerpunkt · Artikel 8 von 23 · Titel"), in the apps' footer
"Artikel 8/23", and a **pause at each article's end** (`aside.iss-end`):
"✓ gelesen", the issue's count, "Weiter im Heft" (the next article not read
yet, skipping those read or skipped), "Zum Inhalt", "überspringen",
"Doch nicht gelesen". In a PDF the pause is a bar that comes up when one
turns from an article's last page onto the next.
- **The states determine each other.** An article one was in and whose end one
reached counts as read (a jump — from the contents, "Weiter" — reads
nothing). The issue counts "7 von 23 gelesen · 2 übersprungen"; "Im Heft
weiterlesen" goes to the article one was in, else the next one open.
"Heft abschließen" marks the rest as skipped, never as read.
- **In the library** an issue shows one bar with a segment per article (read,
skipped, open, the one one is in). A tap opens its contents: Rubriken, the
articles with their state, a teaser and the reading time; the ⋮ / mark
sets read / unread / skipped by hand.
**The index** comes from the file, on the device: an EPUB's table of contents
(its leaves are the articles, their parents the Rubriken; without one, each
spine file with text), a PDF's outline (page ranges). Teasers and reading times
(200 words a minute) come from the text. It is kept in the encrypted meta as
`issue: {v: 1, a: [{h, t, r, x, m, p?, e?}]}` (href, title, Rubrik, teaser,
minutes, a PDF's first and last page), or `{v: 1, a: [], none: true}` for an
issue without a usable table of contents (read as a whole). Built in the
background for issues without one (not on e-ink: there when the issue is
opened, or it comes from another device).
**The states** are in the meta too: `issueRead: {href: 'r' | 's'}`,
`issueAt: href` (the article one was in). A change goes to the server after
2.5 s; offline it waits in `localStorage.diora_issue_pending` and goes with the
next save (or when the connection is back).

View file

@ -50,8 +50,8 @@ async function loadBookList() {
try { try {
const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct);
const meta = JSON.parse(new TextDecoder().decode(metaBuf)); 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, series: meta.series, seriesIndex: meta.seriesIndex}; 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, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt};
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}); 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, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, 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) { } catch (e) {
bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; 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}); 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); const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress);
if (cachedBooks.length) { if (cachedBooks.length) {
cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || '')); 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, series: b.series, seriesIndex: b.seriesIndex}; 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, issue: b.issue, issueRead: b.issueRead, issueAt: b.issueAt};
const uploadArea = $('book-upload-area'); const uploadArea = $('book-upload-area');
if (uploadArea) uploadArea.style.display = 'none'; if (uploadArea) uploadArea.style.display = 'none';
renderBookList(cachedBooks); renderBookList(cachedBooks);
@ -159,6 +159,9 @@ function repairBook(bookId) {
cover: prevMeta.cover, cover: prevMeta.cover,
series: prevMeta.series, series: prevMeta.series,
seriesIndex: prevMeta.seriesIndex, seriesIndex: prevMeta.seriesIndex,
// A new file: its articles are read anew; what was read of them stays.
issueRead: prevMeta.issueRead,
issueAt: prevMeta.issueAt,
})); }));
const [metaEnc, dataEnc] = await Promise.all([ const [metaEnc, dataEnc] = await Promise.all([
encryptBytes(key, metaJson), encryptBytes(key, metaJson),
@ -442,6 +445,10 @@ async function _updateBookMeta(bookId, overrides) {
cover: cached.cover !== undefined ? cached.cover : book.cover, cover: cached.cover !== undefined ? cached.cover : book.cover,
series: cached.series !== undefined ? cached.series : book.series, series: cached.series !== undefined ? cached.series : book.series,
seriesIndex: cached.seriesIndex !== undefined ? cached.seriesIndex : book.seriesIndex, seriesIndex: cached.seriesIndex !== undefined ? cached.seriesIndex : book.seriesIndex,
// An issue's articles and what was read of them (54-issues.js).
issue: cached.issue !== undefined ? cached.issue : book.issue,
issueRead: cached.issueRead !== undefined ? cached.issueRead : book.issueRead,
issueAt: cached.issueAt !== undefined ? cached.issueAt : book.issueAt,
...overrides, ...overrides,
}; };
// The list decides; `folder` follows it (the first), for older clients. // The list decides; `folder` follows it (the first), for older clients.

View file

@ -182,6 +182,23 @@ function _libCard(b, label) {
const sel = _lib.selecting && _lib.selecting.has(b.id); const sel = _lib.selecting && _lib.selecting.has(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
const pct = _libPct(b); const pct = _libPct(b);
// An issue with its articles (54-issues.js): read of them instead of a percentage.
const iss = typeof _issueSummary === 'function' && _isMagazine(b) ? _issueSummary(b) : null;
if (iss && _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(_issueCounts(iss))}</span>${_issueSegments(iss, -1, 'iss-seg-thin')}</span>
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></div>`;
}
if (iss) {
return `<div class="lib-card${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
<span class="lib-coverbox">${_libCover(b, 'lib-cover')}
<button type="button" class="lib-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></span>
${_issueSegments(iss, -1, 'iss-seg-thin')}
<span class="lib-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${iss.read}/${iss.total} gelesen</span></div>`;
}
if (_libLayout() === 'list') { if (_libLayout() === 'list') {
return `<div class="lib-row${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}"> return `<div class="lib-row${sel ? ' lib-sel' : ''}" data-lib-book="${b.id}">
${_libCover(b, 'lib-thumb')} ${_libCover(b, 'lib-thumb')}
@ -230,19 +247,23 @@ function _libAgo(iso) {
function _libContinue(b, recent) { function _libContinue(b, recent) {
if (!b) return ''; if (!b) return '';
const iss = typeof _issueSummary === 'function' && _isMagazine(b) ? _issueSummary(b) : null;
const nx = iss && iss.next >= 0 ? iss.arts[iss.next] : null;
const pages = b.pages ? ` · Seite ${Math.max(1, Math.round(b.pages * (b.scroll_fraction || 0)))} von ${b.pages}` : ''; const pages = b.pages ? ` · Seite ${Math.max(1, Math.round(b.pages * (b.scroll_fraction || 0)))} von ${b.pages}` : '';
const ago = _libAgo(b.last_read); const ago = _libAgo(b.last_read);
const row = (recent || []).map(r => `<div class="lib-recent-item" data-lib-book="${r.id}"> const row = (recent || []).map(r => `<div class="lib-recent-item" data-lib-book="${r.id}">
<span class="lib-coverbox">${_libCover(r, 'lib-cover')}${r.is_read ? '<span class="lib-badge">✓</span>' : ''}</span> <span class="lib-coverbox">${_libCover(r, 'lib-cover')}${r.is_read ? '<span class="lib-badge">✓</span>' : ''}</span>
<span class="lib-bar"><span style="width:${_libPct(r)}%"></span></span> ${(() => { const ri = typeof _issueSummary === 'function' && _isMagazine(r) ? _issueSummary(r) : null;
return ri ? _issueSegments(ri, -1, 'iss-seg-thin') : `<span class="lib-bar"><span style="width:${_libPct(r)}%"></span></span>`; })()}
<span class="lib-title">${_libEsc(r.title)}</span></div>`).join(''); <span class="lib-title">${_libEsc(r.title)}</span></div>`).join('');
return `<section class="lib-recent" aria-label="Zuletzt gelesen"> return `<section class="lib-recent" aria-label="Zuletzt gelesen">
<div class="lib-continue" data-lib-book="${b.id}">${_libCover(b, 'lib-hero-cover')} <div class="lib-continue" data-lib-book="${b.id}">${_libCover(b, 'lib-hero-cover')}
<span class="lib-text"><span class="lib-kicker">Weiterlesen${ago ? ' · ' + ago : ''}</span> <span class="lib-text"><span class="lib-kicker">${iss ? 'Im Heft weiterlesen' : 'Weiterlesen'}${ago ? ' · ' + ago : ''}</span>
<span class="lib-title lib-hero-title">${_libEsc(b.title)}</span> <span class="lib-title lib-hero-title">${_libEsc(b.title)}</span>
<span class="lib-meta">${_libEsc(b.author || '')}</span> <span class="lib-meta">${nx ? _libEsc('Artikel ' + (iss.next + 1) + ' · ' + nx.t) : _libEsc(b.author || '')}</span>
<span class="lib-hero-progress"><span class="lib-bar"><span style="width:${_libPct(b)}%"></span></span> ${iss ? `<span class="lib-hero-progress">${_issueSegments(iss, -1)}<span class="lib-meta">${_libEsc(_issueCounts(iss))}</span></span>`
<span class="lib-meta">${_libPct(b)} %${pages}</span></span> : `<span class="lib-hero-progress"><span class="lib-bar"><span style="width:${_libPct(b)}%"></span></span>
<span class="lib-meta">${_libPct(b)} %${pages}</span></span>`}
<span class="lib-play" aria-hidden="true">▶ Weiterlesen</span></span></div> <span class="lib-play" aria-hidden="true">▶ Weiterlesen</span></span></div>
${row ? `<div class="lib-recent-head">Zuletzt gelesen</div><div class="lib-recent-row">${row}</div>` : ''} ${row ? `<div class="lib-recent-head">Zuletzt gelesen</div><div class="lib-recent-row">${row}</div>` : ''}
</section>`; </section>`;
@ -321,6 +342,9 @@ function _libRender(allBooks) {
const books = _bookSectionFilter(allBooks); const books = _bookSectionFilter(allBooks);
const magazines = window.DioraNav && _bookSection === 'magazines'; const magazines = window.DioraNav && _bookSection === 'magazines';
const shown = _libShown(books); const shown = _libShown(books);
// An issue opened (54-issues.js): its contents instead of the list, without the list's search and chips.
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 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']];
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>
@ -339,6 +363,8 @@ function _libRender(allBooks) {
h += '<p class="lib-empty">Noch keine Bücher. Mit + eine .epub- oder .pdf-Datei hinzufügen.</p>'; h += '<p class="lib-empty">Noch keine Bücher. Mit + eine .epub- oder .pdf-Datei hinzufügen.</p>';
} else if (!books.length) { } 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>`; 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 (issueOpen) {
h += _issueView(books.find(b => b.id === _lib.issue));
} else if (_lib.series && !magazines) { } else if (_lib.series && !magazines) {
h += _libSeriesView(books); h += _libSeriesView(books);
} else { } else {
@ -358,6 +384,8 @@ function _libRender(allBooks) {
const status = document.getElementById('book-upload-status'); const status = document.getElementById('book-upload-status');
if (status && status.textContent.trim()) listEl.querySelector('.lib-head').after(status); if (status && status.textContent.trim()) listEl.querySelector('.lib-head').after(status);
_libChips(books); _libChips(books);
// Issues still without their articles' index: built now and then, in the background.
if (magazines && typeof _issueIndexLater === 'function') _issueIndexLater(books);
if (focused || (_lib.searching && !_lib.q)) { if (focused || (_lib.searching && !_lib.q)) {
const n = listEl.querySelector('.lib-q'); const n = listEl.querySelector('.lib-q');
if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); } if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); }
@ -617,6 +645,11 @@ function _libSheet(b) {
customAlert('Dieses Buch lässt sich mit deinem Schlüssel nicht entschlüsseln. Über ⋮ → „Schlüssel einfügen …“ den richtigen Schlüssel eintragen.'); customAlert('Dieses Buch lässt sich mit deinem Schlüssel nicht entschlüsseln. Über ⋮ → „Schlüssel einfügen …“ den richtigen Schlüssel eintragen.');
return; return;
} }
if (b && _isMagazine(b) && typeof _issueOpen === 'function') {
if (card.classList.contains('lib-continue') && _issueSummary(b)) _issueContinue(id);
else _issueOpen(id);
return;
}
openBook(id); openBook(id);
}, true); }, true);
// A long press on a book: choosing several. // A long press on a book: choosing several.
@ -649,6 +682,7 @@ function _libSheet(b) {
window.__dioraBack = function () { window.__dioraBack = function () {
const shown = $('tab-books') && $('tab-books').style.display !== 'none'; const shown = $('tab-books') && $('tab-books').style.display !== 'none';
if (shown && _lib.selecting) { _lib.selecting = null; renderBookList(_lastBookListData); return true; } if (shown && _lib.selecting) { _lib.selecting = null; renderBookList(_lastBookListData); return true; }
if (shown && _lib.issue != null) { _lib.issue = null; renderBookList(_lastBookListData); return true; }
if (shown && _lib.series) { _lib.series = 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; } if (shown && (_lib.q || _lib.searching)) { _lib.q = ''; _lib.searching = false; renderBookList(_lastBookListData); return true; }
return back ? back() : false; return back ? back() : false;

784
static/js/app/54-issues.js Normal file
View file

@ -0,0 +1,784 @@
'use strict';
// ---------------------------------------------------------------------------
// Zeitschriften: das Heft und seine Artikel (docs/library.md, "Heft und Artikel").
// The issue stays the document — read through in the editors' order — and the article
// is the unit one moves in: its own state (gelesen / übersprungen), reading time, a
// running head ("Artikel 8 von 23") and a pause at its end. Each determines the other:
// reading the issue through marks the articles one passes; reading articles moves the
// issue on ("7 von 23"); "Im Heft weiterlesen" goes to the next one not read yet.
//
// The index comes from the file, on the device (the server never sees it): an EPUB's
// table of contents (its leaves are the articles, their parents the Rubriken) or a PDF's
// outline (page ranges). It is kept in the book's encrypted meta (`issue`), the states
// too (`issueRead`, `issueAt`), so every device has them; a state that couldn't be saved
// (offline) waits in localStorage and goes with the next save. Chrome-61-safe (tolino).
// ---------------------------------------------------------------------------
const ISSUE_WPM = 200; // words per minute, for the reading times
const _issuePendingKey = 'diora_issue_pending';
function _issueEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
const _issueMeta = b => (b && (bookMetaCache[b.id] || {})) || {};
function _issueIndexOf(b) {
const m = _issueMeta(b);
const ix = m.issue || (b && b.issue);
return ix && ix.a && ix.a.length ? ix.a : null;
}
// ---- state: {href: 'r' (gelesen) | 's' (übersprungen)} and the article one was in -----------
function _issuePending() {
try { return JSON.parse(localStorage.getItem(_issuePendingKey) || '{}') || {}; } catch (e) { return {}; }
}
function _issueSetPending(all) {
try { localStorage.setItem(_issuePendingKey, JSON.stringify(all)); } catch (e) {}
}
function _issueState(b) {
const m = _issueMeta(b);
const read = Object.assign({}, m.issueRead || b.issueRead || {});
let at = m.issueAt || b.issueAt || '';
const p = _issuePending()[b.id];
if (p) {
Object.keys(p.read || {}).forEach(h => { if (p.read[h]) read[h] = p.read[h]; else delete read[h]; });
if (p.at) at = p.at;
}
return {read, at};
}
// What the issue amounts to: counts, the next article to read, the time still to read.
function _issueSummary(b) {
const arts = _issueIndexOf(b);
if (!arts) return null;
const st = _issueState(b);
let read = 0, skipped = 0, left = 0;
arts.forEach(a => {
const s = st.read[a.h];
if (s === 'r') read++;
else if (s === 's') skipped++;
else left += a.m || 0;
});
const at = arts.findIndex(a => a.h === st.at);
const open = i => !st.read[arts[i].h];
let next = -1;
if (at >= 0 && open(at)) next = at;
for (let i = at + 1; next < 0 && i < arts.length; i++) if (open(i)) next = i;
for (let i = 0; next < 0 && i < arts.length; i++) if (open(i)) next = i;
return {arts, st, total: arts.length, read, skipped, left, next, at, started: at >= 0 && open(at)};
}
function _issueMinutes(m) {
if (m < 60) return Math.max(1, Math.round(m)) + ' min';
const h = Math.floor(m / 60);
const r = Math.round(m - h * 60);
return r >= 45 ? 'knapp ' + (h + 1) + ' h' : r >= 15 ? 'gut ' + h + ' h' : h + ' h';
}
// One bar for the issue, one segment per article: read (full), skipped (hatched), open
// (empty), the one one is in (accent). `now`: index of the article shown right now.
function _issueSegments(sum, now, cls) {
return `<span class="iss-seg${cls ? ' ' + cls : ''}" aria-label="${sum.read} von ${sum.total} Artikeln gelesen">` +
sum.arts.map((a, i) => {
const s = sum.st.read[a.h];
const k = i === now ? 'iss-s-now' : s === 'r' ? 'iss-s-read' : s === 's' ? 'iss-s-skip' : i === sum.at ? 'iss-s-at' : '';
return `<span class="${k}"></span>`;
}).join('') + '</span>';
}
function _issueCounts(sum) {
const parts = [`${sum.read} von ${sum.total} gelesen`];
if (sum.skipped) parts.push(`${sum.skipped} übersprungen`);
return parts.join(' · ');
}
let _issueSaveTimer = {};
function _issueMark(bookId, href, state, at) {
const all = _issuePending();
const p = all[bookId] || {read: {}};
if (href) p.read[href] = state || '';
if (at) p.at = at;
all[bookId] = p;
_issueSetPending(all);
clearTimeout(_issueSaveTimer[bookId]);
_issueSaveTimer[bookId] = setTimeout(() => _issueFlush(bookId), 2500);
}
async function _issueFlush(bookId) {
const all = _issuePending();
const p = all[bookId];
const b = _lastBookListData.find(x => x.id === bookId);
if (!p || !b) return;
const st = _issueState(b);
try {
await _updateBookMeta(bookId, {issueRead: st.read, issueAt: st.at});
const now = _issuePending();
if (JSON.stringify(now[bookId]) === JSON.stringify(p)) { delete now[bookId]; _issueSetPending(now); }
} catch (e) { /* offline: it waits and goes with the next save */ }
}
window.addEventListener('online', () => Object.keys(_issuePending()).forEach(id => _issueFlush(Number(id))));
// ---- the index -----------------------------------------------------------------------------------
// The articles of a table of contents: its leaves, each under the nearest entry above it
// (the Rubrik); one per target. [{h, t, r}]
function _issueArticlesFromToc(toc) {
const out = [];
const seen = new Set();
const parents = [];
for (let i = 0; i < toc.length; i++) {
const e = toc[i];
parents[e.depth] = e.label;
parents.length = e.depth + 1;
const nxt = toc[i + 1];
const leaf = !nxt || nxt.depth <= e.depth;
if (!leaf || !e.href || seen.has(e.href)) continue;
seen.add(e.href);
out.push({h: e.href, t: (e.label || '').trim() || 'Ohne Titel', r: e.depth > 0 ? (parents[e.depth - 1] || '').trim() : ''});
}
return out;
}
function _issueTextStats(text, title) {
let t = String(text || '').replace(/\s+/g, ' ').trim();
const words = t ? t.split(' ').length : 0;
if (title && t.toLowerCase().indexOf(title.toLowerCase()) === 0) t = t.slice(title.length).trim();
const x = t.length > 110 ? t.slice(0, 110).replace(/\s+\S*$/, '') + ' …' : t;
return {x, m: Math.max(1, Math.round(words / ISSUE_WPM))};
}
const _issueHtmlText = html => String(html || '')
.replace(/<head\b[\s\S]*?<\/head>/gi, ' ')
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
async function _issueIndexEpub(bytes) {
const zip = await JSZip.loadAsync(bytes);
const container = zip.file('META-INF/container.xml');
if (!container) return null;
const cdoc = new DOMParser().parseFromString(await container.async('text'), 'application/xml');
const root = cdoc.querySelector('rootfile');
if (!root) return null;
const opfPath = root.getAttribute('full-path');
const opfDir = opfPath.indexOf('/') >= 0 ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : '';
const opfDoc = new DOMParser().parseFromString(await zip.file(opfPath).async('text'), 'application/xml');
const manifest = {};
Array.prototype.forEach.call(opfDoc.querySelectorAll('manifest > item'), item => {
manifest[item.getAttribute('id')] = {href: opfDir + item.getAttribute('href'),
mediaType: item.getAttribute('media-type') || '', properties: item.getAttribute('properties') || ''};
});
const spine = Array.prototype.map.call(opfDoc.querySelectorAll('spine > itemref'), r => (manifest[r.getAttribute('idref')] || {}).href).filter(Boolean);
let arts = _issueArticlesFromToc(await _parseEpubToc(zip, opfDoc, manifest));
const files = {};
const fileText = async path => {
if (!(path in files)) {
const f = zip.file(path) || zip.file(decodeURIComponent(path));
files[path] = f ? await f.async('text') : '';
}
return files[path];
};
if (arts.length < 2) {
// No usable table of contents: one article per spine file with some text in it.
arts = [];
for (const path of spine) {
const html = await fileText(path);
const m = /<h[1-3][^>]*>([\s\S]*?)<\/h[1-3]>/i.exec(html);
const text = _issueHtmlText(html);
if (text.replace(/\s+/g, '').length < 300) continue;
arts.push({h: path, t: m ? _issueHtmlText(m[1]).replace(/\s+/g, ' ').trim() : 'Artikel ' + (arts.length + 1), r: ''});
}
}
// Each article's text: from its target to the next article's (within a file by the
// fragment's place, across files along the spine).
const split = h => { const k = h.indexOf('#'); return k >= 0 ? [h.slice(0, k), h.slice(k + 1)] : [h, '']; };
const posIn = (html, frag) => {
if (!frag) return 0;
const m = new RegExp('id\\s*=\\s*["\']' + frag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '["\']').exec(html);
if (!m) return 0;
return html.lastIndexOf('<', m.index);
};
for (let i = 0; i < arts.length; i++) {
const [file, frag] = split(arts[i].h);
const [nFile, nFrag] = i + 1 < arts.length ? split(arts[i + 1].h) : ['', ''];
let html = '';
const startIdx = spine.indexOf(file);
if (nFile === file) {
const whole = await fileText(file);
html = whole.slice(posIn(whole, frag), nFrag ? posIn(whole, nFrag) : whole.length);
} else {
const whole = await fileText(file);
html = whole.slice(posIn(whole, frag));
const endIdx = nFile ? spine.indexOf(nFile) : spine.length;
for (let s = startIdx + 1; startIdx >= 0 && s < (endIdx < 0 ? spine.length : endIdx); s++) html += await fileText(spine[s]);
if (nFile && nFrag) { const n = await fileText(nFile); html += n.slice(0, posIn(n, nFrag)); }
}
Object.assign(arts[i], _issueTextStats(_issueHtmlText(html), arts[i].t));
}
return arts;
}
async function _issueIndexPdf(bytes) {
if (typeof pdfjsLib === 'undefined' || typeof _parsePdfOutline !== 'function') return null;
const pdf = await pdfjsLib.getDocument({data: new Uint8Array(bytes)}).promise;
const outline = await pdf.getOutline();
if (!outline || !outline.length) return [];
const arts = _issueArticlesFromToc(await _parsePdfOutline(pdf, outline))
.filter(a => /^#pdf-page-\d+$/.test(a.h));
arts.forEach(a => { a.p = parseInt(a.h.slice(10), 10); });
arts.sort((a, b) => a.p - b.p);
const pageText = {};
const textOf = async n => {
if (!(n in pageText)) {
try {
const page = await pdf.getPage(n);
const c = await page.getTextContent();
pageText[n] = c.items.map(it => it.str).join(' ');
} catch (e) { pageText[n] = ''; }
}
return pageText[n];
};
for (let i = 0; i < arts.length; i++) {
const end = i + 1 < arts.length ? Math.max(arts[i].p, arts[i + 1].p - 1) : pdf.numPages;
arts[i].e = end;
let text = '';
for (let n = arts[i].p; n <= Math.min(end, arts[i].p + 30); n++) text += ' ' + await textOf(n);
// Pages shared with the next article: counted for both (the next one starts on it).
const s = _issueTextStats(text, arts[i].t);
// The teaser: from where the title stands on the first page, if it does.
const first = (await textOf(arts[i].p)).replace(/\s+/g, ' ');
const k = first.toLowerCase().indexOf(arts[i].t.toLowerCase());
if (k >= 0) s.x = _issueTextStats(first.slice(k + arts[i].t.length), '').x;
Object.assign(arts[i], s);
}
try { pdf.destroy(); } catch (e) {}
return arts;
}
// The file, decrypted (the browser's cache, or — in the apps — their store).
async function _issueBytes(id) {
let rec = typeof _getCachedBook === 'function' ? await _getCachedBook(id) : null;
if (!rec) {
const r = await fetch('/books/' + id + '/data/', {credentials: 'same-origin'});
if (r.ok) rec = await r.json();
}
if (!rec || !rec.data_ct) return null;
return decryptBytes(await getOrCreateEncKey(), rec.data_iv, rec.data_ct);
}
const _issueBuilding = new Map();
// The index of an issue, built once (and kept in its meta) if it has none yet.
function _issueEnsureIndex(bookId) {
const b = _lastBookListData.find(x => x.id === bookId);
if (!b) return Promise.resolve(null);
const have = _issueIndexOf(b);
if (have || (_issueMeta(b).issue && _issueMeta(b).issue.none)) return Promise.resolve(have);
if (_issueBuilding.has(bookId)) return _issueBuilding.get(bookId);
const job = (async () => {
const bytes = await _issueBytes(bookId);
if (!bytes) return null;
const pdf = (_issueMeta(b).type || b.type) === 'pdf';
const arts = pdf ? await _issueIndexPdf(bytes) : await _issueIndexEpub(bytes);
if (!arts) return null;
// An issue without a usable table of contents stays a whole (and isn't tried again).
const issue = arts.length >= 2 ? {v: 1, a: arts} : {v: 1, a: [], none: true};
try { await _updateBookMeta(bookId, {issue}); } catch (e) {
bookMetaCache[bookId] = Object.assign({}, bookMetaCache[bookId] || {}, {issue});
}
return issue.a.length ? issue.a : null;
})().catch(() => null);
_issueBuilding.set(bookId, job);
job.then(() => _issueBuilding.delete(bookId));
return job;
}
// Issues still without an index: one after the other, when nothing else goes on (not on
// an e-reader: there an issue's index is built when it's opened, or comes from elsewhere).
let _issueQueueBusy = false;
function _issueIndexLater(books) {
if (_issueQueueBusy || (typeof _LIB_EINK === 'function' && _LIB_EINK())) return;
const todo = books.filter(b => _isMagazine(b) && b.keyOk !== false && !_issueIndexOf(b) &&
!(_issueMeta(b).issue && _issueMeta(b).issue.none));
if (!todo.length) return;
_issueQueueBusy = true;
const idle = window.requestIdleCallback || (fn => setTimeout(fn, 1500));
idle(async () => {
const reader = document.getElementById('reader-overlay');
if (reader && reader.style.display !== 'none') { _issueQueueBusy = false; return; }
await _issueEnsureIndex(todo[0].id);
_issueQueueBusy = false;
if (typeof renderBookList === 'function') renderBookList(_lastBookListData);
});
}
// ---- the library: an issue opened (its contents) --------------------------------------------------
function _issueView(b) {
const sum = _issueSummary(b);
const back = `<div class="iss-top"><button type="button" class="iss-back" data-issue-act="up">‹ Zeitschriften</button>
<button type="button" class="lib-more iss-more" data-lib-more="${b.id}" aria-label="Optionen">⋮</button></div>`;
const head = `<div class="iss-head">${_libCover(b, 'iss-cover')}
<span class="iss-head-text"><span class="iss-title">${_issueEsc(b.title)}</span>
${b.author ? `<span class="lib-meta">${_issueEsc(b.author)}</span>` : ''}
${sum ? `<span class="lib-meta">${sum.total} Artikel · ${_issueMinutes(sum.arts.reduce((n, a) => n + (a.m || 0), 0))}</span>` : ''}</span></div>`;
if (!sum) {
const none = _issueMeta(b).issue && _issueMeta(b).issue.none;
return back + head + (none
? `<p class="lib-empty">Dieses Heft hat kein Inhaltsverzeichnis, aus dem sich Artikel ablesen lassen – es liest sich als Ganzes.</p>
<button type="button" class="iss-go" data-issue-act="read">▶ Lesen</button>`
: '<p class="lib-empty">Das Inhaltsverzeichnis wird gelesen …</p>');
}
const nx = sum.next >= 0 ? sum.arts[sum.next] : null;
let h = back + head + `<div class="iss-sum">${_issueSegments(sum, -1)}
<span class="lib-meta">${_issueCounts(sum)}${sum.left ? ' · noch ' + _issueMinutes(sum.left) : ''}</span>
<span class="iss-actions">
${nx ? `<button type="button" class="iss-go" data-issue-act="continue">▶ ${sum.started ? 'Im Heft weiterlesen' : 'Weiter mit Artikel ' + (sum.next + 1)}</button>`
: '<span class="lib-meta">Alles gelesen oder übersprungen.</span>'}
${nx ? '<button type="button" class="iss-btn" data-issue-act="finish">Heft abschließen</button>' : '<button type="button" class="iss-btn" data-issue-act="reopen">Wieder öffnen</button>'}
</span></div>`;
let rubrik = null;
sum.arts.forEach((a, i) => {
if (a.r !== rubrik) {
rubrik = a.r;
if (rubrik) h += `<h3 class="iss-rubrik">${_issueEsc(rubrik)}</h3>`;
}
const s = sum.st.read[a.h];
const mark = s === 'r' ? '✓' : s === 's' ? '–' : i === sum.at ? '◐' : '○';
const cls = s === 'r' ? ' iss-read' : s === 's' ? ' iss-skip' : i === sum.next ? ' iss-next' : '';
h += `<div class="iss-row${cls}">
<button type="button" class="iss-mark" data-issue-mark="${i}" aria-label="Artikel ${i + 1}: Stand ändern">${mark}</button>
<button type="button" class="iss-art" data-issue-art="${i}">
<span class="iss-n">${i + 1}</span>
<span class="iss-art-text"><span class="iss-art-title">${_issueEsc(a.t)}</span>
${a.x ? `<span class="iss-teaser">${_issueEsc(a.x)}</span>` : ''}</span>
<span class="iss-min">${a.m ? a.m + ' min' : ''}</span></button></div>`;
});
return h;
}
// What to open for "Im Heft weiterlesen": the article one was in (at its place), else the next one open.
function _issueContinue(bookId) {
const b = _lastBookListData.find(x => x.id === bookId);
const sum = b && _issueSummary(b);
if (!sum || sum.next < 0) { openBook(bookId); return; }
if (sum.started && sum.next === sum.at) { openBook(bookId); return; }
_issueOpenAt(bookId, sum.next);
}
async function _issueOpenAt(bookId, index) {
const b = _lastBookListData.find(x => x.id === bookId);
const arts = b && _issueIndexOf(b);
if (!arts || !arts[index]) { openBook(bookId); return; }
_issueMark(bookId, null, null, arts[index].h);
await openBook(bookId);
if (currentBookId !== bookId) return;
_issueJump(arts[index]);
}
function _issueJump(a) {
if (a.p && typeof currentPdfDoc !== 'undefined' && currentPdfDoc && readerSettings.pdfPaginated &&
typeof enterPdfPaginatedMode === 'function') {
if (typeof pdfGoToPage === 'function') pdfGoToPage(a.p);
else { pdfCurrentPage = a.p; enterPdfPaginatedMode(); }
return;
}
if (typeof jumpToTocEntry === 'function') jumpToTocEntry(a.h);
}
function _issueSheet(bookId, i) {
const b = _lastBookListData.find(x => x.id === bookId);
const sum = b && _issueSummary(b);
if (!sum) return;
const a = sum.arts[i];
const s = sum.st.read[a.h];
const set = v => { _issueMark(bookId, a.h, v); renderBookList(_lastBookListData); };
const items = [
['Lesen', () => _issueOpenAt(bookId, i)],
s === 'r' ? ['Als ungelesen markieren', () => set('')] : ['Als gelesen markieren', () => set('r')],
s === 's' ? ['Nicht mehr überspringen', () => set('')] : ['Überspringen', () => set('s')],
];
const wrap = document.createElement('div');
wrap.className = 'dfo-scrim';
wrap.innerHTML = `<div class="dfo-sheet" role="dialog"><div class="dfo-head"><strong>${i + 1} · ${_issueEsc(a.t)}</strong>
<button type="button" class="dfo-x" data-x="1" aria-label="Schließen">✕</button></div>
${items.map((it, k) => `<button type="button" class="lib-sheet-item" data-i="${k}">${it[0]}</button>`).join('')}</div>`;
wrap.addEventListener('click', e => {
const t = e.target.closest('[data-i]');
if (e.target === wrap || e.target.closest('[data-x]')) { wrap.remove(); return; }
if (t) { wrap.remove(); items[Number(t.dataset.i)][1](); }
});
document.body.appendChild(wrap);
}
function _issueFinish(bookId, reopen) {
const b = _lastBookListData.find(x => x.id === bookId);
const sum = b && _issueSummary(b);
if (!sum) return;
sum.arts.forEach(a => {
const s = sum.st.read[a.h];
if (!reopen && !s) _issueMark(bookId, a.h, 's');
if (reopen && s === 's') _issueMark(bookId, a.h, '');
});
renderBookList(_lastBookListData);
}
(function () {
const listEl = document.getElementById('book-list');
if (!listEl) return;
listEl.addEventListener('click', e => {
const id = _lib.issue;
if (id == null) return;
const act = e.target.closest('[data-issue-act]');
const mark = e.target.closest('[data-issue-mark]');
const art = e.target.closest('[data-issue-art]');
if (!act && !mark && !art) return;
e.stopPropagation();
if (art) { _issueOpenAt(id, Number(art.dataset.issueArt)); return; }
if (mark) { _issueSheet(id, Number(mark.dataset.issueMark)); return; }
const a = act.dataset.issueAct;
if (a === 'up') { _lib.issue = null; renderBookList(_lastBookListData); }
else if (a === 'continue') _issueContinue(id);
else if (a === 'read') openBook(id);
else if (a === 'finish') _issueFinish(id, false);
else if (a === 'reopen') _issueFinish(id, true);
});
})();
// Opened from the library: its contents (built first if needed).
function _issueOpen(bookId) {
_lib.issue = bookId;
renderBookList(_lastBookListData);
const sc = document.querySelector('.main-content');
if (sc) sc.scrollTop = 0;
window.scrollTo(0, 0);
_issueEnsureIndex(bookId).then(() => { if (_lib.issue === bookId) renderBookList(_lastBookListData); });
}
// ---- the reader: the running head, the article one is in, the pause at its end ----------------
let _issueR = null; // {bookId, pdf, arts, els, cur, seen}
function _issueRunLabel() {
if (!_issueR || _issueR.cur < 0) return '';
const a = _issueR.arts[_issueR.cur];
return `Artikel ${_issueR.cur + 1}/${_issueR.arts.length} · ${a.t}`;
}
window._issueRunLabel = _issueRunLabel;
async function _issueReaderOpened(bookId, isPdf) {
_issueReaderClosed();
const b = _lastBookListData.find(x => x.id === bookId);
if (!b || !_isMagazine(b)) return;
const arts = _issueIndexOf(b) || await _issueEnsureIndex(bookId);
if (!arts || currentBookId !== bookId) return;
const contentEl = document.getElementById('reader-content');
_issueR = {bookId, pdf: !!isPdf, arts, els: [], cur: -1, seen: new Set(), shown: new Set()};
if (!isPdf) {
const target = h => {
const k = h.indexOf('#');
const file = k >= 0 ? h.slice(0, k) : h;
const frag = k >= 0 ? h.slice(k + 1) : '';
let el = null;
if (frag) { try { el = contentEl.querySelector('#' + CSS.escape(frag)); } catch (e) {} }
if (!el) el = Array.prototype.find.call(contentEl.querySelectorAll('[data-epub-src]'), c => c.getAttribute('data-epub-src') === file) || null;
return el;
};
_issueR.els = arts.map(a => target(a.h));
// The pauses push the text down: the place shown stays where it is.
const anchor = typeof getPositionAnchor === 'function' ? getPositionAnchor(contentEl) : '';
// A pause at each article's end: before the next one starts (inside its chapter, so an
// e-reader showing one chapter at a time shows it with the article).
arts.forEach((a, i) => {
const el = _issueR.els[i];
if (!el) return;
const aside = document.createElement('aside');
aside.className = 'iss-end';
aside.dataset.issueEnd = i;
aside.innerHTML = _issueEndHtml(i);
const next = _issueR.els[i + 1];
if (next && !next.hasAttribute('data-epub-src')) next.parentNode.insertBefore(aside, next);
else {
const chapters = contentEl.querySelectorAll(':scope > [data-epub-src]');
let ch = next ? next.previousElementSibling : chapters[chapters.length - 1];
while (ch && !ch.hasAttribute('data-epub-src')) ch = ch.previousElementSibling;
(ch || el.closest('[data-epub-src]') || contentEl).appendChild(aside);
}
});
contentEl.addEventListener('click', _issueEndClick);
if (anchor && typeof restoreFromAnchor === 'function') {
if (typeof _suppressScrollJumpDetect === 'function') _suppressScrollJumpDetect();
restoreFromAnchor(contentEl, anchor);
}
} else {
// Pages turned one by one (paginated PDF) don't scroll: look now and then.
_issueR.poll = setInterval(_issueTrack, 1200);
}
_issueEnsureHead();
contentEl.addEventListener('scroll', _issueScrollLater, {passive: true});
_issueTrack();
}
function _issueReaderClosed() {
if (!_issueR) return;
const contentEl = document.getElementById('reader-content');
if (contentEl) {
contentEl.removeEventListener('scroll', _issueScrollLater);
contentEl.removeEventListener('click', _issueEndClick);
}
const head = document.getElementById('iss-runhead');
if (head) head.remove();
const bar = document.getElementById('iss-pdf-end');
if (bar) bar.remove();
const id = _issueR.bookId;
clearInterval(_issueR.poll);
_issueR = null;
if (_issuePending()[id]) _issueFlush(id);
}
function _issueEnsureHead() {
let head = document.getElementById('iss-runhead');
if (!head) {
head = document.createElement('div');
head.id = 'iss-runhead';
const header = document.querySelector('#reader-overlay .reader-header');
if (header && header.parentNode) header.parentNode.insertBefore(head, header.nextSibling);
else return;
}
_issueRenderHead();
}
function _issueRenderHead() {
const head = document.getElementById('iss-runhead');
const b = _issueR && _lastBookListData.find(x => x.id === _issueR.bookId);
const sum = b && _issueSummary(b);
if (!head || !sum) return;
const a = _issueR.cur >= 0 ? sum.arts[_issueR.cur] : null;
head.innerHTML = `<span class="iss-rh-text">${a && a.r ? `<span class="iss-rh-rubrik">${_issueEsc(a.r)}</span>` : ''}
<span class="iss-rh-title">${a ? `Artikel ${_issueR.cur + 1} von ${sum.total} · ${_issueEsc(a.t)}` : `${sum.total} Artikel`}</span></span>
${_issueSegments(sum, _issueR.cur, 'iss-seg-thin')}`;
if (window.DioraUI && DioraUI.updateFooter) DioraUI.updateFooter();
}
let _issueScrollTimer = 0;
function _issueScrollLater() {
clearTimeout(_issueScrollTimer);
_issueScrollTimer = setTimeout(_issueTrack, 160);
}
// Which article is shown, which ends were reached: an article one was in and whose end
// came into view counts as read.
function _issueTrack() {
if (!_issueR) return;
const contentEl = document.getElementById('reader-content');
if (!contentEl) return;
let cur = _issueR.cur;
if (_issueR.pdf) {
const page = typeof pdfCurrentPage !== 'undefined' && readerSettings.pdfPaginated ? pdfCurrentPage : _issuePdfPage(contentEl);
let c = -1;
_issueR.arts.forEach((a, i) => { if (a.p <= page) c = i; });
const prev = _issueR.cur;
const last = _issueR.page;
cur = c;
// Read on from an article's last page onto the next one: that article was read
// (a jump — the contents, "Weiter" — reads nothing).
if (prev >= 0 && cur === prev + 1 && last != null && page === last + 1 &&
last >= (_issueR.arts[prev].e || last) && _issueR.seen.has(prev)) _issueEnded(prev);
_issueR.page = page;
} else {
const r = contentEl.getBoundingClientRect();
const top = r.top + 60;
let c = -1, best = -Infinity;
_issueR.els.forEach((el, i) => {
if (!el || !el.getClientRects().length) return;
const t = el.getBoundingClientRect().top;
if (t <= top && t > best) { best = t; c = i; }
});
if (c >= 0) cur = c;
contentEl.querySelectorAll('aside.iss-end').forEach(aside => {
const i = Number(aside.dataset.issueEnd);
if (!aside.getClientRects().length) return;
const ar = aside.getBoundingClientRect();
if (ar.top < r.bottom - 40 && ar.bottom > r.top) {
if (_issueR.seen.has(i) && !_issueR.shown.has(i)) { _issueR.shown.add(i); _issueEnded(i); }
else _issueRenderEnd(aside, i);
}
});
}
if (cur !== _issueR.cur && cur >= 0) {
_issueR.cur = cur;
_issueR.seen.add(cur);
_issueMark(_issueR.bookId, null, null, _issueR.arts[cur].h);
}
_issueRenderHead();
}
function _issuePdfPage(contentEl) {
const top = contentEl.getBoundingClientRect().top + 20;
const wrappers = contentEl.querySelectorAll('.pdf-page-wrapper');
for (const w of wrappers) {
if (w.getBoundingClientRect().bottom > top) return parseInt(w.id.replace('pdf-page-', ''), 10) || 1;
}
return 1;
}
// The end of article i reached: read (unless already decided otherwise).
function _issueEnded(i) {
const b = _lastBookListData.find(x => x.id === _issueR.bookId);
const st = b && _issueState(b);
const a = _issueR.arts[i];
if (st && !st.read[a.h]) _issueMark(_issueR.bookId, a.h, 'r');
if (_issueR.pdf) _issuePdfEnd(i);
else {
const aside = document.querySelector(`#reader-content aside.iss-end[data-issue-end="${i}"]`);
if (aside) _issueRenderEnd(aside, i);
}
}
// The next article to read after i, skipping those read or skipped.
function _issueNextAfter(sum, i) {
for (let k = i + 1; k < sum.arts.length; k++) if (!sum.st.read[sum.arts[k].h]) return k;
for (let k = 0; k < i; k++) if (!sum.st.read[sum.arts[k].h]) return k;
return -1;
}
function _issueEndHtml(i) {
const b = _lastBookListData.find(x => x.id === _issueR.bookId);
const sum = _issueSummary(b);
const a = sum.arts[i];
const s = sum.st.read[a.h];
const n = _issueNextAfter(sum, i);
// On a PDF the next article is usually the page one just turned to: no need to offer it.
const na = n >= 0 && !(_issueR.pdf && n === _issueR.cur) ? sum.arts[n] : null;
const jumped = n > i + 1 ? `<span class="iss-end-hint">„Weiter“ überspringt ${n - i - 1 === 1 ? 'Artikel ' + (i + 2) : 'die Artikel ' + (i + 2) + '–' + n} (schon gelesen oder übersprungen).</span>` : '';
return `<span class="iss-end-state"><strong>${s === 'r' ? '✓ ' : ''}„${_issueEsc(a.t)}“${s === 'r' ? ' gelesen' : s === 's' ? ' übersprungen' : ' – Ende'}</strong>
<span class="lib-meta">${_issueEsc(b.title)}: ${_issueCounts(sum)}${sum.left ? ' · noch ' + _issueMinutes(sum.left) : ''}</span></span>
${na ? `<button type="button" class="iss-end-next" data-issue-end-act="next" data-n="${n}">
<span class="iss-end-kicker">Weiter im Heft · Artikel ${n + 1}${na.r ? ' · ' + _issueEsc(na.r) : ''}${na.m ? ' · ' + na.m + ' min' : ''}</span>
<span class="iss-end-title">${_issueEsc(na.t)}</span></button>${jumped}`
: '<span class="lib-meta">Das Heft ist durch.</span>'}
<span class="iss-end-actions">
<button type="button" class="iss-btn" data-issue-end-act="toc">Zum Inhalt</button>
${na && n === i + 1 ? `<button type="button" class="iss-btn" data-issue-end-act="skip" data-n="${n}">Artikel ${n + 1} überspringen</button>` : ''}
${s === 'r' ? `<button type="button" class="iss-btn" data-issue-end-act="unread" data-i="${i}">Doch nicht gelesen</button>` : ''}
</span>`;
}
function _issueRenderEnd(aside, i) {
const html = _issueEndHtml(i);
if (aside.innerHTML !== html) aside.innerHTML = html;
}
function _issuePdfEnd(i) {
let bar = document.getElementById('iss-pdf-end');
if (!bar) {
bar = document.createElement('aside');
bar.id = 'iss-pdf-end';
bar.className = 'iss-end iss-end-float';
bar.addEventListener('click', _issueEndClick);
document.body.appendChild(bar);
}
bar.innerHTML = `<button type="button" class="dfo-x iss-end-x" data-issue-end-act="close" aria-label="Schließen">✕</button>` + _issueEndHtml(i);
clearTimeout(bar.timer);
bar.timer = setTimeout(() => bar.remove(), 12000);
}
function _issueEndClick(e) {
const t = e.target.closest('[data-issue-end-act]');
if (!t || !_issueR) return;
e.stopPropagation();
e.preventDefault();
const id = _issueR.bookId;
const act = t.dataset.issueEndAct;
const bar = document.getElementById('iss-pdf-end');
if (act === 'close') { if (bar) bar.remove(); return; }
if (act === 'next') {
if (bar) bar.remove();
const n = Number(t.dataset.n);
_issueR.seen.add(n);
_issueJump(_issueR.arts[n]);
} else if (act === 'toc') {
if (bar) bar.remove();
closeReader();
if (window.DioraNav) DioraNav.go('magazines');
_issueOpen(id);
} else if (act === 'skip') {
_issueMark(id, _issueR.arts[Number(t.dataset.n)].h, 's');
} else if (act === 'unread') {
_issueMark(id, _issueR.arts[Number(t.dataset.i)].h, '');
}
setTimeout(() => {
if (!_issueR) return;
document.querySelectorAll('aside.iss-end[data-issue-end]').forEach(a => _issueRenderEnd(a, Number(a.dataset.issueEnd)));
if (bar && bar.isConnected) bar.remove();
_issueRenderHead();
}, 0);
}
// ---- styles ---------------------------------------------------------------------------------------
(function () {
const style = document.createElement('style');
style.textContent = [
'.iss-seg { display: flex; gap: 2px; height: 8px; }',
'.iss-seg > span { flex: 1; min-width: 2px; border-radius: 2px; background: var(--border, #2a2a2a); }',
'.iss-seg > .iss-s-read { background: var(--fg, #eee); }',
'.iss-seg > .iss-s-skip { background: repeating-linear-gradient(135deg, var(--muted, #9a9a9a) 0 2px, var(--border, #2a2a2a) 2px 4px); }',
'.iss-seg > .iss-s-at { background: linear-gradient(90deg, var(--accent, #e63946) 45%, var(--border, #2a2a2a) 45%); }',
'.iss-seg > .iss-s-now { background: var(--accent, #e63946); flex: 2.5; }',
'.iss-seg-thin { height: 5px; }',
'#book-list .lib-card .iss-seg, #book-list .lib-row .iss-seg, #book-list .lib-continue .iss-seg { margin-top: 4px; }',
'#book-list .iss-top { display: flex; align-items: center; justify-content: space-between; margin: 0 0 8px; }',
'#book-list .iss-back { min-height: 44px; padding: 0 6px; border: none; background: none; color: inherit; font: inherit; font-size: 15px; cursor: pointer; }',
'#book-list .iss-top .iss-more { position: static; opacity: 1; border: none; background: none; color: inherit; font-size: 20px; width: 44px; height: 44px; }',
'#book-list.iss-open .lib-head, #book-list.iss-open .lib-chips { display: none; }',
'#book-list .iss-head { display: flex; gap: 14px; margin: 0 0 12px; }',
'#book-list .iss-cover { flex: none; width: 96px; height: 144px; border-radius: 4px; object-fit: cover; }',
'#book-list .iss-head-text { display: flex; flex-direction: column; gap: 4px; min-width: 0; }',
'#book-list .iss-title { font-size: 21px; font-weight: 700; line-height: 1.25; }',
'#book-list .iss-sum { display: flex; flex-direction: column; gap: 8px; margin: 0 0 8px; }',
'#book-list .iss-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }',
'.iss-go { min-height: 44px; padding: 0 18px; border: none; border-radius: 22px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font: inherit; font-size: 14px; font-weight: 600; cursor: pointer; }',
'.iss-btn { min-height: 40px; padding: 0 14px; border: 1px solid var(--border-strong, #444); border-radius: 20px; background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }',
'#book-list .iss-rubrik { margin: 18px 0 2px; font-size: 12px; font-weight: 400; letter-spacing: .08em; text-transform: uppercase; color: var(--muted, #9a9a9a); }',
'#book-list .iss-row { display: flex; align-items: center; border-bottom: 1px solid var(--border, #2a2a2a); }',
'#book-list .iss-row.iss-next { background: var(--card, #0b0b0b); border: 1px solid var(--accent, #e63946); border-radius: 8px; }',
'#book-list .iss-mark { flex: none; width: 40px; min-height: 48px; border: none; background: none; color: var(--muted, #9a9a9a); font: inherit; font-size: 16px; cursor: pointer; }',
'#book-list .iss-read .iss-mark { color: var(--fg, #eee); }',
'#book-list .iss-next .iss-mark { color: var(--accent, #e63946); }',
'#book-list .iss-art { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; padding: 9px 8px 9px 0; border: none; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#book-list .iss-n { flex: none; width: 22px; font-size: 12px; color: var(--muted, #9a9a9a); text-align: right; }',
'#book-list .iss-art-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
'#book-list .iss-art-title { font-size: 15px; line-height: 1.3; }',
'#book-list .iss-next .iss-art-title { font-weight: 700; }',
'#book-list .iss-read .iss-art-title { color: var(--muted, #9a9a9a); }',
'#book-list .iss-skip .iss-art-title { color: var(--muted, #9a9a9a); text-decoration: line-through; }',
'#book-list .iss-teaser { font-size: 12px; color: var(--muted, #9a9a9a); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#book-list .iss-min { flex: none; font-size: 12px; color: var(--muted, #9a9a9a); }',
'#iss-runhead { display: flex; flex-direction: column; gap: 4px; padding: 4px 16px 6px; border-bottom: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); }',
'body.reader-immersive #iss-runhead { display: none; }',
'.iss-rh-text { display: flex; flex-direction: column; min-width: 0; }',
'.iss-rh-rubrik { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted, #9a9a9a); }',
'.iss-rh-title { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
'aside.iss-end { display: flex; flex-direction: column; gap: 12px; margin: 2.5em 0; padding: 16px; border: 1px solid currentColor; border-radius: 12px; font-family: system-ui, sans-serif; font-size: 15px; line-height: 1.35; text-indent: 0; }',
'.iss-end-state { display: flex; flex-direction: column; gap: 3px; }',
'.iss-end-next { display: flex; flex-direction: column; gap: 4px; padding: 12px 14px; border: none; border-radius: 10px; background: var(--accent, #e63946); color: var(--on-accent, #fff); font: inherit; text-align: left; cursor: pointer; }',
'.iss-end-kicker { font-size: 12px; opacity: .9; }',
'.iss-end-title { font-size: 17px; font-weight: 600; }',
'.iss-end-hint { font-size: 12px; opacity: .75; }',
'.iss-end-actions { display: flex; flex-wrap: wrap; gap: 8px; }',
'.iss-end-float { position: fixed; left: 12px; right: 12px; bottom: 16px; z-index: 960; margin: 0; background: var(--panel, #161616); color: var(--fg, #eee); box-shadow: 0 4px 18px rgba(0,0,0,.4); }',
'.iss-end-x { position: absolute; right: 6px; top: 6px; }',
'body.eink .iss-seg > span { background: #fff; outline: 1px solid #000; outline-offset: -1px; border-radius: 0; }',
'body.eink .iss-seg > .iss-s-read { background: #000; }',
'body.eink .iss-seg > .iss-s-skip { background: repeating-linear-gradient(135deg, #000 0 1px, #fff 1px 4px); }',
'body.eink .iss-seg > .iss-s-at, body.eink .iss-seg > .iss-s-now { background: linear-gradient(90deg, #000 50%, #fff 50%); }',
'body.eink .iss-go, body.eink .iss-end-next { background: #000; color: #fff; }',
'body.eink #book-list .iss-row.iss-next { background: #fff; border: 2px solid #000; }',
'body.eink #book-list .iss-row, body.eink #iss-runhead { border-color: #000; }',
'body.eink .iss-end-float { background: #fff; color: #000; border: 2px solid #000; box-shadow: none; }',
].join('\n');
document.head.appendChild(style);
})();

View file

@ -360,6 +360,9 @@ async function _openBookOnce(bookId) {
enterReaderImmersiveMode(); enterReaderImmersiveMode();
// An issue of a magazine: its articles (54-issues.js).
if (typeof _issueReaderOpened === 'function') _issueReaderOpened(bookId, isPdf);
} catch (e) { } catch (e) {
console.error('openBook failed:', e); console.error('openBook failed:', e);
overlay.style.display = 'none'; overlay.style.display = 'none';
@ -496,6 +499,7 @@ async function saveReaderProgress(force = false) {
} }
function closeReader() { function closeReader() {
if (typeof _issueReaderClosed === 'function') _issueReaderClosed();
exitReaderImmersiveMode(); exitReaderImmersiveMode();
stopReadAloud(); stopReadAloud();
// Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none

View file

@ -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-v126'; const CACHE = 'diora-v127';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',
@ -24,6 +24,7 @@ const SHELL = [
'/static/js/app/50-reader-state.js', '/static/js/app/50-reader-state.js',
'/static/js/app/52-book-list.js', '/static/js/app/52-book-list.js',
'/static/js/app/53-library.js', '/static/js/app/53-library.js',
'/static/js/app/54-issues.js',
'/static/js/app/56-cloud-import.js', '/static/js/app/56-cloud-import.js',
'/static/js/app/58-pdf.js', '/static/js/app/58-pdf.js',
'/static/js/app/60-tts.js', '/static/js/app/60-tts.js',

View file

@ -375,6 +375,7 @@
<script src="/static/js/app/50-reader-state.js"></script> <script src="/static/js/app/50-reader-state.js"></script>
<script src="/static/js/app/52-book-list.js"></script> <script src="/static/js/app/52-book-list.js"></script>
<script src="/static/js/app/53-library.js"></script> <script src="/static/js/app/53-library.js"></script>
<script src="/static/js/app/54-issues.js"></script>
<script src="/static/js/app/56-cloud-import.js"></script> <script src="/static/js/app/56-cloud-import.js"></script>
<script src="/static/js/app/58-pdf.js"></script> <script src="/static/js/app/58-pdf.js"></script>
<script src="/static/js/app/60-tts.js"></script> <script src="/static/js/app/60-tts.js"></script>