com.google.ical.iter.RecurrenceIteratorFactory Maven / Gradle / Ivy
// Copyright (C) 2006 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.ical.iter;
import com.google.ical.values.DateTimeValue;
import com.google.ical.values.DateTimeValueImpl;
import com.google.ical.values.DateValue;
import com.google.ical.values.DateValueImpl;
import com.google.ical.values.Frequency;
import com.google.ical.values.IcalObject;
import com.google.ical.values.RDateList;
import com.google.ical.values.RRule;
import com.google.ical.values.TimeValue;
import com.google.ical.values.Weekday;
import com.google.ical.values.WeekdayNum;
import com.google.ical.util.Predicate;
import com.google.ical.util.Predicates;
import com.google.ical.util.TimeUtils;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.TimeZone;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
/**
* for calculating the occurrences of an individual RFC 2445 RRULE or groups of
* RRULES, RDATES, EXRULES, and EXDATES.
*
* Glossary
* Period - year|month|day|...
* Day of the week - an int in [0-6]. See RRULE_WDAY_* in rrule.js
* Day of the year - zero indexed in [0,365]
* Day of the month - 1 indexed in [1,31]
* Month - 1 indexed integer in [1,12]
*
* Abstractions
* Generator - a function corresponding to an RRULE part that takes a date and
* returns a later (year or month or day depending on its period) within the
* next larger period.
* A generator ignores all periods in its input smaller than its period.
*
* Filter - a function that returns true iff the given date matches the subrule.
*
* Condition - returns true if the given date is past the end of the recurrence.
*
*
All the functions that represent rule parts are stateful.
*
* @author [email protected] (Mike Samuel)
*/
public class RecurrenceIteratorFactory {
private static final Logger LOGGER = Logger.getLogger(
RecurrenceIteratorFactory.class.getName());
/**
* given a block of RRULE, EXRULE, RDATE, and EXDATE content lines, parse
* them into a single recurrence iterator.
* @param rdata ical text.
* @param dtStart the date of the first occurrence in timezone tzid, which is
* used to fill in optional fields in the RRULE, such as the day of the
* month for a monthly repetition when no ther day specified.
* Note: this may not be the first date in the series since an EXRULE or
* EXDATE might force it to be skipped, but there will be no earlier date
* generated by this ruleset.
* @param strict true if any failure to parse should result in a
* ParseException. false causes bad content lines to be logged and ignored.
*/
public static RecurrenceIterator createRecurrenceIterator(
String rdata, DateValue dtStart, TimeZone tzid, boolean strict)
throws ParseException {
return createRecurrenceIterable(rdata, dtStart, tzid, strict).iterator();
}
public static RecurrenceIterable createRecurrenceIterable(
String rdata, final DateValue dtStart, final TimeZone tzid,
final boolean strict)
throws ParseException {
final IcalObject[] contentLines = parseContentLines(rdata, tzid, strict);
return new RecurrenceIterable() {
public RecurrenceIterator iterator() {
List inclusions =
new ArrayList();
List exclusions =
new ArrayList();
// always include DTStart
inclusions.add(new RDateIteratorImpl(
new DateValue[] {TimeUtils.toUtc(dtStart, tzid)}));
for (IcalObject contentLine : contentLines) {
try {
String name = contentLine.getName();
if ("rrule".equalsIgnoreCase(name)) {
inclusions.add(createRecurrenceIterator(
(RRule) contentLine, dtStart, tzid));
} else if ("rdate".equalsIgnoreCase(name)) {
inclusions.add(
createRecurrenceIterator((RDateList) contentLine));
} else if ("exrule".equalsIgnoreCase(name)) {
exclusions.add(createRecurrenceIterator(
(RRule) contentLine, dtStart, tzid));
} else if ("exdate".equalsIgnoreCase(name)) {
exclusions.add(
createRecurrenceIterator((RDateList) contentLine));
}
} catch (IllegalArgumentException ex) {
// bad frequency on rrule or exrule
if (strict) { throw ex; }
LOGGER.log(
Level.SEVERE,
"Dropping bad recurrence rule line: " + contentLine.toIcal(),
ex);
}
}
return new CompoundIteratorImpl(inclusions, exclusions);
}
};
}
/**
* like {@link #createRecurrenceIterator(String,DateValue,TimeZone,boolean)}
* but defaults to strict parsing.
*/
public static RecurrenceIterator createRecurrenceIterator(
String rdata, DateValue dtStart, TimeZone tzid)
throws ParseException {
return createRecurrenceIterator(rdata, dtStart, tzid, true);
}
/**
* create a recurrence iterator from an rdate or exdate list.
*/
public static RecurrenceIterator createRecurrenceIterator(RDateList rdates) {
DateValue[] dates = rdates.getDatesUtc();
Arrays.sort(dates);
int k = 0;
for (int i = 1; i < dates.length; ++i) {
if (!dates[i].equals(dates[k])) { dates[++k] = dates[i]; }
}
if (++k < dates.length) {
DateValue[] uniqueDates = new DateValue[k ];
System.arraycopy(dates, 0, uniqueDates, 0, k);
dates = uniqueDates;
}
return new RDateIteratorImpl(dates);
}
/**
* create a recurrence iterator from an rrule.
* @param rrule the recurrence rule to iterate.
* @param dtStart the start of the series, in tzid.
* @param tzid the timezone to iterate in.
*/
public static RecurrenceIterator createRecurrenceIterator(
RRule rrule, DateValue dtStart, TimeZone tzid) {
assert null != tzid;
assert null != dtStart;
Frequency freq = rrule.getFreq();
Weekday wkst = rrule.getWkSt();
DateValue untilUtc = rrule.getUntil();
int count = rrule.getCount();
int interval = rrule.getInterval();
WeekdayNum[] byDay = rrule.getByDay().toArray(new WeekdayNum[0]);
int[] byMonth = rrule.getByMonth();
int[] byMonthDay = rrule.getByMonthDay();
int[] byWeekNo = rrule.getByWeekNo();
int[] byYearDay = rrule.getByYearDay();
int[] bySetPos = rrule.getBySetPos();
int[] byHour = rrule.getByHour();
int[] byMinute = rrule.getByMinute();
int[] bySecond = rrule.getBySecond();
if (interval <= 0) { interval = 1; }
if (null == wkst) {
wkst = Weekday.MO;
}
// Optimize out BYSETPOS where possible.
if (bySetPos.length != 0) {
switch (freq) {
case HOURLY:
// ;BYHOUR=3,6,9;BYSETPOS=-1,1
// is equivalent to
// ;BYHOUR=3,9
if (byHour.length != 0 && byMinute.length <= 1
&& bySecond.length <= 1) {
byHour = filterBySetPos(byHour, bySetPos);
}
// Handling bySetPos for rules that are more frequent than daily
// tends to lead to large amounts of processor being used before other
// work limiting features can kick in since there many seconds between
// dtStart and where the year limit kicks in.
// There are no known use cases for the use of bySetPos with hourly
// minutely and secondly rules so we just ignore it.
bySetPos = NO_INTS;
break;
case MINUTELY:
// ;BYHOUR=3,6,9;BYSETPOS=-1,1
// is equivalent to
// ;BYHOUR=3,9
if (byMinute.length != 0 && bySecond.length <= 1) {
byMinute = filterBySetPos(byMinute, bySetPos);
}
// See bySetPos handling comment above.
bySetPos = NO_INTS;
break;
case SECONDLY:
// ;BYHOUR=3,6,9;BYSETPOS=-1,1
// is equivalent to
// ;BYHOUR=3,9
if (bySecond.length != 0) {
bySecond = filterBySetPos(bySecond, bySetPos);
}
// See bySetPos handling comment above.
bySetPos = NO_INTS;
break;
default:
}
}
DateValue start = dtStart;
if (bySetPos.length != 0) {
// Roll back till the beginning of the period to make sure that any
// positive indices are indexed properly.
// The actual iterator implementation is responsible for anything
// < dtStart.
switch (freq) {
case YEARLY:
start = dtStart instanceof TimeValue
? new DateTimeValueImpl(start.year(), 1, 1, 0, 0, 0)
: new DateValueImpl(start.year(), 1, 1);
break;
case MONTHLY:
start = dtStart instanceof TimeValue
? new DateTimeValueImpl(start.year(), start.month(), 1, 0, 0, 0)
: new DateValueImpl(start.year(), start.month(), 1);
break;
case WEEKLY:
int d = (7 + wkst.ordinal() - Weekday.valueOf(dtStart).ordinal()) % 7;
start = TimeUtils.add(dtStart, new DateValueImpl(0, 0, -d));
break;
default: break;
}
}
// recurrences are implemented as a sequence of periodic generators.
// First a year is generated, and then months, and within months, days
ThrottledGenerator yearGenerator = Generators.serialYearGenerator(
freq == Frequency.YEARLY ? interval : 1, dtStart);
Generator monthGenerator = null;
Generator dayGenerator = null;
Generator secondGenerator = null;
Generator minuteGenerator = null;
Generator hourGenerator = null;
// When multiple generators are specified for a period, they act as a union
// operator. We could have multiple generators (for day say) and then
// run each and merge the results, but some generators are more efficient
// than others, so to avoid generating 53 sundays and throwing away all but
// 1 for RRULE:FREQ=YEARLY;BYDAY=TU;BYWEEKNO=1, we reimplement some of the
// more prolific generators as filters.
// TODO(msamuel): don't need a list here
List> filters =
new ArrayList>();
switch (freq) {
case SECONDLY:
if (bySecond.length == 0 || interval != 1) {
secondGenerator = Generators.serialSecondGenerator(interval, dtStart);
if (bySecond.length != 0) {
filters.add(Filters.bySecondFilter(bySecond));
}
}
break;
case MINUTELY:
if (byMinute.length == 0 || interval != 1) {
minuteGenerator = Generators.serialMinuteGenerator(interval, dtStart);
if (byMinute.length != 0) {
filters.add(Filters.byMinuteFilter(byMinute));
}
}
break;
case HOURLY:
if (byHour.length == 0 || interval != 1) {
hourGenerator = Generators.serialHourGenerator(interval, dtStart);
if (byHour.length != 0) {
filters.add(Filters.byHourFilter(bySecond));
}
}
break;
case DAILY:
break;
case WEEKLY:
// week is not considered a period because a week may span multiple
// months &| years. There are no week generators, but so a filter is
// used to make sure that FREQ=WEEKLY;INTERVAL=2 only generates dates
// within the proper week.
if (0 != byDay.length) {
dayGenerator = Generators.byDayGenerator(byDay, false, start);
byDay = NO_DAYS;
if (interval > 1) {
filters.add(Filters.weekIntervalFilter(interval, wkst, dtStart));
}
} else {
dayGenerator = Generators.serialDayGenerator(interval * 7, dtStart);
}
break;
case YEARLY:
if (0 != byYearDay.length) {
// The BYYEARDAY rule part specifies a COMMA separated list of days of
// the year. Valid values are 1 to 366 or -366 to -1. For example, -1
// represents the last day of the year (December 31st) and -306
// represents the 306th to the last day of the year (March 1st).
dayGenerator = Generators.byYearDayGenerator(byYearDay, start);
break;
}
// $FALL-THROUGH$
case MONTHLY:
if (0 != byMonthDay.length) {
// The BYMONTHDAY rule part specifies a COMMA separated list of days
// of the month. Valid values are 1 to 31 or -31 to -1. For example,
// -10 represents the tenth to the last day of the month.
dayGenerator = Generators.byMonthDayGenerator(byMonthDay, start);
byMonthDay = NO_INTS;
} else if (0 != byWeekNo.length && Frequency.YEARLY == freq) {
// The BYWEEKNO rule part specifies a COMMA separated list of ordinals
// specifying weeks of the year. This rule part is only valid for
// YEARLY rules.
dayGenerator = Generators.byWeekNoGenerator(byWeekNo, wkst, start);
byWeekNo = NO_INTS;
} else if (0 != byDay.length) {
// Each BYDAY value can also be preceded by a positive (n) or negative
// (-n) integer. If present, this indicates the nth occurrence of the
// specific day within the MONTHLY or YEARLY RRULE. For example,
// within a MONTHLY rule, +1MO (or simply 1MO) represents the first
// Monday within the month, whereas -1MO represents the last Monday of
// the month. If an integer modifier is not present, it means all days
// of this type within the specified frequency. For example, within a
// MONTHLY rule, MO represents all Mondays within the month.
dayGenerator = Generators.byDayGenerator(
byDay, Frequency.YEARLY == freq && 0 == byMonth.length, start);
byDay = NO_DAYS;
} else {
if (Frequency.YEARLY == freq) {
monthGenerator = Generators.byMonthGenerator(
new int[] { dtStart.month() }, start);
}
dayGenerator = Generators.byMonthDayGenerator(
new int[] { dtStart.day() }, start);
}
break;
}
if (secondGenerator == null) {
secondGenerator = Generators.bySecondGenerator(bySecond, start);
}
if (minuteGenerator == null) {
if (byMinute.length == 0 && freq.compareTo(Frequency.MINUTELY) < 0) {
minuteGenerator = Generators.serialMinuteGenerator(1, dtStart);
} else {
minuteGenerator = Generators.byMinuteGenerator(byMinute, start);
}
}
if (hourGenerator == null) {
if (byHour.length == 0 && freq.compareTo(Frequency.HOURLY) < 0) {
hourGenerator = Generators.serialHourGenerator(1, dtStart);
} else {
hourGenerator = Generators.byHourGenerator(byHour, start);
}
}
if (dayGenerator == null) {
boolean dailyOrMoreOften = freq.compareTo(Frequency.DAILY) <= 0;
if (byMonthDay.length != 0) {
dayGenerator = Generators.byMonthDayGenerator(byMonthDay, start);
byMonthDay = NO_INTS;
} else if (byDay.length != 0) {
dayGenerator = Generators.byDayGenerator(
byDay, Frequency.YEARLY == freq, start);
byDay = NO_DAYS;
} else if (dailyOrMoreOften) {
dayGenerator = Generators.serialDayGenerator(
Frequency.DAILY == freq ? interval : 1, dtStart);
} else {
dayGenerator = Generators.byMonthDayGenerator(
new int[] { dtStart.day() }, start);
}
}
if (0 != byDay.length) {
filters.add(Filters.byDayFilter(byDay, Frequency.YEARLY == freq, wkst));
byDay = NO_DAYS;
}
if (0 != byMonthDay.length) {
filters.add(Filters.byMonthDayFilter(byMonthDay));
}
// generator inference common to all periods
if (0 != byMonth.length) {
monthGenerator = Generators.byMonthGenerator(byMonth, start);
} else if (null == monthGenerator) {
monthGenerator = Generators.serialMonthGenerator(
freq == Frequency.MONTHLY ? interval : 1, dtStart);
}
// the condition tells the iterator when to halt.
// The condition is exclusive, so the date that triggers it will not be
// included.
Predicate condition;
boolean canShortcutAdvance = true;
if (0 != count) {
condition = Conditions.countCondition(count);
// We can't shortcut because the countCondition must see every generated
// instance.
// TODO(msamuel): if count is large, we might try predicting the end date
// so that we can convert the COUNT condition to an UNTIL condition.
canShortcutAdvance = false;
} else if (null != untilUtc) {
if ((untilUtc instanceof TimeValue) != (dtStart instanceof TimeValue)) {
// TODO(msamuel): warn
if (dtStart instanceof TimeValue) {
untilUtc = TimeUtils.dayStart(untilUtc);
} else {
untilUtc = TimeUtils.toDateValue(untilUtc);
}
}
condition = Conditions.untilCondition(untilUtc);
} else {
condition = Predicates.alwaysTrue();
}
// combine filters into a single function
Predicate super DateValue> filter;
switch (filters.size()) {
case 0:
filter = Predicates.alwaysTrue();
break;
case 1:
filter = filters.get(0);
break;
default:
filter = Predicates.and(filters);
break;
}
if (false) {
System.err.println(" start=" + start + "\ndtStart=" + dtStart);
System.err.println(" yearGenerator=" + yearGenerator);
System.err.println(" monthGenerator=" + monthGenerator);
System.err.println(" dayGenerator=" + dayGenerator);
System.err.println(" hourGenerator=" + hourGenerator);
System.err.println("minuteGenerator=" + minuteGenerator);
System.err.println("secondGenerator=" + secondGenerator);
}
Generator instanceGenerator = null;
if (0 != bySetPos.length) {
instanceGenerator = InstanceGenerators.bySetPosInstanceGenerator(
bySetPos, freq, wkst, filter,
yearGenerator, monthGenerator, dayGenerator, hourGenerator,
minuteGenerator, secondGenerator);
} else {
instanceGenerator = InstanceGenerators.serialInstanceGenerator(
filter, yearGenerator, monthGenerator, dayGenerator,
hourGenerator, minuteGenerator, secondGenerator);
}
return new RRuleIteratorImpl(
dtStart, tzid, condition, instanceGenerator,
yearGenerator, monthGenerator, dayGenerator,
hourGenerator, minuteGenerator, secondGenerator,
canShortcutAdvance);
}
/**
* a recurrence iterator that returns the union of the given recurrence
* iterators.
*/
public static RecurrenceIterator join(
RecurrenceIterator a, RecurrenceIterator... b) {
List incl = new ArrayList();
incl.add(a);
incl.addAll(Arrays.asList(b));
return new CompoundIteratorImpl(
incl, Collections.emptyList());
}
/**
* an iterator over all the dates included except those excluded, i.e.
* inclusions - exclusions
.
* Exclusions trump inclusions, and {@link DateValue dates} and
* {@link DateTimeValue date-times} never match one another.
* @param included non null.
* @param excluded non null.
* @return non null.
*/
public static RecurrenceIterator except(
RecurrenceIterator included, RecurrenceIterator excluded) {
return new CompoundIteratorImpl(
Collections.singleton(included),
Collections.singleton(excluded));
}
private static final Pattern FOLD = Pattern.compile("(?:\\r\\n?|\\n)[ \t]");
private static final Pattern NEWLINE = Pattern.compile("[\\r\\n]+");
private static final Pattern RULE = Pattern.compile(
"^(?:R|EX)RULE[:;]", Pattern.CASE_INSENSITIVE);
private static final Pattern DATE = Pattern.compile(
"^(?:R|EX)DATE[:;]", Pattern.CASE_INSENSITIVE);
private static IcalObject[] parseContentLines(
String rdata, TimeZone tzid, boolean strict)
throws ParseException {
String unfolded = FOLD.matcher(rdata).replaceAll("").trim();
if ("".equals(unfolded)) { return new IcalObject[0]; }
String[] lines = NEWLINE.split(unfolded);
IcalObject[] out = new IcalObject[lines.length];
int nbad = 0;
for (int i = 0; i < lines.length; ++i) {
String line = lines[i].trim();
try {
if (RULE.matcher(line).find()) {
out[i] = new RRule(line);
} else if (DATE.matcher(line).find()) {
out[i] = new RDateList(line, tzid);
} else {
throw new ParseException(lines[i], i);
}
} catch (ParseException ex) {
if (strict) {
throw ex;
}
LOGGER.log(Level.SEVERE,
"Dropping bad recurrence rule line: " + line, ex);
++nbad;
} catch (IllegalArgumentException ex) {
if (strict) {
throw ex;
}
LOGGER.log(Level.SEVERE,
"Dropping bad recurrence rule line: " + line, ex);
++nbad;
}
}
if (0 != nbad) {
IcalObject[] trimmed = new IcalObject[out.length - nbad];
for (int i = 0, k = 0; i < trimmed.length; ++k) {
if (null != out[k]) { trimmed[i++] = out[k]; }
}
out = trimmed;
}
return out;
}
/**
* Given an array like BYMONTH=2,3,4,5 and a set pos like BYSETPOS=1,-1
* reduce both clauses to a single one, BYMONTH=2,5 in the preceding.
*/
private static int[] filterBySetPos(int[] members, int[] bySetPos) {
members = Util.uniquify(members);
IntSet iset = new IntSet();
for (int pos : bySetPos) {
if (pos == 0) { continue; }
if (pos < 0) {
pos += members.length;
} else {
--pos; // Zero-index.
}
if (pos >= 0 && pos < members.length) {
iset.add(members[pos]);
}
}
return iset.toIntArray();
}
private static final int[] NO_INTS = new int[0];
private static final WeekdayNum[] NO_DAYS = new WeekdayNum[0];
private RecurrenceIteratorFactory() {
// uninstantiable
}
}