Getting the date of the next or current recurrence of an iCal event

As Adam Bell has explained in another thread:

The script below contains the important bits from a project that Adam and I were discussing off-list just before Christmas. It’s been despecialised [!] to return the date of the next or current recurrence of an iCal event. For expired events, it returns missing value. It can handle both recurring and single events.

The main handler is getNextRecurrence(), which should be passed the event’s recurrence text, its start date, and the date against which recurrences are to be checked (normally the current date). It’s left to the calling process to extract the recurrence and the start date from the event. This is for greater flexibility and to allow the extraction to be optimised as in the demo code at the end of the script.

The script can handle all the recurrence possibilities implemented in iCal 2.0.5 and follows that version’s convention that, if an event is due to recur on a day that doesn’t exist in the target month, it’s held over until the next target date that does exist. The time of the event isn’t taken into account here.

There’s a more comprehensive version here.

11th March 2011: Now works with iCal 3.0 and iCal 4.0 too.

-- A date known to be the same in both Snow Leopard and earlier. It's also a Monday in January. Other fixed dates used in the script are calculated from this.
property Monday15830103 : «data isot313538332D30312D3033» as date -- date "Monday 3 January 1583 00:00:00".

(* Oversee the calculation of the next due recurrence date (if any) of an iCal
event, given its recurrence value, start date, and the date of the check. Return
the recurrence date or, if the event's expired, missing value. *)
on getNextRecurrence(RRULE, startDate, checkDate)
	-- Flag to allow the script to work with both the impaired date manipulation in AppleScript 2.1 and the largely OK implementations in earlier versions.
	global SnowLeopard
	set SnowLeopard to ((system attribute "ascv") mod 65536 ≥ 528) -- 'true' if AS 2.1 or later.
	
	set startDate to startDate - (startDate's time)
	set checkDate to checkDate - (checkDate's time)
	
	set nextRecurrence to missing value -- In case we don't find anything.
	if (startDate comes before checkDate) then -- The start date's in the past.
		if ((RRULE is not missing value) and (count RRULE) > 0) then -- This is a recurring event.
			considering case
				set endDate to getEndDate(RRULE) + (startDate's time)
				if (endDate does not come before checkDate) then -- Not yet expired.
					set interval to getInterval(RRULE)
					set maxRecurrences to getMaxRecurrences(RRULE)
					set freq to getRulePartValue(RRULE, "FREQ")
					if (freq is "DAILY") then
						set nextRecurrence to nextDaily(startDate, checkDate, endDate, interval, maxRecurrences)
					else if (freq is "WEEKLY") then
						set nextRecurrence to nextWeekly(RRULE, startDate, checkDate, endDate, interval, maxRecurrences)
					else if (freq is "MONTHLY") then
						set nextRecurrence to nextMonthly(RRULE, startDate, checkDate, endDate, interval, maxRecurrences)
					else if (freq is "YEARLY") then
						set nextRecurrence to nextYearly(RRULE, startDate, checkDate, endDate, interval, maxRecurrences)
					end if
				end if
			end considering
		end if
	else -- A start date in the present or future is any event's (recurring or not) "next recurrence".
		set nextRecurrence to startDate
	end if
	
	return nextRecurrence
end getNextRecurrence

(* Get the next due recurrence date of a "DAILY" event. (Usually checkDate.) *)
on nextDaily(startDate, checkDate, endDate, interval, maxRecurrences)
	set interval to interval * days
	set nextRecurrence to checkDate - (checkDate - 1 - startDate) mod interval + interval - 1
	if ((nextRecurrence - startDate) div interval + 1 > maxRecurrences) or (nextRecurrence comes after endDate) then set nextRecurrence to missing value
	
	return nextRecurrence
end nextDaily

(* Get the next due recurrence date of a "WEEKLY" event. *)
on nextWeekly(RRULE, startDate, checkDate, endDate, interval, maxRecurrences)
	set BYDAYspecified to (RRULE contains "BYDAY")
	if (BYDAYspecified) then
		-- Get the offsets (in seconds) of the specified week-start weekday from Sunday and the recurrence weekdays from that.
		set {WKSToffset, weekdayOffsets} to getWeekdayInstanceStuff(RRULE)
		-- Work out the start date of the week that starts on the WKST weekday and contains the event's start date.
		set weekStart to startDate - (startDate - (Monday15830103 + ((startDate's time) + WKSToffset - 1.83974976E+10))) mod weeks
	else
		-- Otherwise, regard the event's start date as the week start and use an offset of 0.
		set weekStart to startDate
		set weekdayOffsets to {0}
	end if
	set recurrencesPerWeek to (count weekdayOffsets)
	set interval to interval * weeks
	
	-- Run the recurrence sequence from the week of the start date, starting with the first weekday in the list.
	-- Start counting after passing the start date, which has already been checked in getNextRecurrence().
	-- Continue to the first recurrence on or after checkDate, or until maxRecurrences is exceeded.
	set nextRecurrence to startDate
	set recurrenceNo to 1 -- Recurrence 1 is the start date.
	set i to 0 -- Index into weekdayOffsets.
	repeat while (nextRecurrence comes before checkDate) and (recurrenceNo < maxRecurrences)
		set i to i + 1
		if (i > recurrencesPerWeek) then
			set i to 1
			set weekStart to weekStart + interval
		end if
		set nextRecurrence to weekStart + (item i of weekdayOffsets)
		if (nextRecurrence comes after startDate) then set recurrenceNo to recurrenceNo + 1
	end repeat
	-- No next recurrence if maxRecurrences is exceeded or endDate passed.
	if (nextRecurrence comes before checkDate) or (nextRecurrence comes after endDate) then set nextRecurrence to missing value
	
	return nextRecurrence
end nextWeekly

(* Get the next due recurrence date of a "MONTHLY" event. *)
on nextMonthly(RRULE, startDate, checkDate, endDate, interval, maxRecurrences)
	set BYDAYspecified to (RRULE contains "BYDAY=")
	if (BYDAYspecified) then
		set {weekdayCode, weekdayInstanceNos} to getWeekdayInstanceStuff(RRULE)
		set recurrencesPerMonth to (count weekdayInstanceNos)
	else
		set dayNumbers to getTargetDays(RRULE, startDate)
		set recurrencesPerMonth to (count dayNumbers)
	end if
	
	-- Run the recurrence sequence from the month of the start date, starting with the first recurrence day or weekday in the list.
	-- Start counting after passing the start date, which has already been checked in getNextRecurrence().
	-- Continue to the first valid occurrence on or after checkDate, or until maxRecurrences is exceeded.
	copy startDate to nextRecurrence
	set recurrenceNo to 1 -- Recurrence 1 is the start date.
	set dayInMonth to true -- Changed to false when a recurrence day doesn't exist in the month.
	set i to 0 -- Index into weekdayInstanceNos or dayNumbers.
	repeat while ((nextRecurrence comes before checkDate) or not (dayInMonth)) and (recurrenceNo < maxRecurrences)
		set i to i + 1
		if (i > recurrencesPerMonth) then
			set i to 1
			set nextRecurrence to addMonths(nextRecurrence, interval)
		end if
		if (BYDAYspecified) then
			-- Get the date of the specified weekday in this month.
			set nextRecurrence to getWeekdayDate(nextRecurrence, weekdayCode, item i of weekdayInstanceNos)
			set dayInMonth to true
		else
			-- Get the date of the (next) specified numbered day in this month.
			set nextRecurrence's day to item i of dayNumbers
			-- If the day doesn't exist in the month, the day and month will now have changed.
			set dayInMonth to (nextRecurrence's day is result)
			if not (dayInMonth) then set nextRecurrence to nextRecurrence - weeks -- Correct the month.
		end if
		if (dayInMonth) and (nextRecurrence comes after startDate) then set recurrenceNo to recurrenceNo + 1
	end repeat
	-- No next recurrence if maxRecurrences is exceeded or endDate passed.
	if (nextRecurrence comes before checkDate) or (nextRecurrence comes after endDate) then set nextRecurrence to missing value
	
	return nextRecurrence
end nextMonthly

(* Get the next due recurrence date of a "YEARLY" event. *)
on nextYearly(RRULE, startDate, checkDate, endDate, interval, maxRecurrences)
	set targetMonths to getTargetMonths(RRULE, startDate)
	set recurrencesPerYear to (count targetMonths)
	set BYDAYspecified to (RRULE contains "BYDAY=")
	if (BYDAYspecified) then set {weekdayCode, weekdayInstanceNos} to getWeekdayInstanceStuff(RRULE)
	
	-- Run the recurrence sequence from the year of the start date, starting with the relevant day of the first month in the list.
	-- Start counting after passing the start date, which has already been checked in getNextRecurrence().
	-- Continue to the first occurrence on or after checkDate, or until maxRecurrences is exceeded.
	copy startDate to nextRecurrence
	set recurrenceNo to 1 -- Recurrence 1 is the start date.
	set defaultDayNo to startDate's day
	set dayInMonth to true -- Set to false when a recurrence day doesn't exist in a target month.
	set i to 0 -- Index into targetMonths.
	repeat while ((nextRecurrence comes before checkDate) or not (dayInMonth)) and (recurrenceNo < maxRecurrences)
		set i to i + 1
		if (i > recurrencesPerYear) then
			set i to 1
			set nextRecurrence's year to (nextRecurrence's year) + interval
		end if
		set targetMonth to item i of targetMonths -- Get the next specified month.
		if (BYDAYspecified) then
			-- Get the date of the specified weekday in this month of this year.
			set nextRecurrence's day to 1
			set nextRecurrence's month to targetMonth
			set nextRecurrence to getWeekdayDate(nextRecurrence, weekdayCode, beginning of weekdayInstanceNos)
		else
			-- Get the date of the specified numbered day in this month of this year.
			set nextRecurrence's day to defaultDayNo
			set nextRecurrence's month to targetMonth
		end if
		-- If the day doesn't exist in the month, the day and month will now have changed.
		set dayInMonth to (nextRecurrence's month is targetMonth)
		if (dayInMonth) and (nextRecurrence comes after startDate) then set recurrenceNo to recurrenceNo + 1
	end repeat
	-- No next recurrence if maxRecurrences is exceeded or endDate passed.
	if (nextRecurrence comes before checkDate) or (nextRecurrence comes after endDate) then set nextRecurrence to missing value
	
	return nextRecurrence
end nextYearly

(* Odd jobs. *)

(* Read a given rule from the event's 'recurrence' text. *)
on getRulePartValue(RRULE, ruleKey)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to ruleKey & "="
	set rulePartValue to text item 2 of RRULE
	set AppleScript's text item delimiters to ";"
	set rulePartValue to text item 1 of rulePartValue
	set AppleScript's text item delimiters to astid
	
	return rulePartValue
end getRulePartValue

(* Get the recurrence's "UNTIL" date (if specified) in AppleScript form or default to 31st December 9999. (Ignore time.) *)
on getEndDate(RRULE)
	set endDate to Monday15830103 + 2.656145952E+11 -- 31st December 9999.
	if (RRULE contains "UNTIL") then
		set n to (text 1 thru 8 of getRulePartValue(RRULE, "UNTIL")) as integer
		set endDate's day to n mod 100
		set endDate's year to n div 10000
		set endDate's month to item (n mod 10000 div 100) of {January, February, March, April, May, June, July, August, September, October, November, December}
	end if
	
	return endDate
end getEndDate

(* Get the event's recurrence interval (if specified) or default to 1. *)
on getInterval(RRULE)
	if (RRULE contains "INTERVAL") then return getRulePartValue(RRULE, "INTERVAL") as integer
	return 1
end getInterval

(* Get a "COUNT" value or default to an arbitrarily high number. *)
on getMaxRecurrences(RRULE)
	if (RRULE contains "COUNT") then
		getRulePartValue(RRULE, "COUNT") as integer
		-- Sometimes temporarily get "COUNT=-1" instead of no COUNT. :\ 1.5.5 bug?
		if (result > -1) then return result
	end if
	return 150000000
end getMaxRecurrences

(* Return a list of the text items in a comma-delimited rule result. *)
on getBYlist(rulePartValue)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to ","
	set theList to rulePartValue's text items
	set AppleScript's text item delimiters to astid
	
	return theList
end getBYlist

(* Derive a list of AppleScript months from the numbers in a "BYMONTH" rule or default to the month of the event's start date. *)
on getTargetMonths(RRULE, startDate)
	if (RRULE contains "BYMONTH=") then
		set targetMonths to getBYlist(getRulePartValue(RRULE, "BYMONTH"))
		set monthList to {January, February, March, April, May, June, July, August, September, October, November, December}
		repeat with thisMonth in targetMonths
			set thisMonth's contents to item (thisMonth as integer) of monthList
		end repeat
	else
		set targetMonths to {startDate's month}
	end if
	
	return targetMonths
end getTargetMonths

(* Get a list of the day numbers specified in a "BYMONTHDAY" rule or default to the day of the event's start date. *)
on getTargetDays(RRULE, startDate)
	if (RRULE contains "BYMONTHDAY=") then
		set targetDays to getBYlist(getRulePartValue(RRULE, "BYMONTHDAY"))
		repeat with thisDay in targetDays
			set thisDay's contents to thisDay as integer
		end repeat
	else
		set targetDays to {startDate's day}
	end if
	
	return targetDays
end getTargetDays

(* Get and analyse the weekday(s) specified in a "BYDAY" rule, returning appropriate results. *)
on getWeekdayInstanceStuff(RRULE)
	set BYDAY to getRulePartValue(RRULE, "BYDAY")
	if (RRULE contains "WEEKLY") then
		-- In a "WEEKLY" environment, return a list of offsets, in seconds, of the specified weekdays from the specified week start.
		-- The week start itself is returned as an offset in seconds from Sunday.
		set weekdayCodes to "SUMOTUWETHFRSA"
		if (RRULE contains "WKST=") then
			set WKSToffset to (offset of getRulePartValue(RRULE, "WKST") in weekdayCodes) div 2 * days
		else
			set WKSToffset to days -- Default to Monday if WKST not specified.
		end if
		set weekdayOffsets to getBYlist(BYDAY)
		repeat with thisEntry in weekdayOffsets
			-- Each weekday offset must be the offset AFTER or including the week start.
			set thisEntry's contents to ((offset of thisEntry in weekdayCodes) div 2 * days + weeks - WKSToffset) mod weeks
		end repeat
		
		return {WKSToffset, weekdayOffsets}
	else
		-- In a "MONTHLY" environment, only one weekday is specified, along with an instance-in-month figure.
		-- The standard allows for the lack of an instance number to mean "every instance in the month".
		-- iCal doesn't currently implement this, but there's a hook for it in the script.  :)
		set weekdayCode to text -2 thru -1 of BYDAY
		if ((count BYDAY) > 2) then
			set instanceNos to {(text 1 thru -3 of BYDAY) as integer}
		else
			set instanceNos to {1, 2, 3, 4, -1}
		end if
		
		return {weekdayCode, instanceNos}
	end if
end getWeekdayInstanceStuff

(* Get a date that's m calendar months after (or before, if m is negative) the input date. *)
to addMonths(oldDate, m)
	global SnowLeopard
	
	copy oldDate to newDate
	set {y, m} to {m div 12, m mod 12}
	if (m < 0) then set {y, m} to {y - 1, m + 12}
	set newDate's year to (newDate's year) + y
	if (m > 0) then
		if (SnowLeopard) then
			set newDate's month to (newDate's month) + m
		else
			tell newDate to set {day, day} to {32 * m, day}
		end if
	end if
	if (newDate's day is not oldDate's day) then set newDate to newDate - (newDate's day) * days
	
	return newDate
end addMonths

(* Get the date of a given instance of a given weekday in the month of a given date. *)
on getWeekdayDate(givenDate, weekdayCode, instanceNo) -- (AS date, 2-letter BYDAY code, integer)
	-- Get a date in the past that's known to have the required weekday. (Sunday 5th January 1000 + 0 to 6 days.)
	set refDate to Monday15830103 + ((givenDate's time) + (offset of weekdayCode in "SUMOTUWETHFRSA") div 2 * days - 30419 * weeks)
	-- Get the last day of the seven-day period in the current month that includes the given instance of any weekday.
	if (instanceNo is -1) then
		tell givenDate to tell it + (32 - (its day)) * days to set periodEnd to it - (its day) * days -- Last day of month.
	else
		copy givenDate to periodEnd
		set periodEnd's day to instanceNo * 7 -- 7th, 14th, 21st, or 28th of month.
	end if
	
	-- Round down to an exact number of weeks after the known-weekday date.  
	return periodEnd - (periodEnd - refDate) mod weeks
end getWeekdayDate

(* -- Demo:
set sampleDate to (current date) - days
set sampleDate's time to 0
set daysEvents to {}

tell application "iCal"
	set allEvents to events of calendar 1
	repeat with thisEvent in allEvents
		set {recurrence:RRULE, start date:startDate} to thisEvent
		set nextRecurrence to my getNextRecurrence(RRULE, startDate, sampleDate)
		if (nextRecurrence is sampleDate) then set end of daysEvents to thisEvent's contents
	end repeat
end tell
return daysEvents *)

Fantastic, Nigel (as always) and a good opportunity for folks to test it.

In my tests, it does have some problems. On one of my recurrent calendars it’s dead on, but on a second in which all events are all day Birthday events, it missed yours, for example, whereas this much simpler script finds them all:

-- This script calculates the days remaining before the next four birthdays in an iCal calendar named "Birthdays".
-- Each birthday is entered as an all day event
-- The script uses Growl to display the result.

-- Get today's date with time set to midnight. Later days to go subtractions must have a time of day in common or differences can be off by one day.
set today to current date
set time of today to 0 -- seconds after midnight

--  Get the Birthday Date List "bDays" and Birthday Name List "bName", correct for GMT, then subtract today after correcting year.
tell application "iCal"
	close window 1
	set bdCals to every calendar whose title contains "Birthdays"
	set bCal to first item of bdCals
	set toGo to {}
	set who to {}
	set when to {}
	-- Collect the date/name lists
	set bCount to count events of bCal
	repeat with n from 1 to bCount
		-- Start date is the birthday for an all day event. 
		-- Summary should be the person's name.
		-- iCal stores times of events in GMT even though it presents them in local time. 
		-- Times must be shifted back to a midnight base before days to go are calculated.
		tell event n of bCal to set {tName, tDate} to {summary of it, start date of it}
		-- adjust for GMT offset
		tell tDate
			if time of it = 75600 then -- 9:00 PM if ADST
				set time of it to (time of it) + 10800
			else -- 8:00 PM if AST
				set time of it to (time of it) + 14400
			end if
			-- adjust the calendar year of the birthday ahead of now
			repeat until (it - today) > 0
				set year of it to (year of it) + 1
			end repeat
			-- Calculate days to go to next birthday
			set daysLeft to ((it - today) / days) as integer
			--set item n of tDate to daysLeft
			set end of toGo to daysLeft
			set end of who to tName
			set end of when to it
		end tell
	end repeat
	--quit
end tell

-- Sort by days to go
sort_items(toGo, who, when)

-- Get first four
set msg_C to " days until "
set BDNotes to ""
set r to return
set rr to r & r
set sp to space
set ap to ASCII character 240
repeat with mm from 1 to 5
	set msg_A to (item mm of toGo) as text
	set msg_B to (item mm of who)
	set msg_D to date string of (item mm of when)
	-- add possessives for names: soAndso's Birthday
	if last character of msg_B = "s" then
		set msg_B to msg_B & "'"
	else
		set msg_B to msg_B & "'s"
	end if
	if msg_B = "Anniversary's" then set msg_B to "Our Anniversary"
	set tBDNote to msg_A & msg_C & msg_B & " on" & r & ap & sp & sp & msg_D
	set BDNotes to BDNotes & tBDNote & rr
end repeat

Growl_It("Birthdays Coming Soon", BDNotes)

to sort_items(sortList, SecondList, thirdList)
	tell (count sortList) to repeat with i from (it - 1) to 1 by -1
		set s to sortList's item i
		set r to SecondList's item i
		set q to thirdList's item i
		repeat with i from (i + 1) to it
			tell sortList's item i to if s > it then
				set sortList's item (i - 1) to it
				set SecondList's item (i - 1) to SecondList's item i
				set thirdList's item (i - 1) to thirdList's item i
			else
				set sortList's item (i - 1) to s
				set SecondList's item (i - 1) to r
				set thirdList's item (i - 1) to q
				exit repeat
			end if
		end repeat
		if it is i and s > sortList's end then
			set sortList's item it to s
			set SecondList's item it to r
			set thirdList's item it to q
		end if
	end repeat
end sort_items

on Growl_It(gTitle, gMessage)
	tell application "GrowlHelperApp"
		notify with name "Next4BD" title gTitle description (return & gMessage) application name "Birthdays" with sticky
	end tell
end Growl_It
----

There can be no worse bug imaginable! :o

Thanks, Adam. I’ll look into it. I notice your script makes adjustments for GMT, which mine doesn’t. That’s obviously the first place to look.

You have got the date of my birthday right, I suppose? :wink:

Adam, your growl part did not seem to work.

Looks like you forgot to register the app.

-- This script calculates the days remaining before the next four birthdays in an iCal calendar named "Birthdays".
-- Each birthday is entered as an all day event
-- The script uses Growl to display the result.

-- Get today's date with time set to midnight. Later days to go subtractions must have a time of day in common or differences can be off by one day.
set today to current date
set time of today to 0 -- seconds after midnight
set appName to "Next4BD"
set notificationName to "Birthdays"
set notifs to {notificationName}
tell application "GrowlHelperApp"
	register as application appName all notifications notifs default notifications notifs
end tell

--  Get the Birthday Date List "bDays" and Birthday Name List "bName", correct for GMT, then subtract today after correcting year.
tell application "iCal"
	close window 1
	set bdCals to every calendar whose title contains "Birthdays"
	set bCal to first item of bdCals
	set toGo to {}
	set who to {}
	set when to {}
	-- Collect the date/name lists
	set bCount to count events of bCal
	repeat with n from 1 to bCount
		-- Start date is the birthday for an all day event. 
		-- Summary should be the person's name.
		-- iCal stores times of events in GMT even though it presents them in local time. 
		-- Times must be shifted back to a midnight base before days to go are calculated.
		tell event n of bCal to set {tName, tDate} to {summary of it, start date of it}
		-- adjust for GMT offset
		tell tDate
			if time of it = 75600 then -- 9:00 PM if ADST
				set time of it to (time of it) + 10800
			else -- 8:00 PM if AST
				set time of it to (time of it) + 14400
			end if
			-- adjust the calendar year of the birthday ahead of now
			repeat until (it - today) > 0
				set year of it to (year of it) + 1
			end repeat
			-- Calculate days to go to next birthday
			set daysLeft to ((it - today) / days) as integer
			--set item n of tDate to daysLeft
			set end of toGo to daysLeft
			set end of who to tName
			set end of when to it
		end tell
	end repeat
	--quit
end tell

-- Sort by days to go
sort_items(toGo, who, when)

-- Get first four
set msg_C to " days until "
set BDNotes to ""
set r to return
set rr to r & r
set sp to space
set ap to ASCII character 240
repeat with mm from 1 to 5
	set msg_A to (item mm of toGo) as text
	set msg_B to (item mm of who)
	set msg_D to date string of (item mm of when)
	-- add possessives for names: soAndso's Birthday
	if last character of msg_B = "s" then
		set msg_B to msg_B & "'"
	else
		set msg_B to msg_B & "'s"
	end if
	if msg_B = "Anniversary's" then set msg_B to "Our Anniversary"
	set tBDNote to msg_A & msg_C & msg_B & " on" & r & ap & sp & sp & msg_D
	set BDNotes to BDNotes & tBDNote & rr
end repeat

Growl_It("Birthdays Coming Soon", BDNotes, notificationName, appName)

to sort_items(sortList, SecondList, thirdList)
	tell (count sortList) to repeat with i from (it - 1) to 1 by -1
		set s to sortList's item i
		set r to SecondList's item i
		set q to thirdList's item i
		repeat with i from (i + 1) to it
			tell sortList's item i to if s > it then
				set sortList's item (i - 1) to it
				set SecondList's item (i - 1) to SecondList's item i
				set thirdList's item (i - 1) to thirdList's item i
			else
				set sortList's item (i - 1) to s
				set SecondList's item (i - 1) to r
				set thirdList's item (i - 1) to q
				exit repeat
			end if
		end repeat
		if it is i and s > sortList's end then
			set sortList's item it to s
			set SecondList's item it to r
			set thirdList's item it to q
		end if
	end repeat
end sort_items

on Growl_It(gTitle, gMessage, notificationName, appName)
	
	tell application "GrowlHelperApp"
		notify with name notificationName title gTitle description (return & gMessage) application name appName with sticky
	end tell
	
end Growl_It
----

As Growl has moved along, it has remained backward compatible with older tickets. Now, as you point out, it is best practice to register your Growl (create a new ticket) every time a script is run, but old tickets (from long long ago) seem to hang in there so the Birthday script (written years ago) continues to work for me. I’ll change it now – no point in relying on that.

Hmm. A bit of experimentation shows that iCal returns an event’s start date in local time. Change the time zone on the computer and a suitably transposed start date value is returned to the script. Jolly good so far.

The text of an event’s recurrence doesn’t change when the time zone changes. This is not because it’s GMT-based, as one would expect from the iCalendar standard: it’s because it relates to the time zone that was in force when the recurrence was set up in iCal. A calendar can have recurring events that were set up in different time zones. They display correctly relative to each other, and are transposed in iCal’s display when the time zone changes, but they retain their recurrence descriptions from the original zones. They’re therefore useless in a script unless you know the time zone for each event. iCal obviously has the information, but doesn’t reveal it via AppleScript. :confused:

In view of this, I’m going to let my script stand for the moment, with the caveat that recurring events are assumed to have been created in the same time zone as that in which the script’s run. Daylight saving time and standard time shouldn’t make any difference as far as I can see, though the check date and the current date should ideally be subject to the same one.

Hi, Adam.

Some more observations after further poking around today with iCal 2.0.5 in Tiger:

(Tested with a custom “Birthdays” calendar. I can’t test with iCal’s built-in one, as this is read only and is locked to Address Book, which I don’t use.)

On my machine, the start time of an all-day event is always returned as midnight, regardless of GMT, the time zone in which the event was set up, or the one in which the computer’s currently running. This is in contrast to a timed event, whose start and end dates are ” as you say ” returned as the local date/times for those instants.

iCal stores calendar details in a text file: ~/Library/Application Support/iCal/Sources/[calendar UID].calendar/corestorage.ics. Among the properties of an all-day are a “DTSTART” that’s defined simply as a date and a “DURATION:” whose value is “P1D”. eg.:

DTSTART;VALUE=DATE:20080214
DURATION:P1D

Timed events have a “DTSTART” that’s defined as a time-zone and the set-up region’s local time for the start date in ISO format. Also a corresponding “DTEND”. eg.:

DTSTART;TZID=Asia/Vladivostok:20080215T020000
DTEND;TZID=Asia/Vladivostok:20080215T023000

(Back in the GMT time zone, the start date for this event is returned as date “Thursday 14 February 2008 16:00:00”.)

With both kinds of event, the event’s stamp date is stored as a GMT date/time in ISO-format:

DTSTAMP:20080214T131337Z

. and returned to a script as the run-zone equivalent.

  1. If all the above holds true on your machine too, I can’t see how my script could have missed my birthday. :wink:

  2. For people born on 29th February, my script follows the iCal practice of giving them one birthday every four years. Yours transposes them to 1st March every year, including leap years. Presumably, in practice, this birthday would have to be set up in iCal as four separate events, starting in successive years and repeating every four years.

  3. OT for this thread: the “Get the first four” section of your script makes the reasonable assumption that there are at least five birthdays in the calendar. It errors if there aren’t.

My mistake, Nigel :confused:

I have your Birthday in my Birthdays Calendar, but it was not set to be repetitive. That begs the question, of course, of why my script found it correctly at first, and still does after the correction. (it could be that I’ve got 2007 built into it somewhere because that’s where your b’day entry was). Curiouser and curiouser!

I’ll dig this evening (got a bunch of errands to do).

Hi, Adam.

Your script starts with the start date and assumes an annually recurring event. It’s not affected by the actual recurrence setting.

So I’ve discovered. That was one of the first scripts I ever wrote, so I kept it simple. :confused:

AppleScript, iCal, & Growl. Not bad for an early script! :cool:

It turns out that although iCal can only set certain recurrence options through its UI, it can understand other iCalendar recurrence rules too ” which can be supplied via AppleScript. If you have an acquaintance called Fred whose birthday’s on 29th February, you can enter his birthday into iCal under 29th February of a suitable year and set it to recur yearly. Then, if Fred celebrates his non-leap-year birthdays on 1st March:

tell application "iCal"
	set fred to first event of calendar "Birthdays" whose summary is "Fred"
	-- Refine the recurrence to the sixtieth day of each year.
	set fred's recurrence to "FREQ=YEARLY;INTERVAL=1;BYYEARDAY=60"
end tell

Or, if Fred always celebrates on the last day of February:

tell application "iCal"
	set fred to first event of calendar "Birthdays" whose summary is "Fred"
	-- Refine the recurrence to the last day of each February.
	set fred's recurrence to "FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=-1"
end tell

iCal will display the event on the appropriate day each year.

I’m obviously going to have to add some more to my script… :wink:

Nigel, Adam
So, I see you two have been able to succeed in scripting recurring events in iCal. I am so in need of this code. However, it makes my head spin, and I have no idea on how to integrate it into the script I have below, which grabs events from a calendar called “Gigs”, then formats them for my CMS that is my website and writes it out to a CSV text file (which I then upload to my website backend).

set theYear to text returned of (display dialog "Please enter a year:" default answer (current date)'s year)

tell (choose from list {"January", "February", "March", "April", "May", "June", "July", "August", ¬
	"September", "October", "November", "December"} with prompt "Please choose a month:")
	if it is false then error number -128
	set theMonth to item 1
end tell

tell application "TextWrangler"
	activate
	if not (exists document "NewGigs") then
		make new document at beginning with properties {name:"NewGigs"}
	end if
end tell

set {preDate, postDate} to monthRange(theMonth, theYear)

tell application "iCal"
	set myEvents to properties of events of calendar "Gigs" whose start date > preDate and start date < postDate
	
	my sortEvents(myEvents, 1, count myEvents)
	
	repeat with i in myEvents
		set gigName to summary of i
		set beginTime to formatDate(start date of i) of me
		set endTime to formatDate(end date of i) of me
		set gigNotes to (description of i)
		if gigNotes is not missing value then
			set {astid, AppleScript's text item delimiters} to {AppleScript's text item delimiters, "##@@E@@@@@@@@@@@@@@"}
			set gigNotes to last text item of gigNotes
			set AppleScript's text item delimiters to astid
			if gigNotes ends with "¬" then set gigNotes to text 1 thru -2 of gigNotes
		else
			set gigNotes to ""
		end if
		tell application "TextWrangler"
			activate
			set last line of document "NewGigs" to beginTime & "," & endTime & "," & gigName & "," & gigName & "," & gigNotes & "," & "general" & return
		end tell
	end repeat
	
	tell application "TextWrangler" to activate
end tell

on sortEvents(a, l, r)
	script o
		property p : a
	end script
	
	using terms from application "iCal"
		repeat with i from l + 1 to r
			set thisEvent to o's p's item i
			set thisTime to thisEvent's start date
			
			repeat with j from i to l by -1
				if (j > l) and (thisTime comes before o's p's item (j - 1)'s start date) then
					set o's p's item j to o's p's item (j - 1)
				else
					set o's p's item j to thisEvent
					exit repeat
				end if
			end repeat
		end repeat
	end using terms from
end sortEvents

on monthRange(m, y)
	tell {(date (m & " 1 " & y)) - 1}
		set end to beginning + 32 * days + 1
		set end's day to 1
		it
	end tell
end monthRange


on formatDate(someDate)
	tell (someDate as «class isot» as string) to return text 1 thru 10 & " - " & text 12 thru 16
end formatDate

Any idea on how I can integrate these so it allows me to also get the recurring gigs I have in my Gigs iCal calendar?

Many thanks,
Todd

Hi, Todd.

To a certain extent. The script in post #1 returns the date of an event’s next recurrence (only) after a given date (ignoring the time). If a recurrence occurs on the given date, that’s the date that’s returned. If the event’s start date is on or after the given date, that’s what’s returned, so the script also works for non-recurring events in that respect. If the event or its recurrence expire before the given date, you get missing value.

The main handler is getNextRecurrence(), which has to be given the event’s recurrence text, its start date, and the date after (or including) which you want to know the next recurrence.

tell application "iCal"
	set {recurrenceText, startDate} to {recurrence, start date} of anEvent
end tell

getNextRecurrence(recurrenceText, startDate, current date)
--> A (midnight) date or 'missing value'.

The script’s good for any kind of recurrence that can be set up in iCal’s information drawer. It doesn’t currently take excluded dates into account, nor does it handle recurrence types that iCal understands but which can’t be set through its GUI. I’m working on that. :slight_smile:

Mine’s rolled down behind the settee somewhere. :confused:

imapercussionist

Nigel,
OK, I’ll give this a look at in my copious free time…many thanks for your reply.

So, do you play regularly? Is this your vocation or avocation? Did you study music in college?

I’m a jazz drummer in the Denver, CO area (although occasionally play out of town).

Best to you,
Todd

Can this script be used to give you other properties of a recurring event, such as the summary? I’ve been trying fruitlessly to make a script that will get the summary from an event in a given calendar that happens today. The calendar is a subscription and uses recurrence (most of the time), so it’s very difficult to get the current event without code like this one.

It’s been easier to just do it manually, and as a computer geek, I just can’t allow that. :slight_smile:

Hi, Jacoby.

I’ve no experience with subscribed calendars, but I imagine their events would be readable by AppleScript in the same way that local calendar events are. If so, you’d need to take the script at the top of this thread and replace the “Demo code” at the bottom with the following, suitably customised to your own situation:

tell application "iCal"
	set {allSummaries, allStartDates, allRecurrenceTexts} to {summary, start date, recurrence} of events of calendar "My calendar"
end tell
set today to (current date)
set todaysSummaries to {}

repeat with i from 1 to (count allSummaries)
	set nextRecurrence to my getNextRecurrence(item i of allRecurrenceTexts, item i of allStartDates, today)
	if (nextRecurrence is today) then set end of todaysSummaries to item i of allSummaries
end repeat
if (todaysSummaries is {}) then
	display dialog "Nothing to do today." with icon note
else
	choose from list todaysSummaries with prompt "Here's what's happening today."
end if

As I’ve probably mentioned above, the script’s only good for unmodified recurrence instances and where time zone isn’t a factor.

(Unnervingly, the time of ‘today’ in the above snippet is set to 0 from inside the ‘getNextRecurrence’ handler. This happens because the date object’s shared by the variables inside and outside the handler. I can’t remember now if I meant it to work it that way or not. It’s not a brilliant coding practice… :/)

Edit: I’ve now corrected this last issue in the script in my original post.

Well, the script worked… more or less. It picked up the summary of the original event that started the recurrence, but not the summary of the current event.

For example, I set up a Test calendar and a Test event, with the summary “Test - this is the original” and daily recurrence starting yesterday. I then change today’s event to say “Test - this is the second one”. The script still gives me the summary “Test - this is the original”. It’s like the summary for recurring events past the originating event isn’t stored anywhere… except, of course, that it has to be! This is really getting frustrating. :frowning:

Well, I did say the script only worked for unmodified recurrence instances ” and an altered summary is a modification!

If you’ve read much of this thread, you’ll know that with unmodified recurrence sequences, there is only one event. It has a ‘recurrence’ property in the form of an iCalendar (RFC 2445) recurrence rule, which iCal interprets “on the fly” (along with the event’s other properties) to calculate and display the recurrence instances in the calendar. The script in post #1, long and complex though it is, only handles the most simple situation: a recurrence sequence that was set in iCal’s GUI, where none of the recurrence instances has been modified, and where the machine running the script is in the same time zone as the machine that created the event.

Recurrence types. Although only certain kinds of recurrence can be set in iCal’s Info window, the app does actually obey many of the other types allowed by the iCalendar specification. (Anything, I think, not involving BYSETPOS or resolutions less than a day.) It even handles situations where an event has more than one recurrence rule, although only one of these is returned by its AppleScript implementation.

Recurrence modifications. If a recurrence instance is deleted in iCal’s GUI, the effect in AppleScript is that the instance’s date/time (an AppleScript date in the machine’s local time) is added to the event’s ‘excluded dates’ property. For any other kind of modification, a new event is created with the same UID as the original, but with other property values changed to reflect the modification(s). Dependent events like this have to be checked by any script working through the recurrence sequence. A major disadvantage when an instance has been modified through being moved to a different time or date is that, although the new event has the new time/date as its ‘start date’, there’s nothing in the AppleScript implementation to show where the instance was moved from. (You need to know this in order to exclude it from the sequence.) The only recourse is to identify and parse the calendar’s .ics file.

Time zone. In the .ics file, the event times include the original machine’s time zone, which iCal uses to transpose the times on machines elsewhere. So if I create an event here in the UK that recurs at two o’clock every Monday and Wednesday morning, and I send the calendar file to Adam Bell in eastern Canada, the event will be shown in iCal on his machine at ten o’clock every Sunday and Tuesday evening. In AppleScript, though, he’ll see the ‘start date’ in his own time, but the ‘recurrence’ rule still specifying Monday and Wednesday. To avoid results that are twenty-four hours out, the script would have to parse the .ics file for time zones as well and be able to transpose between any two.

Once you decide to expand the script beyond the very basic, you have to take all these issues into account. Each of them adds a complexity of complexities to the scripting problem and the time zone issue may only be solvable with the help of AppleScript Studio. It’s a task for someone who possibly scripts for a living or who can switch off the rest of his/her life entirely for several days and is even more youthfully sharp than myself! :rolleyes:

The script in post #1 has now been updated to work with iCal 3.0 and iCal 4.0 and with the problematic date manipulation in AppleScript 2.1 (Snow Leopard).