/**
* Copyright 2015 Dhatim
*
* 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 org.dhatim.businesshours;
import java.time.DayOfWeek;
import java.time.temporal.ChronoField;
import java.time.temporal.ValueRange;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Optional;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.function.ToIntFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javafx.util.Pair;
class BusinessHoursParser {
/**
* Maps containing the fields supported by the parsing The keys are the
* field itself, while the value is a Pair containing:
* <ul>
* <li>A pattern allowing to identify the field range in the input
* string</li>
* <li>A function to parse field values into integers</li>
* </ul>
*/
private static final Map<ChronoField, Pair<Pattern, ToIntFunction<String>>> SUPPORTED_FIELDS
= Collections.unmodifiableMap(new HashMap<ChronoField, Pair<Pattern, ToIntFunction<String>>>() {
{
put(ChronoField.MINUTE_OF_HOUR, new Pair<>(Pattern.compile("(?:min|minute) *\\{(.*?)\\}"), Integer::parseInt));
put(ChronoField.HOUR_OF_DAY, new Pair<>(Pattern.compile("(?:hr|hour) *\\{(.*?)\\}"), BusinessHoursParser::hourStringToInt));
put(ChronoField.DAY_OF_WEEK, new Pair<>(Pattern.compile("(?:wday|wd) *\\{(.*?)\\}"), BusinessHoursParser::weekDayStringToInt));
}
});
private static final Pattern TWELVE_HOURS_TIME_PATTERN = Pattern.compile("(\\d{1,2})(am|noon|pm)");
private static final Map<String, Integer> WEEKDAYS_MAPPING
= Collections.unmodifiableMap(new HashMap<String, Integer>() {
{
put("mo", DayOfWeek.MONDAY.getValue());
put("tu", DayOfWeek.TUESDAY.getValue());
put("we", DayOfWeek.WEDNESDAY.getValue());
put("th", DayOfWeek.THURSDAY.getValue());
put("fr", DayOfWeek.FRIDAY.getValue());
put("sa", DayOfWeek.SATURDAY.getValue());
put("su", DayOfWeek.SUNDAY.getValue());
}
});
private static int hourStringToInt(String hour) {
try {
// if the hour is in 24h format
return Integer.parseInt(hour);
} catch (NumberFormatException e) {
Matcher matcher = TWELVE_HOURS_TIME_PATTERN.matcher(hour);
if (matcher.matches()) {
// 12 hours format
int result = Integer.parseInt(matcher.group(1));
String dayHalf = matcher.group(2);
if ("am".equals(dayHalf) && result == 12) {
// 12am = midnight
result = 0;
} else if ("pm".equals(dayHalf) && result != 12) {
// add 12 hours in the afternoon to convert in 24 hours format
// (except for 12pm which is noon)
result += 12;
}
return result;
} else {
throw new IllegalArgumentException("Invalid hour format: " + hour);
}
}
}
private static int weekDayStringToInt(String weekDay) {
int result;
try {
// if the weekday is numeral
result = Integer.parseInt(weekDay);
} catch (NumberFormatException e) {
// if the week day is in letters, only the first two letters are significant
result = Optional.of(weekDay)
.filter(wd -> wd.length() >= 2)
.map(wd -> wd.toLowerCase(Locale.ENGLISH).substring(0, 2))
.map(WEEKDAYS_MAPPING::get)
.orElseThrow(() -> new IllegalArgumentException("Invalid weekday value: " + weekDay));
}
return result;
}
private static Set<String> getStringRanges(String subPeriod, Pattern pattern) {
Set<String> ranges = new HashSet<>();
Matcher matcher = pattern.matcher(subPeriod);
while (matcher.find()) {
String match = matcher.group(1).trim();
Arrays.stream(match.split(" ")).forEach(ranges::add);
}
return ranges;
}
private static Stream<ValueRange> getRange(String stringRange, ValueRange fullRange, ToIntFunction<String> valueParser) {
int start;
int end;
String[] boundaries = stringRange.split("-");
switch (boundaries.length) {
case 1:
start = end = valueParser.applyAsInt(boundaries[0]);
break;
case 2:
start = valueParser.applyAsInt(boundaries[0]);
end = valueParser.applyAsInt(boundaries[1]);
break;
default:
throw new IllegalArgumentException("Invalid range: " + stringRange);
}
return start <= end
? Stream.of(ValueRange.of(start, end))
: Stream.of(ValueRange.of(start, fullRange.getMaximum()), ValueRange.of(fullRange.getMinimum(), end));
}
private static List<ValueRange> defaultRange(List<ValueRange> providedRanges, ValueRange fullRange) {
//no range specified means any time is ok
return providedRanges.isEmpty() ? Collections.singletonList(fullRange) : providedRanges;
}
/**
* compute every possible field range combination
*
* @param acceptedRanges the accepted ranges for each field. (The map must
* be sorted to guarantee a consistent iteration order)
* @return a set containing all the possible combinations of field ranges
*/
private static Set<NavigableMap<ChronoField, ValueRange>> getRangeCombinations(SortedMap<ChronoField, List<ValueRange>> acceptedRanges) {
int combinationNb = acceptedRanges.values().stream()
.mapToInt(List::size)
.reduce(1, (a, b) -> a * b);
Set<NavigableMap<ChronoField, ValueRange>> combinations = new HashSet<>(combinationNb);
for (int i = 0; i < combinationNb; i++) {
int divisor = 1;
NavigableMap<ChronoField, ValueRange> combination = new TreeMap<>();
for (Map.Entry<ChronoField, List<ValueRange>> entry : acceptedRanges.entrySet()) {
List<ValueRange> ranges = entry.getValue();
combination.put(entry.getKey(), ranges.get((i / divisor) % ranges.size()));
divisor *= entry.getValue().size();
}
combinations.add(combination);
}
return combinations;
}
private static int getRangeLength(ValueRange range) {
return range.checkValidIntValue(range.getMaximum(), null)
- range.checkValidIntValue(range.getMinimum(), null) + 1;
}
/**
* Break down the provided ranges combination into a set of continuous
* business periods
*
* @param ranges the range combination
* @return a stream of {@link BusinessPeriod}
*/
private static Stream<BusinessPeriod> toBusinessPeriods(NavigableMap<ChronoField, ValueRange> ranges) {
//no need to break the least significant range since it already is continuous
ChronoField firstField = ranges.firstKey();
ValueRange firstRange = ranges.get(firstField);
NavigableMap<ChronoField, ValueRange> remainingRanges = ranges.tailMap(firstField, false);
//compute the total number of periods
int periodNb = remainingRanges
.values()
.stream()
.mapToInt(BusinessHoursParser::getRangeLength)
.reduce(1, (a, b) -> a * b);
List<BusinessPeriod> periods = new ArrayList<>();
//compute all the periods
for (int i = 0; i < periodNb; i++) {
int divisor = 1;
Map<ChronoField, Integer> startFields = new HashMap<>();
Map<ChronoField, Integer> endFields = new HashMap<>();
startFields.put(firstField, firstRange.checkValidIntValue(firstRange.getMinimum(), firstField));
endFields.put(firstField, firstRange.checkValidIntValue(firstRange.getMaximum(), firstField));
for (Map.Entry<ChronoField, ValueRange> entry : remainingRanges.entrySet()) {
ChronoField field = entry.getKey();
ValueRange range = entry.getValue();
int rangeLength = getRangeLength(range);
int value = range.checkValidIntValue(range.getMinimum(), field) + (i / divisor) % rangeLength;
startFields.put(field, value);
endFields.put(field, value);
divisor *= rangeLength;
}
periods.add(new BusinessPeriod(BusinessTemporal.of(startFields), BusinessTemporal.of(endFields)));
}
return periods.stream();
}
public static Set<BusinessPeriod> parse(String businessHours) {
//split the string in distinct sub periods,
//convert them in business periods
//and merge intersecting periods
return BusinessPeriod.merge(
Arrays.stream(businessHours.split(","))
.flatMap(BusinessHoursParser::parseSubBusinessHours)
.collect(Collectors.toSet()));
}
private static Stream<BusinessPeriod> parseSubBusinessHours(String subPeriod) {
//compute the list of acceptable ranges for each field
SortedMap<ChronoField, List<ValueRange>> acceptedRanges = new TreeMap<ChronoField, List<ValueRange>>();
SUPPORTED_FIELDS.forEach(
(field, parsingElts) -> acceptedRanges.put(
field,
defaultRange(
getStringRanges(subPeriod, parsingElts.getKey())
.stream()
.flatMap(stringRange -> getRange(stringRange, field.range(), parsingElts.getValue()))
.collect(Collectors.toList()),
field.range())));
//get all range combination and convert them to business periods
return getRangeCombinations(acceptedRanges)
.stream()
.flatMap(BusinessHoursParser::toBusinessPeriods);
}
}