Offline edits that send when back online, books saved on the device, and keyboard access for dialogs and Bible verses; roadmap

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 11:36:32 -04:00
1 parent 275ef2009f
commit 95ade02bf2
11 files changed
+96 -18

No files matched your search

+3 -2
View File
@@ -46,8 +46,8 @@ function bbChapterHtml(book, c) {
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> `;
if (b[2].some(Array.isArray)) { flush(); html += `<div class="bpoem vs" data-v="${b[1]}" tabindex="0" role="button">${bbVerseHtml(b[1], b[2], b[3], true)}</div>`; }
else para += `<span class="vs" data-v="${b[1]}" tabindex="0" role="button">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `;
}
}
flush(); return html;
@@ -81,6 +81,7 @@ async function viewBible(args = []) {
<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('keydown', e => { if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('.vs')) { e.preventDefault(); e.target.click(); } }); // (a verse is picked with the keyboard too)
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);
+1 -1
View File
@@ -36,7 +36,7 @@
who(); navigator.serviceWorker.addEventListener('controllerchange', who);
}
// offline: say so once, and that what is on screen is the last saved copy
{ const note = () => { const el = document.getElementById('offnote') || Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); el.textContent = 'You are offline. Showing what was last saved; changes need a connection.'; el.hidden = navigator.onLine; if (!el.isConnected) document.body.appendChild(el); };
{ const note = () => { const el = document.getElementById('offnote') || Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); el.textContent = 'You are offline. Showing what was last saved; changes need a connection.'; el.hidden = navigator.onLine; if (!el.isConnected) document.body.appendChild(el); if (typeof offqNote === 'function') offqNote(); };
addEventListener('offline', note); addEventListener('online', note); if (!navigator.onLine) note(); }
})();
+1
View File
@@ -69,6 +69,7 @@
<script defer src="/tasks.js"></script>
<script defer src="/audiobooks.js"></script>
<script defer src="/music.js"></script>
<script defer src="/offline-queue.js"></script>
<script defer src="/video.js"></script>
<script defer src="/office.js"></script>
<script defer src="/player.js"></script>
+2 -1
View File
@@ -1,7 +1,8 @@
'use strict';
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
const jfetch = async (path, opts) => {
const r = await fetch(path, opts); const j = await r.json().catch(() => ({}));
let r; try { r = await fetch(path, opts); } catch (e) { const q = typeof offQueued === 'function' && offQueued(path, opts); if (q) return q; throw e; } // (a few kinds of change are kept for later when there is no signal)
const j = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
return j;
};
+39
View File
@@ -0,0 +1,39 @@
'use strict';
// ---- Changes made with no signal: adding a task, ticking one off, adding to the shopping list and writing a note are kept on this device and sent when the connection is back.
// Only these few safe changes are queued (nothing that could clash with what someone else did meanwhile); anything else says it needs a connection. Tasks show the waiting changes.
const OFFQ_RULES = [[/^\/api\/tasks$/, b => !b.id && String(b.list || '').startsWith('local:')], [/^\/api\/tasks\/toggle$/, b => String(b.list || '').startsWith('local:')], [/^\/api\/recipes\/shopping\/add$/, () => true], [/^\/api\/notes\/$/, b => !b.id]];
const offqKey = () => 'offq:' + (typeof ME !== 'undefined' && ME ? ME.id : '');
const offqRead = () => { try { const a = JSON.parse(localStorage.getItem(offqKey())); return Array.isArray(a) ? a : []; } catch { return []; } };
const offqWrite = l => { try { if (l.length) localStorage.setItem(offqKey(), JSON.stringify(l)); else localStorage.removeItem(offqKey()); } catch { /* storage full or blocked */ } offqNote(); };
const offPending = () => offqRead().length;
function offqNote() { const n = offPending(); let el = document.getElementById('offnote'); if (!el && n) { el = Object.assign(document.createElement('div'), { id: 'offnote', role: 'status' }); document.body.appendChild(el); } if (!el) return; if (n || !navigator.onLine) { el.hidden = false; el.textContent = (navigator.onLine ? '' : 'You are offline. Showing what was last saved. ') + (n ? n + ' change' + (n === 1 ? '' : 's') + ' waiting to send.' : 'Changes need a connection.'); } else el.hidden = true; }
/** Called by jfetch when a request failed on the network: keep it for later if it is one of the safe kinds. Returns the answer to give, or null (the error stands). */
function offQueued(path, opts) {
const method = String((opts && opts.method) || 'GET').toUpperCase(); if (method !== 'POST' || typeof (opts && opts.body) !== 'string') return null;
let b; try { b = JSON.parse(opts.body); } catch { return null; } const rule = OFFQ_RULES.find(r => r[0].test(path)); if (!rule || !b || !rule[1](b)) return null;
const l = offqRead(); if (l.length >= 100) return null; l.push({ id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6), path, body: opts.body, t: Date.now() }); offqWrite(l);
toast('Saved on this device. It sends when you are back online.'); return { ok: true, queued: true };
}
let offqBusy = false;
/** Send what is waiting, oldest first; stops at the first network failure, and drops a change the server refuses (saying so). */
async function offqFlush() {
if (offqBusy || !navigator.onLine) return; offqBusy = true; let sent = 0;
try {
for (;;) {
const l = offqRead(); if (!l.length) break; const it = l[0];
let r; try { r = await fetch(it.path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: it.body }); } catch { break; }
if (!r.ok && r.status >= 500) break; // (the server is having trouble: try again later)
offqWrite(offqRead().filter(x => x.id !== it.id)); if (r.ok) sent++; else { const j = await r.json().catch(() => ({})); toast('A change made offline was not accepted: ' + (j.error || r.status)); }
}
} finally { offqBusy = false; }
if (sent) { toast(sent + ' change' + (sent === 1 ? '' : 's') + ' sent'); if (/^#\/(tasks|planner)/.test(location.hash) && typeof route === 'function') route(); }
}
/** What the Tasks list should show while changes are waiting: queued ticks applied, and queued new tasks added (marked as waiting). */
function offApplyTasks(tasks) {
const q = offqRead(); if (!q.length) return tasks; const out = tasks.map(t => ({ ...t }));
for (const it of q) { let b; try { b = JSON.parse(it.body); } catch { continue; }
if (/toggle$/.test(it.path)) { const t = out.find(x => x.id === b.id); if (t) t.done = !!b.done; }
else if (it.path === '/api/tasks' && b.fields) out.push({ id: 'queued:' + it.id, list: b.list, title: b.fields.title, notes: b.fields.notes || '', due: b.fields.due || '', dueTime: b.fields.dueTime || '', done: !!b.fields.done, parent: '', repeat: 'none', _ln: 'waiting to send' }); }
return out;
}
addEventListener('online', () => { offqNote(); setTimeout(offqFlush, 800); }); addEventListener('offline', offqNote); setTimeout(offqFlush, 4000); setInterval(offqFlush, 60e3);
+16 -1
View File
@@ -77,11 +77,26 @@ async function rdLibrary() {
$('#rdhint').insertAdjacentHTML('beforeend', ' Press and hold, or right-click, a book for more.');
}
function rdBookMenu(b) {
const rdOffKey = id => `/api/read/book/${id}/file`;
const rdSaved = async id => { try { return !!(await (await caches.open('hub-offline')).match(rdOffKey(id))); } catch { return false; } };
/** Keep a book (and what the reader needs to open it) on this device, so it opens with no signal. */
async function rdSaveOffline(b) {
if (!('caches' in window)) return toast('This browser cannot save for offline');
try {
toast('Saving “' + b.title + '”…'); const c = await caches.open('hub-offline'), r = await fetch(rdOffKey(b.id) + '?inline=1'); if (!r.ok) throw new Error('Could not download the book (' + r.status + ')');
await c.put(rdOffKey(b.id), r); if (b.cover) { try { const cv = await fetch(`/api/read/book/${b.id}/cover`); if (cv.ok) await c.put(`/api/read/book/${b.id}/cover`, cv); } catch { /* the cover is a nicety */ } }
await rdLibs(rdKind(b.ext)).catch(() => {}); // (so the reader's own scripts are in the cache too)
if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {}); toast('Saved on this device. It opens with no signal.');
} catch (e) { try { await (await caches.open('hub-offline')).delete(rdOffKey(b.id)); } catch {} toast(e.message || 'Could not save'); }
}
async function rdRemoveOffline(b) { try { const c = await caches.open('hub-offline'); await c.delete(rdOffKey(b.id)); await c.delete(`/api/read/book/${b.id}/cover`); toast('Removed from this device'); } catch { /* nothing saved */ } }
async function rdBookMenu(b) {
if (!b) return;
const saved = await rdSaved(b.id);
actionSheet(b.title, b.author || b.ext.toUpperCase(), [
{ icon: 'book', label: 'Open', run: () => { location.hash = '#/read/open/' + b.id; } },
{ icon: 'edit', label: 'Edit title and author', run: () => rdEditBook(b) },
saved ? { icon: 'dl', label: 'Remove from this device', run: () => rdRemoveOffline(b) } : { icon: 'dl', label: 'Save on this device (read offline)', run: () => rdSaveOffline(b) },
...(allowed('files') ? [{ icon: 'files', label: 'Save to Files', run: () => saveToFiles(b.title, { kind: 'book', id: b.id }) }] : []),
{ icon: 'dl', label: 'Download', run: () => { const a = document.createElement('a'); a.href = `/api/read/book/${b.id}/file`; a.download = b.title + '.' + b.ext; document.body.appendChild(a); a.click(); a.remove(); } },
{ icon: 'trash', label: 'Delete from library', danger: true, run: () => confirmSheet('Delete this book?', `"${b.title}" and your bookmarks, notes and highlights in it are removed.`, 'Delete', async () => {
+9 -2
View File
@@ -10,11 +10,18 @@ function openSheet(title, sub, bodyHtml) {
el.querySelector('.scrim').onclick = dismissSheet; el.querySelector('.sheetx').onclick = dismissSheet;
swipeToDismiss(el.querySelector('.sheet'), () => closeSheet(), { handle: '.grip, h3, .sheetx', scrim: el.querySelector('.scrim') });
requestAnimationFrame(() => el.classList.add('on'));
// keyboard: focus moves into the dialog (not into a field, so a phone's keyboard does not pop up), Tab stays inside it, and closing gives focus back to what opened it
el._from = document.activeElement; const box = el.querySelector('.sheet'); box.tabIndex = -1; try { box.focus({ preventScroll: true }); } catch { /* old browser */ }
el.addEventListener('keydown', e => {
if (e.key !== 'Tab') return; const f = [...box.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]):not([type=hidden]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')].filter(x => x.offsetParent !== null || x === document.activeElement); if (!f.length) return;
const first = f[0], last = f[f.length - 1]; if (e.shiftKey && (document.activeElement === first || document.activeElement === box)) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
});
return el;
}
function closeSheet() { document.getElementById('sheet')?.remove(); }
const sheetGone = s => { const from = s && s._from; if (from && from.isConnected && from !== document.body && !document.getElementById('sheet')) { try { from.focus({ preventScroll: true }); } catch { /* it cannot take focus */ } } };
function closeSheet() { const s = document.getElementById('sheet'); if (s) { s.remove(); sheetGone(s); } }
/** Close with the slide-down (the close button, a tap outside); closeSheet() is the instant one other code calls before opening something else. */
function dismissSheet() { const s = document.getElementById('sheet'); if (!s || s._closing) return; s._closing = true; s.classList.remove('on'); setTimeout(() => { if (s.isConnected) s.remove(); }, 230); }
function dismissSheet() { const s = document.getElementById('sheet'); if (!s || s._closing) return; s._closing = true; s.classList.remove('on'); setTimeout(() => { if (s.isConnected) { s.remove(); sheetGone(s); } }, 230); }
/** Swipe down to dismiss: `el` follows the finger and `close` runs when it is let go far enough or fast enough. Touches starting in `handle` always count; elsewhere only when nothing under the finger is scrolled down, and never on sliders or text fields. On a phone-sized screen only, unless `always` (full-screen views). */
function swipeToDismiss(el, close, { handle = '', scrim = null, always = false } = {}) {
let y0 = 0, x0 = 0, t0 = 0, on = false, dead = true;
+8 -2
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v119';
const CACHE = 'hub-v122';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
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'];
@@ -35,7 +35,7 @@ async function offlineAudio(req) {
}
// Saved data for offline use: the lists a person is likely to want with no signal (tasks, notes, recipes, calendar, contacts, chores). Network first; when the network fails the last
// copy is used. Kept per person (the page tells us who is signed in), so a shared tablet never shows one person's data to another. Reading only: changes need a connection.
const DATA_OK = /^\/api\/(tasks(\/(upcoming|summary|lists|due))?|notes|recipes(\/(r\/[\w-]+|shopping))?|cal\/(events|list)|contacts|chores(\/\w+)?|bible\/(state))\/?$/;
const DATA_OK = /^\/api\/(read\/(list|book\/\w+\/notes)|tasks(\/(upcoming|summary|lists|due))?|notes|recipes(\/(r\/[\w-]+|shopping))?|cal\/(events|list)|contacts|chores(\/\w+)?|bible\/(state))\/?$/;
const whoNow = async () => { try { const r = await (await caches.open('hub-who')).match('/who'); return r ? await r.text() : ''; } catch { return ''; } };
self.addEventListener('message', e => {
const d = e.data || {}; if (d.type !== 'who') return;
@@ -54,6 +54,11 @@ async function dataFirstNetwork(req) {
throw err;
}
}
// A book saved on this device (see reader.js): answered from the saved copy whatever the address's query, otherwise the network as normal.
async function offlineBook(req) {
const hit = await (await caches.open('hub-offline')).match(req.url, { ignoreSearch: true });
return hit || fetch(req);
}
// Share target: other apps send files here (POST); keep them and open the app, which asks where to put them.
async function takeShare(request) {
try {
@@ -68,6 +73,7 @@ self.addEventListener('fetch', e => {
const u = new URL(e.request.url);
if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/podcast\/podcasts\/[^/]+\/file$/.test(u.pathname)) return e.respondWith(offlineAudio(e.request));
if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/read\/book\/\w+\/(file|cover)$/.test(u.pathname)) return e.respondWith(offlineBook(e.request)); // (books saved on this device)
if (e.request.method === 'GET' && u.origin === location.origin && /^\/plex\/library\/parts\//.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); // (albums saved on this device)
if (e.request.method === 'GET' && u.origin === location.origin && DATA_OK.test(u.pathname)) return e.respondWith(dataFirstNetwork(e.request));
if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return;
+3 -2
View File
@@ -37,9 +37,10 @@ async function loadTaskList(cur, list) {
const box = $('#tl'); if (!box) return;
try {
const smart = cur === 'today' || cur === 'upcoming', all = cur === 'all' || smart, today = ymd(new Date()), byId = Object.fromEntries(taskLists.map(l => [l.id, l]));
const tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => []))))
let tasks = all ? (await Promise.all(taskLists.map(l => jfetch('/api/tasks?list=' + encodeURIComponent(l.id)).then(r => r.map(t => ({ ...t, list: l.id, _ln: l.name + (l.icloud ? ' (iCloud)' : '') }))).catch(() => []))))
.flat() : await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
if (!$('#tl')) return;
if (typeof offApplyTasks === 'function') tasks = offApplyTasks(tasks); // (changes made offline and not sent yet)
taskStore.length = 0;
const ids = new Set(tasks.map(t => t.id)), byParent = {};
tasks.forEach(t => { if (t.parent && ids.has(t.parent)) (byParent[t.parent] = byParent[t.parent] || []).push(t); });
@@ -56,7 +57,7 @@ async function loadTaskList(cur, list) {
+ (done.length ? `<p style="margin:18px 0 6px"><a href="#" id="sd" style="color:var(--accent)">${showDone ? 'Hide' : 'Show'} completed (${done.length})</a></p>${showDone ? `<div class="list">${done.map(t => row(t) + (byParent[t.id] || []).sort(byDue).map(k => row(k, true)).join('')).join('')}</div>` : ''}` : '');
const sd = $('#sd'); if (sd) sd.onclick = e => { e.preventDefault(); store.set('task_show_done', !showDone); loadTaskList(cur, list); };
box.querySelectorAll('button[data-t]').forEach(b => b.onclick = async () => {
const t = taskStore[+b.dataset.t]; b.disabled = true;
const t = taskStore[+b.dataset.t]; if (String(t.id).startsWith('queued:')) return toast('Waiting to send; it can be ticked once it has been sent.'); b.disabled = true;
try { const r = await jfetch('/api/tasks/toggle', { method: 'POST', body: JSON.stringify({ list: t.list, id: t.id, href: t.href, done: !t.done }) }); if (r && r.rolled) toast('Repeats: next due ' + r.rolled); loadTaskList(cur, list); }
catch (err) { b.disabled = false; toast(err.message); }
});