Back

contacts.papp

All 125 lines. No libraries, nothing hidden. Read it before you run it.

contacts.papp
<!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>
Download contacts.papp