From 176b1b8169ca83e3908f737ec1a8a80b41881a48 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:55:32 -0400 Subject: [PATCH] Open Library in the book store; podcast topic search and transcript search; Recipes (Mealie) with a shopping list synced to Tasks; Contacts linked to Mail and the Calendar - Store: Open Library search, books fetched from the Internet Archive (free editions only) - Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment - Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks - Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar Co-Authored-By: Claude Sonnet 5.5 --- README.md | 7 ++ cal.js | 1 + contacts.js | 116 ++++++++++++++++++++++++++++++ docker-compose.yml | 4 ++ package.json | 2 +- podcast.js | 51 +++++++++++--- public/app.js | 16 +++-- public/calendar.js | 5 ++ public/contacts-view.js | 121 ++++++++++++++++++++++++++++++++ public/index.html | 2 + public/lyrics-view.js | 4 +- public/mail.js | 3 + public/podcast-view.js | 42 +++++++++-- public/recipes-view.js | 90 ++++++++++++++++++++++++ public/style.css | 11 +++ public/sw.js | 2 +- public/tasks.js | 3 +- recipes.js | 148 +++++++++++++++++++++++++++++++++++++++ server.js | 9 ++- store.js | 19 ++++- tasks.js | 2 +- tools/contacts-test.js | 34 +++++++++ tools/podcast-test.js | 10 ++- tools/recipes-test.js | 65 +++++++++++++++++ tools/store-test.js | 12 ++-- tools/transcript-test.js | 11 +++ transcripts.js | 51 ++++++++++++-- users.js | 4 +- 28 files changed, 799 insertions(+), 46 deletions(-) create mode 100644 contacts.js create mode 100644 public/contacts-view.js create mode 100644 public/recipes-view.js create mode 100644 recipes.js create mode 100644 tools/contacts-test.js create mode 100644 tools/recipes-test.js diff --git a/README.md b/README.md index 9c0b118..b535d5b 100644 --- a/README.md +++ b/README.md @@ -143,3 +143,10 @@ CPU only: about a quarter of the running time or more. One job at a time; result In the background the server also keeps transcripts up to date by itself: every ten minutes it looks at the newest `TRANSCRIBE_KEEP` (default 5) episodes of every show in Podgrab, uses the show's published transcript when there is one, and otherwise transcribes the episode (one at a time, at most two new ones per round, a failing one retried a few times). `TRANSCRIBE_AUTO=0` switches this off; playing an episode or pressing the button still works. Settings -> Health shows how many are saved and what it is working on. + +## Podcast search, transcript search, Recipes and Contacts + +- **Podcast discovery**: *Add podcast* searches Apple's free directory and, when `PODCASTINDEX_KEY` and `PODCASTINDEX_SECRET` are set (a free key from podcastindex.org), Podcast Index too, with topic buttons (News, History, Science...) that list trending shows. A feed in both appears once. +- **Search what's said** (Podcast screen): finds a word or phrase in every transcript on the server and plays that episode from that moment. Only podcasts on your own list (and audiobooks) are searched; older transcripts are matched to their episode the first time. +- **Recipes** (needs `MEALIE_TOKEN`; `MEALIE_URL` defaults to `https://mealie.necloud.us`): browse and search your Mealie recipes, read one (ingredients, steps, times), import a recipe from a web link, and add its ingredients to the Mealie shopping list (Mealie adds up repeats). The shopping list is also kept in **Tasks** as a "Shopping" list: ticking, adding or deleting an item in either place is carried over when either screen opens or an item changes. Turn the tab on per person in the admin screen. +- **Contacts**: an address book per person (name, company, emails, phones, address, birthday, notes). Mail suggests contacts while you type a recipient, a message you are reading can be saved as a contact (or opens the one you have), birthdays appear in the Calendar, and .vcf files from iCloud, Google or a phone can be imported and exported. diff --git a/cal.js b/cal.js index baf5251..1fc63a2 100644 --- a/cal.js +++ b/cal.js @@ -329,6 +329,7 @@ async function collectEvents(win) { for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true }); for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName }); } catch (e) { errors.push('NE Hub: ' + e.message); } + try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts return { events: all, errors }; } diff --git a/contacts.js b/contacts.js new file mode 100644 index 0000000..8f9d255 --- /dev/null +++ b/contacts.js @@ -0,0 +1,116 @@ +// Contacts: each person's address book, kept in NE Hub. Mail uses it to suggest recipients, the calendar shows birthdays from it, and vCards (.vcf, the +// format iCloud, Google and phones export) can be imported and exported. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const { readBody } = require('./body'); + +const MAX_CONTACTS = 5000, MAX_IMPORT = 8e6; +const file = uid => path.join(uid ? users.dirOf(uid) : users.dir(), 'contacts.json'); +const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } }; +const save = (l, uid) => atomicWrite(file(uid), JSON.stringify(l), { mode: 0o600 }); +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const fail = (code, message) => Object.assign(new Error(message), { code }); + +const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n); +const EMAIL = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/; +/** A birthday as "YYYY-MM-DD" (year known) or "--MM-DD" (year not known), or '' when it is not a real date. */ +function cleanBirthday(v) { + const s = String(v || '').trim(); let m; + if ((m = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(s))) { const [, y, mo, d] = m; return real(+y, +mo, +d) ? `${y}-${mo}-${d}` : ''; } + if ((m = /^(?:--)?(\d{2})-?(\d{2})$/.exec(s))) { const [, mo, d] = m; return real(2000, +mo, +d) ? `--${mo}-${d}` : ''; } + return ''; +} +const real = (y, mo, d) => { const t = new Date(Date.UTC(y, mo - 1, d)); return t.getUTCFullYear() === y && t.getUTCMonth() === mo - 1 && t.getUTCDate() === d; }; +const list = (v, n, ok) => (Array.isArray(v) ? v : []).map(x => ({ label: str(x && x.label, 20) || 'other', value: str(x && x.value, 120) })).filter(x => x.value && (!ok || ok(x.value))).slice(0, n); +/** Whatever the browser or an import sent, reduced to the fields a contact has. */ +function clean(b, old = {}) { + const c = { + id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120), + emails: list(b.emails, 10, v => EMAIL.test(v)), phones: list(b.phones, 10), address: str(b.address, 300), + birthday: cleanBirthday(b.birthday), notes: str(b.notes, 2000), updated: Date.now(), + }; + c.emails.forEach(e => { e.value = e.value.toLowerCase(); }); + if (!c.name) c.name = c.emails[0] ? c.emails[0].value.split('@')[0] : c.org; + if (!c.name) throw fail(400, 'Give the contact a name or an email address'); + return c; +} + +// ---- vCards +const unesc = s => String(s).replace(/\\([nN,;\\])/g, (m, c) => c === 'n' || c === 'N' ? '\n' : c); +const esc = s => String(s || '').replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;'); +/** The contacts in a vCard file (versions 2.1, 3.0 and 4.0; photos and other extras are left out). */ +function parseVcf(text) { + const lines = []; for (const raw of String(text).replace(/\r\n?/g, '\n').split('\n')) { if (/^[ \t]/.test(raw) && lines.length) lines[lines.length - 1] += raw.slice(1); else lines.push(raw); } + const out = []; let cur = null; + for (const line of lines) { + if (/^BEGIN:VCARD/i.test(line)) { cur = { emails: [], phones: [] }; continue; } + if (/^END:VCARD/i.test(line)) { if (cur) out.push(cur); cur = null; continue; } + if (!cur) continue; + const i = line.indexOf(':'); if (i < 1) continue; + const head = line.slice(0, i).split(';'), name = head[0].replace(/^.*\./, '').toUpperCase(), params = head.slice(1).join(';'), val = line.slice(i + 1); + const label = ((/TYPE=([^;:]+)/i.exec(params) || /(?:^|;)(home|work|cell|mobile|fax|pref)(?:;|$)/i.exec(params) || [])[1] || 'other').split(',').filter(x => !/^(pref|internet|voice)$/i.test(x))[0] || 'other'; + if (name === 'FN') cur.name = unesc(val); + else if (name === 'N' && !cur.n) { const p = val.split(';').map(unesc); cur.n = [p[1], p[0]].filter(Boolean).join(' '); } + else if (name === 'ORG') cur.org = unesc(val.split(';')[0]); + else if (name === 'EMAIL') cur.emails.push({ label: label.toLowerCase(), value: unesc(val) }); + else if (name === 'TEL') cur.phones.push({ label: label.toLowerCase(), value: unesc(val).replace(/^tel:/i, '') }); + else if (name === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', '); + else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, ''); + else if (name === 'NOTE') cur.notes = unesc(val); + } + return out.map(c => ({ ...c, name: c.name || c.n || '' })); +} +function toVcf(l) { + return l.map(c => ['BEGIN:VCARD', 'VERSION:3.0', 'FN:' + esc(c.name), 'N:;' + esc(c.name) + ';;;', c.org && 'ORG:' + esc(c.org), + ...c.emails.map(e => `EMAIL;TYPE=${/^(home|work)$/.test(e.label) ? e.label.toUpperCase() : 'INTERNET'}:${esc(e.value)}`), + ...c.phones.map(p => `TEL;TYPE=${/^(home|work|cell|fax)$/.test(p.label) ? p.label.toUpperCase() : 'VOICE'}:${esc(p.value)}`), + c.address && 'ADR:;;' + esc(c.address) + ';;;;', c.birthday && 'BDAY:' + (c.birthday.startsWith('--') ? c.birthday.replace(/-/g, '').replace(/^/, '--') : c.birthday.replace(/-/g, '')), + c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : ''); +} + +/** Birthdays as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Repeats every year. */ +function birthdayEvents(win, uid) { + const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4); + for (const c of load(uid)) { + if (!c.birthday) continue; + const [mo, d] = c.birthday.startsWith('--') ? [c.birthday.slice(2, 4), c.birthday.slice(5, 7)] : [c.birthday.slice(5, 7), c.birthday.slice(8, 10)], born = c.birthday.startsWith('--') ? 0 : +c.birthday.slice(0, 4); + for (let y = y0; y <= y1; y++) { + const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue; + const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10); + out.push({ title: `${c.name}'s birthday${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: 'birthday:' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color: '#d9578a', editable: false, contactId: c.id }); + } + } + return out; +} + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/contacts'.length) || '/'; + try { + if (p === '/' && req.method === 'GET') return json(res, 200, load().sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))); + if (p === '/' && req.method === 'POST') { // add or change one + const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = load(), old = b.id ? l.find(x => x.id === b.id) : null; + if (b.id && !old) throw fail(404, 'Contact not found'); + if (!old && l.length >= MAX_CONTACTS) throw fail(400, 'That is plenty of contacts'); + const c = clean(b, old || {}); if (old) l[l.indexOf(old)] = c; else l.push(c); + save(l); return json(res, 200, c); + } + if (p === '/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); save(load().filter(x => x.id !== id)); return json(res, 200, { ok: true }); } + if (p === '/import' && req.method === 'POST') { // a .vcf file's text: new people are added, and ones with the same email address are filled in rather than doubled + const b = JSON.parse(await readBody(req, MAX_IMPORT) || '{}'), l = load(); let added = 0, merged = 0, skipped = 0; + for (const raw of parseVcf(b.vcf || '')) { + let c; try { c = clean(raw); } catch { skipped++; continue; } + const have = l.find(x => x.emails.some(e => c.emails.some(f => f.value === e.value))) || (!c.emails.length && l.find(x => x.name.toLowerCase() === c.name.toLowerCase())); + if (have) { for (const k of ['org', 'address', 'birthday', 'notes']) if (!have[k] && c[k]) have[k] = c[k]; for (const k of ['emails', 'phones']) for (const x of c[k]) if (!have[k].some(y => y.value.toLowerCase() === x.value.toLowerCase())) have[k].push(x); merged++; } + else if (l.length < MAX_CONTACTS) { l.push(c); added++; } else skipped++; + } + save(l); return json(res, 200, { added, merged, skipped }); + } + if (p === '/export' && req.method === 'GET') { + const body = toVcf(load()); res.writeHead(200, { 'content-type': 'text/vcard; charset=utf-8', 'content-disposition': 'attachment; filename="contacts.vcf"', 'cache-control': 'no-store' }); return res.end(body); + } + return json(res, 404, { error: 'Not found' }); + } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status || 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); } +} + +module.exports = { handle, load, parseVcf, toVcf, birthdayEvents, clean, cleanBirthday }; diff --git a/docker-compose.yml b/docker-compose.yml index cf0570e..517344c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,10 @@ services: OPENAUDIBLE_URL: ${OPENAUDIBLE_URL:-} # audiobooks straight from OpenAudible (e.g. http://172.16.16.106:8000); the username and password in Settings -> Audiobooks sign-in are OpenAudible's. Leave empty to go through the audiobook player instead PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/} PODGRAB_PASSWORD: ${PODGRAB_PASSWORD:-} # Podgrab's PASSWORD setting, if it has one (the sign-in name is always podgrab); stays on the server + PODCASTINDEX_KEY: ${PODCASTINDEX_KEY:-} # optional: a free key from https://api.podcastindex.org (with the secret below) adds Podcast Index to podcast search and browsing by topic + PODCASTINDEX_SECRET: ${PODCASTINDEX_SECRET:-} + MEALIE_URL: ${MEALIE_URL:-https://mealie.necloud.us} # Recipes tab: your Mealie server, and an API token (Mealie: your profile -> API tokens); the token stays on the server + MEALIE_TOKEN: ${MEALIE_TOKEN:-} ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret PUBLIC_URL: ${PUBLIC_URL:-} # e.g. https://hub.necloud.us (OnlyOffice posts saved edits here) HUB_SETUP_CODE: ${HUB_SETUP_CODE:-} # first passkey; if empty, a code is printed in the log diff --git a/package.json b/package.json index d153caa..38eaaa7 100644 --- a/package.json +++ b/package.json @@ -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 bible lrclib visualizer body; 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 lrclib visualizer body transcript recipes contacts; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/podcast.js b/podcast.js index 444ae4d..c6fd514 100644 --- a/podcast.js +++ b/podcast.js @@ -20,6 +20,25 @@ const ALLOWED = [ const subsFile = uid => path.join(users.dirOf(uid), 'podcast-subs.json'); const subsRead = uid => { try { const j = JSON.parse(fs.readFileSync(subsFile(uid), 'utf8')); return Array.isArray(j) ? j : null; } catch { return null; } }; const subsWrite = (uid, ids) => { const f = subsFile(uid), tmp = f + '.tmp'; fs.writeFileSync(tmp, JSON.stringify([...new Set(ids)]), { mode: 0o600 }); fs.renameSync(tmp, f); }; + +// ---- Podcast Index (https://podcastindex.org): a free, open directory searched by topic as well as name. Needs a free key and secret (PODCASTINDEX_KEY, PODCASTINDEX_SECRET). +const PI_URL = (process.env.PODCASTINDEX_URL || 'https://api.podcastindex.org/api/1.0').replace(/\/$/, ''); +const PI_TOPICS = ['News', 'Technology', 'Business', 'Science', 'History', 'Health', 'Religion', 'Education', 'Comedy', 'Sports', 'Music', 'Society']; +const piOn = () => !!(process.env.PODCASTINDEX_KEY && process.env.PODCASTINDEX_SECRET); +async function piGet(path) { + const date = String(Math.floor(Date.now() / 1000)), sig = require('crypto').createHash('sha1').update(process.env.PODCASTINDEX_KEY + process.env.PODCASTINDEX_SECRET + date).digest('hex'); + const r = await fetch(PI_URL + path, { headers: { 'user-agent': 'NE-Hub/1.0', 'x-auth-key': process.env.PODCASTINDEX_KEY, 'x-auth-date': date, authorization: sig }, signal: AbortSignal.timeout(10000) }); + if (!r.ok) throw new Error('status ' + r.status); return r.json(); +} +const piShape = f => ({ title: f.title, author: f.author || f.ownerName || '', feedUrl: f.url, image: f.image || f.artwork || '' }); +const piKeep = f => f && /^https?:\/\//i.test(f.url || '') && !f.dead; +const piSearch = async q => ((await piGet('/search/byterm?max=20&q=' + encodeURIComponent(q))).feeds || []).filter(piKeep).map(piShape); +const piTrending = async cat => ((await piGet('/podcasts/trending?max=20&lang=en&cat=' + encodeURIComponent(cat))).feeds || []).filter(piKeep).map(piShape); +async function appleSearch(q) { + const r = await fetch((process.env.APPLE_PODCASTS_URL || 'https://itunes.apple.com/search') + '?' + new URLSearchParams({ media: 'podcast', limit: '15', term: q }), { signal: AbortSignal.timeout(10000) }); + if (!r.ok) throw new Error('status ' + r.status); + return ((await r.json()).results || []).filter(x => /^https?:\/\//i.test(x.feedUrl || '')).map(x => ({ title: x.collectionName, author: x.artistName, feedUrl: x.feedUrl, image: x.artworkUrl100 })); +} const idOf = o => o && (o.ID !== undefined ? o.ID : o.id); async function podgrab(p, init = {}) { const r = await fetch(BASE + p, { ...init, headers: { accept: 'application/json', ...(AUTH ? { authorization: AUTH } : {}), ...init.headers }, redirect: 'manual', signal: AbortSignal.timeout(20000) }); @@ -36,16 +55,17 @@ const enabled = () => !!BASE; async function handle(req, res) { const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/podcast'.length) || '/'; - if (p === '/discover') { // find new shows in Apple's free podcast directory (the search words go to Apple; nothing else does) - const q = (u.searchParams.get('q') || '').trim().slice(0, 100); - if (q.length < 2) return json(res, 200, []); - try { - const r = await fetch('https://itunes.apple.com/search?' + new URLSearchParams({ media: 'podcast', limit: '15', term: q }), { signal: AbortSignal.timeout(10000) }); - if (!r.ok) throw new Error('status ' + r.status); - const out = ((await r.json()).results || []).filter(x => /^https?:\/\//i.test(x.feedUrl || '')).map(x => ({ title: x.collectionName, author: x.artistName, feedUrl: x.feedUrl, image: x.artworkUrl100 })); - return json(res, 200, out); - } catch { return json(res, 502, { error: 'The podcast directory did not answer' }); } + if (p === '/discover') { // find new shows: Podcast Index (free, with a key) and Apple's free directory (the search words go to them; nothing else does) + const q = (u.searchParams.get('q') || '').trim().slice(0, 100), topic = (u.searchParams.get('topic') || '').trim().slice(0, 40); + if (topic && !piOn()) return json(res, 200, []); + if (!topic && q.length < 2) return json(res, 200, []); + const [pi, ap] = await Promise.allSettled([piOn() ? (topic ? piTrending(topic) : piSearch(q)) : Promise.resolve([]), topic ? Promise.resolve([]) : appleSearch(q)]); + if (pi.status === 'rejected' && ap.status === 'rejected') return json(res, 502, { error: 'The podcast directory did not answer' }); + const seen = new Set(), out = []; + for (const x of [...(pi.value || []), ...(ap.value || [])]) { const k = x.feedUrl.replace(/\/+$/, '').toLowerCase(); if (!seen.has(k)) { seen.add(k); out.push(x); } } + return json(res, 200, out.slice(0, 30)); } + if (p === '/discover/topics') return json(res, 200, piOn() ? PI_TOPICS : []); if (p === '/progress') { if (req.method === 'GET') return json(res, 200, prog.load()); if (req.method !== 'POST') return json(res, 405, { error: 'Method not allowed' }); @@ -142,4 +162,15 @@ async function episodeAudio(itemId) { if (r.status >= 300 && r.status < 400 && loc && /^https?:\/\//i.test(loc)) return fetch(loc, { signal: AbortSignal.timeout(2 * 3600e3) }); // (Podgrab has no copy of its own: the original) return r; } -module.exports = { handle, enabled, feedInfo, recentEpisodes, episodeAudio }; +/** Every episode of the shows on this person's list: [{ id, title, show }], remembered for a few minutes (the transcript search uses it to know what they may see). */ +const epsCache = new Map(); +async function myEpisodes(me) { + const c = epsCache.get(me.id); if (c && Date.now() - c.t < 300000) return c.v; + const f = (o, k) => { const hit = Object.keys(o || {}).find(x => x.toLowerCase() === k.toLowerCase()); return hit === undefined ? undefined : o[hit]; }, v = []; + if (BASE) for (const sid of await mySubs(me)) { + const sh = await (await podgrab('/podcasts/' + sid)).json().catch(() => null), items = await (await podgrab('/podcasts/' + sid + '/items')).json().catch(() => []); + for (const it of Array.isArray(items) ? items : []) v.push({ id: String(idOf(it)), title: f(it, 'Title') || '', show: (sh && f(sh, 'Title')) || '', thumb: '/api/podcast/podcasts/' + sid + '/image' }); + } + epsCache.set(me.id, { t: Date.now(), v }); return v; +} +module.exports = { handle, myEpisodes, enabled, feedInfo, recentEpisodes, episodeAudio }; diff --git a/public/app.js b/public/app.js index 2d63a00..5a7513a 100644 --- a/public/app.js +++ b/public/app.js @@ -93,6 +93,8 @@ const ICONS = { shuffle: '', more: '', cal: '', + chef: '', + people: '', search: '', book: '', mic: '', @@ -160,8 +162,8 @@ 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'], ['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' }; +const TABS = [['home', 'Home'], ['files', 'Files'], ['music', 'Music'], ['video', 'Movies & TV'], ['mail', 'Mail'], ['contacts', 'Contacts'], ['signal', 'Signal'], ['tasks', 'Tasks'], ['recipes', 'Recipes'], ['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', recipes: 'chef', contacts: 'people', 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]; @@ -171,14 +173,14 @@ const navTabs = () => { const out = [], done = new Set(); for (const t of TABS) 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', ['listen', 'reading', 'video']], ['', ['settings']]]; +const NAV_GROUPS = [['', ['home', 'search']], ['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', '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 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' }; +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', recipes: '#e0742b', contacts: '#5b8def', 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); }; @@ -248,7 +250,7 @@ function drawSubnav(name) { 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, 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 }); +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, recipes: args => viewRecipes(args), contacts: args => viewContacts(args), 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 {}; } }; @@ -316,6 +318,8 @@ function homeApps() { office: () => tile('#/office', '#e0742b', 'doc', 'Office', 'OnlyOffice'), audio: () => tile('#/audio', '#8a5cf0', 'audio', 'Audiobooks', 'Listen'), podcast: () => tile('#/podcast', '#c2417a', 'mic', 'Podcast', 'Podcasts'), + recipes: () => tile('#/recipes', '#e0742b', 'chef', 'Recipes', 'Mealie & shopping list'), + contacts: () => tile('#/contacts', '#5b8def', 'people', 'Contacts', 'People & birthdays'), radio: () => tile('#/radio', '#7b4fd6', 'radio', 'Radio', 'The Journey FM & more'), read: () => tile('#/read', '#2b8a5e', 'book', 'Books', 'EPUB, PDF, DOCX'), signal: () => tile('#/signal', '#3a76f0', 'chat', 'Signal', sg.unread ? sg.unread + ' unread' : 'Messages'), @@ -323,7 +327,7 @@ function homeApps() { mail: () => tile('#/mail', '#4f7cff', 'mail', 'Mail', mailUnread ? mailUnread + ' unread' : 'Outlook.com'), }; 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']]]; + const HOME_GROUPS = [['Work', ['planner', 'files', 'office', 'links', 'recipes']], ['Communicate', ['mail', 'contacts', '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]) => `

${esc(label)}

${ids.map(id => T[id]()).join('')}
`).join(''); } diff --git a/public/calendar.js b/public/calendar.js index 644102e..31f997a 100644 --- a/public/calendar.js +++ b/public/calendar.js @@ -124,6 +124,11 @@ function openEvent(e) { if (sh) mo.observe(document.body, { childList: true }); return; } + if (e.contactId) { // a birthday from Contacts + const el = openSheet(e.title, 'Birthdays', `
${allowed('contacts') ? 'Open contact' : ''}
`); + el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; }; + return; + } if (e.editable) return editEvent(e); const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end); const el = openSheet(e.title, e.cal, `
When${esc(when)}
diff --git a/public/contacts-view.js b/public/contacts-view.js new file mode 100644 index 0000000..828320b --- /dev/null +++ b/public/contacts-view.js @@ -0,0 +1,121 @@ +'use strict'; +// ---- Contacts: your address book. Mail suggests these as you type a recipient, a message can be saved as a contact, birthdays show up in the calendar, +// and a .vcf file (iCloud, Google or a phone export) can be imported or exported. +const ctApi = (p, opts) => jfetch('/api/contacts' + p, opts); +const ctOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); +let ctList = null, ctAt = 0, ctQuery = ''; +const ctLoad = async force => { if (!force && ctList && Date.now() - ctAt < 60000) return ctList; ctList = await ctApi('/'); ctAt = Date.now(); return ctList; }; +const ctInit = c => (c.name.match(/\p{L}/gu) || ['?']).slice(0, 2).join('').toUpperCase(); +const ctHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; }; +const ctAv = c => `${esc(ctInit(c))}`; +const ctBday = b => { if (!b) return ''; const noYear = b.startsWith('--'), [y, m, d] = noYear ? [0, b.slice(2, 4), b.slice(5, 7)] : [b.slice(0, 4), b.slice(5, 7), b.slice(8, 10)]; return new Date(2000, +m - 1, +d).toLocaleDateString([], { month: 'long', day: 'numeric' }) + (noYear ? '' : ', ' + y); }; +const ctFind = (l, address) => { const a = String(address || '').toLowerCase(); return a ? l.find(c => c.emails.some(e => e.value === a)) : null; }; + +async function viewContacts(args = []) { + const v = $('#view'); + v.innerHTML = '

Contacts

Loading…

'; + try { + const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return; + v.innerHTML = `

Contacts

+
`; + const draw = () => { + const q = ctQuery.toLowerCase(), rows = l.filter(c => !q || [c.name, c.org, c.notes, ...c.emails.map(e => e.value), ...c.phones.map(p => p.value)].some(x => String(x).toLowerCase().includes(q))); + let last = ''; + $('#ctl').innerHTML = rows.length ? rows.map(c => { const g = (ctInit(c)[0] || '#'), head = g !== last ? `
${esc(last = g)}
` : ''; + return head + `${ctAv(c)}${esc(c.name)}${esc(c.org || (c.emails[0] || {}).value || (c.phones[0] || {}).value || '')}`; }).join('') + : `

${l.length ? 'No one matches.' : 'No contacts yet. Add one, or import a .vcf file from iCloud, Google or your phone.'}

`; + }; + draw(); $('#ctq').oninput = e => { ctQuery = e.target.value.trim(); draw(); }; + $('#ctnew').onclick = () => ctEdit(null); + $('#ctmore').onclick = ctMore; + if (args[0] === 'c' && args[1]) { const c = l.find(x => x.id === args[1]); if (c) ctShow(c); } + } catch (e) { + if (!location.hash.startsWith('#/contacts')) return; + v.innerHTML = `

Contacts

${esc(e.message)}

`; $('#ctretry').onclick = () => route(); + } +} + +function ctMore() { + const el = openSheet('Contacts', '', `
+ Export all as .vcfA backup you can open in any contacts app
`); + el.querySelector('#ctfile').onchange = async e => { + const f = e.target.files[0]; if (!f) return; + try { if (f.size > 7e6) throw new Error('That file is too large'); const r = await ctApi('/import', ctOpt('POST', { vcf: await f.text() })); closeSheet(); toast(`Added ${r.added}, updated ${r.merged}${r.skipped ? ', skipped ' + r.skipped : ''}`); route(); } catch (err) { toast(err.message); } + }; +} + +function ctShow(c) { + const mailOn = allowed('mail') && cfg.mail; + const line = (label, html) => html ? `
${esc(label)}${html}
` : ''; + const el = openSheet(c.name, c.org || '', `
+ ${c.emails.map(e => line(e.label, mailOn ? `${esc(e.value)}` : esc(e.value))).join('')} + ${c.phones.map(p => line(p.label, `${esc(p.value)}`)).join('')} + ${line('address', c.address ? `${esc(c.address)}` : '')} + ${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '
'))}
+
${mailOn && c.emails[0] ? `` : ''}
`); + el.querySelectorAll('[data-mail]').forEach(a => a.onclick = e => { e.preventDefault(); closeSheet(); ctWrite(a.dataset.mail); }); + if (el.querySelector('#ctw')) el.querySelector('#ctw').onclick = () => { closeSheet(); ctWrite(c.emails[0].value); }; + if (el.querySelector('#ctm')) el.querySelector('#ctm').onclick = () => { closeSheet(); mail.q = c.emails[0].value; location.hash = '#/mail'; if (location.hash === '#/mail' && typeof mailLoadList === 'function') mailLoadList(true); }; + el.querySelector('#cte').onclick = () => { closeSheet(); ctEdit(c); }; + el.querySelector('#ctd').onclick = () => { closeSheet(); confirmSheet('Delete this contact?', c.name, 'Delete', async () => { try { await ctApi('/delete', ctOpt('POST', { id: c.id })); toast('Deleted'); ctList = null; route(); } catch (e) { toast(e.message); } }); }; +} +/** A new message to this address. */ +function ctWrite(address) { if (typeof mailCompose === 'function') mailCompose({ to: address }); } + +function ctEdit(c, seed) { + const x = c || seed || { name: '', org: '', emails: [], phones: [], address: '', birthday: '', notes: '' }; + const rows = (kind, items, ph) => `
${(items.length ? items : [{ label: '', value: '' }]).map(i => `
`).join('')}
Add another`; + const el = openSheet(c ? 'Edit contact' : 'New contact', '', `
+ ${rows('emails', x.emails, 'name@example.com')}${rows('phones', x.phones, '555 123 4567')} + +
+
`); + el.querySelector('#ctx').onclick = closeSheet; + el.querySelectorAll('[data-more]').forEach(a => a.onclick = e => { e.preventDefault(); const box = el.querySelector(`.ctrows[data-k="${a.dataset.more}"]`), first = box.firstElementChild; const n = first.cloneNode(true); n.querySelectorAll('input').forEach(i => { i.value = ''; }); box.appendChild(n); n.querySelector('[data-v]').focus(); }); + const grab = k => [...el.querySelectorAll(`.ctrows[data-k="${k}"] .bar`)].map(r => ({ label: r.querySelector('[data-l]').value.trim().toLowerCase(), value: r.querySelector('[data-v]').value.trim() })).filter(i => i.value); + el.querySelector('#ctf').onsubmit = async e => { + e.preventDefault(); const err = el.querySelector('#cter'); err.textContent = ''; + const keepYearless = x.birthday && x.birthday.startsWith('--') && !el.querySelector('#ctb').value; + const body = { id: c && c.id, name: el.querySelector('#ctn').value, org: el.querySelector('#cto').value, emails: grab('emails'), phones: grab('phones'), address: el.querySelector('#cta').value, birthday: keepYearless ? x.birthday : el.querySelector('#ctb').value, notes: el.querySelector('#ctt').value }; + try { const saved = await ctApi('/', ctOpt('POST', body)); ctList = null; closeSheet(); toast('Saved'); if (location.hash.startsWith('#/contacts')) route(); return saved; } catch (er) { err.textContent = er.message; } + }; + el.querySelector('#ctn').focus(); +} + +// ---- Mail: suggestions while typing a recipient, and "Save contact" on a message you are reading +/** Suggest matching contacts under a recipient box (a comma-separated list: the part being typed is what is matched). */ +function ctSuggest(input) { + if (!input || typeof allowed !== 'function' || !allowed('contacts')) return; + let box, sel = -1, rows = []; + const part = () => { const v = input.value, i = v.lastIndexOf(','); return { head: v.slice(0, i + 1), word: v.slice(i + 1).trim() }; }; + const close = () => { if (box) { box.remove(); box = null; } sel = -1; }; + const pick = r => { const { head } = part(); input.value = (head ? head.replace(/\s*$/, ' ') : '') + r.email + ', '; close(); input.focus(); }; + const show = async () => { + const { word } = part(), w = word.toLowerCase(); if (w.length < 1) return close(); + let l; try { l = await ctLoad(); } catch { return; } + rows = []; for (const c of l) for (const e of c.emails) if (c.name.toLowerCase().includes(w) || e.value.includes(w) || (c.org || '').toLowerCase().includes(w)) rows.push({ name: c.name, email: e.value }); + rows = rows.slice(0, 6); if (!rows.length) return close(); + if (!box) { box = document.createElement('div'); box.className = 'ctsug'; input.parentNode.style.position = input.parentNode.style.position || 'relative'; input.insertAdjacentElement('afterend', box); } + box.innerHTML = rows.map((r, i) => ``).join(''); + box.querySelectorAll('button').forEach(b => b.onmousedown = e => { e.preventDefault(); pick(rows[b.dataset.i]); }); + }; + input.addEventListener('input', show); input.addEventListener('blur', () => setTimeout(close, 120)); + input.addEventListener('keydown', e => { + if (!box) return; + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + rows.length) % rows.length; box.querySelectorAll('button').forEach((b, i) => b.classList.toggle('on', i === sel)); } + else if (e.key === 'Enter' && sel >= 0) { e.preventDefault(); pick(rows[sel]); } + else if (e.key === 'Escape') { e.stopPropagation(); close(); } + }); +} +/** On a message being read: save the sender, or open them if they are already a contact. */ +async function ctMailHook(rd, m) { + if (typeof allowed !== 'function' || !allowed('contacts') || !m.from || !m.from.address) return; + const p = rd.querySelector('.mfrom'); if (!p) return; + try { + const l = await ctLoad(), have = ctFind(l, m.from.address); + if (!rd.contains(p)) return; + const a = document.createElement('a'); a.href = '#'; a.style.cssText = 'color:var(--accent);font-size:13px;margin-left:8px'; a.textContent = have ? 'In contacts' : 'Save contact'; + a.onclick = e => { e.preventDefault(); if (have) location.hash = '#/contacts/c/' + have.id; else ctEdit(null, { name: m.from.name || '', org: '', emails: [{ label: '', value: m.from.address }], phones: [], address: '', birthday: '', notes: '' }); }; + p.appendChild(a); + } catch {} +} diff --git a/public/index.html b/public/index.html index 9b287e3..742ea89 100644 --- a/public/index.html +++ b/public/index.html @@ -76,6 +76,8 @@ + + diff --git a/public/lyrics-view.js b/public/lyrics-view.js index 7defea2..9d7e18f 100644 --- a/public/lyrics-view.js +++ b/public/lyrics-view.js @@ -48,7 +48,7 @@ async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc } /** A transcript: the show's own if it publishes one, else (on request) one made on this server. Polls while that is being made. */ async function lyrSpoken(t, start) { - const q = new URLSearchParams({ key: t.key, src: t.src || '' }); + const q = new URLSearchParams({ key: t.key, src: t.src || '', title: t.title || '', artist: t.artist || '', thumb: t.thumb || '' }); for (let n = 0; ; n++) { // (n restarts at -1 after an automatic start, so the POST goes out once) if (!lyr.el || lyr.key !== t.key) return null; let r; try { r = await jfetch('/api/transcript?' + q, start && n <= 0 ? { method: 'POST' } : undefined); } catch (e) { return { found: false, error: e.message }; } @@ -168,7 +168,7 @@ pl.a.addEventListener('playing', async () => { if (!t || !String(t.key).startsWith('pod:') || !t.src || lyrAsked.has(t.key) || !cfg.transcribe || lyrPref().noAuto) return; lyrAsked.add(t.key); try { - const q = new URLSearchParams({ key: t.key, src: t.src }), r = await jfetch('/api/transcript?' + q); + const q = new URLSearchParams({ key: t.key, src: t.src, title: t.title || '', artist: t.artist || '', thumb: t.thumb || '' }), r = await jfetch('/api/transcript?' + q); if (r.state === 'none' && r.canTranscribe) await jfetch('/api/transcript?' + q, { method: 'POST' }); } catch { lyrAsked.delete(t.key); } }); diff --git a/public/mail.js b/public/mail.js index 3c3a9fb..6b0e0ac 100644 --- a/public/mail.js +++ b/public/mail.js @@ -473,6 +473,7 @@ function mailDrawReader() { `; if ($('#mshow')) $('#mshow').onclick = () => { mail.images = true; mailDrawReader(); }; mailFit(rd.querySelector('.mbody')); + if (typeof ctMailHook === 'function') ctMailHook(rd, m); rd.querySelectorAll('[data-a]').forEach(b => b.onclick = () => mailAction(b.dataset.a)); } @@ -513,6 +514,8 @@ function mailCompose(o = {}) { ${reply ? '' : `
`}
`); el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet; + if (o.to && el.querySelector('#mto')) el.querySelector('#mto').value = o.to; + if (typeof ctSuggest === 'function') for (const id of ['#mto', '#mcc2', '#mbcc']) ctSuggest(el.querySelector(id)); const cc = el.querySelector('#mcc'); if (cc) cc.onclick = e => { e.preventDefault(); el.querySelector('#mcb').hidden = false; el.querySelector('#mccw').hidden = true; }; const fi = el.querySelector('#mfile'); const total = () => files.reduce((n, f) => n + f.size, 0); diff --git a/public/podcast-view.js b/public/podcast-view.js index 8dcce60..b2f1232 100644 --- a/public/podcast-view.js +++ b/public/podcast-view.js @@ -48,36 +48,66 @@ async function viewPodcast(args = []) { async function pdHome() { const list = await pdApi('/podcasts?sort=lastepisode&order=desc'), shows = Array.isArray(list) ? list : (list.podcasts || []); if (!location.hash.startsWith('#/podcast')) return; - $('#view').innerHTML = `

Podcast

Verse By Verse
+ $('#view').innerHTML = `

Podcast

Verse By Verse
${shows.length ? `` : '

No podcasts on your list yet. Search for one, or paste its feed address.

'}
`; + $('#pdtr').onclick = pdTranscriptSearch; $('#pdadd').onclick = () => { const el = openSheet('Add podcast', 'Search for a show, or paste its feed (RSS) address', ` -
-

Searching uses Apple's public podcast directory, so the words you type are sent to Apple.

`); +
+

Searching uses public podcast directories (Apple's, and Podcast Index when the server has a key), so the words you type are sent to them.

`); const q = el.querySelector('#pdq'), res = el.querySelector('#pdres'), err = el.querySelector('#pde'); let timer, seq = 0, found = []; const add = async (url, btn) => { err.textContent = ''; if (btn) btn.disabled = true; try { await pdApi('/podcasts', pdOpt('POST', { url })); closeSheet(); toast('Added; episodes will appear in a moment'); route(); } catch (e) { err.textContent = e.status === 502 || e.status === 409 || e.status === 400 ? 'Podgrab could not add that (it may already be there)' : e.message; if (btn) btn.disabled = false; } }; + const show = list => { + found = list; res.innerHTML = found.length ? found.map((f, i) => `
${esc(f.title)}${esc(f.author || '')}
`).join('') : '

Nothing found.

'; + res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b)); + }; + pdApi('/discover/topics').then(ts => { const box = el.querySelector('#pdtopics'); if (!box || !ts.length) return; box.innerHTML = ts.map(t => ``).join(''); + box.querySelectorAll('[data-t]').forEach(b => b.onclick = async () => { const my = ++seq; q.value = ''; err.textContent = ''; res.innerHTML = '

Looking…

'; try { const l = await pdApi('/discover?topic=' + encodeURIComponent(b.dataset.t)); if (my === seq) show(l); } catch (e) { if (my === seq) res.innerHTML = `

${esc(e.message)}

`; } }); }).catch(() => {}); const run = async () => { const term = q.value.trim(), my = ++seq; err.textContent = ''; if (/^https?:\/\/\S+$/i.test(term)) { res.innerHTML = ``; res.querySelector('#pdfeed').onclick = e => add(term, e.currentTarget); return; } if (term.length < 2) { res.innerHTML = ''; return; } res.innerHTML = '

Searching…

'; try { - found = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return; - res.innerHTML = found.length ? found.map((f, i) => `
${esc(f.title)}${esc(f.author || '')}
`).join('') : '

Nothing found.

'; - res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => add(found[b.dataset.i].feedUrl, b)); + const l = await pdApi('/discover?q=' + encodeURIComponent(term)); if (my !== seq) return; show(l); } catch (e) { if (my === seq) res.innerHTML = `

${esc(e.message)}

`; } }; q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus(); }; } +// Search what has been said in every transcript you have: tap a result to play that episode from that moment. +const pdClock = ms => { const s = Math.floor(ms / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60), x = s % 60; return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); }; +function pdTranscriptSearch() { + const el = openSheet("Search what's said", 'Finds a word or phrase in every transcript you have', `
`); + const q = el.querySelector('#trq'), res = el.querySelector('#trres'); let timer, seq = 0, found = []; + const hl = (text, w) => { const re = new RegExp('(' + w.split(/\s+/).filter(x => x.length > 1).map(x => x.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi'); return esc(text).replace(re, '$1'); }; + const play = (r, h) => { + closeSheet(); const key = r.key, track = { key, title: r.title, artist: r.artist, album: '', thumb: r.thumb, src: r.src, nocache: true }; + if (pdPlaying() === key.slice(4) && String(cur().key) === key) { pl.a.currentTime = h.t / 1000; pl.a.play().catch(() => {}); return; } + playQueue([track], 0); pl.a.addEventListener('loadedmetadata', () => { const c = cur(); if (c && c.key === key) pl.a.currentTime = h.t / 1000; }, { once: true }); + }; + const run = async () => { + const term = q.value.trim(), my = ++seq; if (term.length < 2) { res.innerHTML = ''; return; } + res.innerHTML = '

Searching…

'; + try { + const out = (await jfetch('/api/transcript/search?q=' + encodeURIComponent(term))).results; if (my !== seq) return; found = out; + res.innerHTML = out.length ? out.map((r, i) => `
${esc(r.title)}${esc(r.artist)}${r.n > r.hits.length ? ' · ' + r.n + ' places' : ''}
+ ${r.hits.map((h, j) => ``).join('')}
`).join('') + : '

Nothing found. Only episodes that already have a transcript can be searched.

'; + res.querySelectorAll('button[data-i]').forEach(b => b.onclick = () => play(found[b.dataset.i], found[b.dataset.i].hits[b.dataset.j])); + } catch (e) { if (my === seq) res.innerHTML = `

${esc(e.message)}

`; } + }; + q.oninput = () => { clearTimeout(timer); timer = setTimeout(run, 350); }; q.onkeydown = e => { if (e.key === 'Enter') { clearTimeout(timer); run(); } }; q.focus(); +} + async function pdShow(id) { const [, show, items0] = await Promise.all([pdLoadProg(), pdApi('/podcasts/' + encodeURIComponent(id)), pdApi('/podcasts/' + encodeURIComponent(id) + '/items')]); if (!location.hash.startsWith('#/podcast')) return; diff --git a/public/recipes-view.js b/public/recipes-view.js new file mode 100644 index 0000000..3965524 --- /dev/null +++ b/public/recipes-view.js @@ -0,0 +1,90 @@ +'use strict'; +// ---- Recipes: your Mealie recipes and shopping list, through the hub (the server holds the Mealie token). A recipe's ingredients go on one shopping list, which is +// also kept in Tasks (a "Shopping" list there), so it can be ticked off from either place. +const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts); +const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); +const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; }; +const rcTabs = on => ``; +let rcQuery = '', rcPage = 1; + +async function viewRecipes(args = []) { + const v = $('#view'); + v.innerHTML = '

Recipes

Loading…

'; + try { + if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1])); + if (args[0] === 'shop') return await rcShop(); + return await rcHome(); + } catch (e) { + if (!location.hash.startsWith('#/recipes')) return; + v.innerHTML = `

Recipes

${esc(e.message)}

`; + $('#rcretry').onclick = () => route(); + } +} + +async function rcHome() { + const draw = (items, more, page) => { + const g = $('#rcgrid'); if (!g) return; + const html = items.map(r => `${r.image ? `` : ''} + ${esc(r.name)}${esc([rcTime(r.minutes), r.rating ? '★'.repeat(Math.round(r.rating)) : ''].filter(Boolean).join(' · '))}`).join(''); + if (page > 1) g.insertAdjacentHTML('beforeend', html); else g.innerHTML = html || '

No recipes found.

'; + const mb = $('#rcmore'); if (mb) mb.hidden = !more; + }; + const first = await rcApi('/?page=1&q=' + encodeURIComponent(rcQuery)); if (!location.hash.startsWith('#/recipes')) return; + $('#view').innerHTML = `

Recipes

${rcTabs('all')} + +
`; + rcPage = 1; draw(first.items, first.more, 1); + let timer, seq = 0; + const load = async page => { const my = ++seq; try { const j = await rcApi('/?page=' + page + '&q=' + encodeURIComponent(rcQuery)); if (my === seq) { rcPage = page; draw(j.items, j.more, page); } } catch (e) { toast(e.message); } }; + $('#rcq').oninput = e => { clearTimeout(timer); timer = setTimeout(() => { rcQuery = e.target.value.trim(); load(1); }, 350); }; + $('#rcmore').onclick = () => load(rcPage + 1); + $('#rcimp').onclick = () => { + const el = openSheet('Import a recipe', 'Mealie reads the recipe from the web page', `
+
`); + el.querySelector('#rcx').onclick = closeSheet; const go = el.querySelector('#rcgo'), err = el.querySelector('#rce'), inp = el.querySelector('#rcurl'); inp.focus(); + const run = async () => { + err.textContent = ''; go.disabled = true; go.textContent = 'Importing…'; + try { const r = await rcApi('/import', rcOpt('POST', { url: inp.value.trim() })); closeSheet(); toast('Imported'); location.hash = '#/recipes/r/' + encodeURIComponent(r.slug); } + catch (e) { err.textContent = e.message; go.disabled = false; go.textContent = 'Import'; } + }; + go.onclick = run; inp.onkeydown = e => { if (e.key === 'Enter') run(); }; + }; +} + +async function rcOne(slug) { + const r = await rcApi('/r/' + encodeURIComponent(slug)); if (!location.hash.startsWith('#/recipes')) return; + const facts = [['Prep', rcTime(r.prep)], ['Cook', rcTime(r.cook)], ['Total', rcTime(r.total)], ['Serves', r.servings]].filter(f => f[1]); + $('#view').innerHTML = `
+
${r.image ? `` : ''} +

${esc(r.name)}

${r.description ? `

${esc(r.description)}

` : ''} + ${facts.length ? `

${facts.map(f => `${esc(f[0])} ${esc(f[1])}`).join(' · ')}

` : ''} + ${r.orgUrl ? `

Original page

` : ''}
+
Shopping list
+

Ingredients

${r.ingredients.map(i => i.title && !i.text ? `

${esc(i.title)}

` : `
${esc(i.text)}
`).join('') || '

No ingredients.

'}
+

Steps

    ${r.steps.map(s => `
  1. ${s.title ? `${esc(s.title)} ` : ''}${esc(s.text).replace(/\n/g, '
    ')}
  2. `).join('') || '
  3. No steps.
  4. '}
+ ${r.notes.length ? `

Notes

${r.notes.map(n => `

${n.title ? `${esc(n.title)}
` : ''}${esc(n.text).replace(/\n/g, '
')}

`).join('')}` : ''}
`; + $('#rcadd').onclick = async e => { + const b = e.currentTarget; b.disabled = true; + try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; } + catch (err) { toast(err.message); b.disabled = false; } + }; +} + +async function rcShop() { + const draw = async l => { + if (!location.hash.startsWith('#/recipes/shop')) return; + const open = l.items.filter(i => !i.checked), done = l.items.filter(i => i.checked), row = i => ``; + $('#view').innerHTML = `

Shopping list

${rcTabs('shop')} +
+
${open.map(row).join('') || '

Nothing to buy. Open a recipe and add its ingredients.

'}
+ ${done.length ? `

Got it

${done.map(row).join('')}
` : ''} +

Also in Tasks as the “Shopping” list: ticking an item there ticks it here.

`; + document.querySelectorAll('.rcitem input').forEach(c => c.onchange = async () => { + c.disabled = true; try { await rcApi('/shopping/check', rcOpt('POST', { id: c.dataset.id, checked: c.checked })); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); c.checked = !c.checked; c.disabled = false; } + }); + $('#rcnew').onsubmit = async e => { e.preventDefault(); const t = $('#rctext').value.trim(); if (!t) return; try { await rcApi('/shopping/add', rcOpt('POST', { text: t })); draw(await rcApi('/shopping')); } catch (err) { toast(err.message); } }; + if ($('#rcclear')) $('#rcclear').onclick = async () => { try { await rcApi('/shopping/clear', rcOpt('POST', {})); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); } }; + }; + try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date) + await draw(await rcApi('/shopping')); +} diff --git a/public/style.css b/public/style.css index 4713a6e..caf0cd2 100644 --- a/public/style.css +++ b/public/style.css @@ -950,3 +950,14 @@ body.vizopen { overflow: hidden; } #viz .viztop small { opacity: .75; font-size: 12px; } #viz .btn, #viz .ibtn { color: #fff; border-color: rgba(255,255,255,.35); background: rgba(0,0,0,.35); } #viz.quiet > .viztop, #viz.quiet > .vizfoot { opacity: 0; pointer-events: none; } + +/* Recipes and Contacts */ +.rchero { display: flex; gap: 16px; align-items: flex-start; margin-bottom: 12px; } +@media (max-width: 560px) { .rchero { flex-direction: column; } .rchero .vimg { width: 100% !important; } } +.rcsteps { padding-left: 22px; line-height: 1.5; } .rcsteps li { margin: 0 0 10px; } +.rcitem input { width: 22px; height: 22px; flex: none; } +.ctgrp { margin: 14px 0 4px; font-size: 13px; font-weight: 600; color: var(--muted); } +.ctav { width: 40px; height: 40px; border-radius: 50%; flex: none; display: grid; place-items: center; color: #fff; font-weight: 600; } +.ctsug { position: absolute; z-index: 30; left: 0; right: 0; background: var(--panel); border: 1px solid var(--line); border-radius: 10px; box-shadow: 0 6px 20px rgba(0,0,0,.15); max-height: 220px; overflow: auto; } +.ctsug button { display: block; width: 100%; text-align: left; padding: 8px 12px; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; } .ctsug button:hover, .ctsug button.on { background: rgba(127,127,127,.15); } +.pdhit { margin-bottom: 8px; } mark { background: rgba(255, 200, 0, .45); color: inherit; border-radius: 3px; } diff --git a/public/sw.js b/public/sw.js index 2efcb3c..9821cd7 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v50'; +const CACHE = 'hub-v51'; 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']; diff --git a/public/tasks.js b/public/tasks.js index 8103047..7259448 100644 --- a/public/tasks.js +++ b/public/tasks.js @@ -9,7 +9,8 @@ async function viewTasks() { const v = $('#view'); v.innerHTML = '
' + plannerHead('tasks') + '

Loading…

'; try { - const { lists, icloud } = await jfetch('/api/tasks/lists'); + let { lists, icloud } = await jfetch('/api/tasks/lists'); + if (lists.some(l => l.mealie) && allowed('recipes')) { try { await jfetch('/api/recipes/shopping/sync', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } }); } catch {} } // (the Shopping list is shared with Mealie: bring it up to date first) if (!location.hash.startsWith('#/tasks')) return; taskLists = lists; let cur = store.get('task_list'); if (cur !== 'all' && !lists.some(l => l.id === cur)) cur = lists.length > 1 ? 'all' : lists[0].id; // (All: every list, NE Hub and iCloud, together) diff --git a/recipes.js b/recipes.js new file mode 100644 index 0000000..0acc732 --- /dev/null +++ b/recipes.js @@ -0,0 +1,148 @@ +// Recipes and the shopping list, from Mealie (https://mealie.io). The hub talks to Mealie's own API with a token that stays on the server +// (MEALIE_URL, MEALIE_TOKEN), so the browser only ever talks to the hub. Recipes are searched and read in the hub, a link can be imported as a new +// recipe, and a recipe's ingredients can be added to Mealie's shopping list, which Mealie merges (two onions and one onion become three). That list is +// also mirrored into a "Shopping" list in Tasks, so it is checked off from either place. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const { readBody } = require('./body'); + +const BASE = () => (process.env.MEALIE_URL || 'https://mealie.necloud.us').replace(/\/+$/, ''); +const TOKEN = () => process.env.MEALIE_TOKEN || ''; +const enabled = () => !!TOKEN(); +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const fail = (code, message) => Object.assign(new Error(message), { code }); + +async function mealie(p, { method = 'GET', body, accept = 'application/json', timeout = 20000, raw } = {}) { + if (!TOKEN()) throw fail(503, 'Recipes are not set up on this server (MEALIE_TOKEN)'); + let r; + try { r = await fetch(BASE() + p, { method, headers: { authorization: 'Bearer ' + TOKEN(), accept, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined, redirect: 'manual', signal: AbortSignal.timeout(timeout) }); } + catch (e) { throw fail(502, e.name === 'TimeoutError' ? 'Mealie did not answer in time' : 'Could not reach Mealie (' + ((e.cause && (e.cause.code || e.cause.message)) || e.message) + ')'); } + if (raw) return r; + if (r.status === 401 || r.status === 403) throw fail(502, 'Mealie refused the token (make a new one in Mealie: your profile, API tokens)'); + if (!r.ok) { let m = ''; try { const j = await r.json(); m = typeof j.detail === 'string' ? j.detail : j.message || ''; } catch {} const e = fail(r.status === 404 ? 404 : 502, 'Mealie answered ' + r.status + (m ? ': ' + String(m).slice(0, 120) : '')); e.status = r.status; throw e; } + const t = await r.text(); try { return t ? JSON.parse(t) : {}; } catch { return t; } +} +const slugOk = s => /^[\w-]{1,200}$/.test(s || ''); +const idOk = s => /^[0-9a-f-]{8,64}$/i.test(s || ''); + +// ---- recipes +const minutes = v => { // Mealie gives times as text ("1 hour 30 minutes", "PT45M" or "45 minutes") + const s = String(v || ''); if (!s) return 0; let m; + if ((m = /^PT(?:(\d+)H)?(?:(\d+)M)?/i.exec(s))) return (+m[1] || 0) * 60 + (+m[2] || 0); + return (/(\d+)\s*(?:h|hour)/i.exec(s) ? +RegExp.$1 * 60 : 0) + (/(\d+)\s*(?:m|min)/i.exec(s) ? +RegExp.$1 : 0) || (/^\d+$/.test(s) ? +s : 0); +}; +const card = r => ({ id: r.id, slug: r.slug, name: r.name || r.slug, description: String(r.description || '').slice(0, 200), minutes: minutes(r.totalTime), servings: r.recipeServings || r.recipeYield || '', rating: r.rating || 0, image: !!r.image }); +const ingredientText = i => String(i.display || i.originalText || [i.quantity ? +(+i.quantity).toFixed(2) : '', i.unit && (i.unit.name || i.unit), i.food && (i.food.name || i.food), i.note].filter(Boolean).join(' ') || '').trim(); +function full(r) { + return { ...card(r), orgUrl: r.orgURL || '', prep: minutes(r.prepTime), cook: minutes(r.performTime || r.cookTime), total: minutes(r.totalTime), notes: (r.notes || []).map(n => ({ title: n.title || '', text: n.text || '' })), + ingredients: (r.recipeIngredient || []).map(i => ({ text: ingredientText(i), title: i.title || '' })).filter(i => i.text || i.title), + steps: (r.recipeInstructions || []).map(s => ({ title: s.title || '', text: s.text || '' })).filter(s => s.text || s.title), + categories: (r.recipeCategory || []).map(c => c.name).filter(Boolean), tags: (r.tags || []).map(c => c.name).filter(Boolean) }; +} + +// ---- the shopping list (Mealie moved it from /groups to /households; the one that answers is remembered) +let shopBase = ''; +async function shop(p, opt) { + if (shopBase) return mealie(shopBase + p, opt); + let last; for (const b of ['/api/households/shopping', '/api/groups/shopping']) { try { const r = await mealie(b + p, opt); shopBase = b; return r; } catch (e) { last = e; if (e.status !== 404) throw e; } } + throw last; +} +const itemOut = i => ({ id: i.id, text: String(i.display || i.note || (i.food && i.food.name) || '').trim(), checked: !!i.checked, label: (i.label && i.label.name) || '', position: i.position || 0 }); +async function mainList() { // the first shopping list (the one called "Shopping List" if there is more than one) + const l = await shop('/lists?perPage=50'), items = Array.isArray(l) ? l : l.items || []; + let pick = items.find(x => /shopping/i.test(x.name || '')) || items[0]; + if (!pick) pick = await shop('/lists', { method: 'POST', body: { name: 'Shopping List' } }); + return pick; +} +async function readList() { const l = await mainList(), d = await shop('/lists/' + l.id); return { id: l.id, name: d.name || l.name, items: (d.listItems || []).map(itemOut).sort((a, b) => a.checked - b.checked || a.position - b.position) }; } + +// ---- mirror into Tasks: a "Shopping" list in the person's Tasks, one task per Mealie item +const tasksFile = () => path.join(users.dir(), 'tasks.json'); +const loadTasks = () => { try { const d = JSON.parse(fs.readFileSync(tasksFile(), 'utf8')); if (d && Array.isArray(d.lists) && Array.isArray(d.tasks)) return d; } catch {} return { lists: [{ id: 'inbox', name: 'Tasks' }], tasks: [] }; }; +/** Make Tasks and Mealie agree, in both directions: ticking, adding and deleting an item in either place shows up in the other. Each task remembers what Mealie last said + * (`mealieChecked`), so a change on one side is told apart from a change on the other. */ +async function sync() { + const list = await readList(), d = loadTasks(), items = new Map(((await shop('/lists/' + list.id)).listItems || []).map(i => [i.id, i])); + let tl = d.lists.find(l => l.name === 'Shopping' && l.mealie); + if (!tl) { tl = { id: crypto.randomBytes(4).toString('hex'), name: 'Shopping', mealie: true, known: [] }; d.lists.push(tl); } + const known = new Set(tl.known || []), mine = d.tasks.filter(t => t.list === tl.id), byItem = new Map(mine.filter(t => t.mealieId).map(t => [t.mealieId, t])); + const out = { added: 0, toTasks: 0, toMealie: 0, sent: 0, removed: 0 }, gone = []; + for (const it of list.items) { + const t = byItem.get(it.id), raw = items.get(it.id); + if (!t) { + if (known.has(it.id)) { gone.push(it.id); continue; } // it was on the Tasks list and has been deleted there + d.tasks.push({ id: crypto.randomBytes(6).toString('hex'), list: tl.id, title: it.text, notes: '', due: '', done: it.checked, completedAt: it.checked ? Date.now() : null, mealieId: it.id, mealieChecked: it.checked, updated: Date.now() }); out.added++; continue; + } + const last = t.mealieChecked === undefined ? it.checked : t.mealieChecked; + if (!!t.done !== last) { await shop('/items/' + it.id, { method: 'PUT', body: { ...raw, checked: !!t.done } }); t.mealieChecked = !!t.done; out.toMealie++; } // (changed in Tasks) + else if (it.checked !== !!t.done) { t.done = it.checked; t.completedAt = it.checked ? Date.now() : null; t.mealieChecked = it.checked; t.updated = Date.now(); out.toTasks++; } // (changed in Mealie) + else t.mealieChecked = it.checked; + } + if (gone.length) { await shop('/items?' + gone.map(i => 'ids=' + i).join('&'), { method: 'DELETE' }); out.removed += gone.length; } + const ids = new Set(list.items.map(i => i.id)); + for (const t of mine) { + if (t.mealieId && !ids.has(t.mealieId)) { d.tasks = d.tasks.filter(x => x !== t); out.removed++; continue; } // gone from Mealie + if (!t.mealieId && !t.done && t.title) { // typed straight into the Shopping list in Tasks + const n = await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: t.title, quantity: 1, checked: false } }), made = n && (n.createdItems ? n.createdItems[0] : n); + if (made && made.id) { t.mealieId = made.id; t.mealieChecked = false; ids.add(made.id); } out.sent++; + } + } + tl.known = [...ids].filter(id => !gone.includes(id)); + atomicWrite(tasksFile(), JSON.stringify(d, null, 2), { mode: 0o600 }); + return { ...out, list: tl.id }; +} + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams; + try { + if (p === '/status') return json(res, 200, { enabled: enabled(), host: new URL(BASE()).host }); + let m; + if (p === '/' && req.method === 'GET') { + const page = Math.max(1, Math.min(200, +q.get('page') || 1)), term = (q.get('q') || '').trim().slice(0, 100); + const j = await mealie(`/api/recipes?page=${page}&perPage=24&orderBy=name&orderDirection=asc${term ? '&search=' + encodeURIComponent(term) : ''}`); + return json(res, 200, { items: (j.items || []).map(card), more: page < (j.total_pages || j.totalPages || 1) }); + } + if ((m = /^\/r\/([\w-]+)$/.exec(p)) && req.method === 'GET') return json(res, 200, full(await mealie('/api/recipes/' + m[1]))); + if ((m = /^\/image\/([0-9a-f-]+)$/i.exec(p)) && req.method === 'GET') { // the picture (small by default) + if (!idOk(m[1])) throw fail(400, 'Bad recipe'); + for (const f of q.get('big') ? ['original.webp'] : ['min-original.webp', 'original.webp']) { + const r = await mealie(`/api/media/recipes/${m[1]}/images/${f}`, { accept: 'image/*', raw: true }).catch(() => null); + if (r && r.ok) { const type = (r.headers.get('content-type') || '').split(';')[0]; if (!/^image\//.test(type)) continue; res.writeHead(200, { 'content-type': type, 'cache-control': 'private, max-age=86400' }); return res.end(Buffer.from(await r.arrayBuffer())); } + } + return json(res, 404, { error: 'No picture' }); + } + if (p === '/import' && req.method === 'POST') { // make a recipe from a web page's address + const b = JSON.parse(await readBody(req, 1e4) || '{}'), url = String(b.url || '').trim(); + if (!/^https?:\/\/\S+$/i.test(url)) throw fail(400, 'Paste the address of a recipe page (starting with http)'); + let slug; try { slug = await mealie('/api/recipes/create/url', { method: 'POST', body: { url, includeTags: false }, timeout: 90000 }); } catch (e) { if (e.status !== 404 && e.status !== 405) throw e; slug = await mealie('/api/recipes/create-url', { method: 'POST', body: { url }, timeout: 90000 }); } + slug = typeof slug === 'string' ? slug : slug && slug.slug; if (!slugOk(slug)) throw fail(502, 'Mealie could not read a recipe from that page'); + return json(res, 200, full(await mealie('/api/recipes/' + slug))); + } + if (p === '/shopping' && req.method === 'GET') return json(res, 200, await readList()); + if (p === '/shopping/add-recipe' && req.method === 'POST') { // the recipe's ingredients go on the list; Mealie adds up what is already there + const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await mealie('/api/recipes/' + (slugOk(b.slug) ? b.slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +b.scale || 1)); + try { await shop(`/lists/${list.id}/recipe/${r.id}`, { method: 'POST', body: { recipeIncrementQuantity: mult } }); } + catch (e) { if (![404, 405, 422].includes(e.status)) throw e; await shop(`/lists/${list.id}/recipe`, { method: 'POST', body: [{ recipeId: r.id, recipeIncrementQuantity: mult }] }); } // (newer Mealie takes a list of recipes) + const s = await sync(); return json(res, 200, { ok: true, ...s, name: r.name }); + } + if (p === '/shopping/add' && req.method === 'POST') { + const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').trim().slice(0, 200); if (!text) throw fail(400, 'Type what to buy'); + const list = await mainList(); await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: text, quantity: 1, checked: false } }); await sync(); return json(res, 200, { ok: true }); + } + if (p === '/shopping/check' && req.method === 'POST') { + const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!idOk(b.id)) throw fail(400, 'Bad item'); + const list = await mainList(), raw = ((await shop('/lists/' + list.id)).listItems || []).find(i => i.id === b.id); if (!raw) throw fail(404, 'That item is not on the list'); + await shop('/items/' + b.id, { method: 'PUT', body: { ...raw, checked: !!b.checked } }); await sync(); return json(res, 200, { ok: true }); + } + if (p === '/shopping/clear' && req.method === 'POST') { // take the ticked items off + const list = await readList(), ids = list.items.filter(i => i.checked).map(i => i.id); + if (ids.length) await shop('/items?' + ids.map(i => 'ids=' + i).join('&'), { method: 'DELETE' }); + await sync(); return json(res, 200, { ok: true, removed: ids.length }); + } + if (p === '/shopping/sync' && req.method === 'POST') return json(res, 200, await sync()); + return json(res, 404, { error: 'Not found' }); + } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : 502, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); } +} + +module.exports = { handle, enabled, minutes, full, ingredientText }; diff --git a/server.js b/server.js index f7446ff..1b64272 100644 --- a/server.js +++ b/server.js @@ -41,6 +41,8 @@ const abapi = require('./abapi'); // audiobook player library, audio and progre 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 recipes = require('./recipes'); // recipes and the shopping list, from Mealie (MEALIE_URL, MEALIE_TOKEN) +const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export) const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM) const librivox = require('./librivox'); // free audiobooks from LibriVox const books = require('./books'); // e-book reader: EPUB, PDF and DOCX library with synced progress and notes @@ -551,9 +553,11 @@ const ACCESS = [ [/^\/api\/bible\//, ['bible']], [/^\/api\/read\//, ['read']], [/^\/api\/radio\//, ['radio']], + [/^\/api\/recipes(\/|$)/, ['recipes']], + [/^\/api\/contacts(\/|$)/, ['contacts']], [/^\/api\/signal\//, ['signal']], [/^\/api\/lyrics$/, ['music', 'radio']], - [/^\/api\/transcript$/, ['podcast', 'audio']], + [/^\/api\/transcript(\/search)?$/, ['podcast', 'audio']], [/^\/api\/mail\//, ['mail']], [/^\/api\/cal\//, ['calendar']], [/^\/api\/tasks/, ['tasks']], @@ -638,6 +642,7 @@ async function handleRequest(req, res) { const to = audiobooksLogin(user); res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end(); } + if (pathname === '/api/transcript/search') return transcripts.searchHandle(req, res); if (pathname === '/api/transcript') return transcripts.handle(req, res); if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server'); if (pathname.startsWith('/api/ab/free/')) return librivox.handle(req, res); @@ -645,6 +650,8 @@ async function handleRequest(req, res) { if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res); if (pathname.startsWith('/api/read/')) return books.handle(req, res); if (pathname.startsWith('/api/radio/')) return radio.handle(req, res); + if (pathname === '/api/recipes' || pathname.startsWith('/api/recipes/')) return recipes.handle(req, res); + if (pathname === '/api/contacts' || pathname.startsWith('/api/contacts/')) return contacts.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); diff --git a/store.js b/store.js index 96457a2..61cc98f 100644 --- a/store.js +++ b/store.js @@ -1,4 +1,4 @@ -// Book store: free, DRM-free books from Project Gutenberg (its own catalogue feed), Standard Ebooks (its public pages), the Internet Archive (public-domain EPUBs) and any OPDS catalogue an +// Book store: free, DRM-free books from Project Gutenberg (its own catalogue feed), Standard Ebooks (its public pages), the Internet Archive (public-domain EPUBs), Open Library (its catalogue of free-to-read editions, which are held by the Internet Archive) and any OPDS catalogue an // 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'); @@ -11,6 +11,7 @@ const BUILTIN = { gutenberg: { name: 'Project Gutenberg', base: () => env('GUTENBERG_URL', 'https://www.gutenberg.org'), hosts: ['gutenberg.org'] }, // its own catalogue feed (fast and official) standardebooks: { name: 'Standard Ebooks', base: () => env('STANDARD_EBOOKS_URL', 'https://standardebooks.org'), hosts: ['standardebooks.org'] }, // its public pages: the OPDS feed is for paying members only archive: { name: 'Internet Archive', base: () => env('ARCHIVE_URL', 'https://archive.org'), hosts: ['archive.org'], needsQuery: true }, + openlibrary: { name: 'Open Library', base: () => env('OPENLIBRARY_URL', 'https://openlibrary.org'), hosts: ['openlibrary.org'], needsQuery: true }, // search here; the book itself comes from the Internet Archive }; const FORMAT_OF = { 'application/epub+zip': 'epub', 'application/pdf': 'pdf', 'application/x-mobipocket-ebook': 'mobi', 'application/vnd.amazon.mobi8-ebook': 'azw3', 'application/vnd.amazon.ebook': 'azw' }; const PREFER = ['epub', 'azw3', 'mobi', 'azw', 'pdf']; @@ -133,10 +134,21 @@ 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}`); + 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; + items.push({ ref: ia, format: 'epub', title: d.title || ia, author: [].concat(d.author_name || []).slice(0, 3).join(', '), summary: '', cover: d.cover_i ? `https://covers.openlibrary.org/b/id/${+d.cover_i}-M.jpg` : '' }); + } + return { items, more: page * 20 < (j.numFound || 0) }; +} + async function searchOne(src, q, page) { 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 === 'openlibrary') return q ? openLibrarySearch(src, q, page) : { 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 || '' }); @@ -176,13 +188,14 @@ async function handle(req, res, p) { } if (p === '/add' && req.method === 'POST') { const b = await readBody(req), src = sourceOf(String(b.source || '')), ref = String(b.ref || ''); - let target; + let target, dl = src; if (src.id === 'gutenberg') target = await gutenbergResolve(src, ref); else if (src.id === 'standardebooks') target = await standardResolve(src, ref); else if (src.id === 'archive') target = await archiveResolve(src, ref); + else if (src.id === 'openlibrary') { dl = sourceOf('archive'); target = await archiveResolve(dl, ref); } // (Open Library's free editions are Internet Archive items) else { if (!sameSite(src, new URL(ref, src.base))) throw fail(400, 'Bad book address'); target = { url: ref, format: String(b.format || 'epub') }; } const ext = books.TYPES[target.format] ? target.format : 'epub'; - const buf = await limited(await get(src, target.url, { accept: 'application/epub+zip, application/octet-stream, */*;q=0.5', timeout: 120000 }), MAX_BOOK); + const buf = await limited(await get(dl, target.url, { accept: 'application/epub+zip, application/octet-stream, */*;q=0.5', timeout: 120000 }), MAX_BOOK); let cover; const cu = String(b.cover || ''); if (cu) { try { const r = await get(src, cu, { accept: 'image/*' }), type = (r.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); cover = { type, data: await limited(r, MAX_COVER) }; } catch {} } let book; try { book = books.addBook(buf, ext, { title: String(b.title || '').slice(0, 200), author: String(b.author || '').slice(0, 200) }, cover); } catch (e) { throw fail(400, e.message); } return json(res, 200, book); diff --git a/tasks.js b/tasks.js index a00f29e..6f373b9 100644 --- a/tasks.js +++ b/tasks.js @@ -78,7 +78,7 @@ async function handle(req, res) { try { if (p === '/api/tasks/lists' && req.method === 'GET') { const d = load(), st = caldav.status(); - return json(res, 200, { lists: [...d.lists.map(l => ({ id: 'local:' + l.id, name: l.name, local: true })), ...caldav.todoLists().map(l => ({ ...l, icloud: true }))], + return json(res, 200, { lists: [...d.lists.map(l => ({ id: 'local:' + l.id, name: l.name, local: true, ...(l.mealie ? { mealie: true } : {}) })), ...caldav.todoLists().map(l => ({ ...l, icloud: true }))], icloud: { connected: !!st.connected, reminderLists: st.reminderLists || 0 } }); } if (p === '/api/tasks/lists' && req.method === 'POST') { diff --git a/tools/contacts-test.js b/tools/contacts-test.js new file mode 100644 index 0000000..f9060c5 --- /dev/null +++ b/tools/contacts-test.js @@ -0,0 +1,34 @@ +// Contacts: adding, editing, finding, vCard import/export (merging people already there), birthdays for the calendar, and each person's own book. +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-ct-')); +const contacts = require('../contacts'), users = require('../users'); +const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', handle: 'Yg' }) }; +const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => contacts.handle(req, res))).listen(0, async () => { + const base = 'http://127.0.0.1:' + hub.address().port + '/api/contacts', post = (p, b, h = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(b), headers: h }); + try { + let r = await post('/', { name: 'Zoe Zed', emails: [{ label: 'work', value: 'Zoe@Example.com' }, { value: 'not an email' }], phones: [{ value: '555 1234' }], birthday: '1990-04-15' }); let c = await r.json(); + assert.equal(r.status, 200); assert.deepEqual(c.emails, [{ label: 'work', value: 'zoe@example.com' }], 'addresses are lower-cased and bad ones dropped'); + assert.equal((await post('/', { name: '', emails: [] })).status, 400, 'a contact needs a name or an address'); + assert.equal((await (await post('/', { emails: [{ value: 'amy@x.org' }] })).json()).name, 'amy', 'with no name the address stands in'); + assert.equal((await (await post('/', { name: 'Bad Day', birthday: '1990-02-31' })).json()).birthday, '', 'a date that does not exist is dropped'); + c = await (await post('/', { id: c.id, name: 'Zoe Zed', org: 'Acme', emails: c.emails, birthday: '--04-15' })).json(); assert.equal(c.org, 'Acme'); assert.equal((await (await fetch(base + '/')).json()).length, 3, 'editing does not add another'); + assert.equal((await post('/', { id: 'nope', name: 'X' })).status, 404); + // vCard in and out + const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Zoe Zed\r\nEMAIL;TYPE=HOME:zoe@example.com\r\nEMAIL:zoe2@example.com\r\nTEL;TYPE=CELL:+1 555 0000\r\nEND:VCARD\r\nBEGIN:VCARD\r\nVERSION:4.0\r\nN:Smith;Jo;;;\r\nORG:Big\\, Co;Sales\r\nEMAIL;TYPE=work:jo@big.co\r\nBDAY:19801225\r\nNOTE:Line one\\nLine two\r\n continued\r\nADR;TYPE=home:;;1 Main St;Town;ST;12345;USA\r\nEND:VCARD\r\nBEGIN:VCARD\r\nEND:VCARD\r\n'; + j = await (await post('/import', { vcf })).json(); assert.deepEqual(j, { added: 1, merged: 1, skipped: 1 }, 'Zoe is filled in, Jo is new, the empty card is skipped'); + const all = await (await fetch(base + '/')).json(), zoe = all.find(x => x.name === 'Zoe Zed'), jo = all.find(x => x.name === 'Jo Smith'); + assert.deepEqual(zoe.emails.map(e => e.value), ['zoe@example.com', 'zoe2@example.com']); assert.equal(zoe.phones.length, 1); assert.equal(jo.org, 'Big, Co'); assert.equal(jo.birthday, '1980-12-25'); assert.equal(jo.notes, 'Line one\nLine twocontinued', 'folded lines are joined'); assert.match(jo.address, /1 Main St/); + const out = await (await fetch(base + '/export')).text(); assert.match(out, /BEGIN:VCARD/); assert.match(out, /FN:Jo Smith/); assert.match(out, /BDAY:19801225/); assert.match(out, /BDAY:--0415/); + const back = contacts.parseVcf(out); assert.equal(back.length, all.length, 'what is exported can be read back'); + // calendar birthdays + const ev = contacts.birthdayEvents({ fd: '2026-04-01', td: '2026-04-30' }, people.a.id); assert.equal(ev.length, 1); assert.equal(ev[0].start, '2026-04-15'); assert.equal(ev[0].end, '2026-04-16'); assert.equal(ev[0].title, "Zoe Zed's birthday"); + const dec = contacts.birthdayEvents({ fd: '2026-12-01', td: '2027-01-10' }, people.a.id); assert.equal(dec[0].title, "Jo Smith's birthday (46)", 'the age is shown when the year is known'); + assert.equal(contacts.birthdayEvents({ fd: '2026-06-01', td: '2026-06-30' }, people.a.id).length, 0); + // each person has their own book + assert.deepEqual(await (await fetch(base + '/', { headers: { 'x-user': 'b' } })).json(), []); + r = await post('/delete', { id: zoe.id }); assert.equal(r.status, 200); assert.equal((await (await fetch(base + '/')).json()).some(x => x.id === zoe.id), false); + console.log('contacts ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); +}); +var j; diff --git a/tools/podcast-test.js b/tools/podcast-test.js index a40ba20..24f42ee 100644 --- a/tools/podcast-test.js +++ b/tools/podcast-test.js @@ -9,10 +9,12 @@ const fake = http.createServer((req, res) => { if (req.url === '/podcasts' && req.method === 'POST') { const u = JSON.parse(b).url; if (u === 'https://a.test/feed') { res.writeHead(409, { 'content-type': 'application/json' }); return res.end('{"message":"exists"}'); } res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{"ID":"p2","Title":"New","URL":"' + u + '"}'); } if (req.url === '/podcastitems/e1/file') { res.writeHead(206, { 'content-type': 'audio/mpeg', 'content-range': 'bytes 0-3/10', 'content-length': '4' }); return res.end('abcd'); } if (req.url === '/podcastitems/e2/file') { res.writeHead(302, { location: 'https://cdn.example.com/a.mp3' }); return res.end(); } + if (req.url.startsWith('/pi/')) { const d = Math.floor(Date.now() / 1000); const okAuth = req.headers['x-auth-key'] === 'k' && req.headers.authorization === require('crypto').createHash('sha1').update('k' + 's' + req.headers['x-auth-date']).digest('hex') && Math.abs(+req.headers['x-auth-date'] - d) < 60; res.writeHead(okAuth ? 200 : 401, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ feeds: [{ title: 'Bread Talk', url: 'https://b.test/feed', author: 'Baker', image: 'https://b.test/i.jpg' }, { title: 'Dead', url: 'https://d.test/feed', dead: 1 }, { title: 'Dup', url: 'https://a.test/feed' }] })); } + if (req.url.startsWith('/apple?')) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ results: [{ collectionName: 'Dup again', artistName: 'X', feedUrl: 'https://a.test/feed/' }, { collectionName: 'Apple Only', feedUrl: 'https://c.test/feed' }] })); } res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}'); }); }).listen(0, async () => { - process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret'; + process.env.PODCAST_URL = 'http://127.0.0.1:' + fake.address().port + '/'; process.env.PODGRAB_PASSWORD = 'secret'; Object.assign(process.env, { PODCASTINDEX_URL: 'http://127.0.0.1:' + fake.address().port + '/pi', APPLE_PODCASTS_URL: 'http://127.0.0.1:' + fake.address().port + '/apple' }); const podcast = require('../podcast'), users = require('../users'); const people = { admin: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), other: users.create({ name: 'Bob', role: 'user', tabs: ['podcast'], handle: 'Yg' }) }; const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'other' ? people.other : people.admin, () => podcast.handle(req, res))).listen(0, async () => { @@ -22,6 +24,12 @@ const fake = http.createServer((req, res) => { assert.equal(seen[0].auth, 'Basic ' + Buffer.from('podgrab:secret').toString('base64'), 'password added by the server'); r = await get('/podcastitems/e1/file', { headers: { range: 'bytes=0-3' } }); assert.equal(r.status, 206); assert.equal(await r.text(), 'abcd'); assert.equal(seen.at(-1).range, 'bytes=0-3'); r = await get('/podcastitems/e2/file'); assert.equal(r.status, 302); assert.equal(r.headers.get('location'), 'https://cdn.example.com/a.mp3'); + assert.deepEqual(await (await get('/discover/topics')).json(), []); // no key yet: Apple only + assert.deepEqual((await (await get('/discover?q=bread')).json()).map(x => x.title), ['Dup again', 'Apple Only']); + Object.assign(process.env, { PODCASTINDEX_KEY: 'k', PODCASTINDEX_SECRET: 's' }); + assert.ok((await (await get('/discover/topics')).json()).includes('History'), 'topics are offered once Podcast Index is set up'); + let dj = await (await get('/discover?q=bread')).json(); assert.deepEqual(dj.map(x => x.title), ['Bread Talk', 'Dup', 'Apple Only'], 'both directories, a feed listed twice shown once, dead feeds dropped'); assert.equal(dj[0].author, 'Baker'); + dj = await (await get('/discover?topic=History')).json(); assert.equal(dj[0].title, 'Bread Talk', 'a topic lists that category\'s trending shows (request signed with the key)'); const other = { 'x-user': 'other' }; r = await get('/podcasts', { headers: other }); assert.deepEqual(await r.json(), [], 'a new person starts with their own empty list'); r = await get('/podcasts/p1/items', { headers: other }); assert.equal(r.status, 404, 'a show not on your list is not reachable'); diff --git a/tools/recipes-test.js b/tools/recipes-test.js new file mode 100644 index 0000000..c624fc4 --- /dev/null +++ b/tools/recipes-test.js @@ -0,0 +1,65 @@ +// Recipes: reading recipes and the shopping list from Mealie, importing a link, adding a recipe to the list, and keeping the list in step with Tasks. +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-rcp-')); +const seen = []; let items = [{ id: 'abcdef01-1', note: 'milk', display: 'milk', checked: false, position: 1 }], nextId = 1, newApi = true; +const recipe = { id: '0123abcd-1', slug: 'bread', name: 'Bread', description: 'Simple', image: 'x', totalTime: 'PT1H30M', prepTime: '20 minutes', performTime: '1 hour', recipeServings: 4, orgURL: 'https://a.test/bread', + recipeIngredient: [{ display: '3 cups flour' }, { title: 'Glaze' }, { quantity: 1, unit: { name: 'tbsp' }, food: { name: 'butter' } }], recipeInstructions: [{ text: 'Mix.' }, { title: 'Bake', text: 'Bake it.' }], recipeCategory: [{ name: 'Baking' }], tags: [] }; +const fake = http.createServer((req, res) => { + let b = ''; req.on('data', c => b += c); req.on('end', () => { + const u = new URL(req.url, 'http://x'), p = u.pathname, J = (o, code = 200) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); }; + seen.push(req.method + ' ' + p + (u.search || '')); if (req.headers.authorization !== 'Bearer tok') return J({ detail: 'Unauthorized' }, 401); + if (p === '/api/recipes' && req.method === 'GET') return J({ page: 1, total_pages: 2, items: [{ id: recipe.id, slug: 'bread', name: 'Bread', totalTime: '45 minutes', image: 'x', rating: 4 }] }); + if (p === '/api/recipes/bread') return J(recipe); + if (p === '/api/recipes/create/url') { assert.equal(JSON.parse(b).url, 'https://a.test/new'); return J('new-one'); } + if (p === '/api/recipes/new-one') return J({ ...recipe, id: '0123abcd-2', slug: 'new-one', name: 'New One' }); + if (p.startsWith('/api/media/recipes/0123abcd-1/images/')) { res.writeHead(200, { 'content-type': 'image/webp' }); return res.end('img'); } + const sh = p.startsWith('/api/households/shopping') ? p.slice('/api/households/shopping'.length) : null; + if (sh === null) return J({}, 404); + if (sh === '/lists' && req.method === 'GET') return J({ items: [{ id: '1111aaaa-1', name: 'Shopping List' }] }); + if (sh === '/lists/1111aaaa-1' && req.method === 'GET') return J({ id: '1111aaaa-1', name: 'Shopping List', listItems: items }); + if (sh === '/lists/1111aaaa-1/recipe/0123abcd-1' && req.method === 'POST') { if (!newApi) return J({}, 404); items.push({ id: 'abcdef01-' + (++nextId), note: '3 cups flour', display: '3 cups flour', checked: false, position: 2 }); return J({}); } + if (sh === '/lists/1111aaaa-1/recipe' && req.method === 'POST') { assert.equal(JSON.parse(b)[0].recipeId, '0123abcd-1'); items.push({ id: 'abcdef01-' + (++nextId), note: '3 cups flour', display: '3 cups flour', checked: false, position: 2 }); return J({}); } + if (sh === '/items' && req.method === 'POST') { const j = JSON.parse(b), it = { id: 'abcdef01-' + (++nextId), note: j.note, display: j.note, checked: false, position: 9 }; items.push(it); return J({ createdItems: [it] }); } + if (sh === '/items' && req.method === 'DELETE') { const ids = u.searchParams.getAll('ids'); items = items.filter(i => !ids.includes(i.id)); return J({}); } + let m; if ((m = /^\/items\/([\w-]+)$/.exec(sh)) && req.method === 'PUT') { const j = JSON.parse(b), it = items.find(i => i.id === m[1]); Object.assign(it, j); return J(it); } + J({}, 404); + }); +}).listen(0, async () => { + const port = fake.address().port; process.env.MEALIE_URL = 'http://127.0.0.1:' + port; process.env.MEALIE_TOKEN = 'tok'; + const recipes = require('../recipes'), users = require('../users'), me = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }); + assert.equal(recipes.minutes('PT1H30M'), 90); assert.equal(recipes.minutes('1 hour 15 minutes'), 75); assert.equal(recipes.minutes('45 minutes'), 45); assert.equal(recipes.minutes(''), 0); + const hub = http.createServer((req, res) => users.runAs(me, () => recipes.handle(req, res))).listen(0, async () => { + const base = 'http://127.0.0.1:' + hub.address().port + '/api/recipes', get = p => fetch(base + p), post = (p, b = {}) => fetch(base + p, { method: 'POST', body: JSON.stringify(b) }); + const tasks = () => JSON.parse(fs.readFileSync(path.join(users.dirOf(me.id), 'tasks.json'), 'utf8')); + try { + let j = await (await get('/?q=bre')).json(); assert.equal(j.items[0].name, 'Bread'); assert.equal(j.items[0].minutes, 45); assert.equal(j.more, true); assert.ok(seen.some(x => x.includes('search=bre'))); + j = await (await get('/r/bread')).json(); assert.equal(j.total, 90); assert.equal(j.prep, 20); assert.equal(j.cook, 60); assert.deepEqual(j.ingredients.map(i => i.text || i.title), ['3 cups flour', 'Glaze', '1 tbsp butter']); assert.equal(j.steps[1].title, 'Bake'); + assert.equal((await get('/r/..%2Fx')).status, 404, 'a recipe name cannot reach other Mealie pages'); + let r = await get('/image/0123abcd-1'); assert.equal(r.headers.get('content-type'), 'image/webp'); assert.equal((await get('/image/not%20ok')).status, 404); + j = await (await post('/import', { url: 'https://a.test/new' })).json(); assert.equal(j.slug, 'new-one'); assert.equal((await post('/import', { url: 'javascript:1' })).status, 400); + // the shopping list + j = await (await get('/shopping')).json(); assert.deepEqual(j.items.map(i => i.text), ['milk']); + j = await (await post('/shopping/add-recipe', { slug: 'bread' })).json(); assert.equal(j.ok, true); assert.equal(j.added, 2, 'the whole list came across to Tasks the first time'); + let t = tasks(), sl = t.lists.find(l => l.name === 'Shopping'); assert.ok(sl && sl.mealie); assert.deepEqual(t.tasks.filter(x => x.list === sl.id).map(x => x.title).sort(), ['3 cups flour', 'milk']); + // ticked in Tasks -> ticked in Mealie + t.tasks.find(x => x.title === 'milk').done = true; fs.writeFileSync(path.join(users.dirOf(me.id), 'tasks.json'), JSON.stringify(t)); + j = await (await post('/shopping/sync')).json(); assert.equal(j.toMealie, 1); assert.equal(items.find(i => i.note === 'milk').checked, true, 'finishing a task ticks the item in Mealie'); + // ticked in Mealie -> done in Tasks + items.find(i => i.note === '3 cups flour').checked = true; j = await (await post('/shopping/sync')).json(); assert.equal(j.toTasks, 1); assert.equal(tasks().tasks.find(x => x.title === '3 cups flour').done, true); + // typed into Tasks -> on the Mealie list; deleted in Tasks -> deleted in Mealie + t = tasks(); t.tasks.push({ id: 'zz', list: sl.id, title: 'eggs', done: false }); t.tasks = t.tasks.filter(x => x.title !== 'milk'); fs.writeFileSync(path.join(users.dirOf(me.id), 'tasks.json'), JSON.stringify(t)); + j = await (await post('/shopping/sync')).json(); assert.equal(j.sent, 1); assert.ok(items.some(i => i.note === 'eggs')); assert.ok(!items.some(i => i.note === 'milk'), 'a task deleted in Tasks leaves Mealie too'); + // typed in the hub -> a task; ticked in the hub -> ticked; removing ticked items + await post('/shopping/add', { text: 'butter' }); assert.ok(tasks().tasks.some(x => x.title === 'butter')); + const bid = items.find(i => i.note === 'butter').id; await post('/shopping/check', { id: bid, checked: true }); assert.equal(items.find(i => i.id === bid).checked, true); assert.equal(tasks().tasks.find(x => x.title === 'butter').done, true); + j = await (await post('/shopping/clear')).json(); assert.ok(j.removed >= 2); assert.ok(!items.some(i => i.checked)); assert.ok(!tasks().tasks.some(x => x.title === 'butter'), 'removed items leave Tasks too'); + // an older Mealie that only knows the list form of "add recipe" + newApi = false; await post('/shopping/add-recipe', { slug: 'bread' }); assert.ok(seen.includes('POST /api/households/shopping/lists/1111aaaa-1/recipe')); + // a wrong token is explained + process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/); + process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503); + console.log('recipes ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); fake.close(); setTimeout(() => process.exit(process.exitCode || 0), 50); + }); +}); diff --git a/tools/store-test.js b/tools/store-test.js index 93b0bda..245211c 100644 --- a/tools/store-test.js +++ b/tools/store-test.js @@ -26,6 +26,8 @@ const fake = http.createServer((req, res) => { if (p === '/ia/metadata/moby1') return J({ metadata: {}, files: [{ name: 'moby1.pdf', format: 'Text PDF' }, { name: 'moby1.epub', format: 'EPUB' }] }); if (p === '/ia/metadata/locked') return J({ metadata: { 'access-restricted-item': 'true' }, files: [{ name: 'x.epub', format: 'EPUB' }] }); if (p === '/ia/download/moby1/moby1.epub') { res.writeHead(200, { 'content-type': 'application/epub+zip' }); return res.end(Buffer.concat([epub, Buffer.from('ia')])); } + // Open Library (search only; the book is an Internet Archive item) + if (p === '/ol/search.json') return J({ numFound: 2, docs: [{ title: 'Moby Dick', author_name: ['Melville'], cover_i: 7, ia: ['moby1'], ebook_access: 'public' }, { title: 'Borrowable', ia: ['locked'], ebook_access: 'borrowable' }] }); // an OPDS catalogue that needs a sign-in (Calibre-Web style) if (p.startsWith('/cal/')) { if (req.headers.authorization !== 'Basic ' + Buffer.from('reader:pw').toString('base64')) { res.writeHead(401); return res.end(); } @@ -37,15 +39,15 @@ const fake = http.createServer((req, res) => { res.writeHead(404); res.end('no'); }).listen(0, async () => { port = fake.address().port; const B = 'http://127.0.0.1:' + port; - Object.assign(process.env, { GUTENBERG_URL: B, ARCHIVE_URL: B + '/ia', STANDARD_EBOOKS_URL: B }); + Object.assign(process.env, { GUTENBERG_URL: B, ARCHIVE_URL: B + '/ia', OPENLIBRARY_URL: B + '/ol', STANDARD_EBOOKS_URL: B }); const books = require('../books'), users = require('../users'); const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['read'], handle: 'Yg' }) }; const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'b' ? people.b : people.a, () => books.handle(req, res))).listen(0, async () => { const base = 'http://127.0.0.1:' + hub.address().port + '/api/read/store', call = (p, o = {}) => fetch(base + p, o), post = (p, b, h = {}) => call(p, { method: 'POST', body: JSON.stringify(b), headers: h }); try { - let r = await call('/sources'); assert.deepEqual((await r.json()).map(s => s.id), ['gutenberg', 'standardebooks', 'archive']); + let r = await call('/sources'); assert.deepEqual((await r.json()).map(s => s.id), ['gutenberg', 'standardebooks', 'archive', 'openlibrary']); r = await call('/search?q=alice'); let j = await r.json(); - assert.deepEqual(j.items.map(i => i.source), ['gutenberg', 'standardebooks', 'archive', 'gutenberg', 'standardebooks'].slice(0, j.items.length), 'one result from each catalogue in turn'); assert.deepEqual(j.errors, {}); + assert.deepEqual(j.items.map(i => i.source), ['gutenberg', 'standardebooks', 'archive', 'openlibrary', 'gutenberg', 'standardebooks'].slice(0, j.items.length), 'one result from each catalogue in turn'); assert.deepEqual(j.errors, {}); const g = j.items.filter(i => i.source === 'gutenberg'); assert.deepEqual(g.map(i => i.ref), ['11', '345'], 'catalogue entries that are not books, and repeats, are left out'); assert.equal(g[0].author, 'Lewis Carroll'); assert.equal(g[1].title, 'Dracula & Co'); assert.match(g[0].cover, /^\/api\/read\/store\/cover\?s=gutenberg/); assert.equal(j.more, true); const se = j.items.filter(i => i.source === 'standardebooks'); assert.equal(se[0].title, 'Dracula alice'); assert.equal(se[0].author, 'Bram Stoker'); assert.equal(se[1].title, 'The Pilgrim’s Progress'); assert.equal(se[0].ref, '/ebooks/bram-stoker/dracula'); assert.ok(!j.items.some(i => i.ref === '../bad'), 'odd identifiers are dropped'); assert.ok(!seen.some(x => x.startsWith('/feeds/opds')), 'the members-only feed is never asked for'); @@ -63,12 +65,14 @@ const fake = http.createServer((req, res) => { r = await post('/add', { source: 'standardebooks', ref: '/ebooks/bram-stoker/dracula', title: 'Dracula', author: 'Bram Stoker' }); assert.equal(r.status, 200, 'the real EPUB is fetched, not the "download has started" page'); assert.ok(seen.some(x => x.includes('bram-stoker_dracula.epub?source=download')) && !seen.some(x => x.includes('kepub') && x.includes('source')), 'the plain EPUB (not the Kobo or advanced one) is chosen'); r = await post('/add', { source: 'standardebooks', ref: '/ebooks/../etc/passwd' }); assert.equal(r.status, 400); r = await post('/add', { source: 'archive', ref: 'moby1', title: 'Moby', author: 'Melville' }); assert.equal((await r.json()).title, 'Moby'); assert.ok(seen.includes('/ia/download/moby1/moby1.epub'), 'the EPUB is chosen over the PDF'); + const ol = (await (await call('/search?q=moby&source=openlibrary')).json()).items; assert.deepEqual(ol.map(i => i.ref), ['moby1'], 'only freely readable editions are listed'); assert.equal(ol[0].author, 'Melville'); assert.match(decodeURIComponent(ol[0].coverUrl), /covers\.openlibrary\.org\/b\/id\/7-M\.jpg/); + r = await post('/add', { source: 'openlibrary', ref: 'moby1', title: 'Moby Dick' }); assert.equal(r.status, 200, 'an Open Library book is fetched from the Internet Archive'); r = await post('/add', { source: 'archive', ref: 'locked' }); assert.equal(r.status, 403); assert.match((await r.json()).error, /DRM/); r = await post('/add', { source: 'gutenberg', ref: '../x' }); assert.equal(r.status, 400); // custom catalogue assert.equal((await post('/sources', { name: 'Mine', url: B + '/cal/opds' }, { 'x-user': 'b' })).status, 403, 'only an admin adds catalogues'); assert.equal((await post('/sources', { name: 'Bad', url: B + '/cal/opds', user: 'reader', pass: 'wrong' })).status, 502, 'a catalogue that refuses the sign-in is not added'); - assert.equal((await call('/sources').then(x => x.json())).length, 3); + assert.equal((await call('/sources').then(x => x.json())).length, 4); r = await post('/sources', { name: 'Mine', url: B + '/cal/opds', user: 'reader', pass: 'pw' }); const mine = await r.json(); assert.equal(r.status, 200); assert.ok(!JSON.stringify(await (await call('/sources')).json()).includes('pw'), 'sign-ins are never sent to the browser'); assert.ok(!fs.readFileSync(path.join(process.env.DATA_DIR, 'store-sources.json'), 'utf8').includes('"pw"'), 'and are encrypted on disk'); j = await (await call('/search?q=hello%20world&source=' + mine.id)).json(); assert.equal(j.items[0].title, 'Own Book hello world', 'search found through the OpenSearch description'); assert.equal(j.items[0].format, 'pdf'); diff --git a/tools/transcript-test.js b/tools/transcript-test.js index 245cf6a..c15da2a 100644 --- a/tools/transcript-test.js +++ b/tools/transcript-test.js @@ -1,5 +1,6 @@ // Transcripts: reading SRT/VTT/JSON, finding an episode's transcript link in a feed, refusing private addresses, and speech-to-text through a fake Whisper server. const http = require('http'), fs = require('fs'), os = require('os'), path = require('path'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'trt-u-')); const tr = require('../transcripts'); let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m); if (!c) bad++; }; @@ -44,6 +45,16 @@ let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m); const r = await tr.whisper(audio, 'audio'); ok(r && r.found && r.lines.length === 2 && r.lines[0].text === 'Welcome to the show.' && r.lines[1].text === 'Today we talk about bread.' && r.source === 'Transcribed on your server', 'speech-to-text segments become timed lines (blank ones dropped, tags removed)'); ok(calls.includes('POST /v1/models/Systran/faster-whisper-small') && calls.includes('POST /v1/models') && installed, 'a model that is not installed is downloaded first (trying the older form, then the newer)'); ok(r.lines[1].t === 2500 && r.lines[1].e === 2500 + 6000, 'times are in milliseconds'); + // searching what was said + const users = require('../users'), me = users.create({ name: 'Zed', role: 'user', handle: 'Wg' }); + const put = (name, ep, lines) => fs.writeFileSync(path.join(dir, 'transcripts', name + '.json'), JSON.stringify({ found: true, lines, ep })); + put('a1', { key: 'ab:book1', src: '/api/ab/books/x', title: 'Bread Book', artist: 'Baker' }, [{ t: 1000, text: 'Today we talk about sourdough bread.' }, { t: 65000, text: 'Knead the dough well' }, { t: 90000, text: 'Let it rest, then bake the bread.' }]); + put('a2', { key: 'pod:77', src: '/api/podcast/podcastitems/77/file', title: 'Not Mine', artist: 'Other' }, [{ t: 0, text: 'sourdough bread everywhere' }]); + put('a3', null, [{ t: 0, text: 'sourdough bread with no name' }]); + const found = await users.runAs(me, () => tr.search(me, 'Sourdough bread')); + ok(found.length === 1 && found[0].title === 'Bread Book' && found[0].hits[0].t === 1000 && found[0].exact, 'a search finds the moment, ignoring capitals; other people\'s podcasts and unnamed files are left out'); + const two = await users.runAs(me, () => tr.search(me, 'knead well')); ok(two.length === 1 && two[0].hits[0].t === 65000 && !two[0].exact, 'all the words on a line match, not only the exact phrase'); + ok((await users.runAs(me, () => tr.search(me, 'x'))).length === 0 && (await users.runAs(me, () => tr.search(me, 'zebra'))).length === 0, 'too short, or not said: nothing'); srv.close(); fs.rmSync(dir, { recursive: true, force: true }); console.log(bad ? '\n' + bad + ' check(s) failed' : '\nAll transcript checks passed'); process.exit(bad ? 1 : 0); })().catch(e => { console.error(e); process.exit(1); }); diff --git a/transcripts.js b/transcripts.js index 89d8d31..031f190 100644 --- a/transcripts.js +++ b/transcripts.js @@ -110,11 +110,11 @@ async function fetchAudio(src, cookie, tmp) { let n = 0; const body = Readable.fromWeb(r.body); body.on('data', c => { n += c.length; if (n > MAX_AUDIO) body.destroy(new Error('That audio file is too large to transcribe')); }); await pipeline(body, fs.createWriteStream(tmp, { mode: 0o600 })); } -function startJob(key, src, cookie, getAudio) { +function startJob(key, src, cookie, getAudio, ep) { jobs.set(key, { state: 'running', since: Date.now() }); chain = chain.then(async () => { const tmp = path.join(os.tmpdir(), 'nehub-tr-' + key); - try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(r)); jobs.delete(key); } + try { if (getAudio) await saveAudio(await getAudio(), tmp); else await fetchAudio(src, cookie, tmp); const r = await whisper(tmp, 'audio'); if (!r) throw new Error('No speech was found in that audio'); atomicWrite(fileOf(key), JSON.stringify(ep ? { ...r, ep } : r)); jobs.delete(key); } catch (e) { jobs.set(key, { state: 'error', since: Date.now(), error: e.message }); setTimeout(() => jobs.delete(key), 10 * 60e3).unref(); } finally { fs.rm(tmp, { force: true }, () => {}); } }); @@ -132,8 +132,8 @@ async function autoTick() { if (fs.existsSync(fileOf(key)) || jobs.has(key) || t.n >= 3 || Date.now() - t.at < 6 * 3600e3 * t.n) continue; // (a failing one is retried a few times, further apart each time) auto.tried.set(key, { n: t.n + 1, at: Date.now() }); let r = { why: '' }; try { r = await nativeTranscript(ep); } catch {} - if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify(r.transcript)); continue; } // (the show publishes one: no need to make one) - startJob(key, '', '', () => podcast.episodeAudio(ep.id)); queued++; + if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify({ ...r.transcript, ep: { key: 'pod:' + ep.id, src: podSrc(ep.id), title: ep.title, artist: ep.show } })); continue; } // (the show publishes one: no need to make one) + startJob(key, '', '', () => podcast.episodeAudio(ep.id), { key: 'pod:' + ep.id, src: podSrc(ep.id), title: ep.title, artist: ep.show }); queued++; } } function startAuto({ on, keep }) { @@ -144,6 +144,7 @@ function startAuto({ on, keep }) { } function status() { let saved = 0; try { saved = fs.readdirSync(cfg.dir).filter(x => x.endsWith('.json')).length; } catch {} return { enabled: !!cfg.api, auto: auto.on, keep: auto.keep, saved, working: [...jobs.values()].filter(j => j.state === 'running').length, failed: [...jobs.values()].filter(j => j.state === 'error').length }; } +const metaOf = q => ({ key: String(q.get('key') || ''), src: String(q.get('src') || ''), title: String(q.get('title') || '').slice(0, 200), artist: String(q.get('artist') || '').slice(0, 200), thumb: String(q.get('thumb') || '').slice(0, 300) }); const SRC_OK = [/^\/api\/podcast\/podcastitems\/[\w-]+\/file$/, /^\/api\/ab\/books\/[^?#\s]+$/]; // only audio this hub already serves to the person const nativeMiss = new Map(); // key -> { t, why }: a show with no transcript is not asked again for a day async function handle(req, res) { @@ -157,7 +158,7 @@ async function handle(req, res) { const job = jobs.get(key); if (job) return json(res, 200, { state: job.state, since: job.since, error: job.error, ...base }); if (req.method === 'POST') { if (!cfg.api) return json(res, 400, { error: 'Transcribing is not set up on this server (see the whisper service in docker-compose.yml)' }); - startJob(key, src, req.headers.cookie); return json(res, 200, { state: 'running', since: Date.now(), ...base }); + startJob(key, src, req.headers.cookie, null, metaOf(q)); return json(res, 200, { state: 'running', since: Date.now(), ...base }); } let why = ''; if (kind === 'pod') { @@ -165,11 +166,47 @@ async function handle(req, res) { if (miss && miss.t > Date.now() - 6 * 3600e3) why = miss.why; else { let r; try { r = await nativeTranscript(await podcast.feedInfo(me, key0.slice(4))); } catch (e) { r = { why: 'Checking the show\'s feed failed (' + e.message + ').' }; } - if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify(r.transcript)); return json(res, 200, { state: 'ready', ...base, transcript: r.transcript }); } + if (r.transcript) { atomicWrite(fileOf(key), JSON.stringify({ ...r.transcript, ep: metaOf(q) })); return json(res, 200, { state: 'ready', ...base, transcript: r.transcript }); } why = r.why; nativeMiss.set(key, { t: Date.now(), why }); } } return json(res, 200, { state: 'none', why, ...base }); } catch (e) { json(res, e.status || 500, { error: e.message }); } } -module.exports = { init, startAuto, status, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true }; + +// ---- searching what has been said: every transcript on disk, for the words typed. Each file remembers which episode it is (`ep`); older ones are matched up from the person's own episodes. +const idx = new Map(); // file -> { mtime, ep, lines } +const fold = t => String(t).toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); +function load(f) { + const st = fs.statSync(path.join(cfg.dir, f)), c = idx.get(f); if (c && c.mtime === st.mtimeMs) return c; + let j; try { j = JSON.parse(fs.readFileSync(path.join(cfg.dir, f), 'utf8')); } catch { return null; } + const v = { mtime: st.mtimeMs, ep: j.ep || null, lines: (j.lines || []).map(l => ({ t: l.t, text: l.text || '', f: fold(l.text || '') })).filter(l => l.f) }; + idx.set(f, v); return v; +} +/** Episodes whose transcript says all of the words (an exact phrase ranks first), with the moments they say it. Podcasts: only those on the person's list. */ +async function search(me, q) { + const words = fold(q).split(' ').filter(Boolean), phrase = words.join(' '); + if (!words.length || phrase.length < 2) return []; + const mine = new Map((await podcast.myEpisodes(me)).map(e => [e.id, e])), byKey = new Map(); + for (const e of mine.values()) byKey.set(keyFor('pod', 'pod:' + e.id, podSrc(e.id)) + '.json', e); + const out = []; + for (const f of fs.readdirSync(cfg.dir).filter(x => x.endsWith('.json'))) { + const d = load(f); if (!d) continue; + let ep = d.ep, m = byKey.get(f); + if (m) ep = { key: 'pod:' + m.id, src: podSrc(m.id), title: ep && ep.title || m.title, artist: ep && ep.artist || m.show, thumb: m.thumb }; // (a podcast on your list: always allowed, and old files get their name here) + else if (!ep || !ep.key || !String(ep.key).startsWith('ab:')) continue; // (other people's podcasts, and files that cannot be told apart, are not shown) + // a line is also checked joined to the next one, so a phrase split across two lines is found + const hits = []; let all = 0; + for (let i = 0; i < d.lines.length; i++) { + const l = d.lines[i], two = l.f + ' ' + (d.lines[i + 1] ? d.lines[i + 1].f : ''), ok = words.every(w => l.f.includes(w)) || (words.length > 1 && words.every(w => two.includes(w)) && !words.every(w => (d.lines[i + 1] ? d.lines[i + 1].f : '').includes(w))); + if (ok) { all++; if (hits.length < 4) hits.push({ t: l.t, text: l.text, exact: l.f.includes(phrase) || two.includes(phrase) }); } + } + if (all) out.push({ key: ep.key, src: ep.src, title: ep.title || 'Episode', artist: ep.artist || '', thumb: ep.thumb || '', n: all, exact: hits.some(h => h.exact), hits }); + } + return out.sort((a, b) => (b.exact - a.exact) || (b.n - a.n)).slice(0, 30); +} +async function searchHandle(req, res) { + const q = (new URL(req.url, 'http://x').searchParams.get('q') || '').trim().slice(0, 100), me = users.current(); + try { json(res, 200, { results: me ? await search(me, q) : [] }); } catch (e) { json(res, 500, { error: e.message }); } +} +module.exports = { search, searchHandle, init, startAuto, status, handle, parseCues, parseJson, transcriptLinks, shape, whisper, getText, nativeTranscript, enabled: () => true }; diff --git a/users.js b/users.js index 28206f9..f5907de 100644 --- a/users.js +++ b/users.js @@ -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', 'bible', 'read', 'verse', 'mail', 'signal', 'calendar', 'tasks', 'search', 'links']; +const TABS = ['files', 'office', 'music', 'video', 'audio', 'podcast', 'radio', 'recipes', 'contacts', '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', bible: 'Bible', 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', recipes: 'Recipes', contacts: 'Contacts', 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; };