From af51b37ae4f7bbedb25c7b3b706c37037c4da096 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 02:27:59 +0000 Subject: [PATCH] Verse By Verse: handouts open as a plain link (no pop-up blocking), friendly page when a lesson has none; studies with an empty or partial feed are read from their page Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj --- public/sw.js | 2 +- public/vbv-view.js | 6 +++--- tools/vbv-test.js | 7 +++++++ vbv.js | 30 +++++++++++++++++++++++++++--- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/public/sw.js b/public/sw.js index f4d8df0..3fafed0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // 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 SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/public/vbv-view.js b/public/vbv-view.js index 69eef21..86e158b 100644 --- a/public/vbv-view.js +++ b/public/vbv-view.js @@ -49,10 +49,10 @@ async function vbStudy(slug) { 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 `
${l.n + 1}. ${esc(l.title)}${esc([vbDate(l.date), done ? 'played' : p ? Math.max(1, Math.round((p.dur - p.pos) / 60)) + ' min left' : ''].filter(Boolean).join(' · '))} - Notes
`; }; + Notes`; }; const resume = nextUp(), rp = vbLeft(key(resume)); $('#view').innerHTML = `
-
${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 | Ministry

Empty 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]*?)', '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((/

    ${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 };