contacts.papp
All 125 lines. No libraries, nothing hidden. Read it before you run it.
<!doctype html>
<meta charset="utf-8">
<meta name="papp-write" content="./contacts-merged.csv">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-3 5 64 64'><g fill='%23111114'><circle cx='20' cy='22' r='9'/><path d='M4 54a16 16 0 0 1 32 0z'/></g><g fill='%23ff4f00' stroke='%23fff' stroke-width='3' paint-order='stroke'><circle cx='37' cy='29' r='9.5'/><path d='M20 61a17 17 0 0 1 34 0z'/></g></svg>">
<!--
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>
:root { color-scheme: light dark; --paper: light-dark(#fff, #0f0f11); --well: light-dark(#f4f4f5, #1c1c20); --ink: light-dark(#0f0f11, #f2f2f3); --soft: light-dark(#6b6b72, #9b9ba4); --rule: light-dark(#e4e4e7, #2b2b31); --accent: #ff4f00; }
body { max-width: 960px; margin: 0 auto; padding: 40px 28px; background: var(--paper); color: var(--ink); font: 14px/1.45 system-ui, sans-serif; }
h1 { margin: 0 0 24px; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
#pair { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
.card { overflow: hidden; border: 1px solid var(--rule); border-radius: 14px; }
.card:last-child { border-color: var(--ink); background: var(--well); }
.card small { display: block; padding: 12px 16px; border-bottom: 1px solid var(--rule); color: var(--soft); font: 12px ui-monospace, monospace; }
.card:last-child small { color: var(--ink); font: 600 12px system-ui, sans-serif; letter-spacing: 0.06em; text-transform: uppercase; }
.card div { min-height: 1.45em; padding: 10px 16px; border-left: 3px solid transparent; overflow-wrap: anywhere; }
.card div::before { content: attr(data-label); display: block; color: var(--soft); font-size: 11px; }
[data-field] { cursor: pointer; }
[data-field]:hover { background: var(--well); }
.card .taken { border-left-color: var(--accent); font-weight: 600; }
#keys { margin-top: 24px; color: var(--soft); }
kbd { padding: 1px 6px; border: 1px solid var(--rule); border-bottom-width: 2px; border-radius: 5px; color: var(--ink); font: 12px ui-monospace, monospace; }
</style>
<h1 id="heading"></h1>
<div id="pair"></div>
<p id="keys">Click a field to take it · <kbd>←</kbd> left · <kbd>→</kbd> right · <kbd>N</kbd> keep both · <kbd>Enter</kbd> next</p>
<script type="module">
const fields = ['name', 'email', 'phone'];
const contacts = [];
for (const file of await papp.readdir('.')) {
if (!file.endsWith('.csv') || file === 'contacts-merged.csv') continue;
for (const line of (await papp.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 papp.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}" data-label="${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 data-label="${field}">${merged ? merged[field] || '–' : ''}</div>`).join('')}
</div>`;
pair.innerHTML = sides.join('') + result;
}
</script>