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

+7 -7
View File
@@ -46,7 +46,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
- [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos) - [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos)
- [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.** - [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.**
- [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.** - [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.**
- [~] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list. Not built: flagging what is already in the pantry.** - [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry counts are kept by hand; they do not go down when you cook.**
- [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).** - [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).**
## 3. Kids and safety ## 3. Kids and safety
@@ -59,14 +59,14 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
- [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.** - [x] **Search everywhere (M):** one search across mail, tasks, notes, recipes, contacts, books, songs, chats and Bible verses **Done: one search across mail, tasks, events, notes, chat, contacts, chores, recipes, files, music, books and the web.**
- [x] **Continue row (S):** **Done: Home, today.js.** the last audiobook, episode, book, movie and song on Home - [x] **Continue row (S):** **Done: Home, today.js.** the last audiobook, episode, book, movie and song on Home
- [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.** - [x] **Notifications center (M):** one inbox for reminders, chores, new episodes, mail and chat, with quiet hours per person **Done: a bell with a per-person list (chat, reminders, announcements); quiet hours still apply to phone alerts.**
- [~] **Tasks 2.0 (M):** **Done: a due time with a notification at that time, and Today and Upcoming views. Lists serve as projects. Not built: subtasks on iCloud lists.** - [x] **Tasks 2.0 (M):** **Done: a due time with a notification at that time, Today and Upcoming views, and subtasks on iCloud lists too. Lists serve as projects.**
- [x] **Mail 2.0 (L):** **Done: an All inboxes view (newest of each account), snooze, templates, and saving an attachment to Files. Not tried against real Outlook, Gmail or iCloud accounts, only the tests.** - [x] **Mail 2.0 (L):** **Done: an All inboxes view (newest of each account), snooze, templates, and saving an attachment to Files. Not tried against real Outlook, Gmail or iCloud accounts, only the tests.**
- [x] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas **Done: anniversaries show in the calendar every year; gift ideas on each contact.** - [x] **Contacts 2.0 (S):** anniversaries and reminders, gift ideas **Done: anniversaries show in the calendar every year; gift ideas on each contact.**
## 5. Media ## 5. Media
- [~] **Movies & TV 2.0 (L):** **Done: favorites, a shared Movie night list with votes, and mixes (random, family night, short, classics). Not built: watch stats. The mixes and favorites need a real Plex to try.** - [x] **Movies & TV 2.0 (L):** **Done: favorites, a shared Movie night list with votes, mixes (random, family night, short, classics) and Your stats (counts, hours, favourite genres from Plex's last-watched dates). The Plex-based ones are tested only against the counting code, not a real Plex.**
- [x] **Podcast and audiobook stats (S):** listening history like Music has **Done: time listened per day, streak and audiobooks finished, as a Home card (progress.js).** - [x] **Podcast and audiobook stats (S):** listening history like Music has **Done: time listened per day, streak and audiobooks finished, as a Home card (progress.js).**
- [~] **Reading goals (S):** **Done: a yearly goal for audiobooks finished, with a listening streak. Not built: a family-wide challenge, e-book goals, exporting notes and highlights.** - [x] **Reading goals (S):** **Done: a yearly goal for books finished (audiobooks and e-books, an e-book counts at 98% read), a listening streak, a family challenge with one shared goal, and downloading notes and highlights.**
- [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.** - [~] **Browse and search by genre (M):** in the Store, search books and audiobooks by genre (fiction, mystery, history, children's, Christian and so on) across the sources, with genre buttons, and the same genre filter on your own library and shelf. Needs each source's genre data (Gutenberg subjects, Open Library subjects, Standard Ebooks tags, Smashwords categories, LibriVox genres), normalised into one list. **Search by genre is done in the Store, Free Books and LibriVox (genres.js); filtering your own library by genre is not built.**
- [x] **Casting (M):** **Done: cast.js (AirPlay and the browser's cast picker).** pick AirPlay or Cast targets for audio - [x] **Casting (M):** **Done: cast.js (AirPlay and the browser's cast picker).** pick AirPlay or Cast targets for audio
@@ -74,13 +74,13 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup,
- [~] **Browser smoke tests in CI (M):** **Done: tools/frontend-test.js loads every page script in one shared scope and fails on a syntax error, a name declared twice or a script that throws as it loads. Not a real browser: nothing clicks through the screens.** - [~] **Browser smoke tests in CI (M):** **Done: tools/frontend-test.js loads every page script in one shared scope and fails on a syntax error, a name declared twice or a script that throws as it loads. Not a real browser: nothing clicks through the screens.**
- [x] **Admin health page (S):** **Done: Settings > Admin checks Plex, Seafile, OnlyOffice, audiobooks, podcasts, Mealie, mail and storage.** - [x] **Admin health page (S):** **Done: Settings > Admin checks Plex, Seafile, OnlyOffice, audiobooks, podcasts, Mealie, mail and storage.**
- [x] **Backup and restore screen (S):** one-click restore, per-person export **Done: Settings > Admin restores a backup (old data kept aside); Settings > General downloads your own data.** - [x] **Backup and restore screen (S):** one-click restore, per-person export **Done: Settings > Admin restores a backup (old data kept aside); Settings > General downloads your own data.**
- [~] **Offline mode (L):** **Done: tasks, notes, recipes, the calendar, contacts and chores can be read with no signal (the last saved copy, kept per person); saved podcast episodes and albums already played. Not built: changing things offline, or reading saved books.** - [x] **Offline mode (L):** **Done: tasks, notes, recipes, the calendar, contacts and chores can be read with no signal (the last saved copy, kept per person); saved podcast episodes and albums already played; books can be saved on the device; adding a task, ticking one, adding to the shopping list and writing a note are kept and sent when the connection returns. Other changes still need a connection.**
- [x] **Performance pass (S):** **Done: the Bible text, the reader libraries and the video player were already loaded on demand; page scripts now load deferred. Text was already compressed and cached.** - [x] **Performance pass (S):** **Done: the Bible text, the reader libraries and the video player were already loaded on demand; page scripts now load deferred. Text was already compressed and cached.**
- [x] **Accessibility (M):** **Done: text size, high contrast and reduce motion (Settings > Look and reading); focus outlines and the skip link already existed. Keyboard use of every screen was not audited.** - [x] **Accessibility (M):** **Done: text size, high contrast and reduce motion (Settings > Look and reading); focus outlines and the skip link already existed; dialogs now keep focus inside and give it back; Bible verses can be picked with the keyboard. The main screens were scanned for things only a mouse can click and that was the one gap found.**
## 7. Platform (later) ## 7. Platform (later)
- [x] **Extensions 2.0 (L):** **Done: notes, recipes and what is playing in the bridge; a Permissions screen per extension; an Updated notice when an admin changes one.** - [x] **Extensions 2.0 (L):** **Done: notes, recipes and what is playing in the bridge; a Permissions screen per extension; an Updated notice when an admin changes one.**
- [~] **Automations (L):** **Done: per-person rules (Settings > Automations): every day at a time, a task is finished or new mail arrives, then add a task, add to Shopping or notify. Not built: new podcast episodes or a low pantry item as triggers.** - [x] **Automations (L):** **Done: per-person rules (Settings > Automations): every day at a time, a task is finished, new mail arrives, a pantry item runs low or a new podcast episode comes out, then add a task, add to Shopping or notify.**
- [x] **Themes (S):** accent colours and an AMOLED dark mode per person **Done: themes.js, Settings > General > Look.** - [x] **Themes (S):** accent colours and an AMOLED dark mode per person **Done: themes.js, Settings > General > Look.**
## Suggested order ## Suggested order
+3 -2
View File
@@ -46,8 +46,8 @@ function bbChapterHtml(book, c) {
else if (b[0] === 'p') flush(); else if (b[0] === 'p') flush();
else if (b[0] === 's') { flush(); html += `<p class="bsub">${esc(b[1])}</p>`; } else if (b[0] === 's') { flush(); html += `<p class="bsub">${esc(b[1])}</p>`; }
else if (b[0] === 'v') { 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>`; } 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]}">${bbVerseHtml(b[1], b[2], b[3], true)}</span> `; 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; 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>`; <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); 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); } } 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 => { 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 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); 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); who(); navigator.serviceWorker.addEventListener('controllerchange', who);
} }
// offline: say so once, and that what is on screen is the last saved copy // 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(); } 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="/tasks.js"></script>
<script defer src="/audiobooks.js"></script> <script defer src="/audiobooks.js"></script>
<script defer src="/music.js"></script> <script defer src="/music.js"></script>
<script defer src="/offline-queue.js"></script>
<script defer src="/video.js"></script> <script defer src="/video.js"></script>
<script defer src="/office.js"></script> <script defer src="/office.js"></script>
<script defer src="/player.js"></script> <script defer src="/player.js"></script>
+2 -1
View File
@@ -1,7 +1,8 @@
'use strict'; 'use strict';
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here) // ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
const jfetch = async (path, opts) => { 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 }); if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
return j; 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.'); $('#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; if (!b) return;
const saved = await rdSaved(b.id);
actionSheet(b.title, b.author || b.ext.toUpperCase(), [ actionSheet(b.title, b.author || b.ext.toUpperCase(), [
{ icon: 'book', label: 'Open', run: () => { location.hash = '#/read/open/' + b.id; } }, { icon: 'book', label: 'Open', run: () => { location.hash = '#/read/open/' + b.id; } },
{ icon: 'edit', label: 'Edit title and author', run: () => rdEditBook(b) }, { 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 }) }] : []), ...(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: '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 () => { { 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; el.querySelector('.scrim').onclick = dismissSheet; el.querySelector('.sheetx').onclick = dismissSheet;
swipeToDismiss(el.querySelector('.sheet'), () => closeSheet(), { handle: '.grip, h3, .sheetx', scrim: el.querySelector('.scrim') }); swipeToDismiss(el.querySelector('.sheet'), () => closeSheet(), { handle: '.grip, h3, .sheetx', scrim: el.querySelector('.scrim') });
requestAnimationFrame(() => el.classList.add('on')); 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; 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. */ /** 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). */ /** 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 } = {}) { function swipeToDismiss(el, close, { handle = '', scrim = null, always = false } = {}) {
let y0 = 0, x0 = 0, t0 = 0, on = false, dead = true; 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. // 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 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 SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
@@ -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 // 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. // 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 ''; } }; 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 => { self.addEventListener('message', e => {
const d = e.data || {}; if (d.type !== 'who') return; const d = e.data || {}; if (d.type !== 'who') return;
@@ -54,6 +54,11 @@ async function dataFirstNetwork(req) {
throw err; 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. // Share target: other apps send files here (POST); keep them and open the app, which asks where to put them.
async function takeShare(request) { async function takeShare(request) {
try { try {
@@ -68,6 +73,7 @@ self.addEventListener('fetch', e => {
const u = new URL(e.request.url); 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 === '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\/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 && /^\/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 && 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; 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; const box = $('#tl'); if (!box) return;
try { 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 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)); .flat() : await jfetch('/api/tasks?list=' + encodeURIComponent(cur));
if (!$('#tl')) return; if (!$('#tl')) return;
if (typeof offApplyTasks === 'function') tasks = offApplyTasks(tasks); // (changes made offline and not sent yet)
taskStore.length = 0; taskStore.length = 0;
const ids = new Set(tasks.map(t => t.id)), byParent = {}; 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); }); 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>` : ''}` : ''); + (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); }; 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 () => { 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); } 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); } catch (err) { b.disabled = false; toast(err.message); }
}); });
+7
View File
@@ -37,4 +37,11 @@ for (const name of ['route', 'viewHome', 'viewSettings', 'viewTasks', 'viewCalen
{ const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx); { const now = Date.UTC(2026, 5, 15, 12), at = d => Math.floor((now - d * 864e5) / 1000), agg = vm.runInContext('vdAgg', ctx);
const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now); const a = agg([{ lastViewedAt: at(1), duration: 6e6, Genre: [{ tag: 'Drama' }, { tag: 'Comedy' }] }, { lastViewedAt: at(3), duration: 3e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(20), duration: 6e6 }, { lastViewedAt: at(200), duration: 6e6, Genre: [{ tag: 'Drama' }] }, { lastViewedAt: at(400), duration: 6e6 }, { duration: 6e6 }], now);
assert.deepEqual([a.week.n, a.week.min, a.month.n, a.year.n], [2, 150, 3, 4], 'week, month and year counts and minutes'); assert.deepEqual(a.genres[0], ['Drama', 3]); assert.equal(a.days[12], 1); assert.equal(a.days[10], 1); assert.equal(a.days.reduce((x, y) => x + y, 0), 2, 'the 14-day strip'); } assert.deepEqual([a.week.n, a.week.min, a.month.n, a.year.n], [2, 150, 3, 4], 'week, month and year counts and minutes'); assert.deepEqual(a.genres[0], ['Drama', 3]); assert.equal(a.days[12], 1); assert.equal(a.days[10], 1); assert.equal(a.days.reduce((x, y) => x + y, 0), 2, 'the 14-day strip'); }
// changes made offline: only the safe kinds are queued, in order, and tasks show them while they wait
{ const run = c => vm.runInContext(c, ctx); run("ME = { id: 'u1' }"); const body = o => JSON.stringify(o);
assert.equal(run("offQueued('/api/tasks', { method: 'GET' })"), null, 'a read is not queued'); assert.equal(run(`offQueued('/api/chat/send', { method: 'POST', body: ${JSON.stringify(body({ text: 'hi' }))} })`), null, 'a chat message is not one of the safe kinds');
assert.equal(run(`offQueued('/api/tasks', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', id: 'x', fields: { title: 'edit' } }))} })`), null, 'editing an existing task is not queued (it could clash)');
assert.equal(run(`offQueued('/api/tasks', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', fields: { title: 'Buy milk', due: '' } }))} }).queued`), true); run(`offQueued('/api/tasks/toggle', { method: 'POST', body: ${JSON.stringify(body({ list: 'local:inbox', id: 't1', done: true }))} })`);
assert.equal(run('offPending()'), 2); const shown = run("offApplyTasks([{ id: 't1', title: 'Old', done: false, list: 'local:inbox' }])"); assert.deepEqual(shown.map(t => [t.title, t.done]), [['Old', true], ['Buy milk', false]], 'a queued tick and a queued new task show right away');
assert.equal(run("offqRead().length"), 2); run("localStorage.removeItem('offq:u1')"); assert.equal(run('offPending()'), 0); }
console.log('frontend test ok (' + listed.length + ' scripts)'); process.exit(0); console.log('frontend test ok (' + listed.length + ' scripts)'); process.exit(0);