A recurrence processor for Java
This library parses recurrence strings as defined in RFC 5545 and RFC 2445 and iterates the instances. In addition it can be used to build valid recurrence strings in a convenient manner.
Check out the "recurrence expansion as a service" demo at http://recurrence-expansion-service.appspot.com
Please note that the interface of the classes in this library is not finalized yet and subject to change. We're going to refactor this to make it more object-oriented and make more classes immutable (in particular the RecurrenceRule class itself).
The iterator has support for RSCALE. At this time is supports four calendar scales:
- GREGORIAN
- JULIAN
- ISLAMIC-CIVIL (aka ISLAMICC)
- ISLAMIC-TBLA
RSCALE is supported in all RFC2445 and RFC5545 modes.
In addition to interpreting recurrence rules, this library provides a set of classes to determine the result of any combination of rrules, rdates and exdates (and exrules, for that matter) as specified in RFC 5545.
Version 0.16.0 introduces a new API that is slightly different from the previous one. The new API fixes a few design issues that made the code more complex than necessary.
There is a new interface called RecurrenceSet
that is implemented by a couple of adapters, decorators and composites. A RecurrenceSet
represents the set of occurrences of a recurrence rule or list or any combination of them (including exclusions).
RecurrenceSet
extends the Iterable
interface, so it can be used with any Iterable
decorator from the jems2 library and in for
loops.
The most common use case is probably just iterating the occurrences of recurrence rules. Although you still can do this using the RecurrenceRuleIterator
returned by RecurrenceRule.iterator(DateTime)
, you may be better off using the OfRule
adapter that implements the
Iterable
interface.
RecurrenceSet occurrences = new OfRule(rrule, startDate);
You can combine this with the First
or While
decorators from the jems2 library to guard against infinite rules and use it to
loop over the occurrences.
for (DateTime occurrence:new First<>(1000, // iterate at most/the first 1000 occurrences
new OfRule(rrule, startDate))) {
// do something with occurrence
}
for (DateTime occurrence:new While<>(endDate::isAfter, // stop at "endDate"
new OfRule(rrule, startDate))) {
// do something with occurrence
}
Note that OfRule
does not iterate the start date if it doesn't match the RRULE. If you want to
iterate any non-synchronized first date, use OfRuleAndFirst
instead!
new OfRule(
new RecurrenceRule("FREQ=YEARLY;BYMONTHDAY=24;BYMONTH=5"),
DateTime.parse("19820523"))
results in
19820524,19830524,19840524,19850524…
Note that 19820523
is not among the results because it doesn't match the rule as it doesn't fall on the 24th.
However,
new OfRuleAndFirst(
new RecurrenceRule("FREQ=YEARLY;BYMONTHDAY=24;BYMONTH=5"),
DateTime.parse("19820523"))
results in
19820523,19820524,19830524,19840524,19850524…
Similarly, iterating comma separated Date or DateTime lists (i.e. RDATE
and EXDATE
) can be done with the OfList
adapter.
for (DateTime occurrence:new OfList(timeZone, rdates)) {
// do something with occurrence
}
You can merge the occurrences of multiple sets with the Merged
class. A Merged
RecurrenceSet
iterates the occurrences
of all given RecurrenceSet
s in chronological order.
RecurrenceSet merged = new Merged(
new OfRule(rule, start),
new OfList(timezone, rdates)
);
The result iterates the occurrences of both, the rule and the rdates in chronological order.
Exceptions can be excluded by composing occurrences and exceptions using Difference
like in
RecurrenceSet withoutExceptions = new Difference(
new OfRule(rule, start),
new OfList(timezone, exdates));
This RecurrenceSet
contains all the occurrences iterated by the given rule, except those in the exdates list. Note that these must be exact matches,
i.e. the exdate 20240216
does not result in the exclusion of 20240216T120000
nor of 20240216T000000
.
Sometimes you might want to skip all the instances prior to a given date. This can be achieved by applying the FastForwarded
decorator like in
RecurrenceSet merged = new FastForwarded(
fastForwardToDate,
new Merged(
new OfRule(rule, start),
new OfList(timezone, rdates)));
Note, that new FastForwarded(fastForwardTo, new OfRule(rrule, start))
and new OfRule(rrule, fastForwardTo)
are not necessarily the same
set of occurrences.
Be aware that RRULEs are infinite if they specify neither COUNT
nor UNTIL
. This might easily result in an infinite loop if not taken care of.
As stated above, a simple way to deal with this is by applying a decorator like First
or While
from the jems2 library:
RecurrenceRule rule = new RecurrenceRule("FREQ=YEARLY;BYMONTHDAY=23;BYMONTH=5");
DateTime start = new DateTime(1982, 4 /* 0-based month numbers! */,23);
for (DateTime occurrence:new First<>(1000, new OfRule(rule, start))) {
// do something with occurrence
}
This will always stop iterating after at most 1000 instances.
You can limit a RecurrenceSet
to the instances that precede a certain DateTime
using the Preceding
decorator. This can also serve as a way to handle infinite rules:
RecurrenceRule rule = new RecurrenceRule("FREQ=MONTHLY;BYMONTHDAY=23");
DateTime start = new DateTime(1982, 4 /* 0-based month numbers! */,23);
for (DateTime occurrence:new Preceding<>(
new DateTime(1983, 0, 1), // all instances before 1983
new OfRule(rule, start))) {
// do something with occurrence
}
The Within
decorator combines Preceding
and FastForwarded
and only iterates
occurrences that fall in the given (right-open) interval.
// a RecurrenceSet that only contains occurrences in 2024
// (assuming the original iterates all-day values)
RecurrenceSet occurrencesOf2024 = new Within(
DateTime.parse("20240101"),
DateTime.parse("20250101"),
recurrenceSet
);
Note, in both cases you must take care that the dates you supply have the same format (floating vs all-day vs absolute) as the occurrences of your recurrence set.
Finite, non-empty RecurrenceSet
s have a last instance that can be determined with the LastInstance
adapter.
LastInstance
is an Optional
of a DateTime
value that's present when the given RecurrenceSet
is finite and
non-empty.
new LastInstance(new OfRule(new RecurrenceRule("FREQ=DAILY;COUNT=10"), startDate));
In a recurring VEVENT
you might find RRULE
s, RDATE
s, EXDATE
s and (in RFC 2445) EXRULE
s. Assuming you have all
these in variables with these respective names the RecurrenceSet
might be constructed like in
RecurrenceSet occurrences = new Difference(
new Merged(
new OfRule(new RecurrenceRule(rrule), dtstart),
new OfList(timezone, rdates)
),
new Merged(
new OfRule(new RecurrenceRule(exrule), dtstart),
new OfList(timezone, exdates)
)
);
By default, the parser is very tolerant and accepts all rules that comply with RFC 5545. You can use other modes to ensure a certain compliance level:
RecurrenceRule rule1 = new RecurrenceRule("FREQ=WEEKLY;BYWEEKNO=1,2,3,4;BYDAY=SU", RfcMode.RFC2445_STRICT);
// -> will throw an InvalidRecurrenceRuleExceptionException because in RFC 2445 BYWEEKNO is only valid in
// combination with YEARLY rules
RecurrenceRule rule2 = new RecurrenceRule("FREQ=WEEKLY;BYWEEKNO=1,2,3,4;BYDAY=SU", RfcMode.RFC2445_LAX);
// -> will iterate Sunday in the first four weeks of the year
RecurrenceRule rule3 = new RecurrenceRule("FREQ=WEEKLY;BYWEEKNO=1,2,3,4;BYDAY=SU", RfcMode.RFC5545_STRICT);
// -> will throw an InvalidRecurrenceRuleExceptionException because in RFC 5545 BYWEEKNO is only valid in
// combination with YEARLY rules
RecurrenceRule rule4 = new RecurrenceRule("FREQ=WEEKLY;BYWEEKNO=1,2,3,4;BYDAY=SU", RfcMode.RFC5545_LAX);
// -> will iterate Sunday in the first four weeks of the year
RecurrenceRule rule5 = new RecurrenceRule("BYWEEKNO=1,2,3,4;BYDAY=SU;FREQ=WEEKLY", RfcMode.RFC2445_STRICT);
// -> will throw an InvalidRecurrenceRuleExceptionException because in RFC 2445 the rule must start with "FREQ="
RecurrenceRule rule6 = new RecurrenceRule("FREQ=MONTHLY;BYMONTH=4;", RfcMode.RFC2445_STRICT);
// -> will throw an InvalidRecurrenceRuleExceptionException because the trailing ";" is invalid
The default mode for parsing rules is RfcMode.RFC5545_LAX
. To support as many rules as possible use RfcMode.RFC2445_LAX
;
To build a rule you have to specify a base frequency and optionally an RfcMode. Then you can start adding BY* rules.
RecurrenceRule rule = new RecurrenceRule(Freq.MONTHLY); // will create a new rule using RfcMode.RFC5545_STRICT mode
rule.setCount(20);
// note that unlike with java.util.Calendar the months in this list are 1-based not 0-based
rule.setByRule(Part.BYMONTH, 1, 3, 5, 7);
rule.setByRule(Part.BYMONTHDAY, 4, 8, 12);
/*
* Alternatively set the values from a list or an array:
*/
Integer[] dayArray = new Integer[]{4, 8, 12};
rule.setByRule(Part.BYMONTHDAY, dayArray);
List<Integer> dayList = Arrays.asList(dayArray);
rule.setByRule(Part.BYMONTHDAY, dayList);
String ruleStr = rule.toString();
// ruleStr is "FREQ=MONTHLY;BYMONTH=1,3,5,7;BYMONTHDAY=4,8,12;COUNT=20"
There are at least two other implentations of recurrence iterators for Java:
- google-rfc-2445
- Android's calendarcommon project
- Add more tests
- Add tests for edge cases
- Add an RRuleBuilder to build RRULEs and make RecurrenceRule immutable
- Add support for more calendar scales
- Fix handling of calendar scales with leap months
- Fix RecurrenceRule.toString() when RSCALE is set
- Add validator and a validator log
Copyright (c) Marten Gajda 2024, licensed under Apache2.