contacts.lmth
All 117 lines. No libraries, nothing hidden. Read it before you run it.
<!doctype html>
<meta charset="utf-8">
<meta name="resworb-write" content="./contacts-merged.csv">
<!--
Contacts: merge two exports and drop the duplicates.
Put this page next to your contact exports as CSV files: a header row, then one contact per row, with no commas inside a value.
name,email,phone
Dan Cole,dan@example.com,+1 555 555 0100
Two contacts are a likely pair if they share an email, the last 9 digits of a phone number,
or a name: one first name starts the other and the last names start with the same letter.
For each pair, click a field on either side to take it, ← or → to take one side whole, N to keep both, Enter for the next.
After the last pair it writes contacts-merged.csv: everyone else, plus your choices. Then delete this page.
-->
<title>Contacts</title>
<style>
body { max-width: 960px; margin: 40px auto; padding: 0 20px; color: #111; font: 15px/1.4 'Helvetica Neue', sans-serif; }
#pair { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.card { border: 1px solid #111; }
.card:last-child { border-color: #002fa7; }
.card div, .card small { display: block; min-height: 1.4em; padding: 0.5rem 0.8rem; border-bottom: 1px solid #e3e3e8; }
.card small { color: #6b6b72; }
.taken { background: #002fa7; color: #fff; }
#keys { color: #6b6b72; }
</style>
<h1 id="heading"></h1>
<div id="pair"></div>
<p id="keys">Click a field to take it · ← left · → right · N keep both · Enter next</p>
<script type="module">
const fields = ['name', 'email', 'phone'];
const contacts = [];
for (const file of await resworb.readdir('.')) {
if (!file.endsWith('.csv') || file === 'contacts-merged.csv') continue;
for (const line of (await resworb.readFile(file)).trim().split('\n').slice(1)) {
const [name = '', email = '', phone = ''] = line.trim().split(',');
contacts.push({ name, email, phone, file });
}
}
// Each contact joins at most one pair: the first it is alike to.
const pairs = [];
const paired = new Set();
for (const a of contacts) {
for (const b of contacts) {
if (a !== b && !paired.has(a) && !paired.has(b) && alike(a, b)) {
pairs.push([a, b]);
paired.add(a).add(b);
}
}
}
const kept = [];
let index = 0;
let merged; // the one contact to keep, or null to keep both
next();
pair.onclick = ({ target }) => {
const { side, field } = target.dataset;
if (!field) return;
const contact = pairs[index][side];
merged = { ...(merged ?? contact), [field]: contact[field] };
draw();
};
addEventListener('keydown', ({ key }) => {
const [a, b] = pairs[index] ?? [];
if (!a) return;
if (key === 'ArrowLeft') merged = { ...a };
if (key === 'ArrowRight') merged = { ...b };
if (key === 'n') merged = null;
if (key === 'Enter') {
kept.push(...(merged ? [merged] : [a, b]));
index++;
next();
} else draw();
});
function alike(a, b) {
if (a.email && a.email.toLowerCase() === b.email.toLowerCase()) return true;
const [phoneA, phoneB] = [a, b].map(contact => contact.phone.replace(/\D/g, '').slice(-9));
if (phoneA.length === 9 && phoneA === phoneB) return true;
const [[firstA, lastA], [firstB, lastB]] = [a, b].map(contact => contact.name.toLowerCase().split(/\s+/));
return Boolean(lastA && lastB && lastA[0] === lastB[0] && (firstA.startsWith(firstB) || firstB.startsWith(firstA)));
}
// Starts a pair with each field taken from whichever side has it, left first.
async function next() {
if (index < pairs.length) {
const [a, b] = pairs[index];
merged = Object.fromEntries(fields.map(field => [field, a[field] || b[field]]));
return draw();
}
const rows = [...contacts.filter(contact => !paired.has(contact)), ...kept];
await resworb.writeFile('contacts-merged.csv', ['name,email,phone', ...rows.map(row => fields.map(field => row[field]).join(','))].join('\n') + '\n');
heading.textContent = `Wrote ${rows.length} contacts to contacts-merged.csv. Check it, then delete this page.`;
pair.remove();
keys.remove();
}
function draw() {
heading.textContent = `Pair ${index + 1} of ${pairs.length}`;
const sides = pairs[index].map((contact, side) => `
<div class="card">
<small>${contact.file}</small>
${fields.map(field => `<div data-side="${side}" data-field="${field}" class="${!merged || merged[field] === contact[field] ? 'taken' : ''}">${contact[field] || '–'}</div>`).join('')}
</div>`);
const result = `
<div class="card">
<small>${merged ? 'Keeps one' : 'Keeps both'}</small>
${fields.map(field => `<div>${merged ? merged[field] || '–' : ''}</div>`).join('')}
</div>`;
pair.innerHTML = sides.join('') + result;
}
</script>