396 lines
11 KiB
JavaScript
396 lines
11 KiB
JavaScript
importScripts('./js/nunjucks.min.js', './js/router.js', './js/render.js');
|
|
|
|
const NETWORK_TIMEOUT_MS = 3000;
|
|
|
|
const DOWNLOAD_TIMEOUT_MS = 30000;
|
|
|
|
const DOWNLOAD_AT_ONCE = 4;
|
|
|
|
const VERSION = 'dev';
|
|
const LIBRARY_VERSION = 'dev';
|
|
const LIBRARY = [];
|
|
|
|
const SHELL_CACHE = 'bible-shell-' + VERSION;
|
|
const LIBRARY_CACHE = 'bible-library-' + LIBRARY_VERSION;
|
|
|
|
const VERSIONS = { shell: VERSION, library: LIBRARY_VERSION };
|
|
|
|
const LIBRARY_URLS = new Set(LIBRARY.map((path) => new URL(path, location.href).href));
|
|
|
|
const PRECACHE = [
|
|
'./boot.html',
|
|
'./',
|
|
'./manifest.webmanifest',
|
|
'./css/app.css',
|
|
'./css/bootstrap.min.css',
|
|
'./js/app.js',
|
|
'./js/htmx.min.js',
|
|
'./js/bootstrap.bundle.min.js'
|
|
].concat(TEMPLATE_URLS);
|
|
|
|
function cacheFor(req) {
|
|
return caches.open(LIBRARY_URLS.has(req.url) ? LIBRARY_CACHE : SHELL_CACHE);
|
|
}
|
|
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil((async () => {
|
|
const cache = await caches.open(SHELL_CACHE);
|
|
let failed = 0;
|
|
await Promise.all(PRECACHE.map((url) =>
|
|
cache.add(new Request(url, { cache: 'reload' })).catch(() => { failed++; })));
|
|
if (failed) console.warn('precache: ' + failed + ' of ' + PRECACHE.length + ' failed');
|
|
await self.skipWaiting();
|
|
})());
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil((async () => {
|
|
const keys = await caches.keys();
|
|
await Promise.all(keys
|
|
.filter((k) => k.startsWith('bible-') && k !== SHELL_CACHE && k !== LIBRARY_CACHE)
|
|
.map((k) => caches.delete(k)));
|
|
await self.clients.claim();
|
|
})());
|
|
});
|
|
|
|
function isFile(url) {
|
|
return url.pathname.split('/').pop().indexOf('.') !== -1;
|
|
}
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const req = event.request;
|
|
const url = new URL(req.url);
|
|
if (url.origin !== location.origin) return;
|
|
|
|
keepFilling(event);
|
|
|
|
if (!isFile(url)) {
|
|
const routed = router.handle(event, url);
|
|
if (routed) {
|
|
return event.respondWith(routed.then((res) => res || answer(req, event)));
|
|
}
|
|
}
|
|
|
|
if (req.method !== 'GET') return;
|
|
event.respondWith(answer(req, event));
|
|
});
|
|
|
|
async function fromNetwork(req, timeout) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeout || NETWORK_TIMEOUT_MS);
|
|
try {
|
|
return await fetch(req, { signal: controller.signal });
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
function cached(req) {
|
|
return caches.match(req, { ignoreSearch: true });
|
|
}
|
|
|
|
async function answer(input, event) {
|
|
const req = typeof input === 'string' ? new Request(input) : input;
|
|
|
|
try {
|
|
const res = await fromNetwork(req);
|
|
if (res && res.ok && res.type === 'basic') {
|
|
const copy = res.clone();
|
|
const write = cacheFor(req).then((c) => c.put(req, copy));
|
|
if (event) event.waitUntil(write);
|
|
else write.catch(() => {});
|
|
}
|
|
return res;
|
|
} catch (err) {
|
|
const hit = await cached(req);
|
|
if (hit) return hit;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function pageRequest(url) {
|
|
return new Request(url, { cache: 'no-cache' });
|
|
}
|
|
|
|
async function readText(input, event) {
|
|
const res = await answer(input, event);
|
|
if (!res || !res.ok) throw new Error('cannot load ' + (input.url || input));
|
|
return res.text();
|
|
}
|
|
|
|
async function readTemplate(path) {
|
|
const hit = await cached(path);
|
|
return hit ? hit.text() : readText(path);
|
|
}
|
|
|
|
const view = createRenderer(readTemplate);
|
|
|
|
const router = new Router(new URL('./', location.href).pathname);
|
|
|
|
const VERSES_URL = './verses.json';
|
|
|
|
let verses = null;
|
|
|
|
function readVerses() {
|
|
if (!verses) {
|
|
verses = readText(pageRequest(VERSES_URL))
|
|
.then(JSON.parse)
|
|
.catch((err) => { verses = null; throw err; });
|
|
}
|
|
return verses;
|
|
}
|
|
|
|
function parseAnchor(id) {
|
|
const chapter = /^ch(\d+)$/.exec(id || '');
|
|
if (chapter) return { chapter: +chapter[1], verse: null };
|
|
const verse = /^v(\d+)_[0-9-]+$/.exec(id || '');
|
|
if (verse) return { chapter: +verse[1], verse: id };
|
|
return null;
|
|
}
|
|
|
|
function verseLabels(chapters, number) {
|
|
const entry = chapters[number - 1];
|
|
if (typeof entry !== 'number') return entry || [];
|
|
const out = [];
|
|
for (let i = 1; i <= entry; i++) out.push(String(i));
|
|
return out;
|
|
}
|
|
|
|
function referenceFor(map, code, anchor) {
|
|
const chapters = map && Object.prototype.hasOwnProperty.call(map, code)
|
|
? map[code] : null;
|
|
if (!chapters || !chapters.length) return null;
|
|
|
|
if (!chapters.some((entry) => (typeof entry === 'number' ? entry : entry.length))) {
|
|
return null;
|
|
}
|
|
|
|
const at = parseAnchor(anchor) || { chapter: 1, verse: null };
|
|
const number = at.chapter >= 1 && at.chapter <= chapters.length ? at.chapter : 1;
|
|
const verse = at.chapter === number ? at.verse : null;
|
|
|
|
return {
|
|
book: code,
|
|
chapter: 'ch' + number,
|
|
chapters: chapters.map((_, i) => ({ id: 'ch' + (i + 1), label: String(i + 1) })),
|
|
verse: verse,
|
|
verses: verseLabels(chapters, number)
|
|
.map((label) => ({ id: 'v' + number + '_' + label, label: label }))
|
|
};
|
|
}
|
|
|
|
function bookOf(url) {
|
|
return url ? router.relative(url).slice(1) : '';
|
|
}
|
|
|
|
async function referenceOf(code, anchor) {
|
|
if (!code) return null;
|
|
try {
|
|
return referenceFor(await readVerses(), code, anchor);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function verseOf(page, at) {
|
|
const code = bookOf(page);
|
|
const anchor = parseAnchor(at);
|
|
if (!page || !code || !anchor || !anchor.verse) return null;
|
|
|
|
const reference = await referenceOf(code, at);
|
|
if (!reference) return null;
|
|
const option = reference.verses.find((verse) => verse.id === at);
|
|
if (!option) return null;
|
|
|
|
return {
|
|
ref: code + ' ' + anchor.chapter + ':' + option.label,
|
|
href: code + '#' + at,
|
|
url: page.origin + page.pathname + '#' + at
|
|
};
|
|
}
|
|
|
|
const OFFLINE_NOTICE = ['Not available offline',
|
|
'This page has not been opened on this device, so there is no copy stored ' +
|
|
'here. Reconnect and try again, or open one you have read before.'];
|
|
const MISSING_NOTICE = ['Not found', 'There is no page at this address.'];
|
|
|
|
async function fragmentFor(path, event) {
|
|
let res;
|
|
try {
|
|
res = await answer(pageRequest(path), event);
|
|
} catch (err) {
|
|
return view.notice(...OFFLINE_NOTICE);
|
|
}
|
|
return res.ok ? res.text() : view.notice(...MISSING_NOTICE);
|
|
}
|
|
|
|
async function servePage(path, code, call) {
|
|
let fragment = await fragmentFor(path, call.event);
|
|
if (!code) {
|
|
fragment += '\n' + await view.render('version.njk', { version: VERSIONS });
|
|
}
|
|
const reference = await referenceOf(code, null);
|
|
if (!call.navigate && !call.restore) {
|
|
return fragment + '\n'
|
|
+ await view.render('reference.njk', { reference: reference, oob: true });
|
|
}
|
|
try {
|
|
return await view.compose(fragment, { mark: await getBookmark(),
|
|
reference: reference });
|
|
} catch (err) {
|
|
console.error('compose failed: ' + err.message);
|
|
return fragment;
|
|
}
|
|
}
|
|
|
|
router.get('/', (call) => servePage('./', '', call));
|
|
router.get('/:page', (call) =>
|
|
servePage('./' + call.params.page, call.params.page, call));
|
|
|
|
router.get('/api/reference', async (call) => {
|
|
const params = call.url.searchParams;
|
|
const page = call.currentUrl();
|
|
const moved = params.has('at') ? params.get('at') : null;
|
|
|
|
const reference = await referenceOf(
|
|
bookOf(page), moved === null ? (page && page.hash.slice(1)) : moved);
|
|
|
|
if (moved === null) {
|
|
return view.render('reference.njk', { reference: reference, settled: true });
|
|
}
|
|
if (!reference) return new Response('', { status: 204 });
|
|
|
|
const target = reference.verse || reference.chapter;
|
|
return new Response(await view.render('verses.njk', { reference: reference }), {
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
'HX-Reswap': 'innerHTML settle:0',
|
|
'HX-Replace-Url': page.pathname + page.search + '#' + target
|
|
}
|
|
});
|
|
});
|
|
|
|
router.get('/api/verse', async (call) => {
|
|
const verse = await verseOf(call.currentUrl(), call.url.searchParams.get('at'));
|
|
if (!verse) return new Response('', { status: 204 });
|
|
return view.render('verse.njk', { verse: verse });
|
|
});
|
|
|
|
router.post('/api/bookmark', async (call) => {
|
|
try {
|
|
const form = await call.form();
|
|
const ref = String(form.get('ref') || '').trim();
|
|
const href = String(form.get('href') || '').trim();
|
|
if (!ref || !href) return new Response('', { status: 400 });
|
|
await withStore('readwrite',
|
|
(s) => s.put({ ref: ref, href: href, savedAt: Date.now() }, BOOKMARK_KEY));
|
|
return new Response(await bookmarkFragment('bookmarked'), {
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
'HX-Trigger-After-Settle': 'verse-done'
|
|
}
|
|
});
|
|
} catch (err) {
|
|
return bookmarkFragment('bookmark-refused');
|
|
}
|
|
});
|
|
|
|
const TOASTS = {
|
|
'copied': { text: 'Link copied.' },
|
|
'copy-refused': { text: 'Copy the link above: this browser would not '
|
|
+ 'take it.', bad: true },
|
|
'bookmarked': { text: 'Bookmark set.' },
|
|
'bookmark-refused': { text: 'Could not save the bookmark: storage '
|
|
+ 'unavailable.', bad: true }
|
|
};
|
|
|
|
function toastFragment(say, oob) {
|
|
const toast = TOASTS[say];
|
|
return view.render('toast.njk', { text: toast.text, bad: toast.bad, oob: oob });
|
|
}
|
|
|
|
router.get('/api/toast', (call) => {
|
|
const say = call.url.searchParams.get('say');
|
|
if (!TOASTS[say]) return new Response('', { status: 204 });
|
|
return toastFragment(say, false);
|
|
});
|
|
|
|
let filling = null;
|
|
let complete = false;
|
|
|
|
function keepFilling(event) {
|
|
if (complete) return;
|
|
if (!filling && self.navigator.onLine !== false) {
|
|
filling = fillLibrary().catch(() => {}).finally(() => { filling = null; });
|
|
}
|
|
if (filling) event.waitUntil(filling);
|
|
}
|
|
|
|
async function fillLibrary() {
|
|
const cache = await caches.open(LIBRARY_CACHE);
|
|
const have = new Set((await cache.keys()).map((req) => req.url));
|
|
const missing = [];
|
|
LIBRARY_URLS.forEach((url) => { if (!have.has(url)) missing.push(url); });
|
|
if (!missing.length) { complete = true; return; }
|
|
|
|
let stopped = false;
|
|
const runners = [];
|
|
for (let i = 0; i < DOWNLOAD_AT_ONCE && i < missing.length; i++) runners.push(run());
|
|
await Promise.all(runners);
|
|
if (!stopped) complete = true;
|
|
|
|
async function run() {
|
|
while (missing.length && !stopped) {
|
|
const url = missing.pop();
|
|
try {
|
|
const res = await fromNetwork(pageRequest(url), DOWNLOAD_TIMEOUT_MS);
|
|
if (res.ok && res.type === 'basic') await cache.put(url, res);
|
|
} catch (err) {
|
|
stopped = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const DB_NAME = 'bible';
|
|
const DB_STORE = 'kv';
|
|
const BOOKMARK_KEY = 'bookmark';
|
|
|
|
const DEFAULT_BOOKMARK = { ref: 'GEN 1:1', href: 'GEN#v1_1' };
|
|
|
|
function openDb() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, 1);
|
|
req.onupgradeneeded = () => req.result.createObjectStore(DB_STORE);
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function withStore(mode, fn) {
|
|
const db = await openDb();
|
|
try {
|
|
return await new Promise((resolve, reject) => {
|
|
const tx = db.transaction(DB_STORE, mode);
|
|
const req = fn(tx.objectStore(DB_STORE));
|
|
tx.oncomplete = () => resolve(req ? req.result : undefined);
|
|
tx.onabort = tx.onerror = () => reject(tx.error);
|
|
});
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function getBookmark() {
|
|
return withStore('readonly', (s) => s.get(BOOKMARK_KEY))
|
|
.catch(() => null)
|
|
.then((mark) => mark || DEFAULT_BOOKMARK);
|
|
}
|
|
|
|
async function bookmarkFragment(say) {
|
|
return await view.render('bookmark.njk', { mark: await getBookmark() })
|
|
+ '\n' + await toastFragment(say, true);
|
|
}
|