Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s

- Chores tab with repeats, points and a week/month board
- Family page: calendar, chores, shopping, notes, chat and tasks on one start page
- Genre search in the Store, Free Books and LibriVox
- Child accounts: safe content, daily limit and bedtime, ask a parent
- Search now covers notes, chat, contacts, chores, recipes and your books
- Notifications center: bell with a per-person list of chat, reminders and announcements
- Roadmap, README and tests updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 11:48:55 -04:00
1 parent cf292e3bc7
commit 0383da87f5
38 files changed
+720 -79

No files matched your search

+9 -1
View File
@@ -1,10 +1,11 @@
// Free audiobooks: LibriVox search and book details, chapter audio is only ever sent to the recording hosts, each person's shelf and listening position.
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-lv-'));
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-lv-')); process.env.LIBRIVOX_INDEX_PAGES = '2';
const gif = Buffer.from('R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==', 'base64'), seen = []; let port;
const fake = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x'), p = u.pathname; seen.push(p + u.search);
const J = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
if (p === '/api/feed/audiobooks/' && u.searchParams.get('extended') === '1' && u.searchParams.get('offset') !== null) { const off = +u.searchParams.get('offset'); const mk = (id, title, gs) => ({ id: String(id), title, description: 'A story.', language: 'English', totaltimesecs: 600, authors: [{ first_name: 'A', last_name: 'Writer' }], coverart_jpg: `http://127.0.0.1:${port}/cover.gif`, genres: gs.map((name, i) => ({ id: String(i), name })) }); return J({ books: off === 0 ? [mk(901, 'The Hound Case', ['Crime & Mystery Fiction', 'Published 1900 onward']), mk(902, 'Dragons Abound', ['Fantastic Fiction'])] : [mk(903, 'Rhymes for Bed', ["Children's Fiction", 'Poetry']), mk(901, 'The Hound Case', ['Crime & Mystery Fiction'])] }); }
if (p === '/api/feed/audiobooks/') {
const book = { id: '55', title: 'Pride &amp; Prejudice', description: '<p>A novel</p>', url_librivox: 'https://librivox.org/pp', language: 'English', copyright_year: '1813', totaltimesecs: 600, authors: [{ first_name: 'Jane', last_name: 'Austen' }], coverart_jpg: `http://127.0.0.1:${port}/cover.gif` };
const full = { ...book, sections: [{ section_number: '1', title: 'Chapter 1', listen_url: `http://127.0.0.1:${port}/dl/1.mp3`, playtime: '300', readers: [{ display_name: 'Ann' }] }, { section_number: '2', title: 'Chapter 2', listen_url: 'https://evil.example.com/2.mp3', playtime: '300' }, { section_number: '3', title: 'Chapter 3', listen_url: `http://127.0.0.1:${port}/dl/3.mp3`, playtime: '300' }] };
@@ -39,6 +40,13 @@ const fake = http.createServer((req, res) => {
await post({ 'lv:55': { pos: 400, dur: 600, t: 10 } }); assert.equal((await post({ 'lv:55': { pos: 5, dur: 600, t: 3 } }))['lv:55'].pos, 400, 'the newest position wins');
assert.deepEqual(await j(await call('/progress', { headers: { 'x-user': 'b' } })), {});
await call('/shelf/55', { method: 'DELETE' }); assert.deepEqual(await j(await call('/shelf')), []);
// genres: gathered once into an index, then searched locally
let j2; r = await call('/search?genre=mystery'); j2 = await r.json(); assert.equal(j2.items.length, 0); assert.equal(j2.building, true, 'the first time it says the index is being built');
for (let i = 0; i < 40 && !lv.loadIdx(); i++) await new Promise(ok => setTimeout(ok, 100)); assert.ok(lv.loadIdx(), 'the index is built and kept');
j2 = await (await call('/search?genre=mystery')).json(); assert.deepEqual(j2.items.map(b => b.id), ['901'], 'a book in two pages is listed once'); assert.equal(j2.items[0].author, 'A Writer'); assert.match(j2.items[0].cover, /^\/api\/ab\/free\/cover\/901/);
assert.deepEqual((await (await call('/search?genre=children')).json()).items.map(b => b.id), ['903']); assert.deepEqual((await (await call('/search?genre=poetry')).json()).items.map(b => b.id), ['903'], 'a book can be in several genres'); assert.deepEqual((await (await call('/search?genre=fantasy')).json()).items.map(b => b.id), ['902']);
assert.deepEqual((await (await call('/search?genre=fiction&q=hound')).json()).items.map(b => b.id), ['901'], 'words narrow a genre'); assert.deepEqual((await (await call('/search?genre=fiction&q=zebra')).json()).items, []);
const gl = await (await call('/genres')).json(); assert.equal(gl.ready, true); assert.equal(gl.genres.find(g => g.id === 'mystery').count, 1); assert.equal(gl.genres.find(g => g.id === 'cooking').count, 0); assert.deepEqual(await (await call('/search?genre=nope')).json(), { items: [], more: false });
console.log('librivox test ok');
} catch (err) { console.error(err); process.exitCode = 1; }
hub.close(); fake.close();