Menu: Listen and Read entries, a bottom bar you choose, an Apps sheet with search and recents. Bible tab with the Berean Standard Bible (search, highlights, notes, synced place)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
15 files changed
+393
-27
No files matched your search
@@ -65,6 +65,8 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
- **Radio**: internet radio, with **The Journey FM** (Liberty University, Lynchburg, Virginia; MP3 192 kbps, `http://ic.liberty.edu:8000/WRVL`) always on top, showing what is playing now and the last songs played from the station's own feed. Below it, search thousands of stations from the community [Radio Browser](https://www.radio-browser.info/) directory (free, no key) by name or with quick topic chips (Christian, Worship, Gospel, Talk, News, Classical, Jazz...). The heart saves a station to *My stations* (your own list). Stations play in the same player as Music, so they keep playing as you move between tabs. The hub fetches each stream itself and passes it on, because many stations only offer plain http, which a browser refuses to play inside an https page; it also reads the song title carried in the stream (the player shows it) and fetches station logos. The hub never connects to private, local or link-local addresses for a station. Give it to people in Settings -> People (Radio). `JOURNEYFM_STREAM_URL`, `JOURNEYFM_NOW_URL` and `RADIO_BROWSER_URL` can override the addresses. `node tools/radio-test.js` tests it against fake stations; the real Journey stream could not be reached from the build environment, so if it will not play, open the station's own *Website* button and tell me what changed. Not included: HLS-only stations, recording, alarms.
|
||||
- **Verse By Verse** (Podcast -> *Verse By Verse*; needs only the Podcast tab, not Podgrab): free verse-by-verse Bible teaching from [Verse By Verse Ministry International](https://versebyverseministry.org). Browse the ministry's studies (about 80: Old and New Testament books, topical series, single teachings, women's and youth), search them, and open a study to see its lessons with dates and summaries. *Start* / *Continue* plays the study lesson after lesson in the same player as Music, a lesson resumes where you left off (kept on the server per person, so it follows you between devices), and each lesson's handout (PDF) opens from *Notes*. The hub reads the study list from the ministry's category pages and the lessons from each study's own podcast feed (`/bible-studies/<study>/podcast.xml`), keeps what it read for 30 to 60 minutes so the ministry's site is not asked on every visit, and sends the browser straight to the ministry's media server for the audio. Only the ministry's own addresses are ever used. `VBV_URL` can point at a mirror. `node tools/vbv-test.js` tests it against a fake copy of the site; it was also tried against the live site (80 studies, a study's lessons, audio and handout links). If the ministry redesigns its pages, the study list is the first thing that would break, and the screen would say so.
|
||||
- **Save to Files**: copy things into your own Seafile from where you find them. **Books** (long-press or right-click a book -> *Save to Files*), **free audiobooks** (a book's sheet -> *Save to Files*, one numbered MP3 per chapter in a folder) and **Verse By Verse** (a study's *Save to Files* button saves every lesson and handout, or just the audio or just the handouts; the download button on a lesson saves that one). You pick the library and folder (it remembers the last one); the hub makes a tidy subfolder (`Books`, `Audiobooks/<title>`, `Verse By Verse/<study>`), names files safely and in order, **skips anything already there** (so running it again fills in what is missing) and counts lessons that have no handout instead of failing. The hub does the downloading and uploading itself with the Seafile sign-in already saved for you, so a whole study (several GB) does not pass through your phone and keeps going if you tap *Hide*; a sheet shows progress, *Stop* cancels, and *Open folder* jumps to the result. It needs the Files tab and the tab the content comes from (Books, Audiobooks or Podcast); each person can have two saves running at once; files up to 500 MB. `node tools/savefiles-test.js` tests it against a fake Seafile.
|
||||
- **Bible**: the **Berean Standard Bible** (BSB, public domain: https://berean.bible/), all 66 books, with its section headings, poetry layout and footnotes. The whole text is one file (`public/vendor/bible/bsb.json`, about 1.5 MB compressed, made by `python3 tools/make-bsb.py` from the free Hello AO Bible API) that the browser loads once and keeps, so reading is instant and works offline. **Choose** a book and chapter, **search** for words (every word must be in the verse) or type a place (`John 3:16`, `1 cor 13:4-7`, `ps 23`), swipe or use the arrow keys to turn chapters, and tap verses to **highlight** (four colours), add a **note**, or **copy** them with their reference. Tap a small letter for a footnote. *My highlights and notes* lists everything. Your place, highlights and notes are kept on the server per person, so they follow you between devices; text size and font are remembered per device. Give it to people in Settings -> People (Bible). `node tools/bible-test.js` tests the saving and checks all 31,086 verses are in the file.
|
||||
- **Menu on a phone**: related tabs share one entry (**Listen**: Music, Radio, Podcast, Audiobooks; **Read**: Bible, Books; each part still needs its own permission), with a row of buttons under the top bar to switch between the parts (it remembers the last one you used). The **bottom bar** is yours to choose: *More* -> *Edit bottom bar* (up to five apps, in the order you tap them; it is saved to your account). **More** opens the **Apps** sheet: every app as a big icon, grouped, with the ones you used lately on top, a search box (typing *rad* finds Radio even though it lives under Listen) and unread badges. Home still shows one tile for each part. On a tablet or computer the side menu uses the same merged entries, and Ctrl/Cmd-K finds every page by name.
|
||||
- **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't
|
||||
see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the
|
||||
expand button opens the full page). New messages get your attention with a toast, a chime, the unread count in the browser tab title and on the installed app's icon, and
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Bible: the text itself (the Berean Standard Bible, public domain) is a static file the browser loads once (public/vendor/bible/bsb.json); this keeps each person's
|
||||
// place, highlights, notes and bookmarks on the server so they follow them between devices.
|
||||
const fs = require('fs'), path = require('path');
|
||||
const users = require('./users');
|
||||
|
||||
const file = () => path.join(users.dir(), 'bible.json');
|
||||
const load = () => { try { const o = JSON.parse(fs.readFileSync(file(), 'utf8')); return { pos: o.pos || null, marks: Array.isArray(o.marks) ? o.marks : [] }; } catch { return { pos: null, marks: [] }; } };
|
||||
const save = o => { const f = file(), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(o), { mode: 0o600 }); fs.renameSync(tmp, f); };
|
||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
const clip = (s, n) => String(s == null ? '' : s).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
|
||||
const BOOK = /^[A-Z0-9]{3}$/;
|
||||
|
||||
/** Merge by id; for each id the newest edit (or deletion) wins, so two devices can add highlights without losing each other's. */
|
||||
function mergeMarks(have, sent) {
|
||||
const by = new Map((have || []).map(m => [m.id, m]));
|
||||
for (const m of Array.isArray(sent) ? sent.slice(0, 3000) : []) {
|
||||
if (!m || !/^[\w-]{1,40}$/.test(m.id || '') || !BOOK.test(m.b || '')) continue;
|
||||
const v = Math.max(1, Math.round(+m.v) || 1), clean = { id: m.id, t: +m.t || 0, type: ['hl', 'note', 'mark'].includes(m.type) ? m.type : 'hl', b: m.b, c: Math.max(1, Math.round(+m.c) || 1), v, ve: Math.max(v, Math.round(+m.ve) || v),
|
||||
color: /^[a-z]{3,12}$/.test(m.color || '') ? m.color : 'yellow', note: clip(m.note, 5000), text: clip(m.text, 1500), del: !!m.del };
|
||||
const old = by.get(m.id); if (!old || clean.t >= old.t) by.set(m.id, clean);
|
||||
}
|
||||
return [...by.values()].filter(m => !m.del || Date.now() - m.t < 30 * 864e5);
|
||||
}
|
||||
const readBody = async req => { const c = []; let n = 0; for await (const d of req) { n += d.length; if (n > 2e6) return null; c.push(d); } try { return JSON.parse(Buffer.concat(c).toString('utf8') || '{}'); } catch { return undefined; } };
|
||||
|
||||
async function handle(req, res) {
|
||||
const p = new URL(req.url, 'http://x').pathname.slice('/api/bible'.length) || '/';
|
||||
if (p === '/state' && req.method === 'GET') { const s = load(); return json(res, 200, { pos: s.pos, marks: s.marks.filter(m => !m.del) }); }
|
||||
if (p === '/pos' && req.method === 'POST') { // where you are reading; the newest report wins
|
||||
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load();
|
||||
if (BOOK.test(b.b || '') && (+b.t || 0) >= ((s.pos || {}).t || 0)) s.pos = { b: b.b, c: Math.max(1, Math.round(+b.c) || 1), v: Math.max(0, Math.round(+b.v) || 0), t: +b.t || Date.now() };
|
||||
save(s); return json(res, 200, s.pos);
|
||||
}
|
||||
if (p === '/marks' && req.method === 'POST') {
|
||||
const b = await readBody(req); if (!b) return json(res, 400, { error: 'Bad request' }); const s = load(); s.marks = mergeMarks(s.marks, b); save(s); return json(res, 200, s.marks.filter(m => !m.del));
|
||||
}
|
||||
return json(res, 404, { error: 'Not found' });
|
||||
}
|
||||
module.exports = { handle, mergeMarks };
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles; do node tools/$t-test.js || exit 1; done",
|
||||
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible; do node tools/$t-test.js || exit 1; done",
|
||||
"audit": "npm audit --omit=dev --audit-level=high"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
+71
-20
@@ -10,7 +10,7 @@ const $ = s => document.querySelector(s);
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
||||
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
|
||||
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']);
|
||||
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
|
||||
const outbox = new Map(); let outTimer = null, warned = false;
|
||||
function flushSettings() {
|
||||
outTimer = null;
|
||||
@@ -37,7 +37,9 @@ async function loadMe() {
|
||||
}
|
||||
// Signal is for desktop-class screens only (a window at least 900 px wide with a mouse or trackpad), and needs the server to have it set up.
|
||||
const sgMQ = matchMedia('(min-width: 900px) and (pointer: fine)');
|
||||
const allowed = id => id === 'planner' ? (allowed('calendar') || allowed('tasks')) : id === 'links' ? (!ME || ME.tabs.includes('links') || ME.tabs.includes('verse')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
|
||||
// Related tabs share one menu entry (a row of buttons at the top of the page switches between them); each still needs its own permission.
|
||||
const MERGED = { listen: ['Listen', ['music', 'radio', 'podcast', 'audio']], reading: ['Read', ['bible', 'read']] };
|
||||
const allowed = id => MERGED[id] ? MERGED[id][1].some(allowed) : id === 'planner' ? (allowed('calendar') || allowed('tasks')) : id === 'links' ? (!ME || ME.tabs.includes('links') || ME.tabs.includes('verse')) : (!ME || ME.tabs.includes(id)) && (id !== 'signal' || (!!cfg.signal && sgMQ.matches)); // (Signal also needs the server to have it set up)
|
||||
/** Pull saved settings from the server into this browser; push up anything only this browser has (first run / migration). */
|
||||
async function loadSettings() {
|
||||
try {
|
||||
@@ -101,6 +103,7 @@ const ICONS = {
|
||||
bookmark: '<path d="M7 4h10v16l-5-4-5 4z"/>',
|
||||
radio: '<rect x="3" y="8" width="18" height="12" rx="2"/><path d="M7 8l10-4M8 14h5M16 14h.01M16 17h.01"/>',
|
||||
heart: '<path d="M12 20s-7-4.4-7-10a4 4 0 017-2.5A4 4 0 0119 10c0 5.6-7 10-7 10z"/>',
|
||||
bible: '<path d="M5 5a2 2 0 012-2h12v15H7a2 2 0 00-2 2z"/><path d="M5 20a2 2 0 002 2h12v-4M12 7v7M9.5 9.5h5"/>',
|
||||
list: '<path d="M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01"/>',
|
||||
font: '<path d="M3 19l5-13 5 13M5 14h6M15 19l3.5-9 3.5 9M16.2 16h4.6"/>',
|
||||
};
|
||||
@@ -157,19 +160,30 @@ const fmtSize = b => b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(0) +
|
||||
const fmtDate = s => new Date(s * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
|
||||
// ---- Navigation
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry (each part still needs its own permission). */
|
||||
const navTabs = () => { const out = []; let done = false; for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; if (t[0] === 'tasks' || t[0] === 'calendar') { if (!done) { out.push(['planner', 'Planner']); done = true; } continue; } out.push(t); } return out; };
|
||||
const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['calendar', 'Calendar'], ['search', 'Search'], ['links', 'Links'], ['office', 'Office'], ['audio', 'Audiobooks'], ['podcast', 'Podcast'], ['radio', 'Radio'], ['bible', 'Bible'], ['read', 'Books'], ['verse', 'Podcast Admin'], ['settings', 'Settings']];
|
||||
const TAB_ICON = { home: 'home', mail: 'mail', signal: 'chat', files: 'files', office: 'doc', audio: 'audio', verse: 'book', podcast: 'mic', radio: 'radio', bible: 'bible', listen: 'audio', reading: 'book', read: 'book', music: 'music', video: 'film', settings: 'cog', calendar: 'cal', search: 'search', tasks: 'check', links: 'link', planner: 'cal' };
|
||||
/** The menu entries this person sees: Calendar and Tasks share one "Planner" entry, and the listening and reading tabs share "Listen" and "Read" (each part still needs its own permission). */
|
||||
const leafOf = id => Object.keys(MERGED).find(k => MERGED[k][1].includes(id));
|
||||
const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) { if (t[0] === 'verse' || !allowed(t[0])) continue; let id = t[0], label = t[1];
|
||||
if (id === 'tasks' || id === 'calendar') { id = 'planner'; label = 'Planner'; } else if (leafOf(id)) { id = leafOf(id); label = MERGED[id][0]; }
|
||||
if (done.has(id)) continue; done.add(id); out.push([id, label]); } return out; };
|
||||
/** Every page one by one (what search and the command palette list), with the merged entries split back out. */
|
||||
const leafTabs = () => TABS.filter(t => t[0] !== 'verse' && t[0] !== 'home' && allowed(t[0]));
|
||||
const tabLabel = id => (TABS.find(x => x[0] === id) || (MERGED[id] ? [0, MERGED[id][0]] : id === 'planner' ? [0, 'Planner'] : [0, '']))[1];
|
||||
/** Menu entries grouped by kind. Anything not listed lands in the last group so a new tab is never lost. */
|
||||
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['music', 'video', 'audio', 'podcast', 'radio', 'read']], ['', ['settings']]];
|
||||
const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Media', ['listen', 'reading', 'video']], ['', ['settings']]];
|
||||
const navGroups = (tabs = navTabs()) => {
|
||||
const out = NAV_GROUPS.map(([label, ids]) => ({ label, tabs: ids.map(id => tabs.find(t => t[0] === id)).filter(Boolean) }));
|
||||
const known = new Set(NAV_GROUPS.flatMap(g => g[1])); out[out.length - 2].tabs.push(...tabs.filter(t => !known.has(t[0])));
|
||||
return out.filter(g => g.tabs.length);
|
||||
};
|
||||
const sg = { st: null, convs: [], cur: null, unread: 0, es: null, timers: [], reply: null, tray: [], names: {} }; // Signal state
|
||||
const PRIMARY = ['home', 'files', 'planner', 'music', 'mail']; // phone tab bar; the rest sit behind "More"
|
||||
const TAB_COLOR = { home: '#3d6bf0', search: '#6b7280', planner: '#d9453a', tasks: '#d9453a', calendar: '#d9453a', files: '#2f9e6e', office: '#e0742b', links: '#0f9d9a', mail: '#4f7cff', signal: '#3a76f0', listen: '#8a5cf0', music: '#e5a00d', radio: '#7b4fd6', podcast: '#c2417a', audio: '#8a5cf0', video: '#c0392b', reading: '#2b8a5e', bible: '#8b5a2b', read: '#2b8a5e', settings: '#6a7280' };
|
||||
// The phone's bottom bar shows up to five entries you choose (Edit bottom bar, in the Apps sheet), then "More".
|
||||
const DEFAULT_PINS = ['home', 'files', 'planner', 'listen', 'mail'];
|
||||
const navPins = () => { const have = new Set(navTabs().map(t => t[0])), saved = store.get('nav_pins'); const out = (Array.isArray(saved) && saved.length ? saved : DEFAULT_PINS).filter(id => have.has(id)).slice(0, 5); return out.length ? out : [...have].slice(0, 4); };
|
||||
const navRecent = () => { try { const l = JSON.parse(localStorage.getItem(nk('nav_recent'))); return Array.isArray(l) ? l : []; } catch { return []; } };
|
||||
const navTouch = id => { if (!id || id === 'home' || id === 'settings') return; try { localStorage.setItem(nk('nav_recent'), JSON.stringify([id, ...navRecent().filter(x => x !== id)].slice(0, 8))); } catch {} };
|
||||
const phone = matchMedia('(max-width: 767px)');
|
||||
let activeTab = 'home';
|
||||
|
||||
@@ -182,10 +196,10 @@ function renderNav(active = activeTab) {
|
||||
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
|
||||
const mine = navTabs();
|
||||
if (phone.matches) {
|
||||
const rest = mine.filter(t => !PRIMARY.includes(t[0])), more = !PRIMARY.includes(active);
|
||||
$('#nav').innerHTML = mine.filter(t => PRIMARY.includes(t[0])).map(tab).join('') +
|
||||
const pins = navPins(), rest = mine.filter(t => !pins.includes(t[0])), more = !pins.includes(active);
|
||||
$('#nav').innerHTML = pins.map(id => mine.find(t => t[0] === id)).filter(Boolean).map(tab).join('') +
|
||||
(rest.length ? `<a href="#" id="more" class="${more ? 'on' : ''}" role="button">${icon('more')}<span>More</span>${rest.some(t => t[0] === 'signal') ? badge(sg.unread) : ''}${rest.some(t => t[0] === 'mail') ? badge(mailUnread) : ''}</a>` : '');
|
||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openMore(); };
|
||||
const mo = $('#more'); if (mo) mo.onclick = e => { e.preventDefault(); openLauncher(); };
|
||||
} else {
|
||||
$('#nav').innerHTML = brand + navGroups(mine).map((g, i) => (i ? `<div class="ngrp" role="presentation">${g.label ? `<span>${esc(g.label)}</span>` : ''}</div>` : '') + g.tabs.map(tab).join('')).join('') + `<div class="spacer"></div><button id="navcol" class="navcol" aria-label="Collapse the menu" title="Collapse or expand the menu">${icon('back')}<span>Collapse</span></button>`;
|
||||
$('#navcol').onclick = () => setNav({ mini: !navPref().mini });
|
||||
@@ -193,14 +207,48 @@ function renderNav(active = activeTab) {
|
||||
}
|
||||
phone.addEventListener('change', () => renderNav());
|
||||
|
||||
function openMore() {
|
||||
const rest = navTabs().filter(t => !PRIMARY.includes(t[0]));
|
||||
const rowOf = ([id, label]) => `<a class="row" href="#/${id}">${icon(TAB_ICON[id])}<span class="grow"><b>${label}</b></span>${(id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : 0) ? `<i class="nbadge" style="position:static">${id === 'signal' ? sg.unread : mailUnread}</i>` : ''}${icon('chev', 'chev')}</a>`;
|
||||
const el = openSheet('More', '', navGroups(rest).map(g => `${g.label ? `<h2 style="margin:14px 0 6px">${esc(g.label)}</h2>` : ''}<div class="list" ${g.label ? '' : 'style="margin-top:10px"'}>${g.tabs.map(rowOf).join('')}</div>`).join(''));
|
||||
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||
/** The Apps sheet behind "More": every app as a big icon, a search box, the ones you used lately on top, and a way to choose the bottom bar. */
|
||||
function openLauncher() {
|
||||
const tabs = navTabs(), badge = id => { const n = id === 'signal' ? sg.unread : id === 'mail' ? mailUnread : id === 'planner' ? (typeof taskDue === 'number' ? taskDue : 0) : 0; return n ? `<i class="nbadge" style="position:absolute;top:-6px;right:-6px">${n > 99 ? '99+' : n}</i>` : ''; };
|
||||
const tile = ([id, label]) => `<a class="lt" href="#/${id}"><span class="lti" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}${badge(id)}</span><span class="ltl">${esc(label)}</span></a>`;
|
||||
const el = openSheet('Apps', '', `<input type="search" id="lnq" placeholder="Search apps" autocomplete="off" autocapitalize="off" aria-label="Search apps"><div id="lnb"></div>
|
||||
<div class="bar" style="margin-bottom:0"><button class="btn ghost" id="lnpin" style="flex:1;justify-content:center">${icon('menu')}Edit bottom bar</button></div>`);
|
||||
el.querySelector('.sheet').classList.add('launcher');
|
||||
const box = el.querySelector('#lnb'), draw = q => {
|
||||
q = q.trim().toLowerCase();
|
||||
if (q) { const hits = leafTabs().filter(t => t[1].toLowerCase().includes(q)).map(([id, l]) => [id, l]); box.innerHTML = hits.length ? `<div class="lgrid">${hits.map(tile).join('')}</div>` : '<p class="empty">No app matches.</p>'; }
|
||||
else {
|
||||
const rec = navRecent().map(id => tabs.find(t => t[0] === id)).filter(Boolean).slice(0, 4);
|
||||
box.innerHTML = (rec.length ? `<h2>Recent</h2><div class="lgrid">${rec.map(tile).join('')}</div>` : '') + navGroups(tabs).map(g => `${g.label ? `<h2>${esc(g.label)}</h2>` : ''}<div class="lgrid" ${g.label ? '' : 'style="margin-top:14px"'}>${g.tabs.map(tile).join('')}</div>`).join('');
|
||||
}
|
||||
box.querySelectorAll('a.lt').forEach(a => a.addEventListener('click', closeSheet));
|
||||
};
|
||||
draw(''); el.querySelector('#lnq').oninput = e => draw(e.target.value); setTimeout(() => { if (document.activeElement && document.activeElement.id === 'lnq') document.activeElement.blur(); }, 0); // (no keyboard popping up until you tap the box)
|
||||
el.querySelector('#lnpin').onclick = () => { closeSheet(); openPinsSheet(); };
|
||||
}
|
||||
/** Choose which entries sit on the phone's bottom bar (up to five, in the order you tap them). */
|
||||
function openPinsSheet() {
|
||||
const tabs = navTabs(); let pins = navPins();
|
||||
const el = openSheet('Bottom bar', 'Tap up to five apps for the bar, in the order you want them. Everything else stays under More.', '<div class="list" id="pnl"></div><div class="bar"><button class="btn ghost" id="pnr" style="flex:1;justify-content:center">Reset</button><button class="btn" id="pns" style="flex:1;justify-content:center">Save</button></div>');
|
||||
const draw = () => { el.querySelector('#pnl').innerHTML = tabs.map(([id, label]) => { const at = pins.indexOf(id); return `<button class="row" data-id="${id}"><span class="ic" style="background:${TAB_COLOR[id] || '#6a7280'}">${icon(TAB_ICON[id] || 'menu')}</span><span class="grow"><b>${esc(label)}</b></span><span class="pinno${at >= 0 ? ' on' : ''}">${at >= 0 ? at + 1 : ''}</span></button>`; }).join('');
|
||||
el.querySelectorAll('#pnl .row').forEach(b => b.onclick = () => { const id = b.dataset.id; if (pins.includes(id)) pins = pins.filter(x => x !== id); else if (pins.length >= 5) return toast('The bar has room for five'); else pins.push(id); draw(); }); };
|
||||
draw();
|
||||
el.querySelector('#pnr').onclick = () => { pins = DEFAULT_PINS.filter(id => tabs.some(t => t[0] === id)); draw(); };
|
||||
el.querySelector('#pns').onclick = () => { store.set('nav_pins', pins.length ? pins : DEFAULT_PINS); closeSheet(); renderNav(); toast('Bottom bar saved'); };
|
||||
}
|
||||
/** Where a "Listen" or "Read" tap goes: the part you used last. */
|
||||
const lastSub = g => { let l = null; try { l = JSON.parse(localStorage.getItem(nk('nav_last_' + g))); } catch {} return MERGED[g][1].includes(l) && allowed(l) ? l : MERGED[g][1].find(allowed); };
|
||||
/** The row of buttons under the top bar that switches between the parts of Listen and Read. */
|
||||
function drawSubnav(name) {
|
||||
const el = $('#subnav'); if (!el) return; const g = leafOf(name), items = g ? MERGED[g][1].filter(allowed) : [];
|
||||
if (!g || items.length < 2) { el.hidden = true; el.innerHTML = ''; return; }
|
||||
try { localStorage.setItem(nk('nav_last_' + g), JSON.stringify(name)); } catch {}
|
||||
el.innerHTML = items.map(id => `<a href="#/${id}" class="${id === name ? 'on' : ''}" ${id === name ? 'aria-current="page"' : ''}>${esc(tabLabel(id))}</a>`).join(''); el.hidden = false;
|
||||
}
|
||||
const navIdOf = name => name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : leafOf(name) || name;
|
||||
let curRoute = '';
|
||||
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : args[0] === 'free' ? viewFreeBooks() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => args[0] === 'vbv' ? viewVbv(args.slice(1)) : viewPodcast(args), radio: viewRadio, bible: args => viewBible(args), listen: () => location.replace('#/' + lastSub('listen')), reading: () => location.replace('#/' + lastSub('reading')), read: args => viewReader(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
|
||||
let editor;
|
||||
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
|
||||
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
|
||||
@@ -228,7 +276,7 @@ function drawNav() {
|
||||
$('#nvf').onclick = () => history.forward();
|
||||
}
|
||||
$('#nvb').disabled = !canBack; $('#nvf').disabled = !canFwd;
|
||||
const t = TABS.find(x => x[0] === activeTab); $('#nvt').textContent = t ? t[1] : '';
|
||||
const t = TABS.find(x => x[0] === (curRoute || activeTab)); $('#nvt').textContent = t ? t[1] : '';
|
||||
el.hidden = false;
|
||||
}
|
||||
addEventListener('popstate', e => { if (e.state && e.state.i != null) { navIdx = e.state.i; drawNav(); } });
|
||||
@@ -244,7 +292,8 @@ function route() {
|
||||
document.body.classList.remove('inedit', 'onaudio');
|
||||
sg.timers.forEach(clearInterval); sg.timers = []; // (Signal screens start their own)
|
||||
const aw = $('#audiowrap'); if (aw) aw.className = 'off'; // hide (not destroy) the audiobooks frame
|
||||
renderNav(name === 'edit' ? 'files' : (name === 'tasks' || name === 'calendar') ? 'planner' : name);
|
||||
curRoute = name; navTouch(navIdOf(name)); drawSubnav(name);
|
||||
renderNav(navIdOf(name));
|
||||
drawNav();
|
||||
routes[name](rest.map(decodeURIComponent));
|
||||
sgDockSync(name);
|
||||
@@ -273,7 +322,9 @@ function homeApps() {
|
||||
links: () => tile('#/links', '#0f9d9a', 'link', 'Links', allLinks().length + ' saved'),
|
||||
mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'),
|
||||
};
|
||||
return navGroups().filter(g => g.tabs.some(t => T[t[0]])).map(g => `<h2>${esc(g.label || 'Apps')}</h2><div class="grid">${g.tabs.map(t => T[t[0]] ? T[t[0]]() : '').join('')}</div>`).join('');
|
||||
T.bible = () => tile('#/bible', '#8b5a2b', 'bible', 'Bible', 'Berean Standard Bible');
|
||||
const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links']], ['Communicate', ['mail', 'signal']], ['Listen', ['music', 'radio', 'podcast', 'audio']], ['Read and watch', ['bible', 'read', 'video']]];
|
||||
return HOME_GROUPS.map(([label, ids]) => [label, ids.filter(id => T[id] && allowed(id))]).filter(g => g[1].length).map(([label, ids]) => `<h2>${esc(label)}</h2><div class="grid">${ids.map(id => T[id]()).join('')}</div>`).join('');
|
||||
}
|
||||
|
||||
function viewHome() {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
'use strict';
|
||||
// ---- Bible: the Berean Standard Bible (public domain, https://berean.bible/). The text is one file the browser loads once (public/vendor/bible/bsb.json) and keeps,
|
||||
// so reading is instant and works offline. Your place, highlights and notes are kept on the server, so they follow you between devices.
|
||||
const bbApi = (p, opts) => jfetch('/api/bible' + p, opts);
|
||||
const bbOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||
const BB_COLORS = { yellow: '#ffd43b', green: '#69db7c', blue: '#74c0fc', pink: '#f783ac' };
|
||||
const BB_ABBR = { gen: 'GEN', ge: 'GEN', gn: 'GEN', ex: 'EXO', exo: 'EXO', exod: 'EXO', lev: 'LEV', lv: 'LEV', num: 'NUM', nm: 'NUM', nu: 'NUM', deut: 'DEU', dt: 'DEU', deu: 'DEU', josh: 'JOS', jos: 'JOS', judg: 'JDG', jdg: 'JDG', jg: 'JDG', ru: 'RUT', '1sam': '1SA', '1sa': '1SA', '2sam': '2SA', '2sa': '2SA', '1ki': '1KI', '1kgs': '1KI', '1kings': '1KI', '2ki': '2KI', '2kgs': '2KI', '2kings': '2KI', '1chr': '1CH', '1ch': '1CH', '2chr': '2CH', '2ch': '2CH',
|
||||
neh: 'NEH', est: 'EST', esth: 'EST', ps: 'PSA', psa: 'PSA', pss: 'PSA', psalm: 'PSA', psalms: 'PSA', prov: 'PRO', pr: 'PRO', pro: 'PRO', eccl: 'ECC', ecc: 'ECC', qoh: 'ECC', song: 'SNG', sos: 'SNG', sng: 'SNG', songofsongs: 'SNG', isa: 'ISA', is: 'ISA', jer: 'JER', lam: 'LAM', ezek: 'EZK', eze: 'EZK', ezk: 'EZK', dan: 'DAN', dn: 'DAN', hos: 'HOS', joel: 'JOL', jol: 'JOL', amos: 'AMO', amo: 'AMO', obad: 'OBA', oba: 'OBA', jonah: 'JON', jon: 'JON', mic: 'MIC', nah: 'NAM', nam: 'NAM', hab: 'HAB', zeph: 'ZEP', zep: 'ZEP', hag: 'HAG', zech: 'ZEC', zec: 'ZEC', mal: 'MAL',
|
||||
matt: 'MAT', mt: 'MAT', mat: 'MAT', mark: 'MRK', mk: 'MRK', mrk: 'MRK', luke: 'LUK', lk: 'LUK', luk: 'LUK', john: 'JHN', jn: 'JHN', jhn: 'JHN', ac: 'ACT', act: 'ACT', rom: 'ROM', ro: 'ROM', rm: 'ROM', '1cor': '1CO', '1co': '1CO', '2cor': '2CO', '2co': '2CO', gal: 'GAL', ga: 'GAL', eph: 'EPH', phil: 'PHP', php: 'PHP', pp: 'PHP', col: 'COL', '1thess': '1TH', '1th': '1TH', '2thess': '2TH', '2th': '2TH', '1tim': '1TI', '1ti': '1TI', '2tim': '2TI', '2ti': '2TI', tit: 'TIT', philem: 'PHM', phm: 'PHM', heb: 'HEB', jas: 'JAS', jm: 'JAS', '1pet': '1PE', '1pe': '1PE', '2pet': '2PE', '2pe': '2PE', '1john': '1JN', '1jn': '1JN', '2john': '2JN', '2jn': '2JN', '3john': '3JN', '3jn': '3JN', jud: 'JUD', rev: 'REV', re: 'REV', revelation: 'REV' };
|
||||
let bb = null, bbLoading = null, bbSt = { pos: null, marks: [] }, bbSel = new Set(), bbCur = null, bbPosT = 0;
|
||||
const bbPrefs = () => { try { return { size: 19, serif: true, ...JSON.parse(localStorage.getItem('bb-prefs') || '{}') }; } catch { return { size: 19, serif: true }; } };
|
||||
const bbSetPrefs = p => { try { localStorage.setItem('bb-prefs', JSON.stringify(p)); } catch {} };
|
||||
const bbUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
|
||||
const bbBook = id => bb.books.find(b => b.id === id);
|
||||
const bbRef = (b, c, v, ve) => `${bbBook(b).name} ${c}${v ? ':' + v + (ve && ve > v ? '-' + ve : '') : ''}`;
|
||||
|
||||
function bbLoad() {
|
||||
if (bb) return Promise.resolve(bb);
|
||||
return bbLoading || (bbLoading = fetch('/vendor/bible/bsb.json').then(r => { if (!r.ok) throw new Error('Could not load the Bible text'); return r.json(); }).then(d => { bb = d; return d; }).catch(e => { bbLoading = null; throw e; }));
|
||||
}
|
||||
/** "John 3:16", "jn 3", "1 cor 13:4-7", "Psalm 23": a book (and chapter, and verse) typed in words. */
|
||||
function bbParseRef(q) {
|
||||
const m = /^\s*((?:[1-3]\s*)?[a-z][a-z .]*?)\s*(\d+)?\s*(?:[:.]\s*(\d+)(?:\s*[-–]\s*(\d+))?)?\s*$/i.exec(q.replace(/\bchapter\b|\bverse\b/gi, ' ')); if (!m) return null;
|
||||
const key = m[1].toLowerCase().replace(/[^a-z0-9]/g, ''); if (key.length < 2) return null;
|
||||
let id = BB_ABBR[key]; if (!id) { const hit = bb.books.find(b => b.name.toLowerCase().replace(/[^a-z0-9]/g, '') === key) || (key.length >= 3 ? bb.books.find(b => b.name.toLowerCase().replace(/[^a-z0-9]/g, '').startsWith(key)) : null); id = hit && hit.id; }
|
||||
const book = id && bbBook(id); if (!book) return null; const c = Math.min(book.chapters.length, Math.max(1, +m[2] || 1)), v = m[3] ? +m[3] : 0;
|
||||
return { b: book.id, c, v, ve: m[4] ? +m[4] : v };
|
||||
}
|
||||
|
||||
// ---- drawing a chapter
|
||||
function bbVerseHtml(n, parts, notes, showNo) {
|
||||
const poem = parts.some(Array.isArray), vn = `<sup class="vn">${n}</sup>`, mark = p => `<sup class="fn" data-v="${n}" data-i="${p - 1}" role="button" aria-label="Footnote">${String.fromCharCode(96 + Math.min(p, 26))}</sup>`;
|
||||
if (!poem) return (showNo ? vn : '') + parts.map(p => typeof p === 'string' ? (p === '\n' ? '<br>' : esc(p)) : mark(p)).join('');
|
||||
const lines = []; // poetry: one line per part; a footnote marker stays at the end of its line
|
||||
for (const p of parts) {
|
||||
if (Array.isArray(p)) lines.push({ l: p[0], h: esc(p[1]) });
|
||||
else if (p === '\n') continue;
|
||||
else { if (!lines.length) lines.push({ l: 1, h: '' }); lines[lines.length - 1].h += typeof p === 'string' ? esc(p) : mark(p); }
|
||||
}
|
||||
return lines.map((x, i) => `<span class="pl l${x.l}">${i === 0 && showNo ? vn : ''}${x.h}</span>`).join('');
|
||||
}
|
||||
function bbChapterHtml(book, c) {
|
||||
let html = '', para = ''; const flush = () => { if (para.trim()) html += `<p class="bp">${para}</p>`; para = ''; };
|
||||
for (const b of book.chapters[c - 1]) {
|
||||
if (b[0] === 'h') { flush(); html += `<h3 class="bh">${esc(b[1])}</h3>`; }
|
||||
else if (b[0] === 'p') flush();
|
||||
else if (b[0] === 's') { flush(); html += `<p class="bsub">${esc(b[1])}</p>`; }
|
||||
else if (b[0] === 'v') {
|
||||
if (b[2].some(Array.isArray)) { flush(); html += `<div class="bpoem vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
|
||||
else para += `<span class="vs" data-v="${b[1]}">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `;
|
||||
}
|
||||
}
|
||||
flush(); return html;
|
||||
}
|
||||
const bbMarksHere = (b, c) => bbSt.marks.filter(m => m.b === b && m.c === c && !m.del);
|
||||
function bbPaint(root) { // highlights, notes and picked verses, on top of the text
|
||||
const here = bbMarksHere(bbCur.b, bbCur.c);
|
||||
root.querySelectorAll('.vs').forEach(el => {
|
||||
const v = +el.dataset.v, hl = here.find(m => m.type === 'hl' && m.v === v), nt = here.some(m => m.type === 'note' && v >= m.v && v <= m.ve);
|
||||
el.className = el.className.replace(/\b(hl-\w+|hasnote|pick)\b/g, '').replace(/\s+/g, ' ').trim() + (hl ? ' hl-' + hl.color : '') + (nt ? ' hasnote' : '') + (bbSel.has(v) ? ' pick' : '');
|
||||
});
|
||||
bbDrawBar();
|
||||
}
|
||||
|
||||
async function viewBible(args = []) {
|
||||
const v = $('#view'); v.innerHTML = '<div class="wrap"><h1>Bible</h1><p class="empty">Loading the Bible…</p></div>';
|
||||
try { await bbLoad(); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Bible</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="bbretry">Try again</button></div></div>`; $('#bbretry').onclick = () => route(); return; }
|
||||
if (!location.hash.startsWith('#/bible')) return;
|
||||
try { const s = await bbApi('/state'); bbSt = s; } catch {}
|
||||
if (args[0] === 'marks') return bbMarks();
|
||||
let b = args[0] && bbBook(args[0]) ? args[0] : null, c = +args[1] || 1, vs = +args[2] || 0;
|
||||
if (!b) { const mine = (() => { try { return JSON.parse(localStorage.getItem(nk('bb-last'))); } catch { return null; } })(), p = mine && (!bbSt.pos || mine.t > bbSt.pos.t) ? mine : bbSt.pos; b = p && bbBook(p.b) ? p.b : 'JHN'; c = p ? p.c : 1; vs = 0; return location.replace('#/bible/' + b + '/' + c); }
|
||||
const book = bbBook(b); c = Math.min(book.chapters.length, Math.max(1, c)); bbCur = { b, c }; bbSel = new Set();
|
||||
const P = bbPrefs(), name = book.name, last = c === book.chapters.length, order = bb.books.findIndex(x => x.id === b);
|
||||
const prev = c > 1 ? [b, c - 1] : order > 0 ? [bb.books[order - 1].id, bb.books[order - 1].chapters.length] : null, next = !last ? [b, c + 1] : order < 65 ? [bb.books[order + 1].id, 1] : null, href = x => '#/bible/' + x[0] + '/' + x[1];
|
||||
v.innerHTML = `<div class="wrap bbwrap"><div class="bbbar"><button class="btn ghost" id="bbpick" aria-label="Choose a book and chapter">${icon('bible')}<b>${esc(name)} ${c}</b></button><span class="grow"></span>
|
||||
<button class="ibtn" id="bbsrch" aria-label="Search the Bible" title="Search">${icon('search')}</button><button class="ibtn" id="bbmk" aria-label="My highlights and notes" title="My highlights and notes">${icon('bookmark')}</button><button class="ibtn" id="bbaa" aria-label="Text size" title="Text size">${icon('font')}</button></div>
|
||||
<article id="bbtext" class="bbtext ${P.serif ? 'serif' : ''}" style="font-size:${P.size}px">${bbChapterHtml(book, c)}</article>
|
||||
<div class="bbnav">${prev ? `<a class="btn ghost" href="${href(prev)}">${icon('back')}${esc(bbBook(prev[0]).name)} ${prev[1]}</a>` : '<span></span>'}${next ? `<a class="btn ghost" href="${href(next)}">${esc(bbBook(next[0]).name)} ${next[1]}${icon('chev')}</a>` : '<span></span>'}</div>
|
||||
<p class="sub" style="text-align:center;margin:18px 0 0;font-size:12px">Berean Standard Bible (BSB), public domain · <a href="https://berean.bible/" target="_blank" rel="noopener" style="color:var(--accent)">berean.bible</a></p><div class="rdsel bbsel" id="bbsel" hidden></div></div>`;
|
||||
const root = $('#bbtext'); bbPaint(root);
|
||||
if (vs) { const el = root.querySelector(`.vs[data-v="${vs}"]`); if (el) { setTimeout(() => el.scrollIntoView({ block: 'center' }), 50); el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 2200); } }
|
||||
root.addEventListener('click', e => {
|
||||
const fn = e.target.closest('.fn'); if (fn) { const n = bbNote(b, c, +fn.dataset.v, +fn.dataset.i); if (n) openSheet(bbRef(b, c, +fn.dataset.v), '', `<p style="font-size:17px;line-height:1.5">${esc(n)}</p>`); return; }
|
||||
const el = e.target.closest('.vs'); if (!el) { bbSel.clear(); bbPaint(root); return; } const n = +el.dataset.v; if (bbSel.has(n)) bbSel.delete(n); else bbSel.add(n); bbPaint(root);
|
||||
});
|
||||
$('#bbpick').onclick = () => bbPicker(); $('#bbsrch').onclick = () => bbSearch(); $('#bbmk').onclick = () => { location.hash = '#/bible/marks'; }; $('#bbaa').onclick = () => bbTextSheet(root);
|
||||
let x0 = 0, y0 = 0, t0 = 0; root.addEventListener('touchstart', e => { x0 = e.changedTouches[0].clientX; y0 = e.changedTouches[0].clientY; t0 = Date.now(); }, { passive: true });
|
||||
root.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - x0, dy = e.changedTouches[0].clientY - y0; if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy) * 2 && Date.now() - t0 < 600 && !bbSel.size) { const to = dx < 0 ? next : prev; if (to) location.hash = href(to); } });
|
||||
bbCur.next = next && href(next); bbCur.prev = prev && href(prev);
|
||||
const here = { b, c, v: 0, t: Date.now() }; try { localStorage.setItem(nk('bb-last'), JSON.stringify(here)); } catch {}
|
||||
clearTimeout(bbPosT); bbPosT = setTimeout(() => { fetch('/api/bible/pos', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(here) }).catch(() => {}); bbSt.pos = here; }, 1000);
|
||||
}
|
||||
addEventListener('keydown', e => { if (!location.hash.startsWith('#/bible') || !bbCur || /^(INPUT|TEXTAREA|SELECT)$/.test((e.target.tagName || '')) || document.getElementById('sheet')) return; if (e.key === 'ArrowRight' && bbCur.next) location.hash = bbCur.next; else if (e.key === 'ArrowLeft' && bbCur.prev) location.hash = bbCur.prev; });
|
||||
const bbNote = (b, c, v, i) => { const blk = bbBook(b).chapters[c - 1].find(x => x[0] === 'v' && x[1] === v); return blk && blk[3] ? blk[3][i] : ''; };
|
||||
|
||||
// ---- the verses you tapped: highlight, note, copy
|
||||
function bbDrawBar() {
|
||||
const bar = $('#bbsel'); if (!bar) return; if (!bbSel.size) { bar.hidden = true; return; }
|
||||
const vs = [...bbSel].sort((a, b) => a - b), hasHl = bbMarksHere(bbCur.b, bbCur.c).some(m => m.type === 'hl' && bbSel.has(m.v));
|
||||
bar.hidden = false; bar.innerHTML = `<span class="bbref">${esc(bbRef(bbCur.b, bbCur.c, vs[0], vs[vs.length - 1]))}${vs.length > 1 && vs[vs.length - 1] - vs[0] + 1 !== vs.length ? ' …' : ''}</span>` +
|
||||
Object.entries(BB_COLORS).map(([c, hex]) => `<button class="rdcol" data-c="${c}" style="background:${hex}" aria-label="Highlight ${c}"></button>`).join('') +
|
||||
`${hasHl ? `<button class="rdsb" data-a="clear">${icon('x')}Clear</button>` : ''}<button class="rdsb" data-a="note">${icon('edit')}Note</button><button class="rdsb" data-a="copy">${icon('copy')}Copy</button>`;
|
||||
bar.querySelectorAll('.rdcol').forEach(b => b.onclick = () => bbHighlight(b.dataset.c)); bar.querySelector('[data-a=copy]').onclick = bbCopy; bar.querySelector('[data-a=note]').onclick = () => bbNoteDialog(null);
|
||||
const cl = bar.querySelector('[data-a=clear]'); if (cl) cl.onclick = () => bbHighlight(null);
|
||||
}
|
||||
const bbVerseText = (b, c, v) => { const blk = bbBook(b).chapters[c - 1].find(x => x[0] === 'v' && x[1] === v); return blk ? blk[2].map(p => typeof p === 'string' ? (p === '\n' ? ' ' : p) : typeof p === 'number' ? '' : p[1]).join(' ').replace(/\s+/g, ' ').trim() : ''; };
|
||||
async function bbSaveMarks(list) { try { bbSt.marks = await bbApi('/marks', bbOpt('POST', list)); } catch (e) { toast('Could not save: ' + e.message); } }
|
||||
async function bbHighlight(color) {
|
||||
const { b, c } = bbCur, now = Date.now(), out = [];
|
||||
for (const v of bbSel) { const old = bbSt.marks.find(m => m.b === b && m.c === c && m.type === 'hl' && m.v === v && !m.del);
|
||||
if (color === null) { if (old) out.push({ ...old, del: true, t: now }); } else out.push({ id: old ? old.id : bbUid(), t: now, type: 'hl', b, c, v, ve: v, color, text: bbVerseText(b, c, v) }); }
|
||||
bbSel = new Set(); if (out.length) { for (const m of out) { const i = bbSt.marks.findIndex(x => x.id === m.id); if (i >= 0) bbSt.marks[i] = m; else bbSt.marks.push(m); } bbPaint($('#bbtext')); await bbSaveMarks(out); bbPaint($('#bbtext')); } else bbPaint($('#bbtext'));
|
||||
}
|
||||
function bbCopy() {
|
||||
const { b, c } = bbCur, vs = [...bbSel].sort((a, b) => a - b); const text = vs.map(v => (vs.length > 1 ? v + ' ' : '') + bbVerseText(b, c, v)).join(' ');
|
||||
navigator.clipboard?.writeText(`${text} — ${bbRef(b, c, vs[0], vs[vs.length - 1])} (BSB)`).then(() => toast('Copied'), () => toast('Could not copy')); bbSel = new Set(); bbPaint($('#bbtext'));
|
||||
}
|
||||
function bbNoteDialog(existing) { // a note on the picked verses, or edit one that exists
|
||||
const m = existing, vs = m ? null : [...bbSel].sort((a, b) => a - b), b = m ? m.b : bbCur.b, c = m ? m.c : bbCur.c, v = m ? m.v : vs[0], ve = m ? m.ve : vs[vs.length - 1];
|
||||
const el = openSheet(bbRef(b, c, v, ve), '', `<blockquote class="rdq">${esc([...Array(ve - v + 1)].map((_, i) => bbVerseText(b, c, v + i)).join(' ').slice(0, 400))}</blockquote><label>Note</label><textarea id="bbnt" rows="5" maxlength="5000" placeholder="Write a note">${esc(m ? m.note : '')}</textarea>
|
||||
<div class="bar">${m ? '<button class="btn danger" id="bbnd" style="flex:1;justify-content:center">Delete</button>' : '<button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button>'}<button class="btn" id="bbns" style="flex:1;justify-content:center">Save</button></div>`);
|
||||
if (el.querySelector('#sx')) el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelector('#bbns').onclick = async () => { const note = el.querySelector('#bbnt').value.trim(); closeSheet(); const mk = { id: m ? m.id : bbUid(), t: Date.now(), type: 'note', b, c, v, ve, note, color: 'yellow', text: bbVerseText(b, c, v), del: !note };
|
||||
const i = bbSt.marks.findIndex(x => x.id === mk.id); if (i >= 0) bbSt.marks[i] = mk; else bbSt.marks.push(mk); bbSel = new Set(); await bbSaveMarks([mk]); if ($('#bbtext')) bbPaint($('#bbtext')); else if (location.hash.startsWith('#/bible/marks')) bbMarks(); };
|
||||
if (m) el.querySelector('#bbnd').onclick = async () => { closeSheet(); const mk = { ...m, del: true, t: Date.now() }; bbSt.marks = bbSt.marks.filter(x => x.id !== m.id); await bbSaveMarks([mk]); if (location.hash.startsWith('#/bible/marks')) bbMarks(); };
|
||||
}
|
||||
|
||||
// ---- choosing a place, searching, your highlights and notes, text size
|
||||
function bbPicker() {
|
||||
let testament = bb.books.findIndex(b => b.id === bbCur.b) >= 39 ? 1 : 0, chosen = null;
|
||||
const el = openSheet('Choose a book', '', '<div id="bpk"></div>'), box = el.querySelector('#bpk');
|
||||
const draw = () => {
|
||||
if (chosen) { const book = bbBook(chosen); box.innerHTML = `<p class="sub" style="margin:0 0 10px"><button class="btn ghost" id="bpb" style="min-height:36px">${icon('back')}Books</button> <b>${esc(book.name)}</b> · pick a chapter</p><div class="bgrid">${book.chapters.map((_, i) => `<a href="#/bible/${chosen}/${i + 1}" class="${chosen === bbCur.b && i + 1 === bbCur.c ? 'on' : ''}">${i + 1}</a>`).join('')}</div>`; box.querySelector('#bpb').onclick = () => { chosen = null; draw(); }; box.querySelectorAll('.bgrid a').forEach(a => a.addEventListener('click', closeSheet)); return; }
|
||||
const list = bb.books.slice(testament ? 39 : 0, testament ? 66 : 39);
|
||||
box.innerHTML = `<div class="seg" style="margin-top:0"><a href="#" data-t="0" class="${testament ? '' : 'on'}">Old Testament</a><a href="#" data-t="1" class="${testament ? 'on' : ''}">New Testament</a></div><div class="bbooks">${list.map(b => `<button data-b="${b.id}" class="${b.id === bbCur.b ? 'on' : ''}">${esc(b.name)}</button>`).join('')}</div>`;
|
||||
box.querySelectorAll('.seg a').forEach(a => a.onclick = e => { e.preventDefault(); testament = +a.dataset.t; draw(); }); box.querySelectorAll('.bbooks button').forEach(b => b.onclick = () => { chosen = b.dataset.b; if (bbBook(chosen).chapters.length === 1) { closeSheet(); location.hash = '#/bible/' + chosen + '/1'; return; } draw(); });
|
||||
};
|
||||
draw();
|
||||
}
|
||||
function bbSearch() {
|
||||
const el = openSheet('Search the Bible', 'A word or phrase, or a place such as John 3:16', '<form><input type="search" id="bbq" placeholder="love, or Romans 8, or 1 Cor 13:4-7" autocomplete="off" autocapitalize="off"></form><div id="bbres" class="list rdlist" style="margin-top:10px"></div>');
|
||||
const inp = el.querySelector('#bbq'), res = el.querySelector('#bbres');
|
||||
const run = () => {
|
||||
const q = inp.value.trim(); if (q.length < 2) { res.innerHTML = ''; return; }
|
||||
const ref = bbParseRef(q), words = q.toLowerCase().split(/\s+/).filter(Boolean), hits = []; let total = 0;
|
||||
for (const book of bb.books) book.chapters.forEach((ch, ci) => ch.forEach(blk => { if (blk[0] !== 'v') return; const t = bbVerseText(book.id, ci + 1, blk[1]), low = t.toLowerCase(); if (words.every(w => low.includes(w))) { total++; if (hits.length < 120) hits.push({ b: book.id, c: ci + 1, v: blk[1], t }); } }));
|
||||
const mark = t => { let h = esc(t); for (const w of words) h = h.replace(new RegExp('(' + w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'ig'), '<mark>$1</mark>'); return h; };
|
||||
res.innerHTML = (ref ? `<a class="row" href="#/bible/${ref.b}/${ref.c}${ref.v ? '/' + ref.v : ''}"><span class="ic" style="background:#8b5a2b">${icon('bible')}</span><span class="grow"><b>Go to ${esc(bbRef(ref.b, ref.c, ref.v, ref.ve))}</b></span>${icon('chev', 'chev')}</a>` : '') +
|
||||
(hits.length ? hits.map(h => `<a class="row" href="#/bible/${h.b}/${h.c}/${h.v}"><span class="grow"><b style="white-space:normal;font-weight:400">${mark(h.t)}</b><small>${esc(bbRef(h.b, h.c, h.v))}</small></span></a>`).join('') + (total > hits.length ? `<p class="sub" style="padding:10px 16px;margin:0">Showing ${hits.length} of ${total}. Add a word to narrow it down.</p>` : `<p class="sub" style="padding:10px 16px;margin:0">${total} verse${total === 1 ? '' : 's'}</p>`) : ref ? '' : '<p class="empty">Nothing found.</p>');
|
||||
res.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
|
||||
};
|
||||
let t; inp.oninput = () => { clearTimeout(t); t = setTimeout(run, 250); }; el.querySelector('form').onsubmit = e => { e.preventDefault(); clearTimeout(t); run(); }; inp.focus();
|
||||
}
|
||||
async function bbMarks() {
|
||||
const order = id => bb.books.findIndex(b => b.id === id), list = bbSt.marks.filter(m => !m.del).sort((a, b) => order(a.b) - order(b.b) || a.c - b.c || a.v - b.v);
|
||||
$('#view').innerHTML = `<div class="wrap bbwrap"><div class="ptop"><a class="btn ghost" href="#/bible">${icon('back')}Bible</a><h1 style="margin:0">My highlights and notes</h1></div>${list.length ? `<div class="list">${list.map(m => `<div class="row bbm" data-id="${esc(m.id)}"><button class="grow bbmj" style="background:none;border:0;text-align:left;padding:0;color:inherit;min-width:0"><b>${m.type === 'hl' ? `<span class="rddot" style="background:${BB_COLORS[m.color] || BB_COLORS.yellow}"></span>` : icon('edit')} ${esc(bbRef(m.b, m.c, m.v, m.ve))}</b><small style="white-space:normal;display:block">${esc(m.text)}</small>${m.note ? `<span style="white-space:pre-wrap;display:block;margin-top:3px">${esc(m.note)}</span>` : ''}</button>
|
||||
${m.type === 'note' ? `<button class="ibtn" data-e aria-label="Edit">${icon('edit')}</button>` : ''}<button class="ibtn" data-d aria-label="Delete">${icon('trash')}</button></div>`).join('')}</div>` : '<p class="empty">Nothing yet. Tap verses while reading to highlight them or add a note.</p>'}</div>`;
|
||||
document.querySelectorAll('.bbm').forEach(r => { const m = list.find(x => x.id === r.dataset.id);
|
||||
r.querySelector('.bbmj').onclick = () => { location.hash = `#/bible/${m.b}/${m.c}/${m.v}`; };
|
||||
const e = r.querySelector('[data-e]'); if (e) e.onclick = () => bbNoteDialog(m);
|
||||
r.querySelector('[data-d]').onclick = async () => { const mk = { ...m, del: true, t: Date.now() }; bbSt.marks = bbSt.marks.filter(x => x.id !== m.id); await bbSaveMarks([mk]); bbMarks(); }; });
|
||||
}
|
||||
function bbTextSheet(root) {
|
||||
const P = bbPrefs(), el = openSheet('Text', '', `<label>Size</label><div class="rdstep"><button type="button" class="btn ghost" data-s="-1">A−</button><span id="bbsv">${P.size}</span><button type="button" class="btn ghost" data-s="1">A+</button></div>
|
||||
<label>Font</label><div class="rdseg"><button type="button" data-f="1" class="${P.serif ? 'on' : ''}">Serif</button><button type="button" data-f="0" class="${P.serif ? '' : 'on'}">Sans-serif</button></div><div class="bar"><button class="btn" id="bbok" style="flex:1;justify-content:center">Done</button></div>`);
|
||||
const apply = () => { bbSetPrefs(P); root.style.fontSize = P.size + 'px'; root.classList.toggle('serif', P.serif); };
|
||||
el.querySelectorAll('[data-s]').forEach(b => b.onclick = () => { P.size = Math.min(34, Math.max(14, P.size + +b.dataset.s)); el.querySelector('#bbsv').textContent = P.size; apply(); });
|
||||
el.querySelectorAll('[data-f]').forEach(b => b.onclick = () => { P.serif = b.dataset.f === '1'; el.querySelectorAll('[data-f]').forEach(x => x.classList.toggle('on', x === b)); apply(); }); el.querySelector('#bbok').onclick = closeSheet;
|
||||
}
|
||||
+2
-1
@@ -49,7 +49,7 @@
|
||||
<body>
|
||||
<div id="shell">
|
||||
<nav id="nav" aria-label="Main"></nav>
|
||||
<div id="main"><div id="navbar" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
|
||||
<div id="main"><div id="navbar" hidden></div><div id="subnav" class="seg" hidden></div><main id="view"></main><div id="audiowrap" class="off"></div><div id="player" hidden></div></div>
|
||||
</div>
|
||||
<div id="toast" role="status"></div>
|
||||
<script src="/webauthn.js"></script>
|
||||
@@ -75,6 +75,7 @@
|
||||
<script src="/audiobooks-view.js"></script>
|
||||
<script src="/librivox-view.js"></script>
|
||||
<script src="/radio-view.js"></script>
|
||||
<script src="/bible-view.js"></script>
|
||||
<script src="/save-to-files.js"></script>
|
||||
<script src="/vbv-view.js"></script>
|
||||
<script src="/lyrics-view.js"></script>
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ function openPalette() {
|
||||
let items = [], sel = 0, seq = 0, timer, live = [];
|
||||
const go = h => () => { location.hash = h; };
|
||||
const base = () => {
|
||||
const out = navTabs().map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === id)) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
|
||||
const out = navTabs().flatMap(t => MERGED[t[0]] ? MERGED[t[0]][1].filter(allowed).map(id => [id, tabLabel(id)]) : [t]).map(([id, label]) => ({ label: 'Go to ' + label, hint: (navGroups().find(g => g.tabs.some(t => t[0] === (leafOf(id) || id))) || {}).label || 'Page', ic: TAB_ICON[id], run: go('#/' + id), kw: label }));
|
||||
if (phone.matches) out.push({ label: 'Edit the bottom bar', hint: 'View', ic: 'menu', run: () => openPinsSheet(), kw: 'menu tabs pins more' });
|
||||
if (allowed('tasks') && allowed('calendar')) out.push({ label: 'Go to Tasks', hint: 'Planner', ic: 'check', run: go('#/tasks'), kw: 'todo' }, { label: 'Go to Calendar', hint: 'Planner', ic: 'cal', run: go('#/calendar'), kw: 'agenda events' });
|
||||
if (allowed('tasks')) out.push({ label: 'New task', hint: 'Create', ic: 'plus', run: () => quickAdd(), kw: 'add todo' });
|
||||
if (allowed('calendar')) out.push({ label: 'New event', hint: 'Create', ic: 'plus', run: () => newEvent(ymd(new Date())), kw: 'add calendar' });
|
||||
|
||||
@@ -902,3 +902,40 @@ body.mselecting .mrow { user-select: none; -webkit-user-select: none; -webkit-to
|
||||
.rdoplay .art { width: 44px; height: 44px; border-radius: 10px; background: var(--panel2); flex: none; overflow: hidden; display: block; } .rdoplay .art img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.rdorow .ibtn { margin-right: 6px; }
|
||||
.rdonow { min-height: 40px; } .rdonow small { display: block; color: var(--muted); } .rdonow b { font-size: 17px; }
|
||||
|
||||
/* Menu: Listen/Read sub-buttons, the Apps sheet behind More, choosing the bottom bar */
|
||||
#subnav { display: flex; gap: 6px; overflow-x: auto; margin: 0; padding: 6px calc(var(--sar) + 12px) 2px calc(var(--sal) + 12px); scrollbar-width: none; flex: none; }
|
||||
#subnav::-webkit-scrollbar { display: none; } #subnav[hidden], body.editing #subnav { display: none; }
|
||||
#subnav a { min-height: 36px; padding: 6px 14px; font-size: 14px; }
|
||||
@media (min-width: 768px) { #subnav { padding-left: 24px; } }
|
||||
#sheet .sheet.launcher { height: 92dvh; max-height: 92dvh; display: flex; flex-direction: column; }
|
||||
#sheet .sheet.launcher #lnb { flex: 1; overflow-y: auto; margin: 4px -4px 0; padding: 0 4px; }
|
||||
#sheet .sheet.launcher #lnq { width: 100%; margin-top: 4px; }
|
||||
#sheet .sheet.launcher h2 { margin: 16px 0 10px; }
|
||||
.lgrid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px 6px; }
|
||||
.lt { display: flex; flex-direction: column; align-items: center; gap: 6px; text-align: center; color: inherit; min-width: 0; }
|
||||
.lti { position: relative; width: 58px; height: 58px; border-radius: 17px; display: grid; place-items: center; color: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
|
||||
.lti svg { width: 27px; height: 27px; } .lt:active .lti { transform: scale(.94); }
|
||||
.ltl { font-size: 12px; line-height: 1.2; max-width: 100%; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.pinno { width: 26px; height: 26px; border-radius: 50%; border: 2px solid var(--line); display: grid; place-items: center; font-weight: 700; font-size: 13px; flex: none; } .pinno.on { background: var(--accent); border-color: var(--accent); color: var(--accent-t); }
|
||||
|
||||
/* Bible */
|
||||
.bbwrap { max-width: 42rem; }
|
||||
.bbbar { display: flex; align-items: center; gap: 6px; position: sticky; top: -8px; z-index: 3; background: var(--bg); padding: 8px 0; margin-top: -8px; }
|
||||
.bbbar .btn b { margin-left: 6px; }
|
||||
.bbtext { line-height: 1.75; padding-bottom: 8px; } .bbtext.serif { font-family: Georgia, "Times New Roman", serif; }
|
||||
.bbtext .bh { font-size: .82em; font-family: system-ui, sans-serif; text-transform: none; letter-spacing: 0; margin: 1.5em 0 .4em; color: var(--muted); }
|
||||
.bbtext .bsub { font-style: italic; color: var(--muted); margin: .2em 0 .8em; } .bbtext .bp { margin: 0 0 .9em; }
|
||||
.bbtext .bpoem { margin: 0 0 .5em; } .bbtext .pl { display: block; padding-left: 1.2em; text-indent: -1.2em; } .bbtext .pl.l2 { padding-left: 2.4em; }
|
||||
.bbtext .vn { font-size: .6em; font-weight: 700; color: var(--accent); margin-right: .25em; vertical-align: super; font-family: system-ui, sans-serif; }
|
||||
.bbtext .fn { font-size: .6em; font-weight: 700; color: var(--accent); margin: 0 .1em; cursor: pointer; vertical-align: super; font-family: system-ui, sans-serif; }
|
||||
.bbtext .vs { border-radius: 3px; -webkit-box-decoration-break: clone; box-decoration-break: clone; cursor: pointer; }
|
||||
.bbtext .vs.pick { background: rgba(61,107,240,.25); } .bbtext .vs.flash { background: rgba(232,89,12,.28); } .bbtext .vs.hasnote { border-bottom: 2px dotted var(--accent); }
|
||||
.bbtext .hl-yellow { background: #ffd43b66; } .bbtext .hl-green { background: #69db7c66; } .bbtext .hl-blue { background: #74c0fc66; } .bbtext .hl-pink { background: #f783ac66; }
|
||||
.bbnav { display: flex; justify-content: space-between; gap: 8px; margin-top: 24px; }
|
||||
.bbsel.rdsel { position: fixed; bottom: calc(env(safe-area-inset-bottom) + 84px); flex-wrap: wrap; justify-content: center; border-radius: 20px; z-index: 15; }
|
||||
.bbref { font-weight: 600; font-size: 13px; white-space: nowrap; width: 100%; text-align: center; }
|
||||
.bbooks { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 4px; max-height: 58dvh; overflow: auto; }
|
||||
.bbooks button { min-height: 44px; border-radius: 10px; border: 1px solid var(--line); background: var(--panel2); color: var(--text); padding: 0 10px; text-align: left; } .bbooks button.on { background: var(--accent); color: var(--accent-t); }
|
||||
.bgrid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; max-height: 58dvh; overflow: auto; }
|
||||
.bgrid a { display: grid; place-items: center; height: 46px; border-radius: 10px; background: var(--panel2); color: inherit; font-weight: 600; } .bgrid a.on { background: var(--accent); color: var(--accent-t); }
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||
const CACHE = 'hub-v44';
|
||||
const CACHE = 'hub-v45';
|
||||
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'];
|
||||
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"id":"BSB","name":"Berean Standard Bible","license":"Public domain (dedicated by Bible Hub / Berean Bible, April 30, 2023)","source":"https://berean.bible/","books":[{"id":"GEN","name":"Genesis","chapters":[[["h","The Creation"],["v",1,["In the beginning God created the heavens and the earth."]],["p"],["v",2,["Now the earth was formless and void, and darkness was over the surface of the deep. And the Spirit of God was hovering over the surface of the waters."]],["h","The First Day"],["p"],["v",3,["And God said, “Let there be light,”",1,"and there was light."],["Cited in 2 Corinthians 4:6"]],["v",4,["And God saw that the light was good, and He separated the light from the darkness."]],["v",5,["God called the light “day,” and the darkness He called “night.”","\n","And there was evening, and there was morning—the first day.",1],["Literally day one"]],["h","The Second Day"],["p"],["v",6,["And God said, “Let there be an expanse",1,"between the waters, to separate the waters from the waters.”"],["Or a canopy or a firmament or a vault; also in verses 7, 8, 14, 15, 17, and 20"]],["v",7,["So God made the expanse and separated the waters beneath it from the waters above. And it was so."]],["v",8,["God called the expanse “sky.”","\n","And there was evening, and there was morning—the second day."]],["h","The Third Day"],["p"],["v",9,["And God said, “Let the waters under the sky be gathered into one place, so that the dry land may appear.” And it was so."]],["v",10,["God called the dry land “earth,” and the gathering of waters He called “seas.” And God saw that it was good."]],["p"],["v",11,["Then God said, “Let the earth bring forth vegetation: seed-bearing plants and fruit trees, each bearing fruit with seed according to its kind.” And it was so."]],["v",12,["The earth produced vegetation: seed-bearing plants according to their kinds and trees bearing fruit with seed according to their kinds. And God saw that it was good."]],["p"],["v",13,["And there was evening, and there was morning—the third day."]],["h","The Fourth Day"],["p"],["v",14,["And God said, “Let there be lights in the expanse of the sky to distinguish between the day and the night, and let them be signs to mark the seasons and days and years."]],["v",15,["And let them serve as lights in the expanse of the sky to shine upon the earth.” And it was so."]],["p"],["v",16,["God made two great lights: the greater light to rule the day and the lesser light to rule the night. And He made the stars as well."]],["p"],["v",17,["God set these lights in the expanse of the sky to shine upon the earth,"]],["v",18,["to preside over the day and the night, and to separate the light from the darkness. And God saw that it was good."]],["p"],["v",19,["And there was evening, and there was morning—the fourth day."]],["h","The Fifth Day"],["p"],["v",20,["And God said, “Let the waters teem with living creatures, and let birds fly above the earth in the open expanse of the sky.”"]],["v",21,["So God created the great sea creatures and every living thing that moves, with which the waters teemed according to their kinds, and every winged bird after its kind. And God saw that it was good."]],["p"],["v",22,["Then God blessed them and said, “Be fruitful and multiply and fill the waters of the seas, and let birds multiply on the earth.”"]],["p"],["v",23,["And there was evening, and there was morning—the fifth day."]],["h","The Sixth Day"],["p"],["v",24,["And God said, “Let the earth bring forth living creatures according to their kinds: livestock, land crawlers, and beasts of the earth according to their kinds.” And it was so."]],["v",25,["God made the beasts of the earth according to their kinds, the livestock according to their kinds, and everything that crawls upon the earth according to its kind. And God saw that it was good."]],["p"],["v",26,["Then God said, “Let Us make man in Our image, after Our likeness, to rule over the fish of the sea and the birds of the air, over the livestock, and over all the earth itself",1,"and every creature that crawls upon it.”"],["MT; Syriac and over all the beasts of the earth"]],["p"],["v",27,[[1,"So God created man in His own image;"],[2,"in the image of God He created him;"],"\n",[2,"male and female He created them."],1],["Cited in Matthew 19:4 and Mark 10:6"]],["p"],["v",28,["God blessed them and said to them, “Be fruitful and multiply, and fill the earth and subdue it; rule over the fish of the sea and the birds of the air and every creature that crawls upon the earth.”"]],["p"],["v",29,["Then God said, “Behold, I have given you every seed-bearing plant on the face of all the earth, and every tree whose fruit contains seed. They will be yours for food."]],["v",30,["And to every beast of the earth and every bird of the air and every creature that crawls upon the earth—everything that has the breath of life in it—I have given every green plant for food.” And it was soLine truncated
|
||||
@@ -38,6 +38,7 @@ const mail = require('./mail'); // Outlook.com mail, through Microsoft Graph (M
|
||||
const transcripts = require('./transcripts'); // words for podcasts and audiobooks: the show's own transcript, or speech-to-text (TRANSCRIBE_API_URL)
|
||||
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
|
||||
const abapi = require('./abapi'); // audiobook player library, audio and progress (AUDIOBOOKS_URL)
|
||||
const bible = require('./bible'); // Bible: each person's place, highlights and notes (the text is a static file)
|
||||
const savefiles = require('./savefiles'); // "Save to Files": copy books, lessons and audiobooks into the person's Seafile
|
||||
const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies)
|
||||
const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM)
|
||||
@@ -547,6 +548,7 @@ const ACCESS = [
|
||||
[/^\/api\/podcast\//, ['podcast']],
|
||||
[/^\/api\/vbv\//, ['podcast']],
|
||||
[/^\/api\/save\//, ['files']],
|
||||
[/^\/api\/bible\//, ['bible']],
|
||||
[/^\/api\/read\//, ['read']],
|
||||
[/^\/api\/radio\//, ['radio']],
|
||||
[/^\/api\/signal\//, ['signal']],
|
||||
@@ -645,6 +647,7 @@ async function handleRequest(req, res) {
|
||||
if (pathname.startsWith('/api/radio/')) return radio.handle(req, res);
|
||||
if (pathname.startsWith('/api/vbv/')) return vbv.handle(req, res);
|
||||
if (pathname.startsWith('/api/save/')) return savefiles.handle(req, res);
|
||||
if (pathname.startsWith('/api/bible/')) return bible.handle(req, res);
|
||||
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
|
||||
if (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
|
||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ const users = require('./users');
|
||||
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
|
||||
|
||||
// Only these keys are accepted. (The music queue stays in the browser.)
|
||||
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins']);
|
||||
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
|
||||
const SECRET = new Set(['ab_creds', 'sf_token']);
|
||||
const MAX_VALUE = 20000; // bytes of JSON per key
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Bible: where you are reading, and your highlights and notes, are kept per person; the newest edit of each wins and two devices can add without losing each other's.
|
||||
const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-bible-'));
|
||||
const bible = require('../bible'), users = require('../users');
|
||||
const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['bible'], handle: 'Yg' }) };
|
||||
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => bible.handle(req, res))).listen(0, async () => {
|
||||
const base = 'http://127.0.0.1:' + hub.address().port + '/api/bible', j = r => r.json(), post = (p, o, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: h });
|
||||
try {
|
||||
assert.deepEqual(await j(await fetch(base + '/state')), { pos: null, marks: [] });
|
||||
await post('/pos', { b: 'JHN', c: 3, v: 16, t: 100 }); let r = await j(await post('/pos', { b: 'GEN', c: 1, t: 50 })); assert.equal(r.b, 'JHN', 'the newest place wins');
|
||||
assert.deepEqual((await j(await post('/pos', { b: '../x', c: 1, t: 999 }))).b, 'JHN', 'a bad book is ignored'); assert.equal((await j(await fetch(base + '/state'))).pos.c, 3);
|
||||
r = await j(await post('/marks', [{ id: 'm1', t: 10, type: 'hl', b: 'JHN', c: 3, v: 16, color: 'green', text: 'For God so loved the world' }])); assert.equal(r.length, 1);
|
||||
r = await j(await post('/marks', [{ id: 'm2', t: 11, type: 'note', b: 'PSA', c: 23, v: 1, ve: 3, note: 'my shepherd' }, { id: 'm1', t: 5, type: 'hl', b: 'JHN', c: 3, v: 16, text: 'stale edit' }, { id: '../bad', t: 1, b: 'JHN' }, { id: 'm3', t: 1, b: 'nope' }]));
|
||||
assert.deepEqual(r.map(m => m.id).sort(), ['m1', 'm2'], 'two devices add without losing each other'); assert.equal(r.find(m => m.id === 'm1').text, 'For God so loved the world', 'an older edit does not win'); assert.equal(r.find(m => m.id === 'm2').ve, 3);
|
||||
r = await j(await post('/marks', [{ id: 'm1', t: 20, b: 'JHN', c: 3, v: 16, del: true }])); assert.deepEqual(r.map(m => m.id), ['m2'], 'deleted marks stay deleted');
|
||||
assert.deepEqual(await j(await fetch(base + '/state', { headers: { 'x-user': 'b' } })), { pos: null, marks: [] }, "each person's place and marks are their own");
|
||||
assert.equal((await fetch(base + '/nope')).status, 404);
|
||||
// the bundled text
|
||||
const d = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'public', 'vendor', 'bible', 'bsb.json'), 'utf8')); assert.equal(d.books.length, 66); assert.equal(d.books.reduce((n, b) => n + b.chapters.length, 0), 1189);
|
||||
const verses = d.books.flatMap(b => b.chapters.flat()).filter(x => x[0] === 'v').length; assert.equal(verses, 31086, 'every verse is there');
|
||||
const jn = d.books.find(b => b.id === 'JHN').chapters[2].find(x => x[0] === 'v' && x[1] === 16); assert.match(jn[2].join(''), /For God so loved the world/);
|
||||
console.log('bible test ok');
|
||||
} catch (err) { console.error(err); process.exitCode = 1; }
|
||||
hub.close();
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
# Builds public/vendor/bible/bsb.json, the Berean Standard Bible (public domain: https://berean.bible/), from the free Hello AO Bible API
|
||||
# (https://bible.helloao.org). Run once (needs a network): python3 tools/make-bsb.py
|
||||
# Compact format: {books:[{id,name,chapters:[[block,...]]}]}; a block is ["h",heading] | ["s",subtitle] | ["p"] (new paragraph) |
|
||||
# ["v",number,[part,...],[note,...]] where a part is prose text, [level,text] for a poetry line (level 1 or 2), or "\n" for a line break inside a verse.
|
||||
import json, urllib.request, concurrent.futures as cf, os, sys
|
||||
API = 'https://bible.helloao.org/api/BSB'
|
||||
def get(p):
|
||||
for t in range(4):
|
||||
try: return json.load(urllib.request.urlopen(urllib.request.Request(API + p, headers={'User-Agent': 'NE-Hub/1.0'}), timeout=40))
|
||||
except Exception as e: err = e
|
||||
raise err
|
||||
books = get('/books.json')['books']
|
||||
jobs = [(b['id'], c) for b in books for c in range(1, b['numberOfChapters'] + 1)]
|
||||
with cf.ThreadPoolExecutor(8) as ex: raw = dict(zip(jobs, ex.map(lambda j: get(f'/{j[0]}/{j[1]}.json'), jobs)))
|
||||
def txt(c): return ' '.join(p if isinstance(p, str) else p.get('text', '') for p in c).replace(' ', ' ').strip()
|
||||
def chapter(d):
|
||||
notes = {n['noteId']: n for n in d['chapter'].get('footnotes', [])}; out = []
|
||||
for x in d['chapter']['content']:
|
||||
t = x['type']
|
||||
if t == 'heading': out.append(['h', txt(x['content'])])
|
||||
elif t == 'hebrew_subtitle': out.append(['s', txt(x['content'])])
|
||||
elif t == 'line_break': out.append(['p'])
|
||||
elif t == 'verse':
|
||||
parts, ns = [], []
|
||||
for p in x['content']:
|
||||
if isinstance(p, str): parts.append(p)
|
||||
elif 'noteId' in p and p['noteId'] in notes: ns.append(notes[p['noteId']]['text']); parts.append(len(ns))
|
||||
elif 'lineBreak' in p: parts.append('\n')
|
||||
elif 'poem' in p: parts.append([p['poem'], p['text']])
|
||||
elif 'text' in p: parts.append(p['text'])
|
||||
out.append(['v', x['number'], parts] + ([ns] if ns else []))
|
||||
return out
|
||||
bible = {'id': 'BSB', 'name': 'Berean Standard Bible', 'license': 'Public domain (dedicated by Bible Hub / Berean Bible, April 30, 2023)', 'source': 'https://berean.bible/', 'books': [
|
||||
{'id': b['id'], 'name': b['commonName'], 'chapters': [chapter(raw[(b['id'], c)]) for c in range(1, b['numberOfChapters'] + 1)]} for b in books]}
|
||||
dest = os.path.join(os.path.dirname(__file__), '..', 'public', 'vendor', 'bible', 'bsb.json'); os.makedirs(os.path.dirname(dest), exist_ok=True)
|
||||
json.dump(bible, open(dest, 'w'), ensure_ascii=False, separators=(',', ':')); print('wrote', dest, os.path.getsize(dest), 'bytes;', sum(len(b['chapters']) for b in bible['books']), 'chapters')
|
||||
@@ -11,9 +11,9 @@ const USERS_DIR = path.join(DATA_DIR, 'users');
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
// Tabs an admin can switch on or off for a person. Home and Settings are always available to everyone (Settings only shows their own).
|
||||
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
|
||||
const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links'];
|
||||
const ALWAYS = ['home', 'settings'];
|
||||
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
|
||||
const TAB_LABELS = { files: 'Files', office: 'Office', music: 'Music', video: 'Movies & TV', audio: 'Audiobooks', podcast: 'Podcast', radio: 'Radio', bible: 'Bible', read: 'Books', verse: 'Podcast Admin (in Links)', mail: 'Mail', signal: 'Signal', calendar: 'Calendar', tasks: 'Tasks', search: 'Search', links: 'Links' };
|
||||
|
||||
let cache = null;
|
||||
const load = () => { if (cache) return cache; try { cache = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); } catch { cache = { users: [] }; } if (!Array.isArray(cache.users)) cache.users = []; return cache; };
|
||||
|
||||
Reference in new issue
Block a user