#!/usr/bin/env python3 """ vcards-to-proton.py Convert readpst-generated .contacts files (concatenated vCard 3.0 records) into a UTF-8 CSV suitable for importing into Proton Contacts. No third-party Python packages are required. Examples: # Examine everything without creating a CSV: python3 vcards-to-proton.py \ --input ~/Documents/contacts \ --dry-run # Process only the main Outlook Contacts folder: python3 vcards-to-proton.py \ --input ~/Documents/contacts \ --include "Contacts.contacts" \ --dry-run # Create the Proton CSV: python3 vcards-to-proton.py \ --input ~/Documents/contacts \ --include "Contacts.contacts" \ --output ~/Documents/proton-contacts.csv # Process all five .contacts files: python3 vcards-to-proton.py \ --input ~/Documents/contacts \ --output ~/Documents/proton-all-contacts.csv """ import argparse import csv import re import sys from collections import defaultdict from pathlib import Path from urllib.parse import unquote EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") # Proton accepts many variations of these names. These are deliberately # conventional names that Proton's importer recognizes. FIELDS = [ "First Name", "Middle Name", "Last Name", "Name", "Email", "Email 2", "Email 3", "Phone", "Phone 2", "Phone 3", "Organization", "Title", "Street", "City", "State", "Postal Code", "Country", "Website", "Notes", ] def unfold_vcard(text): """ Undo vCard line folding. A continuation line begins with a space or tab. """ return re.sub(r"\r?\n[ \t]", "", text) def split_escaped(value, separator=";"): """ Split a vCard value while respecting escaped separators. """ result = [] current = [] escaped = False for char in value: if escaped: current.append("\\" + char) escaped = False elif char == "\\": escaped = True elif char == separator: result.append("".join(current)) current = [] else: current.append(char) if escaped: current.append("\\") result.append("".join(current)) return result def unescape_vcard(value): """ Decode common vCard 3.0 escaped characters. """ value = value.replace(r"\n", "\n") value = value.replace(r"\N", "\n") value = value.replace(r"\,", ",") value = value.replace(r"\;", ";") value = value.replace(r"\\", "\\") # Decode percent-encoded values where present. value = unquote(value) return value def parse_property(line): """ Parse: PROPERTY;PARAM=value:value Returns: property_name, parameters, value """ if ":" not in line: return None, {}, "" left, value = line.split(":", 1) parts = left.split(";") name = parts[0].upper() parameters = defaultdict(list) for part in parts[1:]: if "=" in part: key, param_value = part.split("=", 1) parameters[key.upper()].extend(param_value.split(",")) elif part: parameters[part.upper()].append("") return name, parameters, unescape_vcard(value) def parse_vcards(path): """ Read all VCARD records from a .contacts file. """ text = path.read_text( encoding="utf-8-sig", errors="replace" ) text = unfold_vcard(text) records = re.findall( r"BEGIN:VCARD\s*(.*?)\s*END:VCARD", text, flags=re.IGNORECASE | re.DOTALL ) contacts = [] for record_number, body in enumerate(records, 1): contact = { "source": path.name, "record": record_number, "first": "", "middle": "", "last": "", "name": "", "emails": [], "phones": [], "organization": "", "title": "", "street": "", "city": "", "state": "", "postal": "", "country": "", "websites": [], "notes": [], } lines = ( body .replace("\r\n", "\n") .replace("\r", "\n") .split("\n") ) for raw_line in lines: raw_line = raw_line.strip() if not raw_line: continue name, parameters, value = parse_property(raw_line) if not name: continue if name == "FN": contact["name"] = value elif name == "N": parts = split_escaped(value) while len(parts) < 5: parts.append("") # vCard N: # Family;Given;Additional;Prefix;Suffix contact["last"] = parts[0] contact["first"] = parts[1] contact["middle"] = parts[2] # Prefix/suffix aren't included in the Proton CSV below. elif name == "EMAIL": email = value.strip() if email and EMAIL_RE.match(email): if email not in contact["emails"]: contact["emails"].append(email) elif name == "TEL": phone = value.strip() if phone and phone not in contact["phones"]: contact["phones"].append(phone) elif name == "ORG": # ORG may contain multiple semicolon-separated components. parts = split_escaped(value) contact["organization"] = ", ".join( p for p in parts if p ) elif name == "TITLE": contact["title"] = value elif name == "ADR": parts = split_escaped(value) while len(parts) < 7: parts.append("") # PO Box;Extended;Street;City;Region;Postal;Country contact["street"] = parts[2] contact["city"] = parts[3] contact["state"] = parts[4] contact["postal"] = parts[5] contact["country"] = parts[6] elif name in ("URL", "X-SOCIALPROFILE"): if value and value not in contact["websites"]: contact["websites"].append(value) elif name == "NOTE": if value and value not in contact["notes"]: contact["notes"].append(value) contacts.append(contact) return contacts def display_name(contact): """ Determine the most useful display name. """ if contact["name"]: return contact["name"] parts = [ contact["first"], contact["middle"], contact["last"], ] name = " ".join(p for p in parts if p).strip() if name: return name if contact["organization"]: return contact["organization"] if contact["emails"]: return contact["emails"][0] return "(unnamed contact)" def contact_key(contact): """ Primary duplicate key. Email is the safest identifier when available. Contacts with no email are conservatively keyed by name + organization. """ if contact["emails"]: return "email:" + contact["emails"][0].strip().lower() return "name:" + "|".join([ contact["last"].strip().lower(), contact["first"].strip().lower(), contact["organization"].strip().lower(), ]) def merge_contacts(existing, incoming): """ Merge information from two duplicate contacts. Existing non-empty values are retained. Missing information is filled from the incoming contact. Multi-value fields are combined. """ scalar_fields = [ "first", "middle", "last", "name", "organization", "title", "street", "city", "state", "postal", "country", ] for field in scalar_fields: if not existing[field] and incoming[field]: existing[field] = incoming[field] for field in [ "emails", "phones", "websites", "notes", ]: for value in incoming[field]: if value not in existing[field]: existing[field].append(value) return existing def convert_to_row(contact): """ Convert internal contact representation to Proton CSV row. """ row = {field: "" for field in FIELDS} row["First Name"] = contact["first"] row["Middle Name"] = contact["middle"] row["Last Name"] = contact["last"] row["Name"] = display_name(contact) emails = contact["emails"][:3] for index, email in enumerate(emails): if index == 0: row["Email"] = email else: row[f"Email {index + 1}"] = email phones = contact["phones"][:3] for index, phone in enumerate(phones): if index == 0: row["Phone"] = phone else: row[f"Phone {index + 1}"] = phone row["Organization"] = contact["organization"] row["Title"] = contact["title"] row["Street"] = contact["street"] row["City"] = contact["city"] row["State"] = contact["state"] row["Postal Code"] = contact["postal"] row["Country"] = contact["country"] if contact["websites"]: row["Website"] = contact["websites"][0] if contact["notes"]: row["Notes"] = "\n".join(contact["notes"]) return row def main(): parser = argparse.ArgumentParser( description=( "Convert readpst .contacts/vCard 3.0 files " "to a Proton-compatible CSV." ) ) parser.add_argument( "-i", "--input", required=True, help="A .contacts file or directory containing .contacts files.", ) parser.add_argument( "-o", "--output", default="proton-contacts.csv", help="Output CSV filename.", ) parser.add_argument( "--include", action="append", help=( "Process only this filename. " "Can be specified more than once." ), ) parser.add_argument( "--exclude", action="append", help=( "Exclude this filename. " "Can be specified more than once." ), ) parser.add_argument( "--no-dedupe", action="store_true", help="Do not merge duplicate contacts.", ) parser.add_argument( "--dry-run", action="store_true", help="Analyze contacts without creating the CSV.", ) parser.add_argument( "--show", type=int, default=20, help="Number of contacts to display during dry-run.", ) args = parser.parse_args() input_path = Path(args.input).expanduser() # Determine input files. if input_path.is_file(): files = [input_path] elif input_path.is_dir(): files = sorted(input_path.glob("*.contacts")) else: print( f"ERROR: Input path does not exist: {input_path}", file=sys.stderr, ) return 2 # Apply --include. if args.include: wanted = set(args.include) files = [ f for f in files if f.name in wanted ] # Apply --exclude. if args.exclude: excluded = set(args.exclude) files = [ f for f in files if f.name not in excluded ] if not files: print( "ERROR: No .contacts files were selected.", file=sys.stderr, ) return 2 print("Files selected:") for file_path in files: print(f" {file_path}") print() all_contacts = [] errors = [] # Read all selected files. for file_path in files: try: contacts = parse_vcards(file_path) all_contacts.extend(contacts) print( f"Read {len(contacts):4d} contacts " f"from {file_path.name}" ) except Exception as error: errors.append( (file_path.name, str(error)) ) # Deduplicate. if args.no_dedupe: final_contacts = all_contacts duplicate_count = 0 else: contacts_by_key = {} order = [] duplicate_count = 0 for contact in all_contacts: key = contact_key(contact) if key in contacts_by_key: merge_contacts( contacts_by_key[key], contact ) duplicate_count += 1 else: contacts_by_key[key] = contact order.append(key) final_contacts = [ contacts_by_key[key] for key in order ] print() print(f"Total vCards read: {len(all_contacts)}") print(f"Duplicates merged: {duplicate_count}") print(f"Contacts to export: {len(final_contacts)}") if errors: print() print("Files with errors:") for filename, error in errors: print(f" {filename}: {error}") # Dry-run. if args.dry_run: print() print("DRY RUN: no CSV file was created.") show_count = min( max(args.show, 0), len(final_contacts) ) if show_count: print() print("Contacts that would be exported:") for number, contact in enumerate( final_contacts[:show_count], 1 ): name = display_name(contact) email_text = ", ".join( contact["emails"] ) if not email_text: email_text = "(no email address)" print( f" {number:3d}. " f"{name} | {email_text}" ) if len(final_contacts) > show_count: print( f"\n ... and " f"{len(final_contacts) - show_count} more." ) return 0 # Write CSV. output_path = Path(args.output).expanduser() output_path.parent.mkdir( parents=True, exist_ok=True ) # utf-8-sig gives the file a UTF-8 BOM, which makes it especially # friendly to spreadsheet applications while remaining valid UTF-8. with output_path.open( "w", encoding="utf-8-sig", newline="" ) as output_file: writer = csv.DictWriter( output_file, fieldnames=FIELDS, extrasaction="ignore" ) writer.writeheader() for contact in final_contacts: writer.writerow( convert_to_row(contact) ) print() print(f"CSV created: {output_path}") return 0 if __name__ == "__main__": raise SystemExit(main())