Last active
June 30, 2021 10:27
-
-
Save ericboehs/d16cfe665558df46f8be6695a9ad14c7 to your computer and use it in GitHub Desktop.
Interface with Apple's iCloud CalDav Calendars
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage: