/**
* Licensed to Jasig under one or more contributor license
* agreements. See the NOTICE file distributed with this work
* for additional information regarding copyright ownership.
* Jasig licenses this file to you 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 org.jasig.schedassist.model;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.commons.lang.time.DateUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
/**
* Factory for {@link AvailableBlock} objects.
*
* @author Nicholas Blair, nblair@doit.wisc.edu
* @version $Id: AvailableBlockBuilder.java 2525 2010-09-10 19:13:01Z npblair $
*/
public final class AvailableBlockBuilder {
protected static final String TIME_REGEX = "(\\d{1,2})\\:([0-5]\\d{1}) ([AP]M)";
protected static final Pattern TIME_PATTERN = Pattern.compile(TIME_REGEX, Pattern.CASE_INSENSITIVE);
protected static final int MINIMUM_MINUTES = 5;
private static Log LOG = LogFactory.getLog(AvailableBlockBuilder.class);
/**
* Construct a list of {@link AvailableBlock} from the following criteria:
*
* <ul>
* <li>startTimePhrase and endTimePhrase should look like HH:MM AM/PM.</li>
* <li>daysOfWeekPhrase looks like "MWF" and uses the following characters:
* <ul>
* <li>N is Sunday</li>
* <li>M is Monday</li>
* <li>T is Tuesday</li>
* <li>W is Wednesday</li>
* <li>R is Thursday</li>
* <li>F is Friday</li>
* <li>S is Saturday</li>
* </ul></li>
* <li>startDate must exist before endDate on the calendar. Any non-midnight time value on either
* of these dates is rolled back to 00:00:00 (midnight).</li>
* </ul>
*
* @param startTimePhrase
* @param endTimePhrase
* @param daysOfWeekPhrase
* @param startDate
* @param endDate
* @return the {@link List} of {@link AvailableBlock}s that fall within the specified date/time criteria.
* @throws InputFormatException if the values for startTimePhrase or endTimePhrase do not match the expected format
*/
public static SortedSet<AvailableBlock> createBlocks(final String startTimePhrase, final String endTimePhrase,
final String daysOfWeekPhrase, final Date startDate, final Date endDate) throws InputFormatException {
return createBlocks(startTimePhrase, endTimePhrase, daysOfWeekPhrase, startDate, endDate, 1);
}
/**
* Construct a list of {@link AvailableBlock} from the following criteria:
*
* <ul>
* <li>startTimePhrase and endTimePhrase should look like HH:MM AM/PM.</li>
* <li>daysOfWeekPhrase looks like "MWF" and uses the following characters:
* <ul>
* <li>N is Sunday</li>
* <li>M is Monday</li>
* <li>T is Tuesday</li>
* <li>W is Wednesday</li>
* <li>R is Thursday</li>
* <li>F is Friday</li>
* <li>S is Saturday</li>
* </ul></li>
* <li>startDate must exist before endDate on the calendar. Any non-midnight time value on either
* of these dates is rolled back to 00:00:00 (midnight).</li>
* </ul>
*
* @param startTimePhrase
* @param endTimePhrase
* @param daysOfWeekPhrase
* @param startDate
* @param endDate
* @param visitorLimit
* @return the {@link List} of {@link AvailableBlock}s that fall within the specified date/time criteria.
* @throws InputFormatException if the values for startTimePhrase or endTimePhrase do not match the expected format
*/
public static SortedSet<AvailableBlock> createBlocks(final String startTimePhrase, final String endTimePhrase,
final String daysOfWeekPhrase, final Date startDate, final Date endDate, final int visitorLimit) throws InputFormatException {
return createBlocks(startTimePhrase, endTimePhrase, daysOfWeekPhrase, startDate, endDate, visitorLimit, null);
}
/**
*
* @param startTimePhrase
* @param endTimePhrase
* @param daysOfWeekPhrase
* @param startDate
* @param endDate
* @param visitorLimit
* @param meetingLocation
* @return
* @throws InputFormatException
*/
public static SortedSet<AvailableBlock> createBlocks(final String startTimePhrase, final String endTimePhrase,
final String daysOfWeekPhrase, final Date startDate, final Date endDate, final int visitorLimit, final String meetingLocation) throws InputFormatException {
SortedSet<AvailableBlock> blocks = new TreeSet<AvailableBlock>();
// set time of startDate to 00:00:00
Date realStartDate = DateUtils.truncate(startDate, Calendar.DATE);
// set time of endDate to 23:59:59
Date dayAfterEndDate = DateUtils.truncate(DateUtils.addDays(endDate, 1), Calendar.DATE);
Date realEndDate = DateUtils.addSeconds(dayAfterEndDate, -1);
if(LOG.isDebugEnabled()) {
LOG.debug("createBlocks calculated realStartDate: " + realStartDate + ", realEndDate: " + realEndDate);
}
List<Date> matchingDays = matchingDays(daysOfWeekPhrase, realStartDate, realEndDate);
for(Date matchingDate : matchingDays) {
Calendar startCalendar = Calendar.getInstance();
startCalendar.setTime(matchingDate);
Calendar endCalendar = (Calendar) startCalendar.clone();
interpretAndUpdateTime(startTimePhrase, startCalendar);
interpretAndUpdateTime(endTimePhrase, endCalendar);
Date blockStartTime = startCalendar.getTime();
Date blockEndTime = endCalendar.getTime();
if(!blockEndTime.after(blockStartTime)) {
throw new InputFormatException("Start time must occur before end time");
}
if(CommonDateOperations.equalsOrAfter(blockStartTime, realStartDate) && CommonDateOperations.equalsOrBefore(blockEndTime, realEndDate)) {
AvailableBlock block = new AvailableBlock(blockStartTime, blockEndTime, visitorLimit, meetingLocation);
blocks.add(block);
}
}
return blocks;
}
/**
* Create a single {@link AvailableBlock} with a visitorLimit of 1.
*
* @param startDate
* @param endDate
* @return the new block
*/
public static AvailableBlock createBlock(final Date startDate, final Date endDate) {
return createBlock(startDate, endDate, 1);
}
/**
* Create a single {@link AvailableBlock}.
*
* @param startDate
* @param endDate
* @param visitorLimit
* @return the new block
*/
public static AvailableBlock createBlock(final Date startDate, final Date endDate, final int visitorLimit) {
return createBlock(startDate, endDate, visitorLimit, null);
}
/**
* Create a single {@link AvailableBlock}.
*
* @param startDate
* @param endDate
* @param visitorLimit
* @param meetingLocation
* @return the new block
*/
public static AvailableBlock createBlock(final Date startDate, final Date endDate, final int visitorLimit, final String meetingLocation) {
return new AvailableBlock(startDate, endDate, visitorLimit, meetingLocation);
}
/**
* Create a single {@link AvailableBlock} with a visitorLimit of 1 and using this application's common time format ("yyyyMMdd-HHmm")
* for the start and end datetimes.
*
* @see CommonDateOperations#parseDateTimePhrase(String)
* @param startTimePhrase
* @param endTimePhrase
* @return the new block
* @throws InputFormatException
*/
public static AvailableBlock createBlock(final String startTimePhrase, final String endTimePhrase) throws InputFormatException {
return createBlock(startTimePhrase, endTimePhrase, 1);
}
/**
* Create a single {@link AvailableBlock} using this applications common time format ("yyyyMMdd-HHmm").
*
* @see CommonDateOperations#parseDateTimePhrase(String)
* @param startTimePhrase
* @param endTimePhrase
* @param visitorLimit
* @return the new block
* @throws InputFormatException
*/
public static AvailableBlock createBlock(final String startTimePhrase, final String endTimePhrase, final int visitorLimit) throws InputFormatException {
return createBlock(startTimePhrase, endTimePhrase, visitorLimit, null);
}
/**
* Create a single {@link AvailableBlock} using this applications common time format ("yyyyMMdd-HHmm").
*
* @see CommonDateOperations#parseDateTimePhrase(String)
* @param startTimePhrase
* @param endTimePhrase
* @param visitorLimit
* @return the new block
* @throws InputFormatException
*/
public static AvailableBlock createBlock(final String startTimePhrase, final String endTimePhrase, final int visitorLimit, final String meetingLocation) throws InputFormatException {
Date startTime = CommonDateOperations.parseDateTimePhrase(startTimePhrase);
Date endTime = CommonDateOperations.parseDateTimePhrase(endTimePhrase);
return createBlock(startTime, endTime, visitorLimit, meetingLocation);
}
/**
* Create a single {@link AvailableBlock} that starts at the startTime Phrase (uses
* {@link CommonDateOperations#parseDateTimePhrase(String)} format) and ends duration minutes later.
*
* @param startTimePhrase
* @param duration
* @return the new block
* @throws InputFormatException
*/
public static AvailableBlock createBlock(final String startTimePhrase, final int duration) throws InputFormatException {
Date startTime = CommonDateOperations.parseDateTimePhrase(startTimePhrase);
Date endTime = DateUtils.addMinutes(startTime, duration);
return createBlock(startTime, endTime);
}
/**
* Create a single {@link AvailableBlock} that ENDS at the endDate argument and starts duration minutes prior.
*
* @param endDate end time
* @param duration how many minutes prior for the start date
* @return the new block
*/
public static AvailableBlock createBlockEndsAt(final Date endDate, final int duration) {
Date startDate = DateUtils.addMinutes(endDate, -duration);
return createBlock(startDate, endDate);
}
/**
* Create an {@link AvailableBlock} from the specified startTime to an endTime interpreted from {@link MeetingDurations#getMinLength()}.
* visitorLimit for the returned {@link AvailableBlock} will be set to 1.
*
* @param startTime
* @param preferredMeetingDurations
* @return the new block
*/
public static AvailableBlock createPreferredMinimumDurationBlock(final Date startTime, final MeetingDurations preferredMeetingDurations) {
return createPreferredMinimumDurationBlock(startTime, preferredMeetingDurations, 1);
}
/**
* Create an {@link AvailableBlock} from the specified startTime to an endTime interpreted from {@link MeetingDurations#getMinLength()}.
*
* @param startTime
* @param preferredMeetingDurations
* @param visitorLimit
* @return the new block
*/
public static AvailableBlock createPreferredMinimumDurationBlock(final Date startTime, final MeetingDurations preferredMeetingDurations, final int visitorLimit) {
Date endTime = DateUtils.addMinutes(startTime, preferredMeetingDurations.getMinLength());
return createBlock(startTime, endTime, visitorLimit);
}
/**
* Creates a minimum size {@link AvailableBlock} by adding MINIMUM_MINUTES minutes to startTime as the endTime
* and visitorLimit of 1.
*
* @param startTimePhrase
* @return the new block
* @throws InputFormatException
*/
public static AvailableBlock createSmallestAllowedBlock(final String startTimePhrase) throws InputFormatException {
return createSmallestAllowedBlock(startTimePhrase, 1);
}
/**
* Creates a minimum size {@link AvailableBlock} by adding MINIMUM_MINUTES minutes to startTime as the endTime.
*
* @see #createSmallestAllowedBlock(Date, int)
* @param startTimePhrase
* @param visitorLimit
* @return the new block
* @throws InputFormatException
*/
public static AvailableBlock createSmallestAllowedBlock(final String startTimePhrase, final int visitorLimit) throws InputFormatException {
Date startTime = CommonDateOperations.parseDateTimePhrase(startTimePhrase);
return createSmallestAllowedBlock(startTime, visitorLimit);
}
/**
* Creates a minimum size {@link AvailableBlock} by adding MINIMUM_MINUTES minutes to startTime as the endTime
* and visitorLimit of 1.
*
* @see #createSmallestAllowedBlock(Date, int)
* @param startTime
* @return the new block
*/
public static AvailableBlock createSmallestAllowedBlock(final Date startTime) {
return createSmallestAllowedBlock(startTime, 1);
}
/**
* Creates a minimum size {@link AvailableBlock} by adding MINIMUM_MINUTES minutes to startTime as the endTime.
*
* @param startTime
* @param visitorLimit
* @return the new block
*/
public static AvailableBlock createSmallestAllowedBlock(final Date startTime, final int visitorLimit) {
Date endTime = DateUtils.addMinutes(startTime, MINIMUM_MINUTES);
return createBlock(startTime, endTime, visitorLimit);
}
/**
* Creates a minimum size {@link AvailableBlock} using the argument endTime as the end and MINIMUM_MINUTES minutes
* prior to endTime as the start.
*
* @param endTime
* @return the new block
*/
public static AvailableBlock createMinimumEndBlock(final Date endTime) {
Date startTime = DateUtils.addMinutes(endTime, -MINIMUM_MINUTES);
return createBlock(startTime, endTime);
}
/**
* Expand one {@link AvailableBlock} into a {@link SortedSet} of {@link AvailableBlock}s with a duration equal
* to the meetingLengthMinutes argument in minutes.
*
* @param largeBlock
* @return the new set
*/
public static SortedSet<AvailableBlock> expand(final AvailableBlock largeBlock, final int meetingLengthMinutes) {
SortedSet<AvailableBlock> smallBlocks = new TreeSet<AvailableBlock>();
long meetingLengthInMsec = convertMinutesToMsec(meetingLengthMinutes);
Date currentStart = largeBlock.getStartTime();
while(largeBlock.getEndTime().getTime() - currentStart.getTime() >= meetingLengthInMsec) {
Date newEndTime = new Date(currentStart.getTime() + meetingLengthInMsec);
AvailableBlock smallBlock = createBlock(currentStart, newEndTime, largeBlock.getVisitorLimit(), largeBlock.getMeetingLocation());
smallBlock.setVisitorsAttending(largeBlock.getVisitorsAttending());
smallBlocks.add(smallBlock);
currentStart = newEndTime;
}
return smallBlocks;
}
/**
* Expand a {@link Set} of {@link AvailableBlock} into a {@link SortedSet} of {@link AvailableBlock}s with a duration equal
* to the meetingLengthMinutes argument in minutes.
*
* @param largeBlocks
* @return the new set
*/
public static SortedSet<AvailableBlock> expand(Set<AvailableBlock> largeBlocks, final int meetingLengthMinutes) {
SortedSet<AvailableBlock> smallBlocks = new TreeSet<AvailableBlock>();
for(AvailableBlock sourceBlock : largeBlocks) {
smallBlocks.addAll(expand(sourceBlock, meetingLengthMinutes));
}
return smallBlocks;
}
/**
* Combine adjacent {@link AvailableBlock}s in the argument {@link Set}.
*
* @param smallBlocks
* @return the new set
*/
public static SortedSet<AvailableBlock> combine(SortedSet<AvailableBlock> smallBlocks) {
SortedSet<AvailableBlock> largeBlocks = new TreeSet<AvailableBlock>();
Iterator<AvailableBlock> smallBlockIterator = smallBlocks.iterator();
if(smallBlockIterator.hasNext()) {
AvailableBlock current = smallBlockIterator.next();
while(smallBlockIterator.hasNext()) {
AvailableBlock next = smallBlockIterator.next();
if(combinable(current, next)) {
// current and next are adjacent AND have the same visitorLimit AND have same meetinglocation
// update current to have current.startTime and next.endTime
try {
current = new AvailableBlock(current.getStartTime(), next.getEndTime(), current.getVisitorLimit(), current.getMeetingLocation());
} catch (IllegalArgumentException e) {
// could not create a block, what to do?
LOG.error("failed to create an AvailableBlock from " + current.getStartTime() + " and " + next.getEndTime(), e);
}
} else {
// current and next are either not adjacent or have a different visitorLimit
// current should be pushed into largeBlocks
largeBlocks.add(current);
// current should now point to next
current = next;
}
}
// guarantee that current gets pushed into largeBlocks
largeBlocks.add(current);
}
return largeBlocks;
}
/**
* 2 blocks are combinable if and onlyl if:
* <ol>
* <li>the end time of the left equals the start time of the right</li>
* <li>the visitor limits are equivalent</li>
* <li>the meeting locations are equivalent</li>
* </ol>
*
* @see #safeMeetingLocationEquals(AvailableBlock, AvailableBlock)
* @param left
* @param right
* @return true if the 2 blocks can be combined
*/
static boolean combinable(AvailableBlock left, AvailableBlock right) {
if(left == null || right == null) {
return false;
}
return left.getEndTime().equals(right.getStartTime()) &&
left.getVisitorLimit() == right.getVisitorLimit() &&
safeMeetingLocationEquals(left, right);
}
/**
* Null safe equality test for {@link AvailableBlock#getMeetingLocation()
* @param left
* @param right
* @return true if the meetingLocation fields are equivalent
*/
static boolean safeMeetingLocationEquals(AvailableBlock left, AvailableBlock right) {
final String leftLocation = left.getMeetingLocation();
final String rightLocation = right.getMeetingLocation();
if(leftLocation == null && rightLocation == null) {
return true;
}
if(leftLocation != null) {
return leftLocation.equals(rightLocation);
}
if(rightLocation != null) {
return rightLocation.equals(leftLocation);
}
// not reachable?
return false;
}
/**
* Returns a {@link List} of {@link Date} objects that fall between startDate and endDate and
* exist on the days specified by daysOfWeekPhrase.
*
* For instance, passing "MWF", a start Date of June 30 2008, and an end Date of July 04 2008, this
* method will return a list of 3 Date objects (one for Monday June 30, one for Wednesday July 2, and
* one for Friday July 4).
*
* The time values for returned {@link Date}s will always be 00:00:00 (in the JVM's default timezone).
*
* @param daysOfWeekPhrase
* @param startDate
* @param endDate
* @return a {@link List} of {@link Date} objects that fall between startDate and endDate and
* exist on the days specified by daysOfWeekPhrase.
*/
protected static List<Date> matchingDays(final String daysOfWeekPhrase, final Date startDate, final Date endDate) {
List<Date> matchingDays = new ArrayList<Date>();
Set<Integer> daysOfWeek = new HashSet<Integer>();
for(char character : daysOfWeekPhrase.toUpperCase().toCharArray()) {
switch(character) {
case 'N':
daysOfWeek.add(Calendar.SUNDAY);
break;
case 'M':
daysOfWeek.add(Calendar.MONDAY);
break;
case 'T':
daysOfWeek.add(Calendar.TUESDAY);
break;
case 'W':
daysOfWeek.add(Calendar.WEDNESDAY);
break;
case 'R':
daysOfWeek.add(Calendar.THURSDAY);
break;
case 'F':
daysOfWeek.add(Calendar.FRIDAY);
break;
case 'S':
daysOfWeek.add(Calendar.SATURDAY);
break;
}
}
Calendar current = Calendar.getInstance();
current.setTime(startDate);
// set the time to 00:00:00 to insure the time doesn't affect our comparison)
// (because there may be a valid time window on endDate)
current = CommonDateOperations.zeroOutTimeFields(current);
while(current.getTime().compareTo(endDate) < 0) {
if(daysOfWeek.contains(current.get(Calendar.DAY_OF_WEEK))) {
matchingDays.add(current.getTime());
}
// increment currentDate +1 day
current.add(Calendar.DATE, 1);
}
return matchingDays;
}
/**
* Parses the timePhrase (e.g. 9:30 AM) and updates the HOUR_OF_DAY and MINUTE fields on the toModify Calendar.
*
* Mutates the toModify Calendar argument.
*
* @param timePhrase
* @param toModify
* @throws InputFormatException
*/
protected static void interpretAndUpdateTime(final String timePhrase, final Calendar toModify) throws InputFormatException {
Matcher matcher = TIME_PATTERN.matcher(timePhrase);
if(matcher.matches()) {
int endHour = Integer.parseInt(matcher.group(1));
int endMinutes = Integer.parseInt(matcher.group(2));
if(endHour == 12 && matcher.group(3).equalsIgnoreCase("am") ) {
endHour = 0;
}
if(matcher.group(3).equalsIgnoreCase("pm") && endHour != 12) {
endHour += 12;
}
toModify.set(Calendar.HOUR_OF_DAY, endHour);
toModify.set(Calendar.MINUTE, endMinutes);
} else {
throw new InputFormatException(timePhrase + " does not match expected format of HH:MM AM/PM");
}
}
/**
* Convert integer minutes to milliseconds (as long).
*
* @param minutes
* @return the number of milliseconds in the minutes argument
*/
protected static long convertMinutesToMsec(final int minutes) {
final long msecPerSecond = 1000;
final long secondsPerMinute = 60;
long result = ((long) minutes) * secondsPerMinute * msecPerSecond;
return result;
}
}