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/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; const hit = await cached(req); if (hit) return hit; 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; } 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 show:#' + target + ':top', '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); }