""" Merge and delete duplicate contacts in Barbara Bardach's main Contacts folder. Reads analysis from bardach_main_dupes_analysis.json, merges data from delete contacts into keepers, then deletes the duplicates. """ import json import subprocess import sys import urllib.parse TENANT_ID = "dd4a82e8-85a3-44ac-8800-07945ab4d95f" CLIENT_ID = "fabb3421-8b34-484b-bc17-e46de9703418" CLIENT_SECRET = "~QJ8Q~NyQSs4OcGqHZyPrA2CVnq9KBfKiimntbMO" USER_EMAIL = "barbara@bardach.net" GRAPH_BASE = f"https://graph.microsoft.com/v1.0/users/{USER_EMAIL}/contacts" ANALYSIS_FILE = r"D:\ClaudeTools\temp\bardach_main_dupes_analysis.json" def get_token(): url = f"https://login.microsoftonline.com/{TENANT_ID}/oauth2/v2.0/token" data = ( f"grant_type=client_credentials" f"&client_id={CLIENT_ID}" f"&client_secret={urllib.parse.quote(CLIENT_SECRET, safe='')}" f"&scope=https%3A%2F%2Fgraph.microsoft.com%2F.default" ) result = subprocess.run( ["curl", "-s", "-X", "POST", url, "-H", "Content-Type: application/x-www-form-urlencoded", "-d", data], capture_output=True, text=True ) resp = json.loads(result.stdout) if "access_token" not in resp: print(f"[ERROR] Failed to get token: {resp}") sys.exit(1) return resp["access_token"] def get_contact(token, contact_id): """GET full contact details from Graph API.""" url = f"{GRAPH_BASE}/{contact_id}" select = "$select=displayName,givenName,surname,emailAddresses,homePhones,businessPhones,personalNotes,companyName,jobTitle,homeAddress,businessAddress,birthday" result = subprocess.run( ["curl", "-s", "-X", "GET", f"{url}?{select}", "-H", f"Authorization: Bearer {token}", "-H", "Content-Type: application/json"], capture_output=True, text=True ) return json.loads(result.stdout) def patch_contact(token, contact_id, payload): """PATCH a contact with the given payload.""" payload_json = json.dumps(payload) url = f"{GRAPH_BASE}/{contact_id}" result = subprocess.run( ["curl", "-s", "-X", "PATCH", url, "-H", f"Authorization: Bearer {token}", "-H", "Content-Type: application/json", "-d", payload_json, "-w", "\n%{http_code}"], capture_output=True, text=True ) lines = result.stdout.strip().rsplit("\n", 1) status = int(lines[-1]) if len(lines) > 1 else 0 return status, lines[0] if len(lines) > 1 else result.stdout def delete_contact(token, contact_id): """DELETE a contact. Returns HTTP status code.""" url = f"{GRAPH_BASE}/{contact_id}" result = subprocess.run( ["curl", "-s", "-X", "DELETE", url, "-H", f"Authorization: Bearer {token}", "-o", "/dev/null", "-w", "%{http_code}"], capture_output=True, text=True ) return int(result.stdout.strip()) def emails_equal(a, b): """Case-insensitive email comparison.""" return (a or "").lower().strip() == (b or "").lower().strip() def has_address_data(addr): """Check if an address dict has any actual data.""" if not addr or not isinstance(addr, dict): return False return any(v for v in addr.values() if v) def build_merge_payload(keeper, delete_contact_data): """Compare keeper and delete contact, return PATCH payload for fields to merge.""" payload = {} merge_notes = [] # --- emailAddresses --- keeper_emails = keeper.get("emailAddresses") or [] delete_emails = delete_contact_data.get("emailAddresses") or [] keeper_addrs = {e.get("address", "").lower().strip() for e in keeper_emails if e.get("address", "").strip()} new_emails = [] for e in delete_emails: addr = (e.get("address") or "").strip() if addr and addr.lower() not in keeper_addrs: new_emails.append(e) if new_emails: # Filter out empty entries from keeper too clean_keeper = [e for e in keeper_emails if (e.get("address") or "").strip()] payload["emailAddresses"] = clean_keeper + new_emails merge_notes.append(f"emails: +{[e['address'] for e in new_emails]}") # --- homePhones --- keeper_hp = keeper.get("homePhones") or [] delete_hp = delete_contact_data.get("homePhones") or [] keeper_hp_set = {p.strip() for p in keeper_hp if p.strip()} new_hp = [p for p in delete_hp if p.strip() and p.strip() not in keeper_hp_set] if new_hp: payload["homePhones"] = list(keeper_hp) + new_hp merge_notes.append(f"homePhones: +{new_hp}") # --- businessPhones --- keeper_bp = keeper.get("businessPhones") or [] delete_bp = delete_contact_data.get("businessPhones") or [] keeper_bp_set = {p.strip() for p in keeper_bp if p.strip()} new_bp = [p for p in delete_bp if p.strip() and p.strip() not in keeper_bp_set] if new_bp: payload["businessPhones"] = list(keeper_bp) + new_bp merge_notes.append(f"businessPhones: +{new_bp}") # --- personalNotes --- keeper_notes = (keeper.get("personalNotes") or "").strip() delete_notes = (delete_contact_data.get("personalNotes") or "").strip() if delete_notes and not keeper_notes: payload["personalNotes"] = delete_notes merge_notes.append(f"personalNotes: set") elif delete_notes and keeper_notes and delete_notes.lower() != keeper_notes.lower(): payload["personalNotes"] = keeper_notes + "\n" + delete_notes merge_notes.append(f"personalNotes: appended") # --- companyName --- keeper_co = (keeper.get("companyName") or "").strip() delete_co = (delete_contact_data.get("companyName") or "").strip() if delete_co and not keeper_co: payload["companyName"] = delete_co merge_notes.append(f"companyName: '{delete_co}'") # --- jobTitle --- keeper_jt = (keeper.get("jobTitle") or "").strip() delete_jt = (delete_contact_data.get("jobTitle") or "").strip() if delete_jt and not keeper_jt: payload["jobTitle"] = delete_jt merge_notes.append(f"jobTitle: '{delete_jt}'") # --- homeAddress --- if has_address_data(delete_contact_data.get("homeAddress")) and not has_address_data(keeper.get("homeAddress")): payload["homeAddress"] = delete_contact_data["homeAddress"] merge_notes.append("homeAddress: set") # --- businessAddress --- if has_address_data(delete_contact_data.get("businessAddress")) and not has_address_data(keeper.get("businessAddress")): payload["businessAddress"] = delete_contact_data["businessAddress"] merge_notes.append("businessAddress: set") # --- birthday --- keeper_bday = keeper.get("birthday") delete_bday = delete_contact_data.get("birthday") if delete_bday and not keeper_bday: payload["birthday"] = delete_bday merge_notes.append(f"birthday: '{delete_bday}'") return payload, merge_notes def main(): with open(ANALYSIS_FILE, "r", encoding="utf-8") as f: analysis = json.load(f) groups = analysis["groups"] print(f"Loaded {len(groups)} duplicate groups to process.\n") token = get_token() print("[OK] Got access token.\n") success_count = 0 error_count = 0 for i, group in enumerate(groups, 1): name = group["name"] keeper_id = group["keeper_id"] delete_ids = group["delete_ids"] print(f"--- Group {i}/{len(groups)}: {name} ---") # GET keeper details keeper = get_contact(token, keeper_id) if "error" in keeper: print(f" [ERROR] Failed to GET keeper: {keeper['error']}") error_count += 1 continue for did in delete_ids: # GET delete contact details del_data = get_contact(token, did) if "error" in del_data: print(f" [ERROR] Failed to GET delete contact: {del_data['error']}") error_count += 1 continue # Build merge payload payload, merge_notes = build_merge_payload(keeper, del_data) if payload: status, resp_body = patch_contact(token, keeper_id, payload) if 200 <= status < 300: print(f" [OK] PATCH keeper - merged: {', '.join(merge_notes)}") # Update our local keeper data with the patched fields keeper.update(payload) else: print(f" [ERROR] PATCH keeper failed (HTTP {status}): {resp_body[:200]}") error_count += 1 continue else: print(f" [INFO] No data to merge from duplicate.") # DELETE the duplicate del_status = delete_contact(token, did) if del_status == 204: print(f" [OK] DELETE duplicate (HTTP 204)") success_count += 1 else: print(f" [ERROR] DELETE failed (HTTP {del_status})") error_count += 1 print() print(f"=== DONE: {success_count} deleted successfully, {error_count} errors ===") if __name__ == "__main__": main()