Skip to content

Instantly share code, notes, and snippets.

@ericboehs
Last active June 30, 2021 10:27
Show Gist options
  • Save ericboehs/d16cfe665558df46f8be6695a9ad14c7 to your computer and use it in GitHub Desktop.
Save ericboehs/d16cfe665558df46f8be6695a9ad14c7 to your computer and use it in GitHub Desktop.
Interface with Apple's iCloud CalDav Calendars
require 'rexml/document'
require 'rexml/xpath'
require 'http'
require 'icalendar'
HTTP::Request::METHODS = HTTP::Request::METHODS + [:report]
module AppleCalDav
class Client
attr_accessor :username, :password
def initialize username, password
@username = username
@password = password
end
def calendars
@calendars ||= (
xml = propfind "/#{dsid}/calendars/", 'allprop'
REXML::XPath.each(xml, "//multistatus/response").map do |cal|
path = cal.elements["href"].text
if cal.elements["propstat"] &&
cal.elements["propstat"].elements["prop"].elements["calendar-enabled"]
name = cal.elements["propstat"].elements["prop"].elements["displayname"].text
{path: path, name: name}
end
end.compact
)
end
def events calendar_path, event_uids=nil
xml = propfind calendar_path, 'href'
event_paths = REXML::XPath.each(xml, "//multistatus/response/href").map &:text
event_paths.map! { |ep| ep.gsub(calendar_path, "").gsub(/\.ics$/, "") }
event_paths.delete_if { |ep| !event_uids.include?(ep) } if event_uids
events = event_paths.map do |path|
raw_event = http_client.get("#{caldav_server}#{calendar_path}#{path}.ics").to_s
next if raw_event == ""
Icalendar::Event.parse raw_event
end.compact.flatten
end
def changes calendar_path, sync_token=nil
# xml = '<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"> <d:prop> <d:getetag/> <c:calendar-data/> </d:prop> <c:filter> <c:comp-filter name="VCALENDAR"> <c:comp-filter name="VEVENT"> <c:time-range start="20160101T000000Z" end="20161001T000000Z"/> </c:comp-filter> </c:comp-filter> </c:filter> </c:calendar-query>'
request_xml = "<d:sync-collection xmlns:d=\"DAV:\"><d:sync-token>#{sync_token}</d:sync-token><d:sync-level>1</d:sync-level><d:prop><d:getetag/></d:prop></d:sync-collection>"
response = http_client.request :report, calendar_path, headers: {Depth: 1}, body: request_xml
xml = REXML::Document.new response.to_s
changes_hash = { sync_token: REXML::XPath.first(xml, "//multistatus/sync-token")&.text }
return changes_hash if sync_token == changes_hash[:sync_token]
filtered_events = Hash[REXML::XPath.each(xml, "//multistatus/response").map do |cal|
path = cal.elements["href"].text
next if path == calendar_path
path = path.gsub(calendar_path, "").gsub(/\.ics$/, "")
status = :changed if cal.elements["status"]&.text =~ /200/
status = :deleted if cal.elements["status"]&.text =~ /404/
[path, status]
end.compact]
changed_event_uids = filtered_events.map { |uid, status| uid if status == :changed }.compact
changed_events = events calendar_path, changed_event_uids
deleted_events = filtered_events.map { |uid, status| uid if status == :deleted }.compact
changes_hash[:changed_events] = changed_events if changed_events.any?
changes_hash[:deleted_events] = deleted_events if deleted_events.any?
changes_hash
end
def create_event calendar_path, event
calendar = Icalendar::Calendar.new
calendar.add_event event
response = http_client.put "#{caldav_server}#{calendar_path}#{event.uid}.ics",
headers: { 'Content-Type' => 'text/calendar' },
body: calendar.to_ical
response.flush
events(calendar_path, [event.uid]).first if response.code == 201
end
def update_event calendar_path, event
if delete_events(calendar_path, [event.uid]).first
create_event calendar_path, event
end
end
def delete_events calendar_path, event_uids
event_uids.each do |event_uid|
response = http_client.delete "#{caldav_server}#{calendar_path}#{event_uid}.ics"
response.flush
response.code == 204
end
end
private
def caldav_server
"https://p01-caldav.icloud.com"
end
def http_client
@http_client ||= HTTP.persistent(caldav_server).basic_auth user: username, pass: password
end
def propfind url, prop, headers={Depth: 1}
xml =
if prop == 'allprop'
'<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>'
else
"<d:propfind xmlns:d=\"DAV:\"><d:prop><d:#{prop}/></d:prop></d:propfind>"
end
response = http_client.headers(headers).request :propfind, url, body: xml
REXML::Document.new response.to_s
end
# Destination Signaling IDentifier for Apple ID
def dsid
@dsid ||= (
xml = propfind '/', 'current-user-principal'
principal = REXML::XPath.first(xml, "//response/propstat/prop/current-user-principal/href").text
principal.split('/')[1].to_i
)
end
end
end
client = AppleCalDav::Client.new ENV.fetch('APPLE_USERNAME'), ENV.fetch('APPLE_PASSWORD')
puts "Calendars:"
puts calendars = client.calendars
calendar_path = calendars.find {|c| c[:name] == "Family" }[:path]
event = Icalendar::Event.new
event.dtstart = DateTime.civil(2016, 9, 23, 8, 30)
event.dtend = DateTime.civil(2016, 9, 23, 9, 00)
event.summary = "A great event!"
event = client.create_event calendar_path, event
sleep 5
event.summary = "A super great event!"
event = client.update_event calendar_path, event
sleep 5
client.delete_events calendar_path, [event.uid]
# changed_events = client.changes(calendars.last.first)
# events = client.events(calendars.last.first).sort_by(&:dtstart)
# puts "Last Event: #{events.last.summary}"
# puts "Events count: #{events.count}"
@ericboehs
Copy link
Author

ericboehs commented Sep 12, 2016

Usage:

 gem install http
 curl -sL https://gist.githubusercontent.com/ericboehs/d16cfe665558df46f8be6695a9ad14c7/raw/icloud-caldav.rb | [email protected] APPLE_PASSWORD=staples ruby

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment