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 => `
`;
+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 = '
';
+ try {
+ const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return;
+ v.innerHTML = `
`;
+ 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 ? `
${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 = `
`; $('#ctretry').onclick = () => route();
+ }
+}
+
+function ctMore() {
+ const el = openSheet('Contacts', '', `
`);
+ 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 ? `
`);
+ 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) => `
`);
+ 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 = `
+ $('#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 = '
';
+ 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 ? `
}?big=1)
` : ''}
+
${esc(r.name)}
${r.description ? `
${esc(r.description)}
` : ''}
+ ${facts.length ? `
${facts.map(f => `${esc(f[0])} ${esc(f[1])}`).join(' · ')}
` : ''}
+ ${r.orgUrl ? `
Original page
` : ''}
+
+
Ingredients
${r.ingredients.map(i => i.title && !i.text ? `
${esc(i.title)}
` : `
${esc(i.text)}
`).join('') || '
No ingredients.
'}
+
Steps
${r.steps.map(s => `- ${s.title ? `${esc(s.title)} ` : ''}${esc(s.text).replace(/\n/g, '
')} `).join('') || '- No steps.
'}
+ ${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; };