This has been bothering me for the past few days. I’ve now cooked up the sprawling hack below, which sets up anniversaries as modified recurrences of wedding day events and seems reasonably fast. It works with Address Book and iCal in Snow Leopard, but I’ve no idea if it works with their current incarnations: Contacts, Calendar, and Mountain Lion.
The modus operandi is to create wedding day events as in the script above, but make them annually recurring. iCal/Calendar is then quit and the .ics files for the just-created events are read and used to derive an iCalendar specification for the entire calendar which includes the required recurrence variations. This is written to an .ics file on the desktop which is in turn imported to the new calendar in iCal/Calendar. It would be less fuss just to edit the original event files, but iCal ignores any changes to them, so it must store the information somewhere else as well.
main()
on main()
set calendar_name to "Anniversaries"
set number_of_anniversaries to 75
tell application "System Events"
set Contacts_open to (application process "Contacts") exists
set Calendar_open to (application process "Calendar") exists
end tell
if (not Contacts_open) then launch application "Contacts"
if (not Calendar_open) then launch application "Calendar"
-- Get the relevant details, where they exist, from EVERY person in Contacts.
set Contacts_data to get_Contacts_data()
if (not Contacts_open) then quit application "Contacts"
-- Create a new calendar with the required name and populate it with recurring events for the weddings of people who have "anniversary" dates.
set calendar_ID to make_calendar(calendar_name)
set event_IDs to make_events(calendar_ID, Contacts_data, number_of_anniversaries)
if (event_IDs is {}) then
display dialog "None of your contacts have anniversaries!" buttons {"!"} default button 1 with icon stop
else
-- Quit Calendar to ensure it forgets the contents of any previous calendars with this name.
quit application "Calendar"
-- Create an iCalendar specification for the existing calendar, with the recurrence summaries edited as required.
set iCalendar_text to compose_ics(calendar_ID, event_IDs, number_of_anniversaries)
-- Write this text to an ics file on the desktop.
write_ics_file(iCalendar_text, (path to desktop as text) & (calendar_name & ".ics"))
-- Reopen Calendar and use some trickery to select the new calendar.
tell application "Calendar"
activate
show event 1 of calendar calendar_name
view calendar at (current date)
end tell
-- "Double-click" the ics file. Calendar will offer to add the new events to the selected calendar.
tell application "Finder" to open file (calendar_name & ".ics") of desktop
-- Click the "OK" button in the Add Events dialog. (Uses GUI Scripting.)
tell application "System Events"
repeat until (window 2 of application process "Calendar" exists)
delay 0.2
end repeat
keystroke return
end tell
end if
-- if (not Calendar_open) then quit application "Calendar"
end main
-- Return the name, id, and custom-date labels and values for every person in Contacts.
on get_Contacts_data()
tell application "Contacts" to return {name, id, {label, value} of custom dates} of people
end get_Contacts_data
-- Make a completely new calendar with the given name and return its UID.
on make_calendar(cal_name)
tell application "Calendar"
if (calendar cal_name exists) then delete calendar cal_name
return uid of (make new calendar with properties {name:cal_name})
end tell
end make_calendar
-- Parse the lists returned by Contacts and, where any custom dates are labelled "anniversary", create corresponding wedding events in Calendar with yearly recurrences. Return the events' IDs.
on make_events(calendar_ID, {names, ids, {date_labels, date_values}}, number_of_anniversaries)
set event_IDs to {}
-- The lists are all in the same order with respect to people.
repeat with person_index from 1 to (count names)
-- Act if this person has a custom date labelled "anniversary".
if (item person_index of date_labels contains "anniversary") then
-- Get the wedding date, the genitive case of the person's name, and a URL for the entry in Contacts.
set persons_date_labels to item person_index of date_labels
repeat with date_index from 1 to (count persons_date_labels)
if (item date_index of persons_date_labels is "anniversary") then
set wedding_date to item date_index of item person_index of date_values
exit repeat
end if
end repeat
set genitive_name to item person_index of names & "'s "
set ABURL to "addressbook://" & item person_index of ids
tell application "Calendar"
tell calendar id calendar_ID
-- Create the wedding event.
set new_event to (make new event at end of events with properties {summary:genitive_name & "Wedding", start date:wedding_date, end date:wedding_date, url:ABURL, allday event:true})
-- Add its uid to the list to return when all the events are done.
set end of event_IDs to new_event's uid
-- If the wedding date's 29th February, set the recurrence according to when the person celebrates anniversaries. Otherwise simply recur annually from the event date.
if ((wedding_date's day is 29) and (wedding_date's month is February)) then
if (button returned of (display dialog (genitive_name & "Wedding Day is/was 29th February! Are the non-leap anniversaries celebrated on 28th February or 1st March?") buttons {"28th February", "1st March"} default button 2 with icon note) is "28th February") then
-- Last day of every February.
set new_event's recurrence to "FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=-1;COUNT=" & (number_of_anniversaries + 1)
else
-- 60th day of every year.
set new_event's recurrence to "FREQ=YEARLY;INTERVAL=1;BYYEARDAY=60;COUNT=" & (number_of_anniversaries + 1)
end if
else
-- Same date every year.
set new_event's recurrence to "FREQ=YEARLY;INTERVAL=1;COUNT=" & (number_of_anniversaries + 1)
end if
end tell
end tell
end if
end repeat
return event_IDs
end make_events
-- Put together an iCalendar specification for the entire calendar containing the VCALENDAR and VEVENT data just written by Contacts and also linked VEVENTs to vary the summaries of the anniversary recurrences.
on compose_ics(calendar_ID, event_IDs, number_of_anniversaries)
-- The list of iCalendar components could get very long, so it'll be referenced via a script object for speed of access.
script iCalendar
property components : {}
end script
-- Prepare a linked-VEVENT template and store some other useful values.
set linked_VEVENT to {"BEGIN:VEVENT", missing value, missing value, missing value, missing value, missing value, missing value, missing value, missing value, missing value, {}, "END:VEVENT"}
set events_folder_path to (path to library folder from user domain as text) & "Calendars:" & calendar_ID & ".calendar:Events:"
set CRLF to return & linefeed
set astid to AppleScript's text item delimiters
-- Get the beginning and end of the VCALENDAR data by subtracting the VEVENT section from one of the target calendar files. (Calendar stores one event per file.)
set AppleScript's text item delimiters to {"BEGIN:VEVENT", "END:VEVENT"}
tell text items of (read file (events_folder_path & beginning of event_IDs & ".ics") as «class utf8») to set VCALENDAR_shell to {beginning, end}
-- Ascertain the kind of line endings used.
if (beginning of VCALENDAR_shell contains CRLF) then
set line_end to CRLF
else if (beginning of VCALENDAR_shell contains linefeed) then
set line_end to linefeed
else
set line_end to return
end if
-- Work through the UIDs of the events created above.
repeat with event_ID in event_IDs
-- Read the event file for this UID and extract the VEVENT section.
set AppleScript's text item delimiters to VCALENDAR_shell
set base_VEVENT to text item 2 of (read file (events_folder_path & event_ID & ".ics") as «class utf8»)
-- Append this original VEVENT data to the output components.
set end of iCalendar's components to base_VEVENT
-- Parse the lines of the original VEVENT and prime the linked-VEVENT template accordingly.
set base_VEVENT to base_VEVENT's paragraphs
set root_line_count to (count base_VEVENT)
repeat with i from 1 to root_line_count
set thisLine to item i of base_VEVENT
if (thisLine begins with "CREATED:") then
set item 2 of linked_VEVENT to thisLine
else if (thisLine begins with "UID:") then
set item 3 of linked_VEVENT to thisLine
else if (thisLine begins with "DTEND;") then
set end_date to (text -8 thru -1 of thisLine) as integer
else if (thisLine begins with "RRULE:") then
set RRULE to thisLine
else if (thisLine begins with "TRANSP:") then
set item 6 of linked_VEVENT to thisLine
else if (thisLine begins with "SUMMARY:") then
-- Reconsititue the SUMMARY entry if split over more than one line.
repeat with j from (i + 1) to root_line_count
if (item j of base_VEVENT begins with space) then
set thisLine to thisLine & text 2 thru -1 of item j of base_VEVENT
else
exit repeat
end if
end repeat
set SUMMARY_root to text 1 thru -8 of thisLine
else if (thisLine begins with "DTSTART;") then
set start_date to (text -8 thru -1 of thisLine) as integer
else if (thisLine begins with "SEQUENCE:") then
set item 10 of linked_VEVENT to "SEQUENCE:" & (text 10 thru -1 of thisLine) + 1
else if (thisLine begins with "URL;") then
-- If the URL entry's split over more than one line, the bits don't need to be rejoined.
set item 11 of linked_VEVENT to {thisLine}
repeat with j from (i + 1) to root_line_count
if (item j of base_VEVENT begins with space) then
set end of item 11 of linked_VEVENT to item j of base_VEVENT
else
exit repeat
end if
end repeat
end if
end repeat
-- Create the required number of linked VEVENTS for the anniversaries, inserting the relevant dates and summaries into the template and coercing it to text each time.
set AppleScript's text item delimiters to line_end
-- For speed and convenience, the 8-digit ISO dates (yyyymmdd) are handled as integers.
set leap_wedding to (start_date mod 10000 is 229)
if (leap_wedding) then set using_Mar1 to (RRULE contains "BYYEARDAY")
repeat with counter from 1 to number_of_anniversaries
set start_date to start_date + 10000 -- Add 1 to the year.
set end_date to end_date + 10000 -- Ditto.
-- If the wedding was on 29th February, use the appropriate month/day digits in the anniversary date integer.
if (leap_wedding) then
set y to start_date div 10000
if (isLeapYear(y)) then
set start_date to y * 10000 + 229
set end_date to y * 10000 + 301
else if (using_Mar1) then
set start_date to y * 10000 + 301
set end_date to y * 10000 + 302
else -- Last day of February. Always ends on 1st March.
set start_date to y * 10000 + 228
end if
end if
set item 4 of linked_VEVENT to "DTEND;VALUE=DATE:" & end_date
-- This entry links this VEVENT to an expression date of the original event's recurrence.
set item 5 of linked_VEVENT to "RECURRENCE-ID;VALUE=DATE:" & start_date
-- Compose the SUMMARY entry for this anniversary.
set anniversary_SUMMARY to SUMMARY_root & counter & ordinal(counter) & " anniversary"
-- If it's longer than 72 characters, split it as necessary. Otherwise don't.
set anniversary_SUMMARY_length to (count anniversary_SUMMARY)
if (anniversary_SUMMARY_length > 72) then
set split_SUMMARY to {text 1 thru 72 of anniversary_SUMMARY}
repeat with i from 73 to anniversary_SUMMARY_length by 71
set j to i + 70
if (j > anniversary_SUMMARY_length) then set j to anniversary_SUMMARY_length
set end of split_SUMMARY to " " & text i thru j of anniversary_SUMMARY
end repeat
set item 7 of linked_VEVENT to split_SUMMARY
else
set item 7 of linked_VEVENT to anniversary_SUMMARY
end if
set item 8 of linked_VEVENT to "DTSTART;VALUE=DATE:" & start_date
-- As a nicety, use the current date/time as the date stamp for the creation of this VEVENT.
set item 9 of linked_VEVENT to compose_DTSTAMP()
-- Add the coerced-to-text version of the template's current contents to the output components.
set end of iCalendar's components to (linked_VEVENT as text)
end repeat
end repeat
-- When all the components for all the events are in place, coerce the lot into one iCalendar text.
set iCalendar_text to beginning of VCALENDAR_shell & iCalendar's components & end of VCALENDAR_shell
set AppleScript's text item delimiters to astid
return iCalendar_text
end compose_ics
on write_ics_file(iCalendar_text, hfs_path)
set fRef to (open for access file hfs_path with write permission)
try
set eof fRef to 0
write iCalendar_text as «class utf8» to fRef
end try
close access fRef
end write_ics_file
on isLeapYear(y)
return ((y mod 4 is 0) and (y mod 400 is not in {100, 200, 300}))
end isLeapYear
on ordinal(n)
set units to n mod 10
if ((units > 3) or ((n - units) mod 100 is 10) or (units < 1)) then
return "th"
else
return item units of {"st", "nd", "rd"}
end if
end ordinal
on compose_DTSTAMP()
set {year:y, month:m, day:d, time:t} to (current date) - (time to GMT)
return "DTSTAMP:" & (y * 10000 + m * 100 + d) & "T" & text 2 thru -1 of ((1000000 + t div hours * 10000 + t mod hours div minutes * 100 + t mod minutes) as text) & "Z"
end compose_DTSTAMP