resworbDownload resworb
Back

contacts.lmth

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

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