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 `
` : ''}
`;
@@ -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 = `