diora-web/static/js/app/54-issues.js
mrwnslz ea7d62789f
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
Zeitschriften: das Heft und seine Artikel
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>
2026-10-17 11:14:28 +02:00

784 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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);
})();