Chores, Family page, genre search, child accounts, wider Search and Notifications center
build-container / build (push) Successful in 4m6s
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:
1 parent
cf292e3bc7
commit
0383da87f5
38 files changed
+720
-79
No files matched your search
@@ -2,7 +2,7 @@
|
||||
// admin adds (Calibre-Web, Kavita, ...). The hub server does all the talking to those sites: searching, covers and downloading a chosen book straight onto the
|
||||
// signed-in person's shelf (books.js), so the browser never contacts them and no catalogue sign-in reaches the browser.
|
||||
const fs = require('fs'), path = require('path'), crypto = require('crypto');
|
||||
const users = require('./users'), auth = require('./auth'), books = require('./books'), smashwords = require('./smashwords');
|
||||
const users = require('./users'), auth = require('./auth'), books = require('./books'), smashwords = require('./smashwords'), genres = require('./genres');
|
||||
|
||||
const UA = 'NE-Hub/1.0 (self-hosted e-book reader)';
|
||||
const MAX_BOOK = 300 * 1024 * 1024, MAX_COVER = 3 * 1024 * 1024, MAX_TEXT = 4 * 1024 * 1024;
|
||||
@@ -95,8 +95,9 @@ async function opdsSearch(src, q, page) {
|
||||
|
||||
// ---- Project Gutenberg (its own OPDS catalogue), Standard Ebooks (its public pages), and the Internet Archive
|
||||
const entriesOf = xml => [...xml.matchAll(/<entry[\s>][\s\S]*?<\/entry>/gi)].map(m => m[0]);
|
||||
async function gutenbergSearch(src, q, page) {
|
||||
const url = `${src.base}/ebooks/search.opds/?${q ? 'query=' + encodeURIComponent(q) : 'sort_order=downloads'}${page > 1 ? '&start_index=' + ((page - 1) * 25 + 1) : ''}`;
|
||||
async function gutenbergSearch(src, q, page, g) {
|
||||
const words = [q, g && g.gu ? 's.' + g.gu : ''].filter(Boolean).join(' ');
|
||||
const url = `${src.base}/ebooks/search.opds/?${words ? 'query=' + encodeURIComponent(words) : 'sort_order=downloads'}${page > 1 ? '&start_index=' + ((page - 1) * 25 + 1) : ''}`;
|
||||
const xml = await getText(src, url, 'application/atom+xml'), items = [], seen = new Set();
|
||||
for (const e of entriesOf(xml)) { const id = (/\/ebooks\/(\d+)\.opds/.exec(tag0(e, 'id')) || [])[1]; if (!id || seen.has(id)) continue; seen.add(id); items.push({ ref: id, format: 'epub', title: tag(e, 'title'), author: tag(e, 'content'), summary: '', cover: `${src.base}/cache/epub/${id}/pg${id}.cover.medium.jpg` }); }
|
||||
return { items, more: /<link[^>]+rel="next"/i.test(xml.replace(/<entry[\s>][\s\S]*?<\/entry>/gi, '')) };
|
||||
@@ -106,8 +107,9 @@ async function gutenbergResolve(src, ref) {
|
||||
if (!/^\d{1,9}$/.test(ref)) throw fail(400, 'Bad book');
|
||||
return { url: `${src.base}/ebooks/${ref}.epub3.images`, format: 'epub' }; // (the address Project Gutenberg itself gives for the EPUB with pictures)
|
||||
}
|
||||
async function standardSearch(src, q, page) {
|
||||
const html = await getText(src, `${src.base}/ebooks?${q ? 'query=' + encodeURIComponent(q) + '&' : ''}page=${page}&per-page=24`, 'text/html, application/xhtml+xml'), items = [];
|
||||
async function standardSearch(src, q, page, g) {
|
||||
if (g && !g.se) return { items: [], more: false }; // (no such tag there)
|
||||
const html = await getText(src, `${src.base}/ebooks?${q ? 'query=' + encodeURIComponent(q) + '&' : ''}${g ? 'tags[]=' + encodeURIComponent(g.se) + '&' : ''}page=${page}&per-page=24`, 'text/html, application/xhtml+xml'), items = [];
|
||||
for (const m of html.matchAll(/<li typeof="schema:Book" about="(\/ebooks\/[^"]+)">([\s\S]*?)<\/li>/g)) {
|
||||
const card = m[2], title = plain((/<p><a [^>]*property="schema:url"><span property="schema:name">([\s\S]*?)<\/span>/.exec(card) || [])[1]);
|
||||
const who = [...(/class="author"[\s\S]*?<\/p>/.exec(card) || [''])[0].matchAll(/<span property="schema:name">([\s\S]*?)<\/span>/g)].map(x => plain(x[1])).join(', '), img = (/<img src="([^"]+)"/.exec(card) || [])[1] || '';
|
||||
@@ -121,8 +123,8 @@ async function standardResolve(src, ref) {
|
||||
const href = links[0] || `${ref}/downloads/${ref.replace(/^\/ebooks\//, '').split('/').join('_')}.epub`;
|
||||
return { url: new URL(href, src.base).href + '?source=download', format: 'epub' }; // (without this the site answers with a "your download has started" page)
|
||||
}
|
||||
async function archiveSearch(src, q, page) {
|
||||
const query = `(${q}) AND mediatype:texts AND format:EPUB AND -access-restricted-item:true`;
|
||||
async function archiveSearch(src, q, page, g) {
|
||||
const query = `(${[q, g ? 'subject:(' + g.ia + ')' : ''].filter(Boolean).join(' AND ')}) AND mediatype:texts AND format:EPUB AND -access-restricted-item:true`;
|
||||
const j = await getJson(src, `${src.base}/advancedsearch.php?q=${encodeURIComponent(query)}&fl[]=identifier&fl[]=title&fl[]=creator&rows=20&page=${page}&output=json`);
|
||||
const docs = (j.response && j.response.docs) || [], total = (j.response && j.response.numFound) || 0;
|
||||
return { items: docs.filter(d => /^[\w.-]+$/.test(d.identifier || '')).map(d => ({ ref: d.identifier, format: 'epub', title: [].concat(d.title || '')[0] || d.identifier, author: [].concat(d.creator || []).join(', '), summary: '', cover: `${src.base}/services/img/${d.identifier}` })), more: page * 20 < total };
|
||||
@@ -135,8 +137,8 @@ async function archiveResolve(src, ref) {
|
||||
return { url: `${src.base}/download/${ref}/${f.name.split('/').map(encodeURIComponent).join('/')}`, format: 'epub' };
|
||||
}
|
||||
|
||||
async function openLibrarySearch(src, q, page) {
|
||||
const j = await getJson(src, `${src.base}/search.json?q=${encodeURIComponent(q)}&has_fulltext=true&fields=key,title,author_name,cover_i,ia,ebook_access&limit=20&page=${page}`);
|
||||
async function openLibrarySearch(src, q, page, g) {
|
||||
const j = await getJson(src, `${src.base}/search.json?${q ? 'q=' + encodeURIComponent(q) + '&' : ''}${g ? 'subject=' + encodeURIComponent(g.ol) + '&' : ''}has_fulltext=true&fields=key,title,author_name,cover_i,ia,ebook_access&limit=20&page=${page}`);
|
||||
const docs = j.docs || [], items = [];
|
||||
for (const d of docs) { // only editions anyone may read (not "borrowable", which has DRM); the Internet Archive identifier is what gets downloaded
|
||||
const ia = [].concat(d.ia || []).find(x => /^[\w.-]+$/.test(x)); if (!ia || d.ebook_access !== 'public') continue;
|
||||
@@ -145,12 +147,13 @@ async function openLibrarySearch(src, q, page) {
|
||||
return { items, more: page * 20 < (j.numFound || 0) };
|
||||
}
|
||||
|
||||
async function searchOne(src, q, page, everywhere) {
|
||||
if (src.id === 'gutenberg') return gutenbergSearch(src, q, page);
|
||||
if (src.id === 'standardebooks') return standardSearch(src, q, page);
|
||||
if (src.id === 'archive') return q ? archiveSearch(src, q, page) : { items: [], more: false };
|
||||
if (src.id === 'smashwords') return smashwords.search(q, page, !everywhere);
|
||||
if (src.id === 'openlibrary') return q ? openLibrarySearch(src, q, page) : { items: [], more: false };
|
||||
async function searchOne(src, q, page, everywhere, g) {
|
||||
if (g && !BUILTIN[src.id]) return { items: [], more: false }; // (an OPDS catalogue of your own cannot be asked for a genre)
|
||||
if (src.id === 'gutenberg') return gutenbergSearch(src, q, page, g);
|
||||
if (src.id === 'standardebooks') return standardSearch(src, q, page, g);
|
||||
if (src.id === 'archive') return q || g ? archiveSearch(src, q, page, g) : { items: [], more: false };
|
||||
if (src.id === 'smashwords') return g && !g.sw ? { items: [], more: false } : smashwords.search(q, page, !everywhere, g);
|
||||
if (src.id === 'openlibrary') return q || g ? openLibrarySearch(src, q, page, g) : { items: [], more: false };
|
||||
return opdsSearch(src, q, page);
|
||||
}
|
||||
const stamp = (src, it) => ({ ...it, source: src.id, sourceName: src.name, cover: it.cover ? `/api/read/store/cover?s=${encodeURIComponent(src.id)}&u=${encodeURIComponent(it.cover)}` : '', coverUrl: it.cover || '' });
|
||||
@@ -159,7 +162,8 @@ const listSources = () => [...Object.keys(BUILTIN), ...customs().map(c => c.id)]
|
||||
async function handle(req, res, p) {
|
||||
const me = users.current();
|
||||
try {
|
||||
if (p === '/sources' && req.method === 'GET') return json(res, 200, listSources());
|
||||
const safe = !!(me && me.kid && me.kid.on && me.kid.safe), SAFE_SOURCES = ['gutenberg', 'standardebooks'];
|
||||
if (p === '/sources' && req.method === 'GET') return json(res, 200, listSources().filter(s => !safe || SAFE_SOURCES.includes(s.id)));
|
||||
if (p === '/sources' && req.method === 'POST') { // add an OPDS catalogue (admin)
|
||||
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can add a catalogue');
|
||||
const b = await readBody(req), name = String(b.name || '').trim().slice(0, 60), url = String(b.url || '').trim();
|
||||
@@ -180,10 +184,12 @@ async function handle(req, res, p) {
|
||||
if (!me || me.role !== 'admin') throw fail(403, 'Only an admin can remove a catalogue');
|
||||
saveCustoms(customs().filter(x => x.id !== m[1])); return json(res, 200, { removed: true });
|
||||
}
|
||||
if (p === '/genres' && req.method === 'GET') return json(res, 200, genres.list());
|
||||
if (p === '/search' && req.method === 'GET') {
|
||||
const u = new URL(req.url, 'http://x'), q = (u.searchParams.get('q') || '').trim().slice(0, 120), page = Math.min(20, Math.max(1, +u.searchParams.get('page') || 1)), want = u.searchParams.get('source') || 'all';
|
||||
const ids = want === 'all' ? listSources().map(s => s.id) : [want], lists = [], errors = {}; let more = false;
|
||||
await Promise.all(ids.map(async id => { let src; try { src = sourceOf(id); const r = await searchOne(src, q, page, want === 'all'); lists.push([id, r.items.map(it => stamp(src, it))]); more = more || r.more; } catch (e) { const nm = src ? src.name : id, msg = e.name === 'TimeoutError' ? 'did not answer in time' : e.message; errors[id] = msg.startsWith(nm) ? msg : nm + ': ' + msg; } }));
|
||||
const u = new URL(req.url, 'http://x'), q = (u.searchParams.get('q') || '').trim().slice(0, 120), page = Math.min(20, Math.max(1, +u.searchParams.get('page') || 1)), want = u.searchParams.get('source') || 'all'; let genre = genres.byId(u.searchParams.get('genre') || '');
|
||||
if (safe) { if (want !== 'all' && !SAFE_SOURCES.includes(want)) throw fail(403, 'That source is not available on this account'); if (!genre || !genres.KID.includes(genre.id)) genre = genres.byId('children'); } // (a child account browses a few safe genres, from a few sources)
|
||||
const ids = want === 'all' ? listSources().map(s => s.id).filter(id => !safe || SAFE_SOURCES.includes(id)) : [want], lists = [], errors = {}; let more = false;
|
||||
await Promise.all(ids.map(async id => { let src; try { src = sourceOf(id); const r = await searchOne(src, q, page, want === 'all', genre); lists.push([id, r.items.map(it => stamp(src, it))]); more = more || r.more; } catch (e) { const nm = src ? src.name : id, msg = e.name === 'TimeoutError' ? 'did not answer in time' : e.message; errors[id] = msg.startsWith(nm) ? msg : nm + ': ' + msg; } }));
|
||||
lists.sort((a, b) => ids.indexOf(a[0]) - ids.indexOf(b[0]));
|
||||
const items = []; for (let i = 0; lists.some(l => l[1][i]); i++) for (const l of lists) if (l[1][i]) items.push(l[1][i]); // one from each catalogue in turn
|
||||
return json(res, 200, { items, errors, more });
|
||||
@@ -194,7 +200,7 @@ async function handle(req, res, p) {
|
||||
const buf = await limited(r, MAX_COVER); res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=604800' }); return res.end(buf);
|
||||
}
|
||||
if (p === '/add' && req.method === 'POST') {
|
||||
const b = await readBody(req), src = sourceOf(String(b.source || '')), ref = String(b.ref || '');
|
||||
const b = await readBody(req), src = sourceOf(String(b.source || '')), ref = String(b.ref || ''); if (safe && !SAFE_SOURCES.includes(src.id)) throw fail(403, 'That source is not available on this account');
|
||||
let target, dl = src;
|
||||
if (src.id === 'gutenberg') target = await gutenbergResolve(src, ref);
|
||||
else if (src.id === 'standardebooks') target = await standardResolve(src, ref);
|
||||
|
||||
Reference in new issue
Block a user