Files
hub/contacts.js
T
nmemmertandClaude Sonnet 5.5 176b1b8169
build-container / build (push) Successful in 7m21s
Open Library in the book store; podcast topic search and transcript search; Recipes (Mealie) with a shopping list synced to Tasks; Contacts linked to Mail and the Calendar
- Store: Open Library search, books fetched from the Internet Archive (free editions only)
- Podcast: Podcast Index discovery by name and topic (optional key); search what is said across transcripts and play from that moment
- Recipes: browse, import from a link, add ingredients to Mealie's shopping list, mirrored two ways with a Shopping list in Tasks
- Contacts: per-person address book, vCard import/export, recipient suggestions in Mail, birthdays in the Calendar

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 08:55:32 -04:00

117 lines
8.8 KiB
JavaScript

// Contacts: each person's address book, kept in NE Hub. Mail uses it to suggest recipients, the calendar shows birthdays from it, and vCards (.vcf, the
// format iCloud, Google and phones export) can be imported and exported.
const fs = require('fs'), path = require('path'), crypto = require('crypto');
const atomicWrite = require('./atomic');
const users = require('./users');
const { readBody } = require('./body');
const MAX_CONTACTS = 5000, MAX_IMPORT = 8e6;
const file = uid => path.join(uid ? users.dirOf(uid) : users.dir(), 'contacts.json');
const load = uid => { try { const j = JSON.parse(fs.readFileSync(file(uid), 'utf8')); return Array.isArray(j) ? j : []; } catch { return []; } };
const save = (l, uid) => atomicWrite(file(uid), JSON.stringify(l), { mode: 0o600 });
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
const fail = (code, message) => Object.assign(new Error(message), { code });
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n);
const EMAIL = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
/** A birthday as "YYYY-MM-DD" (year known) or "--MM-DD" (year not known), or '' when it is not a real date. */
function cleanBirthday(v) {
const s = String(v || '').trim(); let m;
if ((m = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(s))) { const [, y, mo, d] = m; return real(+y, +mo, +d) ? `${y}-${mo}-${d}` : ''; }
if ((m = /^(?:--)?(\d{2})-?(\d{2})$/.exec(s))) { const [, mo, d] = m; return real(2000, +mo, +d) ? `--${mo}-${d}` : ''; }
return '';
}
const real = (y, mo, d) => { const t = new Date(Date.UTC(y, mo - 1, d)); return t.getUTCFullYear() === y && t.getUTCMonth() === mo - 1 && t.getUTCDate() === d; };
const list = (v, n, ok) => (Array.isArray(v) ? v : []).map(x => ({ label: str(x && x.label, 20) || 'other', value: str(x && x.value, 120) })).filter(x => x.value && (!ok || ok(x.value))).slice(0, n);
/** Whatever the browser or an import sent, reduced to the fields a contact has. */
function clean(b, old = {}) {
const c = {
id: old.id || crypto.randomBytes(6).toString('hex'), name: str(b.name, 120), org: str(b.org, 120),
emails: list(b.emails, 10, v => EMAIL.test(v)), phones: list(b.phones, 10), address: str(b.address, 300),
birthday: cleanBirthday(b.birthday), notes: str(b.notes, 2000), updated: Date.now(),
};
c.emails.forEach(e => { e.value = e.value.toLowerCase(); });
if (!c.name) c.name = c.emails[0] ? c.emails[0].value.split('@')[0] : c.org;
if (!c.name) throw fail(400, 'Give the contact a name or an email address');
return c;
}
// ---- vCards
const unesc = s => String(s).replace(/\\([nN,;\\])/g, (m, c) => c === 'n' || c === 'N' ? '\n' : c);
const esc = s => String(s || '').replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
/** The contacts in a vCard file (versions 2.1, 3.0 and 4.0; photos and other extras are left out). */
function parseVcf(text) {
const lines = []; for (const raw of String(text).replace(/\r\n?/g, '\n').split('\n')) { if (/^[ \t]/.test(raw) && lines.length) lines[lines.length - 1] += raw.slice(1); else lines.push(raw); }
const out = []; let cur = null;
for (const line of lines) {
if (/^BEGIN:VCARD/i.test(line)) { cur = { emails: [], phones: [] }; continue; }
if (/^END:VCARD/i.test(line)) { if (cur) out.push(cur); cur = null; continue; }
if (!cur) continue;
const i = line.indexOf(':'); if (i < 1) continue;
const head = line.slice(0, i).split(';'), name = head[0].replace(/^.*\./, '').toUpperCase(), params = head.slice(1).join(';'), val = line.slice(i + 1);
const label = ((/TYPE=([^;:]+)/i.exec(params) || /(?:^|;)(home|work|cell|mobile|fax|pref)(?:;|$)/i.exec(params) || [])[1] || 'other').split(',').filter(x => !/^(pref|internet|voice)$/i.test(x))[0] || 'other';
if (name === 'FN') cur.name = unesc(val);
else if (name === 'N' && !cur.n) { const p = val.split(';').map(unesc); cur.n = [p[1], p[0]].filter(Boolean).join(' '); }
else if (name === 'ORG') cur.org = unesc(val.split(';')[0]);
else if (name === 'EMAIL') cur.emails.push({ label: label.toLowerCase(), value: unesc(val) });
else if (name === 'TEL') cur.phones.push({ label: label.toLowerCase(), value: unesc(val).replace(/^tel:/i, '') });
else if (name === 'ADR') cur.address = val.split(';').map(unesc).filter(Boolean).join(', ');
else if (name === 'BDAY') cur.birthday = val.replace(/T.*$/, '');
else if (name === 'NOTE') cur.notes = unesc(val);
}
return out.map(c => ({ ...c, name: c.name || c.n || '' }));
}
function toVcf(l) {
return l.map(c => ['BEGIN:VCARD', 'VERSION:3.0', 'FN:' + esc(c.name), 'N:;' + esc(c.name) + ';;;', c.org && 'ORG:' + esc(c.org),
...c.emails.map(e => `EMAIL;TYPE=${/^(home|work)$/.test(e.label) ? e.label.toUpperCase() : 'INTERNET'}:${esc(e.value)}`),
...c.phones.map(p => `TEL;TYPE=${/^(home|work|cell|fax)$/.test(p.label) ? p.label.toUpperCase() : 'VOICE'}:${esc(p.value)}`),
c.address && 'ADR:;;' + esc(c.address) + ';;;;', c.birthday && 'BDAY:' + (c.birthday.startsWith('--') ? c.birthday.replace(/-/g, '').replace(/^/, '--') : c.birthday.replace(/-/g, '')),
c.notes && 'NOTE:' + esc(c.notes), 'END:VCARD'].filter(Boolean).join('\r\n')).join('\r\n') + (l.length ? '\r\n' : '');
}
/** Birthdays as all-day calendar events inside `win` ({ fd, td }: local dates, inclusive) for the calendar view. Repeats every year. */
function birthdayEvents(win, uid) {
const out = [], y0 = +win.fd.slice(0, 4), y1 = +win.td.slice(0, 4);
for (const c of load(uid)) {
if (!c.birthday) continue;
const [mo, d] = c.birthday.startsWith('--') ? [c.birthday.slice(2, 4), c.birthday.slice(5, 7)] : [c.birthday.slice(5, 7), c.birthday.slice(8, 10)], born = c.birthday.startsWith('--') ? 0 : +c.birthday.slice(0, 4);
for (let y = y0; y <= y1; y++) {
const day = `${y}-${mo}-${d}`; if (!real(y, +mo, +d) || day < win.fd || day > win.td) continue;
const end = new Date(Date.UTC(y, +mo - 1, +d + 1)).toISOString().slice(0, 10);
out.push({ title: `${c.name}'s birthday${born && y > born ? ` (${y - born})` : ''}`, location: '', allDay: true, start: day, end, uid: 'birthday:' + c.id + ':' + y, series: false, cal: 'Birthdays', calId: 'contacts', color: '#d9578a', editable: false, contactId: c.id });
}
}
return out;
}
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/contacts'.length) || '/';
try {
if (p === '/' && req.method === 'GET') return json(res, 200, load().sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })));
if (p === '/' && req.method === 'POST') { // add or change one
const b = JSON.parse(await readBody(req, 1e5) || '{}'), l = load(), old = b.id ? l.find(x => x.id === b.id) : null;
if (b.id && !old) throw fail(404, 'Contact not found');
if (!old && l.length >= MAX_CONTACTS) throw fail(400, 'That is plenty of contacts');
const c = clean(b, old || {}); if (old) l[l.indexOf(old)] = c; else l.push(c);
save(l); return json(res, 200, c);
}
if (p === '/delete' && req.method === 'POST') { const { id } = JSON.parse(await readBody(req, 1e4) || '{}'); save(load().filter(x => x.id !== id)); return json(res, 200, { ok: true }); }
if (p === '/import' && req.method === 'POST') { // a .vcf file's text: new people are added, and ones with the same email address are filled in rather than doubled
const b = JSON.parse(await readBody(req, MAX_IMPORT) || '{}'), l = load(); let added = 0, merged = 0, skipped = 0;
for (const raw of parseVcf(b.vcf || '')) {
let c; try { c = clean(raw); } catch { skipped++; continue; }
const have = l.find(x => x.emails.some(e => c.emails.some(f => f.value === e.value))) || (!c.emails.length && l.find(x => x.name.toLowerCase() === c.name.toLowerCase()));
if (have) { for (const k of ['org', 'address', 'birthday', 'notes']) if (!have[k] && c[k]) have[k] = c[k]; for (const k of ['emails', 'phones']) for (const x of c[k]) if (!have[k].some(y => y.value.toLowerCase() === x.value.toLowerCase())) have[k].push(x); merged++; }
else if (l.length < MAX_CONTACTS) { l.push(c); added++; } else skipped++;
}
save(l); return json(res, 200, { added, merged, skipped });
}
if (p === '/export' && req.method === 'GET') {
const body = toVcf(load()); res.writeHead(200, { 'content-type': 'text/vcard; charset=utf-8', 'content-disposition': 'attachment; filename="contacts.vcf"', 'cache-control': 'no-store' }); return res.end(body);
}
return json(res, 404, { error: 'Not found' });
} catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status || 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); }
}
module.exports = { handle, load, parseVcf, toVcf, birthdayEvents, clean, cleanBirthday };