Getting the next recurrence of an iCal event II - now V!

This is the next stage worth posting of my initial attempt at a script to predict when the next expression of a repeating iCal event will occur. Whereas the earlier script simply returned a date calculated from the event’s start date and recurrence rule, this one takes into account detachments, exclusions, time zones, and times of day and returns a full set of data for the repeat instance found. If the instance has been moved or edited in iCal, the data returned will reflect this.

The main handler, getNextExpression, takes an iCal event UID and an AppleScript date and returns a record containing information about the next expression occurring after the date ” or at the very moment of the date if a repeat happens to start then. If the event isn’t expressed at all after the date, the return is ‘missing value’. A third, boolean parameter (‘straddle’) allows the acceptance or not of an expression whose start and end dates happen to straddle the given date.

Since the script can also return the very first instance of a repeating event under the right circumstances, it also works with non-repeating events.

The code was originally written and tested against iCal 1.5 and iCal 2.0. It now works with iCal 4.0 too and with the impaired date manipulation in AppleScript 2.1 (Snow Leopard). It should also work with iCal 3.0. (Thanks to StefanK and Adam Bell for information regarding iCal 3.0’s calendar file storage.)

The input date and returned dates are for the computer’s local time zone, but, as in iCal itself, the recurrence sequence is followed in the time zone where the event was created. All-day events are timezoneless.

The script handles every repeat type that can be set in iCal’s user interface and with those which iCal obeys but which can only be set by AppleScripting the ‘recurrence’ value or by manually editing the .ics files. (See the following post.) The iCalendar standard allows an event to have more than one recurrence rule. iCals 1.5.5 and 2.0.5 honoured multiple rules where they existed in the .ics files, but iCal 4.0 only heeds the first. The script, however, retains its multiple-recurrence ability.

No version of iCal is known to observe the BYSETPOS rule part or rule parts governing repeat intervals of less than a day, so the script doesn’t bother either.

Because iCal’s AppleScript implementation doesn’t return the necessary time-zones or detached-event information, the script gets everything it needs from the .ics files. This makes it somewhat less than instantaneous and it won’t work properly with recently created or edited events if applied to them before iCal gets around to updating the files.

-- A known Monday in the past in the proleptic Gregorian calendar. Although its string representation is incorrect in most manifestations of AppleScript 2 so far, the date object itself is fine. Other reference dates used in the script are derived from it as offsets.
property Monday10000106 : «data isot313030302D30312D3036» as date -- date "Monday 6 January 1000 00:00:00".

(*** Main handler. ***)

(* Return the effective elements and properties of the next (or only) expression of an iCal/Calendar event at or after a given moment, or return 'missing value' if none. *)
-- checkDate (AppleScript date object): the computer-local date/time from which to search.
-- eventUID (text): the event's UID as returned by iCal or Calendar.
-- straddle (boolean): 'true' to include expressions already in progress at the check date; 'false' to go strictly by start times.
on getNextExpression from checkDate for eventUID given straddle:straddleOK
	-- A faster time-zone transposition technique is possible with AppleScript 2.
	global AppleScript_2
	set AppleScript_2 to ((system attribute "ascv") mod 65536 ≥ 512) -- 'true' if AS 2.0 or later.
	
	considering case
		-- Get the available details of the root event and of any detached events having this UID.
		set theData to getAllData(eventUID, checkDate)
		set {|computer TZ|:computerTZ, |event TZ|:eventTZ, |root event data|:{|RRULEs|:RRULEs, |start date|:startDate}} to theData
		-- Transpose the check date to the event's time zone.
		set checkDate to TZtoTZ(checkDate, computerTZ, eventTZ)
		set hits to {}
		set RRULECount to (count RRULEs)
		if (RRULECount > 0) then
			-- This is a repeating event. Get the details of the event's next expression under each of its governing RRULEs (although iCal and Calendar only allow one RRULE to be set and only iCals 1 and 2 honoured multiple RRULEs in imported calendars). The dates returned will have been transposed where necessary to the computer's time zone.
			repeat with i from 1 to RRULECount
				set RRULE to item i of RRULEs
				set freq to getRulePartValue(RRULE, "FREQ")
				if (freq is "DAILY") then
					set end of hits to nextDaily(theData, checkDate, RRULE, straddleOK)
				else if (freq is "WEEKLY") then
					set end of hits to nextWeekly(theData, checkDate, RRULE, straddleOK)
				else if (freq is "MONTHLY") then
					set end of hits to nextMonthly(theData, checkDate, RRULE, straddleOK)
				else if (freq is "YEARLY") then
					set end of hits to nextYearly(theData, checkDate, RRULE, straddleOK)
				end if
			end repeat
		else if ((startDate ≥ checkDate) or ((straddleOK) and (startDate + (theData's |root event data|'s duration) > checkDate))) then
			-- This is non-recurring event on, after, or acceptably straddling the check date.
			set end of hits to getInstanceData(theData, startDate)
		end if
	end considering
	
	set hits to hits's records
	if (hits is {}) then
		-- No expressions of the event after the check date.
		set topHit to missing value
	else
		-- Otherwise get the earliest one.
		set topHit to beginning of hits
		repeat with i from 2 to (count hits)
			if (|start date| of item i of hits comes before topHit's |start date|) then set topHit to item i of hits
		end repeat
	end if
	
	return topHit
end getNextExpression

(*** Handlers for the four "FREQ" values recognised by iCal/Calendar. ***)

(* Get the next expression of a "DAILY" rule. *)
on nextDaily(theData, checkDate, RRULE, straddleOK)
	set {|root event data|:{|start date|:startDate}, |excluded dates|:exdates} to theData
	set interval to getInterval(RRULE) * days
	set expiryDate to getExpiryDate(RRULE, theData's |event TZ|)
	set maxExpressions to getMaxExpressions(RRULE)
	
	set BYMONTH to (RRULE contains "BYMONTH=")
	if ((BYMONTH) or (RRULE contains "BYWEEKNO=")) then -- BYMONTH or BYWEEKNO sub-rule.
		-- Run the recurrence sequence from the start date to first valid expression on or after the check date or until any recurrence limit is exceeded.
		if (BYMONTH) then
			set targetMonths to getTargetMonths(RRULE, startDate)
		else -- BYWEEKNO
			set {weekYearStart, weekYearEnd} to getWeekYear(RRULE, startDate)
			set targetWeeks to getIntegerList(getRulePartValue(RRULE, "BYWEEKNO"))
		end if
		set recurrenceDate to startDate
		set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
		set hit to true
		set recurrenceNo to 1
		repeat while (((expressionDate comes before checkDate) and not ((straddleOK) and (checkDate - expressionDate < duration)) or (recurrenceDate is in exdates) or (not hit)) and (recurrenceNo < maxExpressions))
			set recurrenceDate to recurrenceDate + interval
			if (BYMONTH) then
				set hit to (recurrenceDate's month is in targetMonths)
			else -- BYWEEKNO
				if (recurrenceDate comes after weekYearEnd) then
					set weekYearStart to weekYearEnd + 1
					set weekYearEnd to weekYearEnd + 53 * weeks
					if (weekYearEnd's day comes after 3) then set weekYearEnd to weekYearEnd - weeks
				end if
				set hit to (((recurrenceDate - weekYearStart) div weeks + 1 is in targetWeeks) or ((recurrenceDate - weekYearEnd) div weeks - 1 is in targetWeeks))
			end if
			if (hit) then set recurrenceNo to recurrenceNo + 1
			set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
		end repeat
		if (((expressionDate comes before checkDate) and not ((straddleOK) and (checkDate - expressionDate < duration))) or (recurrenceDate comes after expiryDate) or (recurrenceDate is in exdates)) then set recurrenceDate to missing value
	else -- No BYxxx rule parts. The recurrence date before the check date can be calculated without having to run the sequence.
		if (checkDate comes after startDate) then
			set recurrenceDate to checkDate - (checkDate - 1 - startDate) mod interval - 1
		else
			set recurrenceDate to startDate
		end if
		-- . but then a little sequencing's needed to sort out the particular circumstances.
		set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
		repeat while ((recurrenceDate is in theData's |excluded dates|) or ((expressionDate comes before checkDate) and not ((straddleOK) and (checkDate - expressionDate < duration))))
			set recurrenceDate to recurrenceDate + interval
			set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
		end repeat
		if (((recurrenceDate - startDate) div interval + 1 > maxExpressions) or (recurrenceDate comes after expiryDate)) then set recurrenceDate to missing value
	end if
	
	return getInstanceData(theData, recurrenceDate)
end nextDaily

(* Get the next expression of a "WEEKLY" rule. *)
on nextWeekly(theData, checkDate, RRULE, straddleOK)
	set {|root event data|:{|start date|:startDate}, |excluded dates|:exdates} to theData
	set interval to getInterval(RRULE) * weeks
	set expiryDate to getExpiryDate(RRULE, theData's |event TZ|)
	set maxExpressions to getMaxExpressions(RRULE)
	
	set BYMONTH to (RRULE contains "BYMONTH")
	if (BYMONTH) then set targetMonths to getTargetMonths(RRULE, startDate)
	
	-- Get the start of the week which contains the event's start date and the offset(s) of the recurrence weekday(s) from that.
	set {weekStart, weekdayOffsets} to getWeekdayStuff(RRULE, startDate)
	set weekEnd to weekStart + weeks - 1
	set recurrencesPerWeek to (count weekdayOffsets)
	
	-- The event's start date isn't necessarily a recurrence weekday.
	-- Initialise an index into weekdayOffsets such that (index + 1) indexes the recurrence weekday after the start date.
	set i to recurrencesPerWeek
	repeat while ((i > 0) and (weekStart + (beginning of item i of weekdayOffsets) comes after startDate))
		set i to i - 1
	end repeat
	-- Run the recurrence sequence from the start date.
	set recurrenceDate to startDate
	set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
	set hit to true
	set recurrenceNo to 1
	repeat while ((((expressionDate comes before checkDate) and not ((straddleOK) and (checkDate - expressionDate < duration))) or (recurrenceDate is in exdates) or (not hit)) and (recurrenceNo < maxExpressions))
		set i to i + 1
		if (i > recurrencesPerWeek) then
			set i to 1
			set weekStart to weekStart + interval
			set weekEnd to weekEnd + interval
		end if
		set recurrenceDate to weekStart + (beginning of item i of weekdayOffsets) + (startDate's time)
		if (BYMONTH) then set hit to (weekStart's month is in targetMonths) or (weekEnd's month is in targetMonths)
		if (hit) then set recurrenceNo to recurrenceNo + 1
		set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
	end repeat
	-- No next expression if maxExpressions exceeded or expiryDate passed.
	if (((expressionDate comes before checkDate) and not ((straddleOK) and (checkDate - expressionDate < duration))) or (recurrenceDate comes after expiryDate) or (recurrenceDate is in exdates)) then set recurrenceDate to missing value
	
	return getInstanceData(theData, recurrenceDate)
end nextWeekly

(* Get the next expression of a "MONTHLY" rule. *)
on nextMonthly(theData, checkDate, RRULE, straddleOK)
	script BYscript -- Used because the BYMONTHDAY and BYDAY script objects need a parent.
		property child : missing value
		property hits : missing value
		
		on getRecurrenceDates(anchorDate)
			set my hits to {}
			child's getRecurrenceDates(anchorDate)
			
			return my hits
		end getRecurrenceDates
		
		(* The getWeekdayDate() handler called from BYDAY is inherited from main script. *)
	end script
	
	set {|root event data|:{|start date|:startDate}, |excluded dates|:exdates} to theData
	set interval to getInterval(RRULE)
	set expiryDate to getExpiryDate(RRULE, theData's |event TZ|)
	set maxExpressions to getMaxExpressions(RRULE)
	
	if (RRULE contains "BYDAY=") then
		set BYscript's child to BYDAYscript(RRULE, startDate, BYscript)
	else -- BYMONTHDAY or no BYxxx rule part.
		set BYscript's child to BYMONTHDAYscript(RRULE, startDate, BYscript)
	end if
	set recurrenceDates to BYscript's getRecurrenceDates(startDate)
	set recurrencesInMonth to (count recurrenceDates)
	
	-- The event's start date isn't necessarily a recurrence weekday or monthday.
	-- Initialise an index into recurrenceDates such that (index + 1) indexes the recurrence instance after the start date.
	set i to recurrencesInMonth
	repeat while (i > 0) and (item i of recurrenceDates comes after startDate)
		set i to i - 1
	end repeat
	set recurrenceNo to 1
	set misses to 0
	-- Run the recurrence sequence from the start date.
	set anchorDate to startDate
	copy anchorDate to recurrenceDate
	set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
	repeat while ((((expressionDate comes before checkDate) and not ((straddleOK) and (checkDate - expressionDate < duration))) or (recurrenceDate is in exdates)) and (recurrenceNo < maxExpressions))
		set i to i + 1
		if (i > recurrencesInMonth) then
			set i to 1
			set anchorDate to addMonths(anchorDate, interval)
			set recurrenceDates to BYscript's getRecurrenceDates(anchorDate)
			set recurrencesInMonth to (count recurrenceDates)
			if (recurrencesInMonth is 0) then
				set misses to misses + 1
				if (misses ≥ ((anchorDate's year) - (startDate's year)) * interval) then exit repeat
			end if
			sort(recurrenceDates)
		end if
		if (recurrencesInMonth) > 0 then
			set recurrenceDate to item i of BYscript's hits
			if (recurrenceDate comes after startDate) then
				set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
				set recurrenceNo to recurrenceNo + 1
			end if
		end if
	end repeat
	-- No next expression if maxExpressions exceeded or expiryDate passed.
	if (((expressionDate comes before checkDate) and not ((straddleOK) and (checkDate - expressionDate < duration))) or (recurrenceDate comes after expiryDate) or (recurrenceDate is in exdates)) then set recurrenceDate to missing value
	
	return getInstanceData(theData, recurrenceDate)
end nextMonthly

(* Get the next expression of a "YEARLY" rule. *)
on nextYearly(theData, checkDate, RRULE, straddleOK)
	set {|root event data|:{|start date|:startDate}, |excluded dates|:exdates} to theData
	set interval to getInterval(RRULE)
	set expiryDate to getExpiryDate(RRULE, theData's |event TZ|)
	set maxExpressions to getMaxExpressions(RRULE)
	
	set BYMONTH to (RRULE contains "BYMONTH=")
	set BYWEEKNO to (RRULE contains "BYWEEKNO=")
	set BYYEARDAY to (RRULE contains "BYYEARDAY=")
	set BYMONTHDAY to (RRULE contains "BYMONTHDAY=")
	set BYDAY to (RRULE contains "BYDAY=")
	
	(* The Dawson & Stenerson document says: "If multiple BYxxx rule parts are specified, then after evaluating the specified FREQ and INTERVAL rule parts, the BYxxx rule parts are applied to the current set of evaluated occurrences in the following order: BYMONTH, BYWEEKNO, BYYEARDAY, BYMONTHDAY, BYDAY, BYHOUR, BYMINUTE, BYSECOND and BYSETPOS; then COUNT and UNTIL are evaluated." It then goes on to give an example whereby a yearly rule has BYMONTH applied to it, then BYDAY, etc., anything not defined being taken from the event's start date. But iCal currently (vs 1 & 2) does as follows, which only makes a difference with badly formed rules: if a rule has incompatible parts, say, both BYWEEKNO and BYMONTHDAY, iCal ignores BYWEEKNO (presumably because BYMONTHDAY implies BYMONTH, which takes precedence over BYWEEKNO), whereas my understanding would be to ignore BYMONTHDAY (BYWEEKNO is defined, BYMONTH isn't). *)
	-- iCal logic:
	if (BYDAY) then
		if (BYMONTH) or not (BYWEEKNO) then
			set BYscript to BYMONTHscript(RRULE, startDate, BYDAY) -- BYMONTH with BYDAY. (Default month if necessary).
		else
			set BYscript to BYWEEKNOscript(RRULE, startDate) -- BYWEEKNO with BYDAY.
		end if
	else if (BYMONTH) or (BYMONTHDAY) then
		set BYscript to BYMONTHscript(RRULE, startDate, BYDAY) -- BYMONTH with BYMONTHDAY. (Default month or day).
	else if (BYWEEKNO) then
		set BYscript to BYWEEKNOscript(RRULE, startDate) -- BYWEEKNO with BYDAY. (Default weekday).
	else if (BYYEARDAY) then
		set BYscript to BYYEARDAYscript(RRULE, startDate)
	else
		set BYscript to BYMONTHscript(RRULE, startDate, false) -- BYMONTH with MONTHDAY. (Default month and day).
	end if
	set defaultDayNo to startDate's day
	set recurrenceDates to BYscript's getRecurrenceDates(startDate)
	set recurrencesInYear to (count recurrenceDates)
	
	-- The event's start date isn't necessarily one of the recurrence days.
	-- Initialise an index into recurrenceDates such that (index + 1) indexes the recurrence instance after the start date.
	set i to recurrencesInYear
	repeat while (i > 0) and (item i of recurrenceDates comes after startDate)
		set i to i - 1
	end repeat
	-- Run the recurrence sequence from the start date.
	copy startDate to anchorDate
	copy startDate to recurrenceDate
	set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
	set recurrenceNo to 1
	set maxExpressions to getMaxExpressions(RRULE)
	set misses to 0
	repeat while ((((expressionDate comes before checkDate) and not ((straddleOK) and (checkDate - expressionDate < duration))) or (recurrenceDate is in exdates)) and (recurrenceNo < maxExpressions))
		set i to i + 1
		if (i > recurrencesInYear) then
			set i to 1
			set anchorDate's year to (anchorDate's year) + interval
			set recurrenceDates to BYscript's getRecurrenceDates(anchorDate)
			set recurrencesInYear to (count recurrenceDates)
			if (recurrencesInYear is 0) then
				set misses to misses + 1
				if (misses ≥ ((anchorDate's year) - (startDate's year)) * interval) then exit repeat
			end if
			sort(recurrenceDates)
		end if
		if (recurrencesInYear > 0) then
			set recurrenceDate to item i of recurrenceDates
			if (recurrenceDate comes after startDate) then
				set recurrenceNo to recurrenceNo + 1
				set {expressionDate, duration} to getExpressionDate(recurrenceDate, theData)
			end if
		end if
	end repeat
	-- No next expression if maxExpressions is exceeded or expiryDate passed.
	if ((expressionDate comes before checkDate) and not ((straddleOK) and (checkDate - expressionDate < duration))) or (recurrenceDate comes after expiryDate) or (recurrenceDate is in exdates) then set recurrenceDate to missing value
	
	return getInstanceData(theData, recurrenceDate)
end nextYearly

(*** BYxxx sequence script object constructors and linkers for "MONTHLY" & "YEARLY" recurrences. The scripts are linked as required to delegate recurrence sequencing subtasks to each other. ***)

on BYMONTHscript(RRULE, startDate, BYDAY)
	script thisScript
		property child : missing value
		property targetMonths : getTargetMonths(RRULE, startDate)
		property hits : missing value
		
		on getRecurrenceDates(anchorDate)
			set my hits to {}
			repeat with i from 1 to (count targetMonths)
				copy anchorDate to recurrenceDate
				set recurrenceDate's day to 1
				set recurrenceDate's month to item i of my targetMonths
				child's getRecurrenceDates(recurrenceDate)
			end repeat
			
			return my hits
		end getRecurrenceDates
		
		(* The getWeekdayDate() handler called from BYDAY is inherited from main script. *)
	end script
	
	if (BYDAY) then
		set thisScript's child to BYDAYscript(RRULE, startDate, thisScript)
	else
		set thisScript's child to BYMONTHDAYscript(RRULE, startDate, thisScript)
	end if
	
	return thisScript
end BYMONTHscript

on BYMONTHDAYscript(RRULE, startDate, dad)
	script thisScript
		property parent : dad
		property monthDays : getTargetDays(RRULE, startDate)
		
		on getRecurrenceDates(anchorDate)
			repeat with i from 1 to (count monthDays)
				copy anchorDate to recurrenceDate
				set d to item i of my monthDays
				if (d > 0) then
					set recurrenceDate's day to d
				else
					set recurrenceDate's day to 32
					set recurrenceDate to recurrenceDate - ((recurrenceDate's day) - d - 1) * days
				end if
				if (recurrenceDate's month is anchorDate's month) then set end of dad's hits to recurrenceDate
			end repeat
		end getRecurrenceDates
	end script
	
	return thisScript
end BYMONTHDAYscript

on BYDAYscript(RRULE, startDate, dad)
	script thisScript
		property parent : dad
		property weekStart : missing value
		property weekdayInstances : missing value
		property instanceNos : missing value
		
		on getRecurrenceDates(anchorDate)
			repeat with i from 1 to (count weekdayInstances)
				set {weekdayOffset, my instanceNos} to item i of my weekdayInstances
				repeat with j from 1 to (count instanceNos)
					set recurrenceDate to getWeekdayDate(anchorDate, weekdayOffset, item j of my instanceNos)
					if (recurrenceDate is not in my hits) then set end of my hits to recurrenceDate
				end repeat
			end repeat
		end getRecurrenceDates
	end script
	
	set {thisScript's weekStart, thisScript's weekdayInstances} to getWeekdayStuff(RRULE, startDate)
	
	return thisScript
end BYDAYscript

on BYWEEKNOscript(RRULE, startDate)
	script thisScript
		property child : missing value
		property weekNos : getIntegerList(getRulePartValue(RRULE, "BYWEEKNO"))
		property weekdayBaseDate : missing value -- Week start + time of start date.
		property hits : missing value
		
		on getRecurrenceDates(anchorDate)
			set {weekYearStart, weekYearEnd} to getWeekYear(anchorDate)
			set my hits to {}
			repeat with i from 1 to (count weekNos)
				set thisWeekNo to item i of my weekNos
				if (thisWeekNo > 0) then
					child's getRecurrenceDates(weekYearStart + (thisWeekNo - 1) * weeks)
				else
					child's getRecurrenceDates(weekYearEnd + (1 + thisWeekNo * weeks))
				end if
			end repeat
			
			return my hits
		end getRecurrenceDates
		
		(* Get the first and last seconds of a year of weeks. ISO-style weeks of year. User-defined week start. *)
		on getWeekYear(anchorDate)
			-- The first week of any year contains 4th January.
			copy anchorDate to Jan4
			set Jan4's day to 4
			set Jan4's month to January
			-- Use that and the WKST day code to get the beginning of that week.
			set weekYearStart to Jan4 - (Jan4 - weekdayBaseDate) mod weeks
			set weekYearEnd to weekYearStart + (53 * weeks - 1)
			if (weekYearEnd's day > 3) then set weekYearEnd to weekYearEnd - weeks
			
			return {weekYearStart, weekYearEnd}
		end getWeekYear
		
		(* Get the date of a given instance of a given weekday before or after a given date. (Called by BYDAY script object.) *)
		on getWeekdayDate(givenDate, weekdayOffset, instanceNo)
			if (instanceNo > 0) then
				return givenDate + (weekdayOffset + (instanceNo - 1) * weeks)
			else
				return givenDate + (weekdayOffset + (instanceNo + 1) * weeks)
			end if
		end getWeekdayDate
	end script
	
	set thisScript's child to BYDAYscript(RRULE, startDate, thisScript)
	set thisScript's weekdayBaseDate to (thisScript's child's weekStart) + (startDate's time)
	
	return thisScript
end BYWEEKNOscript

on BYYEARDAYscript(RRULE, startDate)
	script thisScript
		property yearDays : getTargetDays(RRULE, startDate)
		property hits : missing value
		
		on getRecurrenceDates(anchorDate)
			set refYear to anchorDate's year
			set hits to {}
			repeat with i from 1 to (count yearDays)
				copy anchorDate to recurrenceDate
				set targetDay to item i of my yearDays
				if (targetDay > 0) then
					set recurrenceDate's month to January
					set recurrenceDate to recurrenceDate + (targetDay - (recurrenceDate's day)) * days
				else
					set recurrenceDate's month to December
					set recurrenceDate to recurrenceDate + (32 + targetDay - (recurrenceDate's day)) * days
				end if
				if (recurrenceDate's year is refYear) then set end of my hits to recurrenceDate
			end repeat
			
			return hits
		end getRecurrenceDates
	end script
	
	return thisScript
end BYYEARDAYscript

(*** Odd jobs. ***)

(* Read a given rule part value from a recurrence rule. *)
on getRulePartValue(RRULE, rulePartKey)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to rulePartKey & "="
	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 an "UNTIL" value as an AppleScript date or default to 31st December 9999 23:59:59 *)
on getExpiryDate(RRULE, eventTZ)
	if (RRULE contains "UNTIL") then return recordToNewTZDate(getDateAndTZ(":" & getRulePartValue(RRULE, "UNTIL")), eventTZ)
	return «data isot393939392D31322D33315432333A35393A3539» as date -- date "Friday 31 December 9999 23:59:59"
end getExpiryDate

(* Get an "INTERVAL" value 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 getMaxExpressions(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 getMaxExpressions

(* Return comma-delimited items from a rule part value as an AppleScript list.. *)
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

(* Return comma-delimited numbers from a rule part as a list of AppleScript integers. *)
on getIntegerList(rulePartValue)
	set theList to getBYlist(rulePartValue)
	repeat with thisItem in theList
		set thisItem's contents to thisItem as integer
	end repeat
	
	return theList
end getIntegerList

(* Derive a list of AppleScript months from the numbers in a "BYMONTH" rule part 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 monthNo in targetMonths
			set monthNo's contents to item monthNo 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 "BYYEARDAY" or "BYMONTHDAY" rule part (using that priority) or default to the day of the event start date. *)
on getTargetDays(RRULE, startDate)
	if (RRULE contains "BYMONTHDAY=") then
		return getIntegerList(getRulePartValue(RRULE, "BYMONTHDAY"))
	else if (RRULE contains "BYYEARDAY=") then
		return getIntegerList(getRulePartValue(RRULE, "BYYEARDAY"))
	else
		return {startDate's day}
	end if
end getTargetDays

(* Get the first and last seconds in a year of weeks. ISO-style weeks of year. User-defined week start. *)
on getWeekYear(RRULE, startDate)
	-- The first week of any year contains 4th January.
	copy startDate to Jan4
	set Jan4's day to 4
	set Jan4's month to January
	-- Use that and the WKST day code to get the beginning of that week.
	set anchorDate to Monday10000106 + getWKSToffset(RRULE)
	set weekYearStart to Jan4 - (Jan4 - anchorDate) mod weeks
	set weekYearEnd to weekYearStart + (53 * weeks - 1)
	if (weekYearEnd's day > 3) then set weekYearEnd to weekYearEnd - weeks
	
	return {weekYearStart, weekYearEnd}
end getWeekYear

(* Analyse the weekday(s) implied or specified in the RRULE and return the results in a convenient form, ie.:
{start of start date's week, {{offset of weekday from week start, {weekday instance number(s)}} [, {., {.}}, .] }} *)
on getWeekdayStuff(RRULE, startDate)
	set weekdayCodes to "MOTUWETHFRSASU"
	set WEEKLYorBYWEEKNO to (RRULE contains "WEEK")
	
	-- Offset of the implied or specified week-start day from Monday.
	if (WEEKLYorBYWEEKNO) then -- Week start relevant. Monday if WKST omitted.
		set WKSToffset to getWKSToffset(RRULE)
	else -- Week start not relevant. 0 used for convenience.
		set WKSToffset to 0
	end if
	set weekStart to startDate - (startDate - Monday10000106) mod weeks + WKSToffset
	if (weekStart comes after startDate) then set weekStart to weekStart - weeks
	
	-- Get the weekday parameter(s), eg. {"1WE", "2TH"}.
	if (RRULE contains "BYDAY=") then -- Weekday(s) specified.
		set weekdayEntries to getBYlist(getRulePartValue(RRULE, "BYDAY"))
	else -- Use the start date's weekday.
		set codeOffset to (startDate - weekStart) div days * 2 + 1
		set weekdayEntries to {"1" & text codeOffset thru (codeOffset + 1) of weekdayCodes}
	end if
	-- Convert to offset-from-week-start/instance-list pairs.
	repeat with thisEntry in weekdayEntries
		set weekdayOffset to ((offset of (text -2 thru -1 of thisEntry) in weekdayCodes) div 2 * days + weeks - WKSToffset) mod weeks
		if (WEEKLYorBYWEEKNO) then -- Only one instance of weekday in a week.
			set instanceNos to {1}
		else if ((count thisEntry) > 2) then -- Instance number included.
			set instanceNos to {(text 1 thru -3 of thisEntry) as integer}
		else -- No instance number means EVERY instance in the month.
			set instanceNos to {1, 2, 3, 4, -1}
		end if
		set thisEntry's contents to {weekdayOffset, instanceNos}
	end repeat
	
	return {weekStart, weekdayEntries}
end getWeekdayStuff

(* Get the offset in seconds of the specified week-start from Monday, or default to 0 (for Monday itself). *)
on getWKSToffset(RRULE)
	if (RRULE contains "WKST=") then return (offset of getRulePartValue(RRULE, "WKST") in "MOTUWETHFRSASU") div 2 * days
	return 0
end getWKSToffset

(* Get a date that's m calendar months after (or before, if m is negative) the input date. *)
on addMonths(oldDate, m)
	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
	set d to oldDate's day
	if (m > 0) then tell newDate + (32 * m - d) * days to set newDate to it + (d - (its day)) * days
	if (newDate's day is not d) 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, weekdayOffset, instanceNo)
	-- A date in the past known to have the required weekday.
	set anchorDate to Monday10000106 + ((givenDate's time) + weekdayOffset)
	-- Get the last day of the seven-day period covered by the instance number in the current month.
	if (instanceNo > 0) then
		copy givenDate to periodEnd
		set periodEnd's day to instanceNo * 7 -- 7th, 14th, 21st, or 28th of month.
	else
		tell givenDate to tell it + (32 - (its day)) * days to set periodEnd to it - ((its day) - (instanceNo + 1) * 7) * days
	end if
	
	-- Round down to an exact number of weeks after the known-weekday date.	
	return periodEnd - (periodEnd - anchorDate) mod weeks
end getWeekdayDate

(* Insertion sort. By kai? *)
on sort(l)
	set lenPlus1 to (count l) + 1
	if (lenPlus1 > 2) then
		repeat with i from lenPlus1 - 2 to 1 by -1
			set v to item i of l
			repeat with j from i + 1 to lenPlus1
				if (j < lenPlus1) then
					set w to item j of l
					if (v > w) then
						set item (j - 1) of l to w
					else
						exit repeat
					end if
				end if
			end repeat
			set item (j - 1) of l to v
		end repeat
	end if
end sort

(* Get a recurrence instance's expression date. Same as the recurrence date unless the instance has been moved.
Now also return the instance duration for "straddle" testing. *)
on getExpressionDate(recurrenceDate, theData)
	set detachmentDates to theData's |detachment dates|
	if (recurrenceDate is in detachmentDates) then
		repeat with i from 1 to (count detachmentDates)
			if (item i of detachmentDates is recurrenceDate) then return {|start date| of item i of theData's |detached event data|, duration of item i of theData's |detached event data|}
		end repeat
	else
		return {recurrenceDate, duration of theData's |root event data|}
	end if
end getExpressionDate

(* Construct a data record for the found recurrence instance. *)
on getInstanceData(theData, recurrenceDate)
	if (recurrenceDate is missing value) then return missing value -- No suitable recurrence instance.
	
	set {|event TZ|:eventTZ, |computer TZ|:computerTZ, |detachment dates|:detachmentDates, |root event data|:rootEventData} to theData
	if (recurrenceDate is in detachmentDates) then -- Detached event.
		repeat with i from 1 to (count detachmentDates)
			if (item i of detachmentDates is recurrenceDate) then
				set instanceData to (item i of theData's |detached event data|) & {|recurrence date|:recurrenceDate, |event zone|:eventTZ} & rootEventData
				exit repeat
			end if
		end repeat
	else -- Main-sequence recurrence.
		set instanceData to {|start date|:recurrenceDate, |recurrence date|:recurrenceDate, |event zone|:eventTZ} & rootEventData
	end if
	
	-- Transpose the instance's date details to the computer's time zone. (Stamp date already done in getAllData().)
	set instanceData's |start date| to TZtoTZ(instanceData's |start date|, eventTZ, computerTZ)
	set instanceData's |end date| to TZtoTZ((instanceData's |start date|) + (instanceData's duration), computerTZ, computerTZ)
	if (instanceData's |RRULEs|) is {} then
		-- A recurrence date isn't relevant to a non-recurring event.
		set instanceData's |recurrence date| to missing value
	else
		set instanceData's |recurrence date| to TZtoTZ(recurrenceDate, eventTZ, computerTZ)
	end if
	
	return instanceData
end getInstanceData

(*** Handlers to get the data from the calendar file. ***)

(* Locate and parse the relevant calendar file, given the event UID. *)
on getAllData(eventUID, checkDate)
	set iCalVersion to getiCalVersion()
	if (iCalVersion begins with "1.") then
		set sc to "find -f ~/Library/Calendars/ \\( -name \"*.ics\" \\)"
	else if (iCalVersion begins with "2.") then
		set sc to "find -f ~/Library/'Application Support'/iCal/Sources/ \\( -path \"*.calendar/corestorage.ics\" \\)"
	else -- Assume a version using the same .ics storage locations as iCal 3.0 and 4.0.
		set sc to "find -f ~/Library/Calendars/ \\( -path \"*.calendar/Events/" & eventUID & ".ics\" \\)"
	end if
	set icsPOSIXPaths to paragraphs of (do shell script sc)
	set computerTZ to (do shell script ("readlink 'etc/localtime' | sed 's|/usr/share/zoneinfo/||'"))
	
	set UIDline to ("UID:" & eventUID) as Unicode text
	set astid to AppleScript's text item delimiters
	-- This repeat was originally to find and parse the event's data in the calendar file(s) returned with iCals 1.5 and 2.0, but it also works for the single event file returned with iCals 3.0 and 4.0.
	repeat with i from 1 to (count icsPOSIXPaths) -- Check each calendar file for the event UID.
		set thisText to (read (item i of icsPOSIXPaths as POSIX file) as «class utf8»)
		if (thisText contains UIDline) then -- The event UID line's in this file.
			set definiteHit to false -- . though conceivably as text in some other event!
			-- Extract the computer's time zone from the POSIX path pointed to by the symbolic link "/etc/localtime" and set up a few defaults and start values.
			set theData to {|computer TZ|:computerTZ, |event TZ|:missing value, |root event data|:missing value, |excluded dates|:{}, |detachment dates|:{}, |detached event data|:{}}
			set RRULEs to {}
			set attendees to {}
			-- Split the file text into blocks which begin with a paragraph end and the data for each event.
			set AppleScript's text item delimiters to "BEGIN:VEVENT" as Unicode text
			set eventBlocks to thisText's text items 2 thru -1
			
			set AppleScript's text item delimiters to ":" as Unicode text
			repeat with j from 1 to (count eventBlocks)
				set thisBlock to item j of eventBlocks
				if (thisBlock contains UIDline) then -- The UID definition's in this block.
					set propertyLines to unsplit(paragraphs 2 thru -1 of thisBlock)
					if (propertyLines contains UIDline) then -- . and occupies an entire line.
						-- Extract the data from each line of the VEVENT block.
						-- Dates are returned as date/time-zone records to be sorted out when they're all in.
						set definiteHit to true
						set detachmentDate to missing value
						set duration to missing value
						set eventData to {|start date|:missing value, |end date|:missing value, duration:missing value, |allday event|:false, uid:eventUID}
						set parsingAlarm to false
						repeat with k from 1 to (count propertyLines) -- Actually stops at "END:VEVENT".
							set thisLine to item k of propertyLines
							set thisValue to text from text item 2 to text item -1 of thisLine
							if (parsingAlarm) then
								if (thisLine begins with "ACTION") then
									set alarmType to thisValue
								else if (thisLine begins with "TRIGGER") then
									set anAlarm's |trigger interval| to isoToDuration(thisValue) div minutes
								else if (thisLine begins with "ATTACH") then
									set |attachment| to thisValue
								else if (thisLine is "END:VALARM") then
									if (alarmType is "DISPLAY") then
										set eventData to eventData & {|display alarms|:{}}
										set end of eventData's |display alarms| to anAlarm
									else if (alarmType is "EMAIL") then
										set eventData to eventData & {|mail alarms|:{}}
										set end of eventData's |mail alarms| to anAlarm
									else if (alarmType is "PROCEDURE") then
										set anAlarm to anAlarm & {filepath:|attachment|}
										set eventData to eventData & {|open file alarms|:{}}
										set end of eventData's |open file alarms| to anAlarm
									else if (alarmType is "AUDIO") then
										set anAlarm to anAlarm & {|sound name|:|attachment|}
										set eventData to eventData & {|sound alarms|:{}}
										set end of eventData's |sound alarms| to anAlarm
									end if
									set parsingAlarm to false
								end if
							else if (thisLine begins with "SUMMARY") then
								set eventData to eventData & {summary:unescape(thisValue)}
							else if (thisLine begins with "LOCATION") then
								set eventData to eventData & {location:unescape(thisValue)}
							else if (thisLine begins with "DTSTART") then
								set eventData's |start date| to getDateAndTZ(thisLine)
								-- If no TZ, this is an all-day event. Use the computer time zone, as per iCal 2.0.5.
								if (result's TZ is missing value) then
									set eventData's |allday event| to true
									set TZ of eventData's |start date| to theData's |computer TZ|
								end if
							else if (thisLine begins with "DTEND") then
								set eventData's |end date| to getDateAndTZ(thisLine)
							else if (thisLine begins with "DTSTAMP") then
								-- The stamp date can be matched to the computer's time zone immediately.				
								set eventData to eventData & {|stamp date|:recordToNewTZDate(getDateAndTZ(thisLine), theData's |computer TZ|)}
							else if (thisLine begins with "SEQUENCE") then
								set eventData to eventData & {sequence:thisValue as integer}
							else if (thisLine begins with "DURATION") then
								set eventData's duration to isoToDuration(thisValue)
							else if (thisLine begins with "RRULE") then
								set end of RRULEs to thisValue
							else if (thisLine begins with "EXDATE") then
								set end of theData's |excluded dates| to getDateAndTZ(thisLine)
							else if (thisLine begins with "RECURRENCE-ID") then
								set detachmentDate to getDateAndTZ(thisLine)
							else if (thisLine begins with "DESCRIPTION") then
								set eventData to eventData & {|description|:unescape(thisValue)}
							else if (thisLine begins with "STATUS") then
								set eventData to eventData & {status:thisValue}
							else if (thisLine begins with "URL") then
								set eventData to eventData & {|url|:thisValue}
							else if (thisLine begins with "ATTENDEE") then
								set end of attendees to getAttendee(thisLine)
							else if (thisLine is "BEGIN:VALARM") then
								set anAlarm to {|trigger interval|:0}
								set parsingAlarm to true
							else if (thisLine is "END:VEVENT") then
								exit repeat
							end if
						end repeat
						
						-- If a duration's not specified, an end date will be. Prefer a duration.
						if (eventData's duration is missing value) then set eventData's duration to (|date| of eventData's |end date|) - (get |date| of eventData's |start date|)
						if (detachmentDate is missing value) then -- Root event.
							-- The recurrence will be based on the root event's start date and time zone.
							set theData's |event TZ| to TZ of eventData's |start date|
							set eventData's |start date| to |date| of eventData's |start date|
							set theData's |root event data| to eventData & {summary:"" as Unicode text, location:missing value, |description|:missing value, status:"NONE" as Unicode text, |url|:missing value, |RRULEs|:RRULEs, attendees:attendees, |display alarms|:{}, |mail alarms|:{}, |open file alarms|:{}, |sound alarms|:{}}
						else -- Detached event.
							set end of theData's |detachment dates| to detachmentDate
							set end of theData's |detached event data| to eventData
						end if
					end if
				end if
			end repeat
			
			if (definiteHit) then exit repeat -- No need to check any more files.
		end if
	end repeat
	set AppleScript's text item delimiters to astid
	
	-- Replace any date/timezone records in |excluded dates| or |detachment dates| with dates in the root event's time zone.
	set eventTZ to theData's |event TZ|
	set recurrenceTime to time of theData's |root event data|'s |start date|
	repeat with flaggedDates in {theData's |excluded dates|, theData's |detachment dates|}
		repeat with i from 1 to (count flaggedDates)
			-- Each "flagged date" is a date/TZ record representing an instance on the event's recurrence sequence whose expression has been either deleted or modified in some way. We just need a date in the root event's timezone.
			set eventTZDate to recordToNewTZDate(item i of flaggedDates, eventTZ)
			-- The time should be the same as that of the root event. If it's different (by up to an hour) because of a clocks-forward discontinuity, standardise it for the script's convenience.
			if (eventTZDate's time is not recurrenceTime) then
				set diff to (eventTZDate's time) - recurrenceTime
				if (diff > hours) then
					set diff to diff - days
				else if (diff < -hours) then
					set diff to diff + days
				end if
				set eventTZDate to eventTZDate - diff
			end if
			set item i of flaggedDates to eventTZDate
		end repeat
	end repeat
	-- Do the same (without the clocks-forward adjustment) with the |start date| values of any detached events. These may or may not be the same as the recurrence sequence dates.
	repeat with thisItem in theData's |detached event data|
		set thisItem's |start date| to recordToNewTZDate(thisItem's |start date|, eventTZ)
	end repeat
	
	return theData
end getAllData

(* Get the iCal or Calendar version from its application file, which should work with all versions and associated OSs. *)
on getiCalVersion()
	tell application "Finder" to set iCalPath to (application file id "wrbt") as text
	try
		return (version of (info for file iCalPath)) as text
	on error
		tell application "System Events" to return (version of file iCalPath) as text
	end try
end getiCalVersion

(* Rejoin any split lines extracted from a VEVENT block. *)
on unsplit(theLines)
	set i to 1
	repeat with j from 2 to (count theLines)
		set thisLine to item j of theLines
		if ((thisLine begins with space) or (thisLine begins with tab)) then -- Continuation lines begin with a single white space.
			try
				set item i of theLines to item i of theLines & text 2 thru -1 of thisLine
			end try
			set item j of theLines to missing value
		else
			set i to j
		end if
	end repeat
	
	return theLines's every Unicode text
end unsplit

(* Remove any unwanted escaping from text data. *)
on unescape(thisText)
	if (thisText contains "\\") then
		set thisText to replaceText("\\,", ",", thisText)
		set thisText to replaceText("\\;", ";", thisText)
		set thisText to replaceText("\"", "\\\"", thisText) -- But actually add escaping to quotes!
		set thisText to (run script ("\"" & thisText & "\"")) as Unicode text
	end if
	
	return thisText
end unescape

(* Text substitution. *)
on replaceText(original, replacement, source)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to original as Unicode text
	set source to source's text items
	set AppleScript's text item delimiters to replacement as Unicode text
	set source to source as Unicode text
	set AppleScript's text item delimiters to astid
	
	return source
end replaceText

(* Return an AppleScript date and a time zone id from a VEVENT date property line. *)
on getDateAndTZ(thisLine)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to ":" as Unicode text
	set {lineBeginning, isot} to thisLine's text items
	set AppleScript's text item delimiters to astid
	
	set ASDate to isotToDate(isot)
	set TZ to missing value
	if (thisLine ends with "Z") then
		set TZ to "GMT"
	else if (lineBeginning contains ";") then
		set param to beginning of getLineParams(lineBeginning)
		if (param begins with "TZID=") then set TZ to text 6 thru -1 of param
	else
		set TZ to missing value
	end if
	
	return {|date|:ASDate, TZ:TZ}
end getDateAndTZ

(* Convert an ISO-format date string to an AppleScript date. *)
on isotToDate(isot)
	set n to (text 1 thru 8 of isot) as integer
	copy Monday10000106 to ASDate
	set ASDate's year to n div 10000
	set ASDate's month to item (n mod 10000 div 100) of {January, February, March, April, May, June, July, August, September, October, November, December}
	set ASDate's day to n mod 100
	
	if ((count isot) > 8) then
		set n to (text 10 thru 15 of isot) as integer
		set ASDate's time to n div 10000 * hours + n mod 10000 div 100 * minutes + n mod 100
	end if
	
	return ASDate
end isotToDate

(* Convert an ISO-format duration string to seconds (as integer). *)
on isoToDuration(ISO8601)
	set prefices to "PT" as Unicode text
	set suffices to "WDHMS" as Unicode text
	
	set duration to 0
	set i to (count ISO8601)
	repeat with j from 1 to i
		set c to character j of ISO8601
		if (c is in prefices) then
			set i to j + 1
		else if (c is in suffices) then
			if (c is "W") then
				set t to weeks
			else if (c is "D") then
				set t to days
			else if (c is "H") then
				set t to hours
			else if (c is "M") then
				set t to minutes
			else -- c is "S".
				set t to 1
			end if
			set duration to duration + t * (text i thru (j - 1) of ISO8601)
			set i to j + 1
		end if
	end repeat
	if (ISO8601 begins with "-") then set duration to -duration
	
	return duration
end isoToDuration

(* Parse an ATTENDEE line for display name, email, and participation status. *)
on getAttendee(theLine)
	set UNKNOWN to "UNKNOWN" as Unicode text
	set anAttendee to {|display name|:UNKNOWN, email:UNKNOWN, |participation status|:UNKNOWN}
	
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to ":" as Unicode text
	set lineParts to theLine's text items
	set AppleScript's text item delimiters to astid
	
	if ((count lineParts) is 3) then set anAttendee's email to end of lineParts
	set params to getLineParams(beginning of lineParts)
	
	repeat with thisParam in params
		if (thisParam begins with "CN") then
			set anAttendee's |display name| to text from word 3 to word -1 of thisParam
		else if (thisParam begins with "PARTSTAT") then
			set anAttendee's |participation status| to text 10 thru -1 of thisParam
		end if
	end repeat
	
	return anAttendee
end getAttendee

(* Return ";"-delimited parameter(s) from a given line beginning. *)
on getLineParams(linePart)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to ";" as Unicode text
	set params to rest of linePart's text items
	set AppleScript's text item delimiters to astid
	
	return params
end getLineParams

(*** Time zone handlers. ***)

(* Take a record containing an AppleScript date and a time zone ID and return the equivalent date in another time zone. *)
on recordToNewTZDate(dateTZRecord, targetTZ)
	set {|date|:ASDate, TZ:dateTZ} to dateTZRecord
	
	if (dateTZ is targetTZ) then return ASDate
	return TZtoTZ(ASDate, dateTZ, targetTZ)
end recordToNewTZDate

(* Transpose an AppleScript date/time from one time zone to another (or correct a date in the same zone when the clocks go forward). *)
on TZtoTZ(TZ1date, TZ1, TZ2)
	global AppleScript_2
	
	if (AppleScript_2) then
		-- Get the Unix era time for the date in time zone 1; get the date for the time in time zone 2 as «class isot»; coerce to AS date.
		return (do shell script ("eraT=$(TZ=" & TZ1 & " date -jf '%Y-%m-%dT%H:%M:%S' '" & (TZ1date as «class isot» as string) & "' '+%s') ; TZ=" & TZ2 & " date -r  \"$eraT\" '+%Y-%m-%dT%H:%M:%S'") as «class isot») as date
	else
		-- The old laborious workaround going through GMT.
		if (TZ1 is "GMT") then
			set GMTDate to TZ1date
		else
			set GMTDate to TZtoGMT(TZ1date, TZ1)
		end if
		
		if (TZ2 is "GMT") then return GMTDate
		return GMTtoTZ(GMTDate, TZ2)
	end if
end TZtoTZ

(* Transpose an AppleScript date/time from the given time zone to GMT. *)
on TZtoGMT(TZDate, TZ)
	-- The difference between TZDate when it's local and the GMT date we want is usually
	-- the same as the difference between the local date when TZDate is GMT and TZDate itself .
	set GMTDate to TZDate - (GMTtoTZ(TZDate, TZ) - TZDate)
	-- . but not around the time the clocks go forward. If the GMT obtained doesn't reciprocate to TZDate,
	-- shift to a nearby local date where the above DOES work, get a new GMT, unshift it by the same amount.
	set testDate to GMTtoTZ(GMTDate, TZ)
	if (testDate is not TZDate) then
		if (GMTDate > testDate) then -- "Clocks forward" is towards GMT.
			set shift to GMTDate - testDate
		else -- "Clocks forward" is away from GMT.
			set shift to -days
		end if
		set nearbyDate to TZDate + shift
		set GMTDate to nearbyDate - (GMTtoTZ(nearbyDate, TZ) - nearbyDate) - shift
	end if
	
	return GMTDate
end TZtoGMT

(* Transpose an AppleScript date/time from GMT to the given time zone. Thanks to Yvan Koenig for pointing out a number format dependency in the original version of this handler. *)
on GMTtoTZ(GMTDate, TZ)
	-- Subtract date "Thursday 1 January 1970 00:00:00" from the GMT date. Result in seconds, as text. 
	set eraTime to GMTDate - («data isot313937302D30312D3031» as date)
	if (eraTime > 99999999) then
		set eraTime to (eraTime div 100000000 as text) & text 2 thru 9 of (100000000 + eraTime mod 100000000 as integer as text)
	else if (eraTime < -99999999) then
		set eraTime to (eraTime div 100000000 as text) & text 3 thru 10 of (-100000000 + eraTime mod 100000000 as integer as text)
	else
		set eraTime to eraTime as text
	end if
	
	return isotToDate(do shell script ("TZ='" & TZ & "' /bin/date -r " & eraTime & " +%Y%m%dT%H%M%S"))
end GMTtoTZ

(* tell application "Calendar" to set eventUID to uid of event 1 of calendar 4
set checkDate to (current date)
-- Get the event's next expression starting at or after the check date.
getNextExpression from checkDate for eventUID without straddle
-- Get the event's next expression starting at or after the check date or in progress at the time.
getNextExpression from checkDate for eventUID with straddle *)

Edit: Main handler and some variables renamed for consistency, bug fixes, adaptation for Mountain Lion (iCal now called Calendar, new date bugs in ML), faster time-zone transposition code for systems which can use it, the |recurrence date| for any non-repeating event returned is now ‘missing value’.

Here’s the text of a calendar containing recurrence types that iCal observes but doesn’t have the facility (apart from AppleScript) to set itself. If you’re interested, it can be saved as a text file (plain or UTF8) with an “.ics” name extension and imported into iCal. (The line wraps beginning with spaces are correct. Unfortunately, this forum won’t let me post them as such, so please replace every instance of “[space]” in the text with an actual space.) The summaries and descriptions explain the recurrences. iCal’s own explanations in its settings drawer won’t be correct.

BEGIN:VCALENDAR CALSCALE:GREGORIAN X-WR-TIMEZONE:Europe/London METHOD:PUBLISH X-WR-RELCALID:41743424-598B-11DE-AC55-003065BF6DA2 PRODID:-//Apple Computer\, Inc//iCal 1.5//EN X-WR-CALNAME:@Test VERSION:2.0 BEGIN:VTIMEZONE LAST-MODIFIED:20090615T090230Z TZID:Europe/London BEGIN:DAYLIGHT DTSTART:20070325T020000 TZOFFSETTO:+0100 TZNAME:BST TZOFFSETFROM:+0000 END:DAYLIGHT BEGIN:STANDARD DTSTART:20071028T020000 TZOFFSETTO:+0000 TZNAME:GMT TZOFFSETFROM:+0100 END:STANDARD END:VTIMEZONE BEGIN:VEVENT SEQUENCE:8 DTSTART;TZID=Europe/London:20070601T120000 DTSTAMP:20090615T090120Z SUMMARY:Daily in June & August UID:415733D2-598B-11DE-AC55-003065BF6DA2 DTEND;TZID=Europe/London:20070601T130000 DESCRIPTION:FREQ=DAILY\;INTERVAL=1\;BYMONTH=6\,8 RRULE:FREQ=DAILY;INTERVAL=1;BYMONTH=6,8 END:VEVENT BEGIN:VEVENT SEQUENCE:8 DTSTART;TZID=Europe/London:20070601T120000 DTSTAMP:20090615T090120Z SUMMARY:Daily in 2nd & 4th weeks of year\, Monday week start UID:415739AA-598B-11DE-AC55-003065BF6DA2 DTEND;TZID=Europe/London:20070601T130000 DESCRIPTION:FREQ=DAILY\;INTERVAL=1\;BYWEEKNO=2\,4\;WKST=MO RRULE:FREQ=DAILY;INTERVAL=1;BYWEEKNO=2,4;WKST=MO END:VEVENT BEGIN:VEVENT SEQUENCE:8 DTSTART;TZID=Europe/London:20070601T120000 DTSTAMP:20090615T090120Z SUMMARY:Yearly\, every day in 1st & last weeks UID:415742BC-598B-11DE-AC55-003065BF6DA2 DTEND;TZID=Europe/London:20070601T130000 DESCRIPTION:FREQ=YEARLY\;INTERVAL=1\;BYWEEKNO=1\,-1\;BYDAY=SU\,MO\,TU\,W [space]E\,TH\,FR\,SA\;WKST=SU RRULE:FREQ=YEARLY;INTERVAL=1;BYWEEKNO=1,-1;BYDAY=SU,MO,TU,WE,TH,FR,SA;WK [space]ST=SU END:VEVENT BEGIN:VEVENT SEQUENCE:8 DTSTART;TZID=Europe/London:20070601T120000 DTSTAMP:20090615T090120Z SUMMARY:Weekly in July & September\, on Wednesdays UID:41574774-598B-11DE-AC55-003065BF6DA2 DTEND;TZID=Europe/London:20070601T130000 DESCRIPTION:FREQ=WEEKLY\;INTERVAL=1\;BYMONTH=7\,9\;BYDAY=WE\;WKST=SU\n\n [space]The recurrence is expressed if any part of the week is in a target month [space]\, even if the Wednesday itself isn't. RRULE:FREQ=WEEKLY;INTERVAL=1;BYMONTH=7,9;BYDAY=WE;WKST=SU END:VEVENT BEGIN:VEVENT SEQUENCE:8 DTSTART;TZID=Europe/London:20070601T120000 DTSTAMP:20090615T090120Z SUMMARY:Last day of every month UID:41574BC1-598B-11DE-AC55-003065BF6DA2 DTEND;TZID=Europe/London:20070601T130000 DESCRIPTION:FREQ=MONTHLY\;INTERVAL=1\;BYMONTHDAY=-1 RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=-1 END:VEVENT BEGIN:VEVENT SEQUENCE:8 DTSTART;TZID=Europe/London:20070601T120000 DTSTAMP:20090615T090120Z SUMMARY:Last day of February UID:4157500E-598B-11DE-AC55-003065BF6DA2 DTEND;TZID=Europe/London:20070601T130000 DESCRIPTION:FREQ=YEARLY\;INTERVAL=1\;BYMONTH=2\;BYMONTHDAY=-1 RRULE:FREQ=YEARLY;INTERVAL=1;BYMONTH=2;BYMONTHDAY=-1 END:VEVENT BEGIN:VEVENT SEQUENCE:9 DTSTART;VALUE=DATE:20070601 DTSTAMP:20090615T090157Z SUMMARY:Leap Day birthday UID:41575448-598B-11DE-AC55-003065BF6DA2 DTEND;VALUE=DATE:20070602 DESCRIPTION:60th day of year:\n\nFREQ=YEARLY\;INTERVAL=1\;BYYEARDAY=60 RRULE:FREQ=YEARLY;INTERVAL=1;BYYEARDAY=60 END:VEVENT BEGIN:VEVENT SEQUENCE:14 DTSTART;VALUE=DATE:20070505 DTSTAMP:20100126T104550Z SUMMARY:UK May Day Bank Holiday W/E UID:41575884-598B-11DE-AC55-003065BF6DA2 DTEND;VALUE=DATE:20070508 RRULE:FREQ=YEARLY;INTERVAL=1;BYWEEKNO=-35;BYDAY=SA;WKST=FR DESCRIPTION:FREQ=YEARLY\;INTERVAL=1\;BYWEEKNO=-35\;WKST=FR\;BYDAY=SA\n\n [space]The three days ending with the first Monday in May begin with the Saturd [space]ay of the thirty-fifth Friday-start week before the end of the year! END:VEVENT BEGIN:VEVENT SEQUENCE:4 DTSTART;TZID=Europe/London:20070601T120000 DTSTAMP:20090615T090120Z SUMMARY:1st day of every month\, every third Tuesday\, every other Thurs [space]day UID:41575CE2-598B-11DE-AC55-003065BF6DA2 DTEND;TZID=Europe/London:20070601T130000 RRULE:FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=1 RRULE:FREQ=WEEKLY;INTERVAL=3;BYDAY=TU;WKST=SU RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TH;WKST=SU DESCRIPTION:Three RRULEs!\n\nFREQ=MONTHLY\;INTERVAL=1\;BYMONTHDAY=1\nFRE [space]Q=WEEKLY\;INTERVAL=3\;BYDAY=TU\;WKST=SU\nFREQ=WEEKLY\;INTERVAL=2\;BYDAY= [space]TH\;WKST=SU END:VEVENT END:VCALENDAR
Edit on 26 January 2010: The “fifth Saturday in April” looseness I used previously for the “UK May Day Bank Holiday W/E” event doesn’t work properly with iCal 4.0 in Snow Leopard ” or, rather, it does! The event’s not expressed when there isn’t a fifth Saturday in April. Fortunately there seems to be an iCalendar-legal alternative: the Saturday of the 35th Friday-start week before the end of the year!

On the other hand, iCal 4.0 neither imports nor heeds multiple recurrence rules ” a somewhat backward step ” so only the first RRULE in the last event above will be followed.

Hi Nigel,

wow, what an exhaustive work !

In Leopard Apple has introduced a Objective-C framework to interact with calendar data.
Just for fun (and for my ObjC improvement) I wrote a small CLI to get the next occurrence of a given UID: nextOccurrenceForEventUID

The AppleScript usage could be like


tell application "iCal" to set eventUID to uid of event 3 of calendar 1
do shell script "/path/to/nextOccurrenceForEventUID " & eventUID
--> e.g. "2009-06-22 07:00:00 +0200"

The ObjC Code is

[code]#import <Foundation/Foundation.h>
#import <CalendarStore/CalendarStore.h>

int main (int argc, const char * argv[]) {
if (argc != 2) {
fprintf(stderr, “usage:\tnextOccurrenceForEventUID UID\n”);
return 1;
}

NSAutoreleasePool * pool = [[NSAutoreleasePool alloc] init];
NSString *UID = [NSString stringWithUTF8String:argv[1]];
NSCalendarDate *startDate = [NSCalendarDate calendarDate]; 
NSCalendarDate *endDate = [startDate dateByAddingYears:1 months:0 days:0 hours:0 minutes:0 seconds:0];
NSPredicate *eventsForThisUID = [CalCalendarStore eventPredicateWithStartDate:startDate 
																	  endDate:endDate 
																		  UID:UID
																	calendars:[[CalCalendarStore defaultCalendarStore] calendars]];

NSArray *events = [[CalCalendarStore defaultCalendarStore] eventsWithPredicate:eventsForThisUID];
if (events != nil && [events count]) {
	NSDateFormatter *dateFormatter = [[[NSDateFormatter alloc] init] autorelease];
	[dateFormatter setDateStyle:NSDateFormatterMediumStyle];
	[dateFormatter setTimeStyle:NSDateFormatterMediumStyle];
	NSString *dateString = [dateFormatter stringFromDate:[[events objectAtIndex:0] startDate]];
	printf("%s\n", [dateString UTF8String]);
}
[pool drain];
return 0;

}[/code]
updated May 22, 2013

Wow, Nigel;

I concur with Stefan – incredible

Here’s a weird bug in the Script Debugger: I downloaded your script using Safari on my PM G5 (10.5.7) and the handler below wouldn’t compile in Script Debugger 4.5.3. It failed on the lines I’ve commented out. The error message for the first commented-out line reads: “Can’t set source to source’s text items. Access not allowed.” If I download it (using the AppleScript link in Safari) on my MBP (Intel) it compiles perfectly in the Script Editor.

If I save the script from SD4.5 on the G5 (with the offending lines commented out), and shift it to my laptop the Script Editor compiles the word “source” as “«constant ASLGkSRC»”. If I open the downloaded version (saved on the G5 from the Script Editor and shifted to the laptop) in Script Debugger 4.5.3 on the laptop, it replaces the word “source” with “|source|” and compiles as it should.

This occurs because the word “source” is a keyword in the Script Debugger Dictionary.

on replaceText(original, replacement, source)
	set astid to AppleScript's text item delimiters
	set AppleScript's text item delimiters to original as Unicode text
	--> set source to source's text items
	set AppleScript's text item delimiters to replacement as Unicode text
	--> set source to source as Unicode text
	set AppleScript's text item delimiters to astid
	return source
end replaceText

Hi, Stefan.

Well, that’s certainly shorter than my effort! Probably a good deal faster too! :lol:

Presumably this new framework takes care of the problem areas such as time zones and modified recurrences.

The reason I mentioned AppleScript Studio in connection with time zones is that it ” and apps created with it ” have AppleScript access to Objective-C’s time-zone hooks. My initial idea was to have a dummy application that could be ‘told’ to quiz these; but a small Objective-C file, callable with ‘do shell script’, would probably be much better.

The broad idea, in respect of my script, would be to convert the “check date” from machine-local time to event-local time, run the recurrence sequence from the event-local point-of-view, then convert the results back to machine-local time. It gets a bit confusing around the times of the various daylight-saving switches. But I need to do more research into how iCal handles these. It may be simpler than I’m fearing. :slight_smile:

Hi, Adam.

That is a problem when compiling someone else’s script code on your own machine. A perfectly good variable name might coincide with a keyword from an OSAX installed on the compiling machine. But I think it’s bad that a “scripting environment” application should allow its own AppleScript keywords to interfere with code that isn’t specifically addressed to it. :confused:

Couldn’t agree more – I’ve posted a report on the SD-Talk User Group to that effect, Nigel.

I’ve now updated the script in post #1 for time-zone awareness and the ability (if required) to return a recurrence instance if the check date’s between its start and end dates.

Now updated to work with iCal 3.0 and iCal 4.0 and with the problematic date manipulation in AppleScript 2.1 (Snow Leopard).

Wow!! A magnum opus, no less.

Yes. It’s probably more suitable for ScriptBuilders than for here. But then ScriptBuilders has been down for an awfully long time…