assistant-skills/contacts/scripts/contacts

290 lines
10 KiB
Text
Executable file

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.12"
# dependencies = ["requests"]
# ///
import argparse
import sys
import uuid
from datetime import datetime, timezone
from xml.etree import ElementTree
import requests
BASE_URL = "https://cc.connorrhodes.com"
USERNAME = "connor"
PASSWORD = "xK9mN2pQ7vR8wL4jT6hY3sF5dG0nZ1bC"
def get_session():
s = requests.Session()
s.auth = (USERNAME, PASSWORD)
return s
def discover_addressbook(session):
principal_url = f"{BASE_URL}/{USERNAME}/"
headers = {"Depth": "1", "Content-Type": "application/xml"}
body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:resourcetype/><d:displayname/></d:prop></d:propfind>'
resp = session.request("PROPFIND", principal_url, headers=headers, data=body)
tree = ElementTree.fromstring(resp.text)
ns = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"}
for resp_elem in tree.findall("d:response", ns):
href = resp_elem.find("d:href", ns).text
propstat = resp_elem.find("d:propstat", ns)
if propstat is not None:
prop = propstat.find("d:prop", ns)
if prop is not None:
rt = prop.find("d:resourcetype", ns)
if rt is not None and rt.find("card:addressbook", ns) is not None:
return href
return None
def fetch_all_contacts(session, addressbook_href):
url = f"{BASE_URL}{addressbook_href}"
headers = {"Depth": "1", "Content-Type": "application/xml"}
body = '<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/></d:prop></d:propfind>'
resp = session.request("PROPFIND", url, headers=headers, data=body)
tree = ElementTree.fromstring(resp.text)
ns = {"d": "DAV:"}
contacts = []
for resp_elem in tree.findall("d:response", ns):
href = resp_elem.find("d:href", ns).text
if href.endswith(".vcf"):
vcf_resp = session.get(f"{BASE_URL}{href}")
contact = parse_vcard(vcf_resp.text)
contact["_href"] = href
contacts.append(contact)
return contacts
def parse_vcard(text):
contact = {"fn": "", "phone": "", "email": "", "note": "", "uid": ""}
unfolded = []
for line in text.splitlines():
if line.startswith(" ") or line.startswith("\t"):
if unfolded:
unfolded[-1] += line[1:]
else:
unfolded.append(line)
for line in unfolded:
if line.startswith("FN:"):
contact["fn"] = line[3:]
elif line.startswith("TEL"):
if ":" in line:
contact["phone"] = line.split(":", 1)[1]
elif line.startswith("EMAIL"):
if ":" in line:
contact["email"] = line.split(":", 1)[1]
elif line.startswith("NOTE:"):
contact["note"] = line[5:]
elif line.startswith("UID:"):
contact["uid"] = line[4:]
return contact
def build_vcard(fn, phone="", email="", note="", uid=None):
if not uid:
uid = str(uuid.uuid4())
parts = fn.strip().rsplit(" ", 1)
last = parts[1] if len(parts) > 1 else ""
first = parts[0]
lines = [
"BEGIN:VCARD",
"VERSION:3.0",
f"UID:{uid}",
f"FN:{fn}",
f"N:{last};{first};;;",
]
if phone:
lines.append(f"TEL;TYPE=cell:{phone}")
if email:
lines.append(f"EMAIL:{email}")
if note:
lines.append(f"NOTE:{note}")
lines.append(f"REV:{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}")
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
def print_contact(contact):
print(f" Name: {contact['fn']}")
if contact["phone"]:
print(f" Phone: {contact['phone']}")
if contact["email"]:
print(f" Email: {contact['email']}")
if contact["note"]:
print(f" Note: {contact['note']}")
print()
def find_duplicates(contacts, name=None, phone=None, email=None):
dupes = []
for c in contacts:
if phone and c["phone"] == phone:
dupes.append(c)
continue
if email and c["email"].lower() == email.lower():
dupes.append(c)
continue
if name and not phone and not email and c["fn"].lower() == name.lower():
dupes.append(c)
return dupes
def search_contacts(contacts, name=None, phone=None, email=None):
results = []
for c in contacts:
match = False
if name and name.lower() in c["fn"].lower():
match = True
if phone and phone in c["phone"]:
match = True
if email and email.lower() in c["email"].lower():
match = True
if match:
results.append(c)
return results
def cmd_create(args, session, addressbook_href):
contacts = fetch_all_contacts(session, addressbook_href)
dupes = find_duplicates(contacts, name=args.name, phone=args.phone, email=args.email)
if dupes:
print("Contact already exists:", file=sys.stderr)
for d in dupes:
print_contact(d)
sys.exit(1)
uid = str(uuid.uuid4())
vcard = build_vcard(args.name, phone=args.phone or "", email=args.email or "", note=args.note or "", uid=uid)
url = f"{BASE_URL}{addressbook_href}{uid}.vcf"
resp = session.put(url, data=vcard.encode("utf-8"), headers={"Content-Type": "text/vcard"})
if resp.ok:
print(f"Created contact: {args.name}")
else:
print(f"Error: {resp.status_code} {resp.text}", file=sys.stderr)
sys.exit(1)
def cmd_read(args, session, addressbook_href):
contacts = fetch_all_contacts(session, addressbook_href)
if args.name or args.phone or args.email:
contacts = search_contacts(contacts, name=args.name, phone=args.phone, email=args.email)
if not contacts:
print("No contacts found.")
return
for c in contacts:
print_contact(c)
def cmd_update(args, session, addressbook_href):
contacts = fetch_all_contacts(session, addressbook_href)
matches = search_contacts(contacts, name=args.find, phone=args.find, email=args.find)
if not matches:
print(f"No contact found matching '{args.find}'.", file=sys.stderr)
sys.exit(1)
if len(matches) > 1:
print(f"Multiple contacts match '{args.find}', be more specific:", file=sys.stderr)
for m in matches:
print_contact(m)
sys.exit(1)
contact = matches[0]
new_fn = args.name if args.name else contact["fn"]
new_phone = args.phone if args.phone else contact["phone"]
new_email = args.email if args.email else contact["email"]
new_note = args.note if args.note else contact["note"]
vcard = build_vcard(new_fn, phone=new_phone, email=new_email, note=new_note, uid=contact["uid"])
url = f"{BASE_URL}{contact['_href']}"
resp = session.put(url, data=vcard.encode("utf-8"), headers={"Content-Type": "text/vcard"})
if resp.ok:
print(f"Updated contact: {new_fn}")
else:
print(f"Error: {resp.status_code} {resp.text}", file=sys.stderr)
sys.exit(1)
def cmd_delete(args, session, addressbook_href):
contacts = fetch_all_contacts(session, addressbook_href)
matches = search_contacts(contacts, name=args.find, phone=args.find, email=args.find)
if not matches:
print(f"No contact found matching '{args.find}'.", file=sys.stderr)
sys.exit(1)
if len(matches) > 1:
print(f"Multiple contacts match '{args.find}', be more specific:", file=sys.stderr)
for m in matches:
print_contact(m)
sys.exit(1)
contact = matches[0]
if not args.yes:
print("About to delete:")
print_contact(contact)
answer = input("Confirm? [y/N] ").strip().lower()
if answer not in ("y", "yes"):
print("Cancelled.")
return
url = f"{BASE_URL}{contact['_href']}"
resp = session.delete(url)
if resp.ok:
print(f"Deleted contact: {contact['fn']}")
else:
print(f"Error: {resp.status_code} {resp.text}", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(description="Manage Radicale contacts")
sub = parser.add_subparsers(dest="command")
p_create = sub.add_parser("create", help="Create a new contact")
p_create.add_argument("--name", required=True, help="Contact name (required)")
p_create.add_argument("--phone", help="Phone number")
p_create.add_argument("--email", help="Email address")
p_create.add_argument("--note", help="Note")
p_read = sub.add_parser("read", help="Read contacts")
p_read.add_argument("--name", help="Filter by name")
p_read.add_argument("--phone", help="Filter by phone")
p_read.add_argument("--email", help="Filter by email")
p_update = sub.add_parser("update", help="Update a contact")
p_update.add_argument("--find", required=True, help="Search term to identify contact")
p_update.add_argument("--name", help="New name")
p_update.add_argument("--phone", help="New phone number")
p_update.add_argument("--email", help="New email address")
p_update.add_argument("--note", help="New note")
p_delete = sub.add_parser("delete", help="Delete a contact")
p_delete.add_argument("--find", required=True, help="Search term to identify contact")
p_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
if args.command == "create" and not any([args.phone, args.email, args.note]):
print("Error: at least one of --phone, --email, or --note is required", file=sys.stderr)
sys.exit(1)
if args.command == "update" and not any([args.name, args.phone, args.email, args.note]):
print("Error: provide at least one field to update (--name, --phone, --email, --note)", file=sys.stderr)
sys.exit(1)
session = get_session()
addressbook_href = discover_addressbook(session)
if not addressbook_href:
print("Error: could not discover addressbook", file=sys.stderr)
sys.exit(1)
{"create": cmd_create, "read": cmd_read, "update": cmd_update, "delete": cmd_delete}[args.command](args, session, addressbook_href)
if __name__ == "__main__":
main()