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>
117 lines
8.8 KiB
JavaScript
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 };
|