Synced files: - Quote wizard frontend (all components, hooks, types, config) - API updates (config, models, routers, schemas, services) - Client work (bg-builders, gurushow) - Scripts (BGB Lesley termination, CIPP, Datto, migration) - Temp files (Bardach contacts, VWP investigation, misc) - Credentials and session logs - Email service, PHP API, session logs Machine: ACG-M-L5090 Timestamp: 2026-03-10 19:11:00 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
246 lines
9.0 KiB
Python
246 lines
9.0 KiB
Python
"""
|
|
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()
|