From ea7d62789f2e5b5a85a36fdb744c0111d9d4874d Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Sat, 17 Oct 2026 11:14:28 +0200 Subject: [PATCH] Zeitschriften: das Heft und seine Artikel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/library.md | 39 ++ static/js/app/52-book-list.js | 13 +- static/js/app/53-library.js | 44 +- static/js/app/54-issues.js | 784 ++++++++++++++++++++++++++++++++++ static/js/app/62-reader.js | 4 + static/js/sw.js | 3 +- templates/radio/player.html | 1 + 7 files changed, 879 insertions(+), 9 deletions(-) create mode 100644 static/js/app/54-issues.js diff --git a/docs/library.md b/docs/library.md index ac99a83..b167270 100644 --- a/docs/library.md +++ b/docs/library.md @@ -81,3 +81,42 @@ doorway), grusel (Horror), klassiker (patterned paper with a title plate), sakral (Religion & Mythologie), spiel (Kinder & Jugend), code (Informatik), typo, wellen, botanik, berge, zellen — and bauhaus when nothing matches. A hash of title and author picks palette, layout and every detail. + +## Zeitschriften: das Heft und seine Artikel + +An issue is a unit (the editors' selection and order, its date, its theme) and +so is each article in it. Neither is reduced to the other +(`static/js/app/54-issues.js`): + +- **The issue stays the document.** It opens and reads through as before, in + the editors' order. The reader marks the article one is in: a running head + under the bar ("Schwerpunkt · Artikel 8 von 23 · Titel"), in the apps' footer + "Artikel 8/23", and a **pause at each article's end** (`aside.iss-end`): + "✓ gelesen", the issue's count, "Weiter im Heft" (the next article not read + yet, skipping those read or skipped), "Zum Inhalt", "überspringen", + "Doch nicht gelesen". In a PDF the pause is a bar that comes up when one + turns from an article's last page onto the next. +- **The states determine each other.** An article one was in and whose end one + reached counts as read (a jump — from the contents, "Weiter" — reads + nothing). The issue counts "7 von 23 gelesen · 2 übersprungen"; "Im Heft + weiterlesen" goes to the article one was in, else the next one open. + "Heft abschließen" marks the rest as skipped, never as read. +- **In the library** an issue shows one bar with a segment per article (read, + skipped, open, the one one is in). A tap opens its contents: Rubriken, the + articles with their state, a teaser and the reading time; the ⋮ / mark + sets read / unread / skipped by hand. + +**The index** comes from the file, on the device: an EPUB's table of contents +(its leaves are the articles, their parents the Rubriken; without one, each +spine file with text), a PDF's outline (page ranges). Teasers and reading times +(200 words a minute) come from the text. It is kept in the encrypted meta as +`issue: {v: 1, a: [{h, t, r, x, m, p?, e?}]}` (href, title, Rubrik, teaser, +minutes, a PDF's first and last page), or `{v: 1, a: [], none: true}` for an +issue without a usable table of contents (read as a whole). Built in the +background for issues without one (not on e-ink: there when the issue is +opened, or it comes from another device). + +**The states** are in the meta too: `issueRead: {href: 'r' | 's'}`, +`issueAt: href` (the article one was in). A change goes to the server after +2.5 s; offline it waits in `localStorage.diora_issue_pending` and goes with the +next save (or when the connection is back). diff --git a/static/js/app/52-book-list.js b/static/js/app/52-book-list.js index 7479f32..700b7d6 100644 --- a/static/js/app/52-book-list.js +++ b/static/js/app/52-book-list.js @@ -50,8 +50,8 @@ async function loadBookList() { try { const metaBuf = await decryptBytes(key, b.meta_iv, b.meta_ct); const meta = JSON.parse(new TextDecoder().decode(metaBuf)); - bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex}; - decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, has_cover: !!b.has_cover, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); + bookMetaCache[b.id] = {title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', filename: meta.filename || '', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt}; + decrypted.push({id: b.id, title: meta.title || '?', author: meta.author || '', type: meta.type || 'epub', folder: meta.folder || '', folders: _foldersOfMeta(meta), isbn: meta.isbn || '', shelfTag: meta.shelfTag || '', pages: meta.pages || 0, magazine: meta.magazine, cover: meta.cover, series: meta.series, seriesIndex: meta.seriesIndex, issue: meta.issue, issueRead: meta.issueRead, issueAt: meta.issueAt, has_cover: !!b.has_cover, scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: true, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); } catch (e) { bookMetaCache[b.id] = {title: `Buch #${b.id}`, author: '', type: 'epub', filename: '', folder: '', isbn: '', shelfTag: ''}; decrypted.push({id: b.id, title: `Buch #${b.id}`, author: '', type: 'epub', folder: '', isbn: '', shelfTag: '', scroll_fraction: b.scroll_fraction, position_anchor: b.position_anchor || '', read_at: b.read_at == null ? null : b.read_at, device: b.device || '', furthest_fraction: b.furthest_fraction || 0, furthest_anchor: b.furthest_anchor || '', uploaded_at: b.uploaded_at, last_read: b.last_read || null, keyOk: false, is_read: !!b.is_read, has_highlights: !!b.has_highlights}); @@ -68,7 +68,7 @@ async function loadBookList() { const cachedBooks = (await _loadCachedBookMeta()).map(_withPendingProgress); if (cachedBooks.length) { cachedBooks.sort((a, b) => (_bookRecency(b) - _bookRecency(a)) || (b.uploaded_at || '').localeCompare(a.uploaded_at || '')); - for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', folders: _foldersOfMeta(b), isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine, cover: b.cover, series: b.series, seriesIndex: b.seriesIndex}; + for (const b of cachedBooks) bookMetaCache[b.id] = {title: b.title, author: b.author, type: b.type || 'epub', filename: b.filename || '', folder: b.folder || '', folders: _foldersOfMeta(b), isbn: b.isbn || '', shelfTag: b.shelfTag || '', pages: b.pages || 0, magazine: b.magazine, cover: b.cover, series: b.series, seriesIndex: b.seriesIndex, issue: b.issue, issueRead: b.issueRead, issueAt: b.issueAt}; const uploadArea = $('book-upload-area'); if (uploadArea) uploadArea.style.display = 'none'; renderBookList(cachedBooks); @@ -159,6 +159,9 @@ function repairBook(bookId) { cover: prevMeta.cover, series: prevMeta.series, seriesIndex: prevMeta.seriesIndex, + // A new file: its articles are read anew; what was read of them stays. + issueRead: prevMeta.issueRead, + issueAt: prevMeta.issueAt, })); const [metaEnc, dataEnc] = await Promise.all([ encryptBytes(key, metaJson), @@ -442,6 +445,10 @@ async function _updateBookMeta(bookId, overrides) { cover: cached.cover !== undefined ? cached.cover : book.cover, series: cached.series !== undefined ? cached.series : book.series, seriesIndex: cached.seriesIndex !== undefined ? cached.seriesIndex : book.seriesIndex, + // An issue's articles and what was read of them (54-issues.js). + issue: cached.issue !== undefined ? cached.issue : book.issue, + issueRead: cached.issueRead !== undefined ? cached.issueRead : book.issueRead, + issueAt: cached.issueAt !== undefined ? cached.issueAt : book.issueAt, ...overrides, }; // The list decides; `folder` follows it (the first), for older clients. diff --git a/static/js/app/53-library.js b/static/js/app/53-library.js index db1cc15..abcf879 100644 --- a/static/js/app/53-library.js +++ b/static/js/app/53-library.js @@ -182,6 +182,23 @@ function _libCard(b, label) { const sel = _lib.selecting && _lib.selecting.has(b.id); const by = (label ? label + (b.author ? ' · ' : '') : '') + (b.author || ''); // "Band 2 · Autor" in a series const pct = _libPct(b); + // An issue with its articles (54-issues.js): read of them instead of a percentage. + const iss = typeof _issueSummary === 'function' && _isMagazine(b) ? _issueSummary(b) : null; + if (iss && _libLayout() === 'list') { + return `
+ ${_libCover(b, 'lib-thumb')} + ${_libEsc(b.title)} + ${_libEsc(_issueCounts(iss))}${_issueSegments(iss, -1, 'iss-seg-thin')} +
`; + } + if (iss) { + return `
+ ${_libCover(b, 'lib-cover')} + + ${_issueSegments(iss, -1, 'iss-seg-thin')} + ${_libEsc(b.title)} + ${iss.read}/${iss.total} gelesen
`; + } if (_libLayout() === 'list') { return `
${_libCover(b, 'lib-thumb')} @@ -230,19 +247,23 @@ function _libAgo(iso) { function _libContinue(b, recent) { if (!b) return ''; + const iss = typeof _issueSummary === 'function' && _isMagazine(b) ? _issueSummary(b) : null; + const nx = iss && iss.next >= 0 ? iss.arts[iss.next] : null; const pages = b.pages ? ` · Seite ${Math.max(1, Math.round(b.pages * (b.scroll_fraction || 0)))} von ${b.pages}` : ''; const ago = _libAgo(b.last_read); const row = (recent || []).map(r => `
${_libCover(r, 'lib-cover')}${r.is_read ? '✓' : ''} - + ${(() => { const ri = typeof _issueSummary === 'function' && _isMagazine(r) ? _issueSummary(r) : null; + return ri ? _issueSegments(ri, -1, 'iss-seg-thin') : ``; })()} ${_libEsc(r.title)}
`).join(''); return `
${_libCover(b, 'lib-hero-cover')} - Weiterlesen${ago ? ' · ' + ago : ''} + ${iss ? 'Im Heft weiterlesen' : 'Weiterlesen'}${ago ? ' · ' + ago : ''} ${_libEsc(b.title)} - ${_libEsc(b.author || '')} - - ${_libPct(b)} %${pages} + ${nx ? _libEsc('Artikel ' + (iss.next + 1) + ' · ' + nx.t) : _libEsc(b.author || '')} + ${iss ? `${_issueSegments(iss, -1)}${_libEsc(_issueCounts(iss))}` + : ` + ${_libPct(b)} %${pages}`}
${row ? `
Zuletzt gelesen
${row}
` : ''}
`; @@ -321,6 +342,9 @@ function _libRender(allBooks) { const books = _bookSectionFilter(allBooks); const magazines = window.DioraNav && _bookSection === 'magazines'; const shown = _libShown(books); + // An issue opened (54-issues.js): its contents instead of the list, without the list's search and chips. + const issueOpen = magazines && _lib.issue != null && typeof _issueView === 'function' && books.some(b => b.id === _lib.issue); + listEl.classList.toggle('iss-open', !!issueOpen); const narrow = window.matchMedia && matchMedia('(max-width: 720px)').matches; const sorts = [['recent', 'Zuletzt'], ['title', 'Titel'], ['author', 'Autor'], ['added', 'Neu hinzugefügt']]; let h = `

${magazines ? 'Zeitschriften' : 'Bücher'}

@@ -339,6 +363,8 @@ function _libRender(allBooks) { h += '

Noch keine Bücher. Mit + eine .epub- oder .pdf-Datei hinzufügen.

'; } else if (!books.length) { h += `

${magazines ? 'Noch keine Zeitschriften. Ein Heft unter Bücher über ⋮ → „Als Zeitschrift führen“ hierher holen.' : 'Alle Bücher stehen bei den Zeitschriften.'}

`; + } else if (issueOpen) { + h += _issueView(books.find(b => b.id === _lib.issue)); } else if (_lib.series && !magazines) { h += _libSeriesView(books); } else { @@ -358,6 +384,8 @@ function _libRender(allBooks) { const status = document.getElementById('book-upload-status'); if (status && status.textContent.trim()) listEl.querySelector('.lib-head').after(status); _libChips(books); + // Issues still without their articles' index: built now and then, in the background. + if (magazines && typeof _issueIndexLater === 'function') _issueIndexLater(books); if (focused || (_lib.searching && !_lib.q)) { const n = listEl.querySelector('.lib-q'); if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); } @@ -617,6 +645,11 @@ function _libSheet(b) { customAlert('Dieses Buch lässt sich mit deinem Schlüssel nicht entschlüsseln. Über ⋮ → „Schlüssel einfügen …“ den richtigen Schlüssel eintragen.'); return; } + if (b && _isMagazine(b) && typeof _issueOpen === 'function') { + if (card.classList.contains('lib-continue') && _issueSummary(b)) _issueContinue(id); + else _issueOpen(id); + return; + } openBook(id); }, true); // A long press on a book: choosing several. @@ -649,6 +682,7 @@ function _libSheet(b) { window.__dioraBack = function () { const shown = $('tab-books') && $('tab-books').style.display !== 'none'; if (shown && _lib.selecting) { _lib.selecting = null; renderBookList(_lastBookListData); return true; } + if (shown && _lib.issue != null) { _lib.issue = null; renderBookList(_lastBookListData); return true; } if (shown && _lib.series) { _lib.series = null; renderBookList(_lastBookListData); return true; } if (shown && (_lib.q || _lib.searching)) { _lib.q = ''; _lib.searching = false; renderBookList(_lastBookListData); return true; } return back ? back() : false; diff --git a/static/js/app/54-issues.js b/static/js/app/54-issues.js new file mode 100644 index 0000000..dea3e59 --- /dev/null +++ b/static/js/app/54-issues.js @@ -0,0 +1,784 @@ +'use strict'; + +// --------------------------------------------------------------------------- +// Zeitschriften: das Heft und seine Artikel (docs/library.md, "Heft und Artikel"). +// The issue stays the document — read through in the editors' order — and the article +// is the unit one moves in: its own state (gelesen / übersprungen), reading time, a +// running head ("Artikel 8 von 23") and a pause at its end. Each determines the other: +// reading the issue through marks the articles one passes; reading articles moves the +// issue on ("7 von 23"); "Im Heft weiterlesen" goes to the next one not read yet. +// +// The index comes from the file, on the device (the server never sees it): an EPUB's +// table of contents (its leaves are the articles, their parents the Rubriken) or a PDF's +// outline (page ranges). It is kept in the book's encrypted meta (`issue`), the states +// too (`issueRead`, `issueAt`), so every device has them; a state that couldn't be saved +// (offline) waits in localStorage and goes with the next save. Chrome-61-safe (tolino). +// --------------------------------------------------------------------------- + +const ISSUE_WPM = 200; // words per minute, for the reading times +const _issuePendingKey = 'diora_issue_pending'; + +function _issueEsc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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 `` + + 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 ``; + }).join('') + ''; +} + +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(//gi, ' ') + .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ') + .replace(/<[^>]+>/g, ' ') + .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); + 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 = /]*>([\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 = `
+
`; + const head = `
${_libCover(b, 'iss-cover')} + ${_issueEsc(b.title)} + ${b.author ? `${_issueEsc(b.author)}` : ''} + ${sum ? `${sum.total} Artikel · ${_issueMinutes(sum.arts.reduce((n, a) => n + (a.m || 0), 0))}` : ''}
`; + if (!sum) { + const none = _issueMeta(b).issue && _issueMeta(b).issue.none; + return back + head + (none + ? `

Dieses Heft hat kein Inhaltsverzeichnis, aus dem sich Artikel ablesen lassen – es liest sich als Ganzes.

+ ` + : '

Das Inhaltsverzeichnis wird gelesen …

'); + } + const nx = sum.next >= 0 ? sum.arts[sum.next] : null; + let h = back + head + `
${_issueSegments(sum, -1)} + ${_issueCounts(sum)}${sum.left ? ' · noch ' + _issueMinutes(sum.left) : ''} + + ${nx ? `` + : 'Alles gelesen oder übersprungen.'} + ${nx ? '' : ''} +
`; + let rubrik = null; + sum.arts.forEach((a, i) => { + if (a.r !== rubrik) { + rubrik = a.r; + if (rubrik) h += `

${_issueEsc(rubrik)}

`; + } + 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 += `
+ +
`; + }); + 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 = ``; + 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 = `${a && a.r ? `${_issueEsc(a.r)}` : ''} + ${a ? `Artikel ${_issueR.cur + 1} von ${sum.total} · ${_issueEsc(a.t)}` : `${sum.total} Artikel`} + ${_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 ? `„Weiter“ überspringt ${n - i - 1 === 1 ? 'Artikel ' + (i + 2) : 'die Artikel ' + (i + 2) + '–' + n} (schon gelesen oder übersprungen).` : ''; + return `${s === 'r' ? '✓ ' : ''}„${_issueEsc(a.t)}“${s === 'r' ? ' gelesen' : s === 's' ? ' übersprungen' : ' – Ende'} + ${_issueEsc(b.title)}: ${_issueCounts(sum)}${sum.left ? ' · noch ' + _issueMinutes(sum.left) : ''} + ${na ? `${jumped}` + : 'Das Heft ist durch.'} + + + ${na && n === i + 1 ? `` : ''} + ${s === 'r' ? `` : ''} + `; +} + +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 = `` + _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); +})(); diff --git a/static/js/app/62-reader.js b/static/js/app/62-reader.js index 268e896..d352e8d 100644 --- a/static/js/app/62-reader.js +++ b/static/js/app/62-reader.js @@ -360,6 +360,9 @@ async function _openBookOnce(bookId) { enterReaderImmersiveMode(); + // An issue of a magazine: its articles (54-issues.js). + if (typeof _issueReaderOpened === 'function') _issueReaderOpened(bookId, isPdf); + } catch (e) { console.error('openBook failed:', e); overlay.style.display = 'none'; @@ -496,6 +499,7 @@ async function saveReaderProgress(force = false) { } function closeReader() { + if (typeof _issueReaderClosed === 'function') _issueReaderClosed(); exitReaderImmersiveMode(); stopReadAloud(); // Save progress BEFORE hiding — scrollHeight/clientHeight return 0 once display:none diff --git a/static/js/sw.js b/static/js/sw.js index 990e69a..aae17be 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v126'; +const CACHE = 'diora-v127'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', @@ -24,6 +24,7 @@ const SHELL = [ '/static/js/app/50-reader-state.js', '/static/js/app/52-book-list.js', '/static/js/app/53-library.js', + '/static/js/app/54-issues.js', '/static/js/app/56-cloud-import.js', '/static/js/app/58-pdf.js', '/static/js/app/60-tts.js', diff --git a/templates/radio/player.html b/templates/radio/player.html index 33b16a4..bbfe557 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -375,6 +375,7 @@ +