1 Commits
4 changed files with 38 additions and 7 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network. // App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v41'; const CACHE = 'hub-v42';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+3 -3
View File
@@ -49,10 +49,10 @@ async function vbStudy(slug) {
const nextUp = () => partial || s.lessons.find(l => !vbDone(key(l))) || s.lessons[0]; const nextUp = () => partial || s.lessons.find(l => !vbDone(key(l))) || s.lessons[0];
const row = l => { const p = vbLeft(key(l)), done = vbDone(key(l)); return `<div class="row vbrow" data-n="${l.n}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play lesson ${l.n + 1}">${icon('play')}</button> const row = l => { const p = vbLeft(key(l)), done = vbDone(key(l)); return `<div class="row vbrow" data-n="${l.n}"><button class="ic" data-play style="background:var(--accent);color:#fff;border:0" aria-label="Play lesson ${l.n + 1}">${icon('play')}</button>
<span class="grow"><b${done ? ' style="color:var(--muted)"' : ''}>${l.n + 1}. ${esc(l.title)}</b><small>${esc([vbDate(l.date), done ? 'played' : p ? Math.max(1, Math.round((p.dur - p.pos) / 60)) + ' min left' : ''].filter(Boolean).join(' · '))}</small></span> <span class="grow"><b${done ? ' style="color:var(--muted)"' : ''}>${l.n + 1}. ${esc(l.title)}</b><small>${esc([vbDate(l.date), done ? 'played' : p ? Math.max(1, Math.round((p.dur - p.pos) / 60)) + ' min left' : ''].filter(Boolean).join(' · '))}</small></span>
<a class="btn ghost" data-notes href="/api/vbv/notes/${esc(slug)}/${l.n}" target="_blank" rel="noopener" style="min-height:36px;padding:0 12px" title="Lesson handout (PDF)">Notes</a></div>`; }; <a class="btn ghost" href="/api/vbv/notes/${esc(slug)}/${l.n}" target="_blank" rel="noopener" style="min-height:36px;padding:0 12px" title="Lesson handout (PDF)">Notes</a></div>`; };
const resume = nextUp(), rp = vbLeft(key(resume)); const resume = nextUp(), rp = vbLeft(key(resume));
$('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast/vbv">${icon('back')}Studies</a></div> $('#view').innerHTML = `<div class="wrap"><div class="ptop"><a class="btn ghost" href="#/podcast/vbv">${icon('back')}Studies</a></div>
<div style="display:flex;gap:16px;align-items:flex-start"><span style="width:120px;flex:none">${vbCover(s)}</span><div style="min-width:0"><h1 style="margin:0">${esc(s.title)}</h1><p class="sub" style="margin:2px 0 0">${esc(s.teacher)} · ${s.lessons.length} lessons</p> <div style="display:flex;gap:16px;align-items:flex-start"><span style="width:120px;flex:none">${vbCover(s)}</span><div style="min-width:0"><h1 style="margin:0">${esc(s.title)}</h1><p class="sub" style="margin:2px 0 0">${esc([s.teacher, s.lessons.length + ' lessons'].filter(Boolean).join(' · '))}</p>
<p class="sub" style="margin:6px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden">${esc(s.description)}</p></div></div> <p class="sub" style="margin:6px 0 0;white-space:normal;display:-webkit-box;-webkit-line-clamp:4;-webkit-box-orient:vertical;overflow:hidden">${esc(s.description)}</p></div></div>
<div class="bar"><button class="btn" id="vbgo">${icon('play')}${started ? 'Continue' : 'Start'}${resume.n ? ' · lesson ' + (resume.n + 1) : ''}</button></div><h2>Lessons</h2><div class="list">${s.lessons.map(row).join('')}</div> <div class="bar"><button class="btn" id="vbgo">${icon('play')}${started ? 'Continue' : 'Start'}${resume.n ? ' · lesson ' + (resume.n + 1) : ''}</button></div><h2>Lessons</h2><div class="list">${s.lessons.map(row).join('')}</div>
<p class="sub" style="margin-top:14px">Teaching and handouts © Verse By Verse Ministry International, shared freely at <a href="https://versebyverseministry.org/bible-studies/${esc(slug)}" target="_blank" rel="noopener" style="color:var(--accent)">versebyverseministry.org</a>.</p></div>`; <p class="sub" style="margin-top:14px">Teaching and handouts © Verse By Verse Ministry International, shared freely at <a href="https://versebyverseministry.org/bible-studies/${esc(slug)}" target="_blank" rel="noopener" style="color:var(--accent)">versebyverseministry.org</a>.</p></div>`;
@@ -63,5 +63,5 @@ async function vbStudy(slug) {
}; };
$('#vbgo').onclick = () => play(resume); $('#vbgo').onclick = () => play(resume);
document.querySelectorAll('.vbrow').forEach(r => { const l = s.lessons[+r.dataset.n]; r.querySelector('[data-play]').onclick = () => play(l); document.querySelectorAll('.vbrow').forEach(r => { const l = s.lessons[+r.dataset.n]; r.querySelector('[data-play]').onclick = () => play(l);
r.querySelector('[data-notes]').onclick = async e => { e.preventDefault(); try { const res = await fetch(e.currentTarget.href, { redirect: 'manual' }); if (res.status === 404) return toast('This lesson has no handout'); window.open(e.currentTarget.href, '_blank', 'noopener'); } catch { window.open(e.currentTarget.href, '_blank', 'noopener'); } }; }); });
} }
+7
View File
@@ -13,6 +13,10 @@ const fake = http.createServer((req, res) => {
<item><title>1 Jude 1-3</title><link>http://127.0.0.1:${port}/lessons/jude-lesson-1</link><description>Contend for the Faith</description><itunes:subtitle>Opening study</itunes:subtitle><enclosure url="http://127.0.0.1:${port}/media/1.mp3" length="5" type="audio/mpeg" /><pubDate>Sun, 29 Aug 2021 06:00:00 -0500</pubDate></item> <item><title>1 Jude 1-3</title><link>http://127.0.0.1:${port}/lessons/jude-lesson-1</link><description>Contend for the Faith</description><itunes:subtitle>Opening study</itunes:subtitle><enclosure url="http://127.0.0.1:${port}/media/1.mp3" length="5" type="audio/mpeg" /><pubDate>Sun, 29 Aug 2021 06:00:00 -0500</pubDate></item>
<item><title>2 Jude 4-8</title><link>http://127.0.0.1:${port}/lessons/jude-lesson-2</link><description>False teachers</description><enclosure url="https://evil.example.com/2.mp3" length="5" type="audio/mpeg" /></item> <item><title>2 Jude 4-8</title><link>http://127.0.0.1:${port}/lessons/jude-lesson-2</link><description>False teachers</description><enclosure url="https://evil.example.com/2.mp3" length="5" type="audio/mpeg" /></item>
<item><title>3 Jude 9-16</title><link>http://127.0.0.1:${port}/lessons/jude-lesson-3</link><description>Woe</description><enclosure url="http://127.0.0.1:${port}/media/3.mp3" length="5" type="audio/mpeg" /></item></channel></rss>`); <item><title>3 Jude 9-16</title><link>http://127.0.0.1:${port}/lessons/jude-lesson-3</link><description>Woe</description><enclosure url="http://127.0.0.1:${port}/media/3.mp3" length="5" type="audio/mpeg" /></item></channel></rss>`);
if (p === '/bible-studies/empty/podcast.xml') return H('application/xml', '<rss><channel><title>Empty</title></channel></rss>');
if (p === '/bible-studies/empty') return H('text/html', `<html><head><title>Empty Study | Ministry</title><meta property="og:image" content="http://127.0.0.1:${port}/img/cover.gif"></head><body><h1>Empty Study</h1><ul class="lesson-list"><li class="lesson -x"><a class="lesson-link" href="/lessons/empty-1"><span class="lesson-number">1A</span><p class="body-text -light">Chapter 1:1-5</p></a></li><li class="lesson"><a class="lesson-link" href="/lessons/empty-2"><span class="lesson-number">1B</span><p class="body-text -light">Chapter 1:6-9</p></a></li></ul></body></html>`);
if (p === '/lessons/empty-1') return H('text/html', `<audio src="http://127.0.0.1:${port}/media/e1.mp3"></audio>`);
if (p === '/lessons/empty-2') return H('text/html', '<video src="x.mp4"></video>');
if (p === '/lessons/jude-lesson-1') return H('text/html', `<a href="http://127.0.0.1:${port}/media/notes1.pdf">Notes</a>`); if (p === '/lessons/jude-lesson-1') return H('text/html', `<a href="http://127.0.0.1:${port}/media/notes1.pdf">Notes</a>`);
if (p === '/lessons/jude-lesson-3') return H('text/html', '<p>No handout</p>'); if (p === '/lessons/jude-lesson-3') return H('text/html', '<p>No handout</p>');
if (p.startsWith('/img/')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); } if (p.startsWith('/img/')) { res.writeHead(200, { 'content-type': 'image/gif' }); return res.end(gif); }
@@ -30,6 +34,9 @@ const fake = http.createServer((req, res) => {
assert.ok(!JSON.stringify(r).includes('/media/'), 'recording addresses are not sent to the browser'); assert.equal(r.lessons[1].title, 'Jude 9-16'); assert.ok(!JSON.stringify(r).includes('/media/'), 'recording addresses are not sent to the browser'); assert.equal(r.lessons[1].title, 'Jude 9-16');
let a = await call('/audio/jude/0'); assert.equal(a.status, 302); assert.equal(a.headers.get('location'), `http://127.0.0.1:${port}/media/1.mp3`); assert.equal((await call('/audio/jude/5')).status, 404); let a = await call('/audio/jude/0'); assert.equal(a.status, 302); assert.equal(a.headers.get('location'), `http://127.0.0.1:${port}/media/1.mp3`); assert.equal((await call('/audio/jude/5')).status, 404);
a = await call('/notes/jude/0'); assert.equal(a.status, 302); assert.equal(a.headers.get('location'), `http://127.0.0.1:${port}/media/notes1.pdf`); assert.equal((await call('/notes/jude/1')).status, 404, 'a lesson with no handout says so'); a = await call('/notes/jude/0'); assert.equal(a.status, 302); assert.equal(a.headers.get('location'), `http://127.0.0.1:${port}/media/notes1.pdf`); assert.equal((await call('/notes/jude/1')).status, 404, 'a lesson with no handout says so');
r = await j(await call('/study/empty')); assert.equal(r.title, 'Empty Study'); assert.deepEqual(r.lessons.map(l => l.title), ['1A \u2013 Chapter 1:1-5', '1B \u2013 Chapter 1:6-9'], 'a study with an empty feed is read from its page');
a = await call('/audio/empty/0'); assert.equal(a.headers.get('location'), `http://127.0.0.1:${port}/media/e1.mp3`, "its recordings are found on each lesson's page when played"); assert.equal((await call('/audio/empty/1')).status, 404, 'a lesson with no recording says so');
a = await call('/notes/jude/1', { headers: { accept: 'text/html' } }); assert.equal(a.status, 404); assert.match(await a.text(), /no handout/, 'opened in a tab, a missing handout is explained in words');
assert.equal((await call('/study/..%2Fx')).status, 404); assert.equal((await call('/study/nothere')).status, 404); assert.equal((await call('/study/..%2Fx')).status, 404); assert.equal((await call('/study/nothere')).status, 404);
a = await call('/cover?u=' + encodeURIComponent(`http://127.0.0.1:${port}/img/cover.gif`)); assert.equal(a.headers.get('content-type'), 'image/gif'); assert.equal((await call('/cover?u=' + encodeURIComponent('https://example.com/a.jpg'))).status, 404); a = await call('/cover?u=' + encodeURIComponent(`http://127.0.0.1:${port}/img/cover.gif`)); assert.equal(a.headers.get('content-type'), 'image/gif'); assert.equal((await call('/cover?u=' + encodeURIComponent('https://example.com/a.jpg'))).status, 404);
const post = o => call('/progress', { method: 'POST', body: JSON.stringify(o) }).then(j); const post = o => call('/progress', { method: 'POST', body: JSON.stringify(o) }).then(j);
+27 -3
View File
@@ -39,13 +39,29 @@ const studiesAll = () => cached('studies', 3600e3, async () => {
}); });
const feed = slug => cached('feed:' + slug, 1800e3, async () => { const feed = slug => cached('feed:' + slug, 1800e3, async () => {
if (!/^[\w-]{1,80}$/.test(slug)) throw fail(400, 'Bad study'); if (!/^[\w-]{1,80}$/.test(slug)) throw fail(400, 'Bad study');
const x = await page(`${BASE()}/bible-studies/${slug}/podcast.xml`, 5e6), ch = x.replace(/<item>[\s\S]*$/, ''); let x = ''; try { x = await page(`${BASE()}/bible-studies/${slug}/podcast.xml`, 5e6); } catch (e) { if (e.code !== 404) throw e; }
const ch = x.replace(/<item>[\s\S]*$/, '');
const tag = (s, t) => plain((new RegExp('<' + t + '>([\\s\\S]*?)</' + t + '>', 'i').exec(s) || [])[1]); const tag = (s, t) => plain((new RegExp('<' + t + '>([\\s\\S]*?)</' + t + '>', 'i').exec(s) || [])[1]);
const items = [...x.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m, i) => { const it = m[1], enc = /<enclosure[^>]*url="([^"]+)"/.exec(it); return { n: i, title: tag(it, 'title').replace(/^\d+\s+/, '') || 'Lesson ' + (i + 1), description: tag(it, 'description'), summary: tag(it, 'itunes:subtitle'), date: tag(it, 'pubDate'), audio: enc && allowed(unent(enc[1])) ? unent(enc[1]) : '', link: tag(it, 'link') }; }).filter(i => i.audio); const items = [...x.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((m, i) => { const it = m[1], enc = /<enclosure[^>]*url="([^"]+)"/.exec(it); return { n: i, title: tag(it, 'title').replace(/^\d+\s+/, '') || 'Lesson ' + (i + 1), description: tag(it, 'description'), summary: tag(it, 'itunes:subtitle'), date: tag(it, 'pubDate'), audio: enc && allowed(unent(enc[1])) ? unent(enc[1]) : '', link: tag(it, 'link') }; }).filter(i => i.audio);
if (items.length < 3) { // a few studies have an empty or partial feed; their lessons are all on the study's own page
let alt = null; try { alt = await fromPage(slug); } catch (e) { if (!items.length) throw e; }
if (alt && alt.lessons.length > items.length) return alt;
}
if (!items.length) throw fail(404, 'No lessons found'); if (!items.length) throw fail(404, 'No lessons found');
const img = (/<itunes:image href="([^"]+)"/.exec(ch) || [])[1]; const img = (/<itunes:image href="([^"]+)"/.exec(ch) || [])[1];
return { slug, title: tag(ch, 'title'), teacher: tag(ch, 'itunes:author'), description: tag(ch, 'itunes:summary') || tag(ch, 'description'), cover: img && allowed(img) ? img : '', lessons: items }; return { slug, title: tag(ch, 'title'), teacher: tag(ch, 'itunes:author'), description: tag(ch, 'itunes:summary') || tag(ch, 'description'), cover: img && allowed(img) ? img : '', lessons: items };
}); });
/** A study whose feed lists nothing: read its lesson list from its page; each lesson's recording is found when it is played. */
async function fromPage(slug) {
const h = await page(`${BASE()}/bible-studies/${slug}`), meta = n => plain((new RegExp('<meta[^>]+(?:property|name)="' + n + '"[^>]+content="([^"]*)"', 'i').exec(h) || [])[1]);
const lessons = [...h.matchAll(/<li class="lesson[^"]*"[\s\S]*?<\/li>/g)].map(m => { const b = m[0], href = (/href="(\/lessons\/[\w-]+)"/.exec(b) || [])[1]; if (!href) return null;
const num = plain((/<span class="lesson-number">([\s\S]*?)<\/span>/.exec(b) || [])[1]), sub = plain((/<p class="body-text[^>]*>([\s\S]*?)<\/p>/.exec(b) || [])[1]);
return { title: [num, sub].filter(Boolean).join(' \u2013 '), description: sub, summary: '', date: '', audio: '', link: BASE() + href }; }).filter(Boolean).map((l, n) => ({ ...l, n, title: l.title || 'Lesson ' + (n + 1) }));
if (!lessons.length) throw fail(404, 'No lessons found for this study');
const cover = meta('og:image');
return { slug, title: plain((/<h1[^>]*>([\s\S]*?)<\/h1>/.exec(h) || [])[1]) || plain((/<title>([^<|]*)/.exec(h) || [])[1]), teacher: '', description: meta('og:description') || meta('description'), cover: allowed(cover) ? cover : '', lessons };
}
const lessonAudio = link => cached('audio:' + link, 6 * 3600e3, async () => { const h = await page(link); return [...h.matchAll(/https?:\/\/[^"'\s<>]+\.mp3/gi)].map(m => unent(m[0])).find(allowed) || ''; });
const coverUrl = u => u ? '/api/vbv/cover?u=' + encodeURIComponent(u) : ''; const coverUrl = u => u ? '/api/vbv/cover?u=' + encodeURIComponent(u) : '';
const publicStudy = s => ({ ...s, cover: coverUrl(s.cover), lessons: s.lessons.map(({ audio, link, ...l }) => l) }); const publicStudy = s => ({ ...s, cover: coverUrl(s.cover), lessons: s.lessons.map(({ audio, link, ...l }) => l) });
const notesOf = (slug, n, lessonUrl) => cached(`notes:${slug}:${n}`, 6 * 3600e3, async () => { const notesOf = (slug, n, lessonUrl) => cached(`notes:${slug}:${n}`, 6 * 3600e3, async () => {
@@ -65,7 +81,8 @@ async function handle(req, res) {
if ((m = /^\/study\/([\w-]+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicStudy(await feed(m[1]))); if ((m = /^\/study\/([\w-]+)$/.exec(p)) && req.method === 'GET') return json(res, 200, publicStudy(await feed(m[1])));
if ((m = /^\/audio\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording is on the ministry's media server; the browser is sent straight there (it supports seeking) if ((m = /^\/audio\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') { // the recording is on the ministry's media server; the browser is sent straight there (it supports seeking)
const l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson'); const l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson');
res.writeHead(302, { location: l.audio, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end(); const audio = l.audio || (l.link && allowed(l.link) ? await lessonAudio(l.link) : ''); if (!audio) throw fail(404, 'This lesson has no audio');
res.writeHead(302, { location: audio, 'cache-control': 'private, max-age=600', 'referrer-policy': 'no-referrer' }); return res.end();
} }
if ((m = /^\/notes\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') { if ((m = /^\/notes\/([\w-]+)\/(\d+)$/.exec(p)) && req.method === 'GET') {
const l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson'); const l = (await feed(m[1])).lessons[+m[2]]; if (!l) throw fail(404, 'No such lesson');
@@ -83,7 +100,14 @@ async function handle(req, res) {
if (req.method === 'POST') return json(res, 200, prog.merge(await readBody(req))); if (req.method === 'POST') return json(res, 200, prog.merge(await readBody(req)));
} }
return json(res, 404, { error: 'Not found' }); return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e.message }); } } catch (e) {
const code = e.code >= 400 && e.code < 600 ? e.code : 502;
if (/^\/notes\//.test(p) && /text\/html/.test(req.headers.accept || '')) { // opened in a new tab: say it in words, with a way back
res.writeHead(code, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
return res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lesson handout</title><body style="font:17px/1.5 system-ui;max-width:32em;margin:15vh auto;padding:0 20px;text-align:center"><h2>${code === 404 ? 'This lesson has no handout' : 'The handout could not be loaded'}</h2><p style="color:#666">${code === 404 ? 'Not every lesson comes with notes.' : 'Verse By Verse Ministry did not answer. Try again in a moment.'}</p><p><a href="javascript:history.length>1?history.back():window.close()">Go back</a></p>`);
}
return json(res, code, { error: e.message });
}
} }
module.exports = { handle }; module.exports = { handle };