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
|
|
|
|
'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
|
2026-10-17 11:48:44 +02:00
|
|
|
|
const ISSUE_INDEX_V = 2; // an index of another version is built anew
|
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
|
|
|
|
const _issuePendingKey = 'diora_issue_pending';
|
|
|
|
|
|
|
|
|
|
|
|
function _issueEsc(s) {
|
|
|
|
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const _issueMeta = b => (b && (bookMetaCache[b.id] || {})) || {};
|
|
|
|
|
|
function _issueIndexOf(b) {
|
|
|
|
|
|
const m = _issueMeta(b);
|
|
|
|
|
|
const ix = m.issue || (b && b.issue);
|
2026-10-17 11:48:44 +02:00
|
|
|
|
return ix && ix.v === ISSUE_INDEX_V && ix.a && ix.a.length ? ix.a : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
// An issue whose file has no usable table of contents (under this version's rules).
|
|
|
|
|
|
function _issueNone(b) {
|
|
|
|
|
|
const ix = _issueMeta(b).issue || (b && b.issue);
|
|
|
|
|
|
return !!(ix && ix.v === ISSUE_INDEX_V && ix.none);
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- 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 -----------------------------------------------------------------------------------
|
|
|
|
|
|
|
2026-10-17 11:48:44 +02:00
|
|
|
|
// The articles of a table of contents. An EPUB's (byFile): an article is a file — the
|
|
|
|
|
|
// first entry pointing into it; the entries after it into the same file are its sections
|
|
|
|
|
|
// (whatever their depth), and an entry whose children point into other files is a Rubrik.
|
|
|
|
|
|
// Otherwise (one file for all, a PDF's outline): the leaves, each under the entry above it.
|
|
|
|
|
|
// Entries that are word processors' leftovers (_Hlk…, _Toc…) don't count. [{h, t, r}]
|
|
|
|
|
|
const _issueJunk = label => /^_?(Hlk|Toc|Ref|GoBack)\d*$/i.test(String(label || '').trim());
|
|
|
|
|
|
|
|
|
|
|
|
function _issueArticlesFromToc(toc, byFile) {
|
|
|
|
|
|
toc = toc.filter(e => !_issueJunk(e.label));
|
|
|
|
|
|
const fileOf = h => String(h || '').split('#')[0];
|
|
|
|
|
|
if (byFile) {
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
for (let i = 0; i < toc.length; i++) {
|
|
|
|
|
|
const e = toc[i];
|
|
|
|
|
|
if (!e.href) continue;
|
|
|
|
|
|
const f = fileOf(e.href);
|
|
|
|
|
|
let container = false;
|
|
|
|
|
|
for (let k = i + 1; k < toc.length && toc[k].depth > e.depth; k++) {
|
|
|
|
|
|
if (toc[k].href && fileOf(toc[k].href) !== f) { container = true; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
if (container || seen.has(f)) continue;
|
|
|
|
|
|
seen.add(f);
|
|
|
|
|
|
let r = '';
|
|
|
|
|
|
for (let k = i - 1, d = e.depth; k >= 0 && d > 0; k--) {
|
|
|
|
|
|
if (toc[k].depth >= d) continue;
|
|
|
|
|
|
d = toc[k].depth;
|
|
|
|
|
|
if (!toc[k].href || fileOf(toc[k].href) !== f) { r = toc[k].label || ''; break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
out.push({h: e.href, t: (e.label || '').replace(/\s+/g, ' ').trim() || 'Ohne Titel', r: r.replace(/\s+/g, ' ').trim()});
|
|
|
|
|
|
}
|
|
|
|
|
|
if (out.length >= 2) return out;
|
|
|
|
|
|
}
|
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
|
|
|
|
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, ' ')
|
2026-10-17 11:48:44 +02:00
|
|
|
|
.replace(/&#x([0-9a-f]+);/gi, (m, h) => String.fromCharCode(parseInt(h, 16)))
|
|
|
|
|
|
.replace(/&#(\d+);/g, (m, d) => String.fromCharCode(parseInt(d, 10)))
|
|
|
|
|
|
.replace(/ /g, ' ')
|
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
|
|
|
|
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/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);
|
2026-10-17 11:48:44 +02:00
|
|
|
|
let arts = _issueArticlesFromToc(await _parseEpubToc(zip, opfDoc, manifest), true);
|
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
|
|
|
|
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)); }
|
|
|
|
|
|
}
|
2026-10-17 11:48:44 +02:00
|
|
|
|
const st = _issueTextStats(_issueHtmlText(html), arts[i].t);
|
|
|
|
|
|
// The headings before the first paragraph that the title doesn't already say (a
|
|
|
|
|
|
// magazine's headline under its kicker): the teaser; else the text's beginning.
|
|
|
|
|
|
const body = html.replace(/<head\b[\s\S]*?<\/head>/i, '');
|
|
|
|
|
|
const firstP = body.search(/<p[\s>]/i);
|
|
|
|
|
|
const heads = [];
|
|
|
|
|
|
const re = /<h[1-4]\b[^>]*>([\s\S]*?)<\/h[1-4]>/gi;
|
|
|
|
|
|
let m;
|
|
|
|
|
|
while ((m = re.exec(body)) && (firstP < 0 || m.index < firstP)) {
|
|
|
|
|
|
const t = _issueHtmlText(m[1]).replace(/\s+/g, ' ').trim();
|
|
|
|
|
|
// Not one of the article's own numbered sections ("I. Verarmung …", "1. …").
|
|
|
|
|
|
if (t && !/^([IVXL]+|\d+)\.\s/.test(t) && _issueNorm(arts[i].t).indexOf(_issueNorm(t)) < 0) heads.push(t);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (heads.length) st.x = heads.join(' – ');
|
|
|
|
|
|
else if (firstP >= 0) st.x = _issueTextStats(_issueHtmlText(body.slice(firstP)), '').x;
|
|
|
|
|
|
Object.assign(arts[i], st);
|
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
|
|
|
|
}
|
|
|
|
|
|
return arts;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 11:48:44 +02:00
|
|
|
|
const _issueNorm = s => String(s || '').toLowerCase().replace(/ß/g, 'ss').normalize('NFKD').replace(/[^a-z0-9]/g, '');
|
|
|
|
|
|
|
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
|
|
|
|
async function _issueIndexPdf(bytes) {
|
2026-10-17 11:48:44 +02:00
|
|
|
|
if (typeof pdfjsLib === 'undefined') return null;
|
PDF-Reader: ein Zeichenweg, Seitenmodus neu, Seitensprung für beide Modi (WIP, ungetestet)
- 58-pdf.js: _pdfDraw – eine Zeichnung je Canvas (die vorige wird
abgebrochen und abgewartet), Pixeldichte, höchstens 16,7 MP; Seiten, die
gehen, geben auch pdf.js' Seitendaten frei; ein gemeinsamer pdf.js-Worker
(_pdfLoad) auch für Uploads, Seitenzahl, Hefte und Cover; Inhalts-
verzeichnis parallel aufgelöst, auch mit Seitenindex als Ziel;
ResizeObserver: Drehen, Breite, Leisten – neu an derselben Seite.
- 66-pdf-paginated.js: Seitenmodus auf _pdfDraw, Zuschnitt in
Bildschirmkoordinaten (gedrehte Seiten) und höchstens 2,5× der ganzen
Seite (eine fast leere Seite wird nicht unendlich groß); nächste und
vorige Seite vorgezeichnet; Tippen zum Blättern blendet die Leisten
nicht mehr ein; Wischen nur deutlich seitwärts; Mausrad; Pinch setzt die
Vorschau zurück; nach dem Blättern wird gespeichert und 'diora-pdf-turn'
gemeldet (E-Ink-Refresh).
- pdfGoToPage für beide Modi: Inhaltsverzeichnis, Seitenzahl im Kopf,
Lesezeichen, Tasten (Seitenmodus jetzt auch Leertaste, Bild auf/ab).
- Beim Scrollen wird die aktuelle Seite mitgeführt (Lesezeichen sagten
immer „Seite 1“).
- Schalter Scrollen | Seiten im PDF-Panel; Zoom-Regler zeichnet erst neu,
wenn er ruht.
- openBook hängt seine Listener über _readerListen an; closeReader nimmt
sie ab (vorher sammelten sie sich bei jedem Öffnen an). Abbrechen und
ein zweites Buch geben das alte Dokument frei; der entschlüsselte
PDF-Puffer wird nicht mehr gehalten.
- Invertieren dreht die Farbtöne zurück (Fotos keine Negative).
- e2e/fixtures/make_test_pdf.py: Test-PDF mit gemischten Formaten,
Outline, Links und einer fast leeren Seite.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 17:52:28 +01:00
|
|
|
|
const pdf = await _pdfLoad(new Uint8Array(bytes)).promise;
|
2026-10-17 11:48:44 +02:00
|
|
|
|
try {
|
|
|
|
|
|
return await _issuePdfArticles(pdf);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
try { pdf.destroy(); } catch (e) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// A PDF's articles: from its outline where it has a real one, else from the contents
|
|
|
|
|
|
// page printed in the issue (most magazines' PDFs have no outline).
|
|
|
|
|
|
async function _issuePdfArticles(pdf) {
|
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
|
|
|
|
const pageText = {};
|
|
|
|
|
|
const textOf = async n => {
|
|
|
|
|
|
if (!(n in pageText)) {
|
|
|
|
|
|
try {
|
2026-10-17 11:48:44 +02:00
|
|
|
|
const c = await (await pdf.getPage(n)).getTextContent();
|
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
|
|
|
|
pageText[n] = c.items.map(it => it.str).join(' ');
|
|
|
|
|
|
} catch (e) { pageText[n] = ''; }
|
|
|
|
|
|
}
|
|
|
|
|
|
return pageText[n];
|
|
|
|
|
|
};
|
2026-10-17 11:48:44 +02:00
|
|
|
|
let arts = [];
|
|
|
|
|
|
const outline = await pdf.getOutline().catch(() => null);
|
|
|
|
|
|
if (outline && outline.length && typeof _parsePdfOutline === 'function') {
|
|
|
|
|
|
arts = _issueArticlesFromToc(await _parsePdfOutline(pdf, outline))
|
|
|
|
|
|
.filter(a => /^#pdf-page-\d+$/.test(a.h))
|
|
|
|
|
|
.map(a => Object.assign(a, {p: parseInt(a.h.slice(10), 10)}));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (arts.length < 3) arts = await _issuePrintedToc(pdf, textOf);
|
|
|
|
|
|
if (arts.length < 2) return [];
|
|
|
|
|
|
arts.sort((a, b) => a.p - b.p);
|
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
|
|
|
|
for (let i = 0; i < arts.length; i++) {
|
2026-10-17 11:48:44 +02:00
|
|
|
|
const a = arts[i];
|
|
|
|
|
|
a.h = '#pdf-page-' + a.p;
|
|
|
|
|
|
a.e = i + 1 < arts.length ? Math.max(a.p, arts[i + 1].p - 1) : pdf.numPages;
|
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
|
|
|
|
let text = '';
|
2026-10-17 11:48:44 +02:00
|
|
|
|
for (let n = a.p; n <= Math.min(a.e, a.p + 40); n++) text += ' ' + await textOf(n);
|
|
|
|
|
|
const s = _issueTextStats(text, '');
|
|
|
|
|
|
// From a printed contents page: no teaser (a page's running heads and captions come
|
|
|
|
|
|
// first in its text); the author stands there instead.
|
|
|
|
|
|
if (a.a !== undefined) s.x = a.x || '';
|
|
|
|
|
|
else if (!a.x) {
|
|
|
|
|
|
// The teaser: what follows the title on its first page, if it stands there.
|
|
|
|
|
|
const first = (await textOf(a.p)).replace(/\s+/g, ' ');
|
|
|
|
|
|
const k = _issueFind(first, a.t);
|
|
|
|
|
|
s.x = _issueTextStats(k >= 0 ? first.slice(k) : '', '').x;
|
|
|
|
|
|
} else s.x = a.x;
|
|
|
|
|
|
Object.assign(a, s);
|
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
|
|
|
|
}
|
|
|
|
|
|
return arts;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-17 11:48:44 +02:00
|
|
|
|
// Where `title` stands in `text` (letters and digits only, any case): the index in
|
|
|
|
|
|
// `text` just after it, or -1.
|
|
|
|
|
|
function _issueFind(text, title) {
|
|
|
|
|
|
const want = _issueNorm(title).slice(0, 40);
|
|
|
|
|
|
if (want.length < 4) return -1;
|
|
|
|
|
|
let norm = '';
|
|
|
|
|
|
const map = [];
|
|
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
|
|
|
|
const c = _issueNorm(text[i]);
|
|
|
|
|
|
for (let k = 0; k < c.length; k++) { norm += c[k]; map.push(i + 1); }
|
|
|
|
|
|
}
|
|
|
|
|
|
const at = norm.indexOf(want);
|
|
|
|
|
|
return at < 0 ? -1 : map[at + want.length - 1];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function _issueSpan(text, title) {
|
|
|
|
|
|
const end = _issueFind(text, title);
|
|
|
|
|
|
if (end < 0) return '';
|
|
|
|
|
|
const want = _issueNorm(title).slice(0, 40);
|
|
|
|
|
|
let n = 0, i = end;
|
|
|
|
|
|
while (i > 0 && n < want.length) { i--; n += _issueNorm(text[i]).length; }
|
|
|
|
|
|
return text.slice(i, end);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The lines of a page as pdf.js lays its text out: pieces on one baseline that sit close
|
|
|
|
|
|
// together make a segment; far apart (another column), two. [{x, y, size, text}]
|
|
|
|
|
|
async function _issuePdfSegments(pdf, n) {
|
|
|
|
|
|
const page = await pdf.getPage(n);
|
|
|
|
|
|
const c = await page.getTextContent();
|
|
|
|
|
|
const items = c.items.filter(it => it.str && it.str.trim())
|
|
|
|
|
|
.map(it => ({x: it.transform[4], y: it.transform[5], w: it.width, size: Math.hypot(it.transform[2], it.transform[3]) || it.height || 10, s: it.str}));
|
|
|
|
|
|
items.sort((a, b) => (b.y - a.y) || (a.x - b.x));
|
|
|
|
|
|
const segs = [];
|
|
|
|
|
|
let line = [];
|
|
|
|
|
|
const flush = () => {
|
|
|
|
|
|
if (!line.length) return;
|
|
|
|
|
|
line.sort((a, b) => a.x - b.x);
|
|
|
|
|
|
let seg = null;
|
|
|
|
|
|
for (const it of line) {
|
|
|
|
|
|
if (seg && it.x - seg.end > Math.max(24, it.size * 3)) { segs.push(seg); seg = null; }
|
|
|
|
|
|
if (!seg) seg = {x: it.x, y: it.y, size: it.size, text: it.s, end: it.x + it.w};
|
|
|
|
|
|
else {
|
|
|
|
|
|
seg.text += (it.x - seg.end > it.size * 0.15 && !/\s$/.test(seg.text) && !/^\s/.test(it.s) ? ' ' : '') + it.s;
|
|
|
|
|
|
seg.end = Math.max(seg.end, it.x + it.w);
|
|
|
|
|
|
seg.size = Math.max(seg.size, it.size);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (seg) segs.push(seg);
|
|
|
|
|
|
line = [];
|
|
|
|
|
|
};
|
|
|
|
|
|
for (const it of items) {
|
|
|
|
|
|
if (line.length && Math.abs(line[0].y - it.y) > Math.min(line[0].size, it.size) * 0.5) flush();
|
|
|
|
|
|
line.push(it);
|
|
|
|
|
|
}
|
|
|
|
|
|
flush();
|
|
|
|
|
|
segs.forEach(s => { s.text = s.text.replace(/\s+/g, ' ').trim(); });
|
|
|
|
|
|
return segs.filter(s => s.text);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The contents page printed in the issue. Its rows: a page number at the start
|
|
|
|
|
|
// ("14 Der Krieg gegen Palästina") or at the end, maybe with the author before the title
|
|
|
|
|
|
// in a column of its own ("Nathan Sperber | Beyond Neoliberalism? | 23"); the rows just
|
|
|
|
|
|
// under one, indented alike in its type size, continue it (more title, the author);
|
|
|
|
|
|
// capitals make a heading (the Rubrik). The printed numbers are matched to the file's
|
|
|
|
|
|
// pages by finding the titles there.
|
|
|
|
|
|
const _ISSUE_L = 'A-Za-zÀ-ÖØ-öø-ÿĀ-ſ';
|
|
|
|
|
|
const _issueNameLike = t => t.length <= 45 &&
|
|
|
|
|
|
/^[A-ZÀ-ÖØ-ÞĀ-ſ][A-Za-zÀ-ÖØ-öø-ÿĀ-ſ.'’-]*(\s+((und|and|&|von|van|de|der|del|y|bin)\s+)?[A-ZÀ-ÖØ-ÞĀ-ſ][A-Za-zÀ-ÖØ-öø-ÿĀ-ſ.'’-]*){1,4}$/.test(t);
|
|
|
|
|
|
|
|
|
|
|
|
function _issueTocRows(segs, N) {
|
|
|
|
|
|
const rows = [];
|
|
|
|
|
|
segs.slice().sort((a, b) => (b.y - a.y) || (a.x - b.x)).forEach(s => {
|
|
|
|
|
|
const row = rows.filter(r => Math.abs(r.y - s.y) < Math.min(r.size, s.size) * 0.4)[0];
|
|
|
|
|
|
if (row) { row.segs.push(s); row.size = Math.max(row.size, s.size); } else rows.push({y: s.y, size: s.size, segs: [s]});
|
|
|
|
|
|
});
|
|
|
|
|
|
const items = [];
|
|
|
|
|
|
const isNum = t => /^\d{1,3}$/.test(t) && parseInt(t, 10) >= 1 && parseInt(t, 10) <= N + 20;
|
|
|
|
|
|
const letters = t => new RegExp('[' + _ISSUE_L + ']{3}').test(t);
|
|
|
|
|
|
rows.forEach(r => {
|
|
|
|
|
|
const g = r.segs.sort((a, b) => a.x - b.x);
|
|
|
|
|
|
const last = g[g.length - 1];
|
|
|
|
|
|
const textSegs = g.filter(x => !isNum(x.text));
|
|
|
|
|
|
if (g.length >= 2 && isNum(last.text) && textSegs.length && letters(textSegs[textSegs.length - 1].text)) {
|
|
|
|
|
|
// … title | number (an author in a column before the title)
|
|
|
|
|
|
const title = textSegs[textSegs.length - 1];
|
|
|
|
|
|
items.push({entry: true, printed: parseInt(last.text, 10), lines: [title.text], lead: false,
|
|
|
|
|
|
author: textSegs.length > 1 ? textSegs.slice(0, -1).map(x => x.text).join(' ') : '',
|
|
|
|
|
|
x: title.x, y: r.y, y0: r.y, size: title.size});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const tail = /^(.*\S)\s+(\d{1,3})$/.exec(last.text);
|
|
|
|
|
|
if (g.length >= 2 && tail && isNum(tail[2]) && letters(tail[1]) && letters(g[0].text)) {
|
|
|
|
|
|
// author | title 57
|
|
|
|
|
|
items.push({entry: true, printed: parseInt(tail[2], 10), lines: [tail[1]], lead: false,
|
|
|
|
|
|
author: g.slice(0, -1).map(x => x.text).join(' '), x: last.x, y: r.y, y0: r.y, size: last.size});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (g.length >= 2 && isNum(g[0].text) && letters(g[1].text)) {
|
|
|
|
|
|
items.push({entry: true, printed: parseInt(g[0].text, 10), lines: [g.slice(1).map(x => x.text).join(' ')], lead: true,
|
|
|
|
|
|
author: '', x: g[1].x, y: r.y, y0: r.y, size: g[1].size});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
g.forEach(seg => {
|
|
|
|
|
|
let m = /^(\d{1,3})\s+(\S.*)$/.exec(seg.text);
|
|
|
|
|
|
let lead = true;
|
|
|
|
|
|
if (!m) { m = /^(.*\S)\s+(\d{1,3})$/.exec(seg.text); lead = false; }
|
|
|
|
|
|
const n = m ? parseInt(lead ? m[1] : m[2], 10) : 0;
|
|
|
|
|
|
const t = m ? (lead ? m[2] : m[1]) : '';
|
|
|
|
|
|
if (m && n >= 1 && n <= N + 20 && letters(t)) {
|
|
|
|
|
|
items.push({entry: true, printed: n, lines: [t], lead, author: '', x: seg.x, y: seg.y, y0: seg.y, size: seg.size});
|
|
|
|
|
|
} else items.push({entry: false, seg});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
return items;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function _issuePrintedToc(pdf, textOf) {
|
|
|
|
|
|
const N = pdf.numPages;
|
|
|
|
|
|
const caps = t => t.length >= 3 && t.length <= 40 && !/\d/.test(t) &&
|
|
|
|
|
|
t.replace(new RegExp('[^' + _ISSUE_L + ']', 'g'), '').length >= 3 && t === t.toUpperCase();
|
|
|
|
|
|
// The contents page: among the first pages the one with most numbered rows whose
|
|
|
|
|
|
// numbers aren't a mere count (1, 2, 3 … is a list, not a contents).
|
|
|
|
|
|
const pages = [];
|
|
|
|
|
|
for (let n = 1; n <= Math.min(16, N); n++) {
|
|
|
|
|
|
const items = _issueTocRows(await _issuePdfSegments(pdf, n), N);
|
|
|
|
|
|
const nums = items.filter(x => x.entry);
|
|
|
|
|
|
const sorted = nums.map(x => x.printed).sort((a, b) => a - b);
|
|
|
|
|
|
let steps = 0;
|
|
|
|
|
|
for (let i = 1; i < sorted.length; i++) if (sorted[i] - sorted[i - 1] === 1) steps++;
|
|
|
|
|
|
pages.push({n, items, score: nums.length * (steps > sorted.length * 0.6 ? 0.2 : 1)});
|
|
|
|
|
|
}
|
|
|
|
|
|
let best = null;
|
|
|
|
|
|
pages.forEach(pg => { if (pg.score >= 4 && (!best || pg.score > best.score)) best = pg; });
|
|
|
|
|
|
if (!best) return [];
|
|
|
|
|
|
// A contents running over several pages: the neighbours with entries too.
|
|
|
|
|
|
best.best = true;
|
|
|
|
|
|
const use = [best];
|
|
|
|
|
|
for (let k = best.n; k < pages.length && pages[k].score >= 2; k++) use.push(pages[k]);
|
|
|
|
|
|
for (let k = best.n - 2; k >= 0 && pages[k].score >= 2; k--) use.unshift(pages[k]);
|
|
|
|
|
|
const isCaps = t => t === t.toUpperCase() && new RegExp('[' + _ISSUE_L + ']{3}').test(t);
|
|
|
|
|
|
const entries = [];
|
|
|
|
|
|
use.forEach(pg => {
|
|
|
|
|
|
const ents = [];
|
|
|
|
|
|
const headings = [];
|
|
|
|
|
|
const capsRun = []; // capitals just above, not used yet: a title's beginning or a heading
|
|
|
|
|
|
pg.items.forEach(it => {
|
|
|
|
|
|
if (it.entry) {
|
|
|
|
|
|
const e = it;
|
|
|
|
|
|
// A title in capitals may begin on the lines above its number ("DAS POLITISCHE FELD
|
|
|
|
|
|
// NACH LINKS / 14 VERSCHIEBEN"); a lone word on top of them is a heading (GESPRÄCH).
|
|
|
|
|
|
if (isCaps(e.lines[0])) {
|
|
|
|
|
|
const above = capsRun.filter(c => c.x >= e.x - 8 && c.x - e.x < 60 && c.y > e.y && c.y - e.y < c.size * 2.2 * (capsRun.length + 1));
|
|
|
|
|
|
above.sort((a, b) => b.y - a.y);
|
|
|
|
|
|
let prev = e.y;
|
|
|
|
|
|
const chain = [];
|
|
|
|
|
|
for (let k = above.length - 1; k >= 0; k--) {
|
|
|
|
|
|
if (above[k].y - prev > above[k].size * 2.4) break;
|
|
|
|
|
|
chain.unshift(above[k]);
|
|
|
|
|
|
prev = above[k].y;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (chain.length >= 1 && !/\s/.test(chain[0].text)) { e.kicker = chain[0].text; chain.shift(); }
|
|
|
|
|
|
chain.forEach(c => { c.used = true; });
|
|
|
|
|
|
if (chain.length) e.lines[0] = chain.map(c => c.text).concat([e.lines[0]]).join(' ').replace(/(\w)- (?=\w)/g, '$1-');
|
|
|
|
|
|
}
|
|
|
|
|
|
e.fromNeighbour = !pg.best;
|
|
|
|
|
|
ents.push(e);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const s = it.seg;
|
|
|
|
|
|
if (/^\d+$/.test(s.text)) return;
|
|
|
|
|
|
if (caps(s.text)) {
|
|
|
|
|
|
const c = {y: s.y, x: s.x, t: s.text, text: s.text, size: s.size};
|
|
|
|
|
|
headings.push(c);
|
|
|
|
|
|
capsRun.push(c);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const e = ents.filter(x => s.x >= x.x - 8 && s.x - x.x < 60 && x.y - s.y > 0 && x.y - s.y < Math.max(x.size, s.size) * 3 && x.lines.length < 6).pop();
|
|
|
|
|
|
if (!e) return;
|
|
|
|
|
|
const by = /^(Von|Mit|By|With)\s+(.+)$/.exec(s.text);
|
|
|
|
|
|
if (by && !e.author) { e.author = by[2]; e.y = s.y; e.done = true; return; }
|
|
|
|
|
|
if (e.done && /(,|&|\bund)$/.test(e.author)) { e.author += ' ' + s.text; e.y = s.y; return; }
|
|
|
|
|
|
if (e.done) return;
|
|
|
|
|
|
if (Math.abs(s.size - e.size) < 2.5) e.lines.push(s.text); // more title, or the author
|
|
|
|
|
|
else (e.sub = e.sub || []).push(s.text); // a line under it in other type
|
|
|
|
|
|
e.y = s.y;
|
|
|
|
|
|
});
|
|
|
|
|
|
// Each entry's heading: the nearest capitals above it in the contents' type size (a
|
|
|
|
|
|
// title's beginning is no heading; a kicker on top of one is).
|
|
|
|
|
|
ents.forEach(e => {
|
|
|
|
|
|
const h = headings.filter(x => x.y > e.y0 && Math.abs(x.x - e.x) < 200 && x.size < e.size * 1.6 &&
|
|
|
|
|
|
!x.used).sort((a, b) => a.y - b.y)[0];
|
|
|
|
|
|
e.r = e.kicker || (h && !isCaps(e.lines[0]) ? h.t : '');
|
|
|
|
|
|
});
|
|
|
|
|
|
entries.push.apply(entries, ents);
|
|
|
|
|
|
});
|
|
|
|
|
|
const sizes = entries.map(e => e.size).sort((a, b) => a - b);
|
|
|
|
|
|
const median = sizes[Math.floor(sizes.length / 2)] || 10;
|
|
|
|
|
|
for (let i = entries.length - 1; i >= 0; i--) if (entries[i].size > median * 1.6) entries.splice(i, 1);
|
|
|
|
|
|
const capsShare = entries.filter(e => isCaps(e.lines[0])).length / entries.length;
|
|
|
|
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
|
|
|
|
if (capsShare < 0.5 && isCaps(entries[i].lines[0]) && !/\s/.test(entries[i].lines[0])) entries.splice(i, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (entries.length < 3) return [];
|
|
|
|
|
|
entries.forEach(e => {
|
|
|
|
|
|
// An author on the last line: the title is the lines before it.
|
|
|
|
|
|
if (!e.author && e.lines.length > 1 && _issueNameLike(e.lines[e.lines.length - 1])) e.author = e.lines.pop();
|
|
|
|
|
|
// "Name: Title" (manuskripte)
|
|
|
|
|
|
const m = /^([^:]{3,45}):\s+(.{3,})$/.exec(e.lines[0]);
|
|
|
|
|
|
if (!e.author && m && _issueNameLike(m[1])) { e.author = m[1]; e.lines[0] = m[2]; }
|
|
|
|
|
|
e.author = (e.author || '').replace(/\s+/g, ' ').trim();
|
|
|
|
|
|
});
|
|
|
|
|
|
// The printed numbers onto the file's pages: the offset most titles agree on.
|
|
|
|
|
|
const votes = {};
|
|
|
|
|
|
const found = {};
|
|
|
|
|
|
for (const e of entries) {
|
|
|
|
|
|
// The title as printed; where the number stands at the end of one run of text, maybe
|
|
|
|
|
|
// with a name before the title ("Marcus Verhagen Grenfell's Fire 121").
|
|
|
|
|
|
const t = e.lines.join(' ');
|
|
|
|
|
|
const words = t.split(' ');
|
|
|
|
|
|
const cands = [t].concat(!e.lead && !e.author && words.length > 3 ? [words.slice(2).join(' '), words.slice(3).join(' ')] : []);
|
|
|
|
|
|
for (let off = -2; off <= 8; off++) {
|
|
|
|
|
|
const p = e.printed + off;
|
|
|
|
|
|
if (p < 1 || p > N) continue;
|
|
|
|
|
|
const text = await textOf(p);
|
|
|
|
|
|
const hits = cands.filter(c => _issueNorm(c).length >= 8 && _issueFind(text, c) >= 0);
|
|
|
|
|
|
const upper = hits.filter(c => { const sp = _issueSpan(text, c); return sp && /[A-ZÄÖÜ]/.test(sp) && sp === sp.toUpperCase(); });
|
|
|
|
|
|
const hit = upper[0] || hits[0];
|
|
|
|
|
|
if (hit) {
|
|
|
|
|
|
votes[off] = (votes[off] || 0) + 1;
|
|
|
|
|
|
(found[off] = found[off] || []).push([e, hit]);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const offs = Object.keys(votes).sort((a, b) => votes[b] - votes[a]);
|
|
|
|
|
|
const off = offs.length ? parseInt(offs[0], 10) : 0;
|
|
|
|
|
|
const titled = new Map((found[off] || []).map(x => [x[0], x[1]]));
|
|
|
|
|
|
const runningHead = async t => {
|
|
|
|
|
|
let n = 0;
|
|
|
|
|
|
for (const q of [Math.round(N * 0.3), Math.round(N * 0.55), Math.round(N * 0.8)]) if (_issueFind(await textOf(q), t) >= 0) n++;
|
|
|
|
|
|
return n >= 2;
|
|
|
|
|
|
};
|
|
|
|
|
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
|
|
|
|
const e = entries[i];
|
|
|
|
|
|
const t = e.lines.join(' ');
|
|
|
|
|
|
// On a further contents page, an entry its page doesn't confirm must look like a title:
|
|
|
|
|
|
// capitals, or a few words without a comma (not a sentence running on).
|
|
|
|
|
|
const plausible = isCaps(t) || (t.length <= 80 && !/,/.test(t) && /\s/.test(t) && /^[^a-zäöüß]/.test(t));
|
|
|
|
|
|
if (await runningHead(t) || (e.fromNeighbour && !titled.get(e) && !plausible)) entries.splice(i, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
entries.forEach(e => {
|
|
|
|
|
|
const p = e.printed + off;
|
|
|
|
|
|
if (p < 1 || p > N) return;
|
|
|
|
|
|
const full = e.lines.join(' ');
|
|
|
|
|
|
const t = titled.get(e) || full;
|
|
|
|
|
|
let author = e.author;
|
|
|
|
|
|
let title = t;
|
|
|
|
|
|
if (!author && t !== full && full.indexOf(t) > 0) author = full.slice(0, full.indexOf(t)).trim();
|
|
|
|
|
|
if (!author && !e.lead && !titled.get(e)) {
|
|
|
|
|
|
const w = full.split(' ');
|
|
|
|
|
|
for (const k of [2, 3]) {
|
|
|
|
|
|
if (w.length > k + 1 && _issueNameLike(w.slice(0, k).join(' '))) { author = w.slice(0, k).join(' '); title = w.slice(k).join(' '); break; }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
const key = p + '|' + _issueNorm(title);
|
|
|
|
|
|
if (seen.has(key)) return;
|
|
|
|
|
|
seen.add(key);
|
|
|
|
|
|
out.push({p, t: title.replace(/\s+/g, ' ').trim(), r: (e.r || '').replace(/\s+/g, ' ').trim(), a: author,
|
|
|
|
|
|
x: e.sub ? e.sub.join(' ').replace(/(\w)- (?=[a-zäöüß])/g, '$1').replace(/\s+/g, ' ').trim() : ''});
|
|
|
|
|
|
});
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
// 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);
|
2026-10-17 11:48:44 +02:00
|
|
|
|
if (have || _issueNone(b)) return Promise.resolve(have);
|
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
|
|
|
|
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).
|
2026-10-17 11:48:44 +02:00
|
|
|
|
const issue = arts.length >= 2 ? {v: ISSUE_INDEX_V, a: arts} : {v: ISSUE_INDEX_V, a: [], none: true};
|
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
|
|
|
|
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;
|
2026-10-17 11:48:44 +02:00
|
|
|
|
const todo = books.filter(b => _isMagazine(b) && b.keyOk !== false && !_issueIndexOf(b) && !_issueNone(b));
|
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
|
|
|
|
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) {
|
2026-10-17 11:48:44 +02:00
|
|
|
|
const none = _issueNone(b);
|
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
|
|
|
|
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>
|
2026-10-17 11:48:44 +02:00
|
|
|
|
${a.a || a.x ? `<span class="iss-teaser">${_issueEsc([a.a, a.x].filter(Boolean).join(' · '))}</span>` : ''}</span>
|
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
|
|
|
|
<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 {
|
2026-10-17 11:48:44 +02:00
|
|
|
|
// The chapter just before the next article; the last article: its own (what follows
|
|
|
|
|
|
// it — notices, the imprint — is no part of it).
|
|
|
|
|
|
let ch = next ? next.previousElementSibling : el.closest('[data-epub-src]');
|
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
|
|
|
|
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);
|
|
|
|
|
|
})();
|