syncing apple contact birthdays to google calendar
recently google calendar on ios decided that it wont show your native contacts' birthdays anymore. very irritated by this, i let my agent built a small one-time script to do a sync.
if you are one of my friends' who's birthdays i missed in this short time of a 5tn company deciding to make my life just a bit more annoying, i apologize.
my usual birthday voice notes will resume normaly again now.
here's the script for you or your agent to help you to do the same in case you suffered the same, annoying experience recently.
download the scriptif you have the gog cli installed and logged in, this is the super easy version:
curl -fsSL https://mahir.is/scripts/sync-apple-contact-birthdays-to-google-calendar.py | python3 - --google --account you@gmail.com
that creates a separate google calendar called Apple Contacts Birthdays and adds the recurring birthday events there.
if not, just generate the .ics file and import it manually:
curl -fsSL https://mahir.is/scripts/sync-apple-contact-birthdays-to-google-calendar.py -o /tmp/apple-birthdays.py
python3 /tmp/apple-birthdays.py --out ~/Desktop/apple-contact-birthdays.ics
or copy the script from here:
#!/usr/bin/env python3
"""Export Apple Contacts birthdays to Google Calendar.
Two modes:
1. No Google access, just make an .ics file:
python3 sync-apple-contact-birthdays-to-google-calendar.py
2. If you have the gog CLI installed and logged in, create a separate Google
Calendar directly:
python3 sync-apple-contact-birthdays-to-google-calendar.py --google --account you@gmail.com
The script reads Apple Contacts locally. It does not upload your contacts
anywhere except birthday calendar events when you pass --google.
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import os
import subprocess
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any
DEFAULT_CALENDAR_NAME = "Apple Contacts Birthdays"
DEFAULT_OUTPUT = "apple-contact-birthdays.ics"
GOOGLE_SOURCE = "apple-contacts-birthdays-script"
TIMEZONE = "Europe/Berlin"
JXA = r'''
const Contacts = Application('Contacts');
const people = Contacts.people();
let birthdays = [];
for (let i = 0; i < people.length; i++) {
const person = people[i];
const birthDate = person.birthDate();
if (!birthDate) continue;
const name = person.name() || "Unnamed contact";
const year = birthDate.getFullYear();
birthdays.push({
contactId: person.id(),
name,
year,
month: birthDate.getMonth() + 1,
day: birthDate.getDate(),
hasYear: year !== 1604
});
}
birthdays.sort((a, b) => {
if (a.month !== b.month) return a.month - b.month;
if (a.day !== b.day) return a.day - b.day;
return a.name.localeCompare(b.name);
});
JSON.stringify({ totalContacts: people.length, birthdays });
'''
def read_apple_contacts_birthdays() -> tuple[int, list[dict[str, Any]]]:
result = subprocess.run(
["osascript", "-l", "JavaScript", "-e", JXA],
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
data = json.loads(result.stdout)
return int(data["totalContacts"]), data["birthdays"]
def escape_ics_text(value: str) -> str:
return (
value.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\r\n", "\\n")
.replace("\n", "\\n")
.replace("\r", "\\n")
)
def fold_ics_line(line: str) -> str:
"""Fold one iCalendar line to 75 octets without splitting UTF-8 bytes."""
encoded = line.encode("utf-8")
if len(encoded) <= 75:
return line
parts: list[str] = []
current = ""
current_len = 0
for char in line:
char_len = len(char.encode("utf-8"))
limit = 75 if not parts else 74 # continuation line starts with a space
if current and current_len + char_len > limit:
parts.append(current)
current = char
current_len = char_len
else:
current += char
current_len += char_len
if current:
parts.append(current)
return "\r\n ".join(parts)
def add_ics_line(lines: list[str], line: str) -> None:
lines.append(fold_ics_line(line))
def birthday_hash(contact: dict[str, Any]) -> str:
raw = f"{contact['contactId']}|{int(contact['month']):02d}-{int(contact['day']):02d}|{contact.get('year')}"
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def stable_uid(contact: dict[str, Any]) -> str:
return f"apple-contact-birthday-{birthday_hash(contact)[:24]}@local"
def google_event_id(contact: dict[str, Any]) -> str:
# Google Calendar event IDs may contain letters, digits, underscores and dashes.
return f"applebd{birthday_hash(contact)[:28]}"
def start_year_for(month: int, day: int) -> int:
today = dt.date.today()
year = today.year
if (month, day) < (today.month, today.day):
year += 1
if month == 2 and day == 29:
while True:
try:
dt.date(year, month, day)
return year
except ValueError:
year += 1
return year
def yyyymmdd(year: int, month: int, day: int) -> str:
return dt.date(year, month, day).strftime("%Y%m%d")
def iso_date(year: int, month: int, day: int) -> str:
return dt.date(year, month, day).isoformat()
def next_iso_date(year: int, month: int, day: int) -> str:
return (dt.date(year, month, day) + dt.timedelta(days=1)).isoformat()
def birthday_description(contact: dict[str, Any]) -> str:
if contact["hasYear"]:
return f"Imported from Apple Contacts. Birth year in Apple Contacts: {int(contact['year'])}."
return "Imported from Apple Contacts. Apple Contacts does not store a birth year for this person."
def build_ics(birthdays: list[dict[str, Any]], calendar_name: str) -> str:
now = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
lines: list[str] = []
add_ics_line(lines, "BEGIN:VCALENDAR")
add_ics_line(lines, "VERSION:2.0")
add_ics_line(lines, "PRODID:-//Apple Contacts Birthday Export//mahir.is//EN")
add_ics_line(lines, "CALSCALE:GREGORIAN")
add_ics_line(lines, "METHOD:PUBLISH")
add_ics_line(lines, f"X-WR-CALNAME:{escape_ics_text(calendar_name)}")
add_ics_line(lines, "X-WR-CALDESC:Birthdays exported from Apple Contacts")
for contact in birthdays:
month = int(contact["month"])
day = int(contact["day"])
start_year = start_year_for(month, day)
name = str(contact["name"])
add_ics_line(lines, "BEGIN:VEVENT")
add_ics_line(lines, f"UID:{stable_uid(contact)}")
add_ics_line(lines, f"DTSTAMP:{now}")
add_ics_line(lines, f"SUMMARY:{escape_ics_text('Birthday: ' + name)}")
add_ics_line(lines, f"DESCRIPTION:{escape_ics_text(birthday_description(contact))}")
add_ics_line(lines, f"DTSTART;VALUE=DATE:{yyyymmdd(start_year, month, day)}")
add_ics_line(lines, "DURATION:P1D")
add_ics_line(lines, "RRULE:FREQ=YEARLY")
add_ics_line(lines, "TRANSP:TRANSPARENT")
add_ics_line(lines, "STATUS:CONFIRMED")
add_ics_line(lines, "END:VEVENT")
add_ics_line(lines, "END:VCALENDAR")
return "\r\n".join(lines) + "\r\n"
def write_ics(birthdays: list[dict[str, Any]], out_path: Path, calendar_name: str) -> None:
out_path.write_text(build_ics(birthdays, calendar_name), encoding="utf-8", newline="")
def autodetect_gog_account() -> str:
result = subprocess.run(
["gog", "auth", "tokens", "list", "--json"],
check=True,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
keys = json.loads(result.stdout).get("keys", [])
emails = sorted({key.split(":")[-1] for key in keys if key.startswith("token:") and "@" in key})
if len(emails) == 1:
return emails[0]
if not emails:
raise SystemExit("gog is installed, but I could not find a logged-in Google account. Run `gog login you@gmail.com` first.")
raise SystemExit("Multiple gog accounts found. Re-run with `--account you@gmail.com`. Found: " + ", ".join(emails))
def export_gog_refresh_token(account: str) -> str:
fd, path = tempfile.mkstemp(prefix="gog-token-", suffix=".json")
os.close(fd)
try:
subprocess.run(
["gog", "auth", "tokens", "export", account, "--out", path, "--overwrite"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
)
with open(path, encoding="utf-8") as f:
return json.load(f)["refresh_token"]
finally:
try:
os.remove(path)
except FileNotFoundError:
pass
def get_google_access_token_from_gog(account: str) -> str:
credentials_path = Path.home() / "Library/Application Support/gogcli/credentials.json"
if not credentials_path.exists():
raise SystemExit(f"Could not find gog credentials at {credentials_path}")
credentials = json.loads(credentials_path.read_text(encoding="utf-8"))
refresh_token = export_gog_refresh_token(account)
body = urllib.parse.urlencode(
{
"client_id": credentials["client_id"],
"client_secret": credentials["client_secret"],
"refresh_token": refresh_token,
"grant_type": "refresh_token",
}
).encode("utf-8")
request = urllib.request.Request(
"https://oauth2.googleapis.com/token",
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))["access_token"]
class GoogleCalendar:
def __init__(self, access_token: str):
self.access_token = access_token
def request(
self,
method: str,
path: str,
body: dict[str, Any] | None = None,
query: dict[str, Any] | None = None,
) -> dict[str, Any]:
url = "https://www.googleapis.com/calendar/v3" + path
if query:
url += "?" + urllib.parse.urlencode(query, doseq=True)
data = None if body is None else json.dumps(body).encode("utf-8")
last_error = ""
for attempt in range(6):
request = urllib.request.Request(
url,
data=data,
method=method,
headers={
"Authorization": f"Bearer {self.access_token}",
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
raw = response.read().decode("utf-8")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as error:
payload = error.read().decode(errors="replace")
last_error = f"Google Calendar API {method} {path} failed: HTTP {error.code}: {payload}"
is_rate_limited = error.code in {429, 403} and "rateLimit" in payload
if is_rate_limited and attempt < 5:
time.sleep(2 ** attempt)
continue
raise RuntimeError(last_error) from error
raise RuntimeError(last_error or f"Google Calendar API {method} {path} failed")
def list_calendars(self) -> list[dict[str, Any]]:
calendars: list[dict[str, Any]] = []
page_token = None
while True:
query = {"maxResults": 250}
if page_token:
query["pageToken"] = page_token
data = self.request("GET", "/users/me/calendarList", query=query)
calendars.extend(data.get("items", []))
page_token = data.get("nextPageToken")
if not page_token:
return calendars
def ensure_calendar(self, calendar_name: str) -> tuple[str, bool]:
for calendar in self.list_calendars():
if calendar.get("summary") == calendar_name:
return calendar["id"], False
calendar = self.request(
"POST",
"/calendars",
body={
"summary": calendar_name,
"description": f"Birthdays imported from Apple Contacts on {dt.date.today().isoformat()}.",
"timeZone": TIMEZONE,
},
)
calendar_id = calendar["id"]
try:
self.request(
"PATCH",
"/users/me/calendarList/" + urllib.parse.quote(calendar_id, safe=""),
body={"selected": True},
)
except Exception:
pass
return calendar_id, True
def existing_event_ids(self, calendar_id: str) -> set[str]:
event_ids: set[str] = set()
page_token = None
while True:
query = {
"maxResults": 2500,
"showDeleted": False,
"singleEvents": False,
}
if page_token:
query["pageToken"] = page_token
data = self.request(
"GET",
"/calendars/" + urllib.parse.quote(calendar_id, safe="") + "/events",
query=query,
)
event_ids.update(event["id"] for event in data.get("items", []) if event.get("id"))
page_token = data.get("nextPageToken")
if not page_token:
return event_ids
def insert_birthday_event(self, calendar_id: str, contact: dict[str, Any]) -> str:
month = int(contact["month"])
day = int(contact["day"])
start_year = start_year_for(month, day)
event_id = google_event_id(contact)
event = {
"id": event_id,
"summary": f"Birthday: {contact['name']}",
"description": birthday_description(contact),
"start": {"date": iso_date(start_year, month, day)},
"end": {"date": next_iso_date(start_year, month, day)},
"recurrence": ["RRULE:FREQ=YEARLY"],
"transparency": "transparent",
"visibility": "private",
"reminders": {"useDefault": False},
"extendedProperties": {
"private": {
"source": GOOGLE_SOURCE,
"syncId": event_id,
"appleContactHash": hashlib.sha256(str(contact["contactId"]).encode("utf-8")).hexdigest(),
}
},
}
path = "/calendars/" + urllib.parse.quote(calendar_id, safe="") + "/events"
try:
self.request("POST", path, body=event, query={"sendUpdates": "none"})
return "created"
except RuntimeError as error:
if "HTTP 409" in str(error):
return "skipped"
raise
def import_birthdays(self, calendar_name: str, birthdays: list[dict[str, Any]]) -> tuple[str, bool, int, int]:
calendar_id, created_calendar = self.ensure_calendar(calendar_name)
existing_ids = self.existing_event_ids(calendar_id)
created = 0
skipped = 0
for contact in birthdays:
if google_event_id(contact) in existing_ids:
skipped += 1
continue
status = self.insert_birthday_event(calendar_id, contact)
if status == "created":
created += 1
time.sleep(0.2)
else:
skipped += 1
return calendar_id, created_calendar, created, skipped
def print_summary(total_contacts: int, birthdays: list[dict[str, Any]]) -> None:
with_year = sum(1 for birthday in birthdays if birthday["hasYear"])
without_year = len(birthdays) - with_year
print(f"read {total_contacts} Apple Contacts")
print(f"found {len(birthdays)} birthdays ({with_year} with year, {without_year} month/day only)")
def main() -> int:
parser = argparse.ArgumentParser(
description="Export Apple Contacts birthdays to an .ics file, or import them directly with gog."
)
parser.add_argument(
"--out",
default=DEFAULT_OUTPUT,
help=f"Output .ics file path. Default: {DEFAULT_OUTPUT}",
)
parser.add_argument(
"--calendar-name",
default=DEFAULT_CALENDAR_NAME,
help=f"Calendar name. Default: {DEFAULT_CALENDAR_NAME!r}",
)
parser.add_argument(
"--google",
action="store_true",
help="Use gog's stored OAuth token to create a separate Google Calendar directly.",
)
parser.add_argument(
"--account",
help="Google account email for gog mode. Optional if gog has exactly one account.",
)
parser.add_argument(
"--also-write-ics",
action="store_true",
help="In --google mode, also write the .ics file.",
)
args = parser.parse_args()
total_contacts, birthdays = read_apple_contacts_birthdays()
print_summary(total_contacts, birthdays)
if args.google:
account = args.account or autodetect_gog_account()
access_token = get_google_access_token_from_gog(account)
google = GoogleCalendar(access_token)
calendar_id, created_calendar, created, skipped = google.import_birthdays(args.calendar_name, birthdays)
print(f"google account: {account}")
print(f"calendar: {args.calendar_name} ({'created' if created_calendar else 'already existed'})")
print(f"calendar id: {calendar_id}")
print(f"created {created} events, skipped {skipped} existing events")
if not args.also_write_ics:
return 0
out_path = Path(args.out).expanduser().resolve()
write_ics(birthdays, out_path, args.calendar_name)
print(f"wrote {out_path}")
print("import it in Google Calendar: Settings > Import & export > Import")
return 0
if __name__ == "__main__":
raise SystemExit(main())
how to use it without gog
save the script as sync-apple-contact-birthdays-to-google-calendar.py, then run:
python3 sync-apple-contact-birthdays-to-google-calendar.py
it will ask macos for Contacts permission if needed, read your Apple Contacts locally, and write:
apple-contact-birthdays.ics
then open Google Calendar on the web and do this:
- settings
- import & export
- import
- choose
apple-contact-birthdays.ics - ideally import it into a separate calendar called
Apple Contacts Birthdays
that separate calendar part is nice because undoing this later is just hiding or deleting that calendar.
if you want an agent to do it, literally paste this:
run this mac script locally, generate apple-contact-birthdays.ics from Apple Contacts, and help me import it into Google Calendar as a separate calendar called Apple Contacts Birthdays.
that's it. no cloud service, no google contacts migration, no weird contact merging. just a one-time calendar file.