${vbCover(s)}${esc(s.title)}
${esc(s.teacher)} · ${s.lessons.length} lessons
+
${vbCover(s)}${esc(s.title)}
${esc([s.teacher, s.lessons.length + ' lessons'].filter(Boolean).join(' · '))}
${esc(s.description)}
Lessons
${s.lessons.map(row).join('')}
Teaching and handouts © Verse By Verse Ministry International, shared freely at versebyverseministry.org.
`;
@@ -63,5 +63,5 @@ async function vbStudy(slug) {
};
$('#vbgo').onclick = () => play(resume);
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'); } }; });
+ });
}
diff --git a/tools/vbv-test.js b/tools/vbv-test.js
index 3ccb150..cf0fb2c 100644
--- a/tools/vbv-test.js
+++ b/tools/vbv-test.js
@@ -13,6 +13,10 @@ const fake = http.createServer((req, res) => {
- 1 Jude 1-3http://127.0.0.1:${port}/lessons/jude-lesson-1Contend for the FaithOpening studySun, 29 Aug 2021 06:00:00 -0500
- 2 Jude 4-8http://127.0.0.1:${port}/lessons/jude-lesson-2False teachers
- 3 Jude 9-16http://127.0.0.1:${port}/lessons/jude-lesson-3Woe
`);
+ if (p === '/bible-studies/empty/podcast.xml') return H('application/xml', '
Empty');
+ if (p === '/bible-studies/empty') return H('text/html', `
Empty Study | MinistryEmpty Study
`);
+ if (p === '/lessons/empty-1') return H('text/html', `
`);
+ if (p === '/lessons/empty-2') return H('text/html', '
');
if (p === '/lessons/jude-lesson-1') return H('text/html', `
Notes`);
if (p === '/lessons/jude-lesson-3') return H('text/html', '
No handout
');
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');
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');
+ 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);
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);
diff --git a/vbv.js b/vbv.js
index 0681fef..5a538e9 100644
--- a/vbv.js
+++ b/vbv.js
@@ -39,13 +39,29 @@ const studiesAll = () => cached('studies', 3600e3, async () => {
});
const feed = slug => cached('feed:' + slug, 1800e3, async () => {
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(/
- [\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(/
- [\s\S]*$/, '');
const tag = (s, t) => plain((new RegExp('<' + t + '>([\\s\\S]*?)' + t + '>', 'i').exec(s) || [])[1]);
const items = [...x.matchAll(/
- ([\s\S]*?)<\/item>/g)].map((m, i) => { const it = m[1], enc = /]*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');
const img = (/ plain((new RegExp(']+(?:property|name)="' + n + '"[^>]+content="([^"]*)"', 'i').exec(h) || [])[1]);
+ const lessons = [...h.matchAll(//g)].map(m => { const b = m[0], href = (/href="(\/lessons\/[\w-]+)"/.exec(b) || [])[1]; if (!href) return null;
+ const num = plain((/([\s\S]*?)<\/span>/.exec(b) || [])[1]), sub = plain((/
]+\.mp3/gi)].map(m => unent(m[0])).find(allowed) || ''; });
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 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 = /^\/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');
- 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') {
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)));
}
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(`
Lesson handout${code === 404 ? 'This lesson has no handout' : 'The handout could not be loaded'}
${code === 404 ? 'Not every lesson comes with notes.' : 'Verse By Verse Ministry did not answer. Try again in a moment.'}
Go back
`);
+ }
+ return json(res, code, { error: e.message });
+ }
}
module.exports = { handle };