'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 = `