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
build-container / build (push) Successful in 7m21s

- 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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-26 08:55:32 -04:00
1 parent 0617664d97
commit 176b1b8169
28 files changed
+799 -46

No files matched your search

+121
View File
@@ -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 => `<span class="ctav" style="background:hsl(${ctHue(c.id)} 45% 45%)">${esc(ctInit(c))}</span>`;
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 = '<div class="wrap"><h1>Contacts</h1><p class="empty">Loading…</p></div>';
try {
const l = await ctLoad(true); if (!location.hash.startsWith('#/contacts')) return;
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Contacts</h1><span class="pspace grow"></span><button class="btn ghost" id="ctmore" aria-label="More">${icon('more')}</button><button class="btn" id="ctnew">${icon('plus')}New</button></div>
<input type="search" id="ctq" placeholder="Search contacts" value="${esc(ctQuery)}" autocomplete="off" aria-label="Search contacts" style="width:100%;margin-bottom:8px"><div id="ctl"></div></div>`;
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 ? `<div class="ctgrp">${esc(last = g)}</div>` : '';
return head + `<a class="row" href="#/contacts/c/${esc(c.id)}">${ctAv(c)}<span class="grow"><b>${esc(c.name)}</b><small>${esc(c.org || (c.emails[0] || {}).value || (c.phones[0] || {}).value || '')}</small></span></a>`; }).join('')
: `<p class="empty">${l.length ? 'No one matches.' : 'No contacts yet. Add one, or import a .vcf file from iCloud, Google or your phone.'}</p>`;
};
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 = `<div class="wrap"><h1>Contacts</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="ctretry">Try again</button></div></div>`; $('#ctretry').onclick = () => route();
}
}
function ctMore() {
const el = openSheet('Contacts', '', `<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Import a .vcf file</b><small>From iCloud, Google or your phone. People already here are filled in, not doubled.</small></span><input type="file" id="ctfile" accept=".vcf,text/vcard,text/x-vcard" hidden></label>
<a class="row" href="/api/contacts/export" download="contacts.vcf"><span class="grow"><b>Export all as .vcf</b><small>A backup you can open in any contacts app</small></span></a></div>`);
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 ? `<div class="row"><span class="grow"><small>${esc(label)}</small><span style="display:block;white-space:normal">${html}</span></span></div>` : '';
const el = openSheet(c.name, c.org || '', `<div class="list">
${c.emails.map(e => line(e.label, mailOn ? `<a href="#" data-mail="${esc(e.value)}" style="color:var(--accent)">${esc(e.value)}</a>` : esc(e.value))).join('')}
${c.phones.map(p => line(p.label, `<a href="tel:${esc(p.value.replace(/[^\d+]/g, ''))}" style="color:var(--accent)">${esc(p.value)}</a>`)).join('')}
${line('address', c.address ? `<a href="https://maps.apple.com/?q=${encodeURIComponent(c.address)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">${esc(c.address)}</a>` : '')}
${line('birthday', esc(ctBday(c.birthday)))}${line('notes', esc(c.notes).replace(/\n/g, '<br>'))}</div>
<div class="bar">${mailOn && c.emails[0] ? `<button class="btn" id="ctw">${icon('mail')}Email</button><button class="btn ghost" id="ctm">Mail from them</button>` : ''}<button class="btn ghost" id="cte">Edit</button><button class="btn danger" id="ctd">Delete</button></div>`);
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) => `<div class="ctrows" data-k="${kind}">${(items.length ? items : [{ label: '', value: '' }]).map(i => `<div class="bar" style="margin:4px 0;gap:6px;flex-wrap:nowrap"><input type="text" data-l placeholder="label" value="${esc(i.label === 'other' ? '' : i.label)}" style="width:80px;flex:none"><input type="text" data-v placeholder="${ph}" value="${esc(i.value)}" style="flex:1" autocapitalize="off"></div>`).join('')}</div><a href="#" data-more="${kind}" style="color:var(--accent);font-size:14px">Add another</a>`;
const el = openSheet(c ? 'Edit contact' : 'New contact', '', `<form id="ctf"><label for="ctn">Name</label><input type="text" id="ctn" value="${esc(x.name)}" autocomplete="off"><label for="cto">Company</label><input type="text" id="cto" value="${esc(x.org)}" autocomplete="off">
<label>Email</label>${rows('emails', x.emails, 'name@example.com')}<label>Phone</label>${rows('phones', x.phones, '555 123 4567')}
<label for="cta">Address</label><input type="text" id="cta" value="${esc(x.address)}" autocomplete="off"><label for="ctb">Birthday</label><input type="date" id="ctb" value="${esc(x.birthday && !x.birthday.startsWith('--') ? x.birthday : '')}">
<label for="ctt">Notes</label><textarea id="ctt" rows="3">${esc(x.notes)}</textarea><div class="err" id="cter"></div>
<div class="bar"><button class="btn ghost" type="button" id="ctx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
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) => `<button type="button" data-i="${i}"><b>${esc(r.name)}</b> <small>${esc(r.email)}</small></button>`).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 {}
}