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 *)