001package org.gbif.common.parsers.date; 002 003import lombok.Builder; 004import lombok.extern.slf4j.Slf4j; 005import org.apache.commons.lang3.StringUtils; 006import org.gbif.api.util.IsoDateInterval; 007import org.gbif.api.vocabulary.OccurrenceIssue; 008import org.gbif.common.parsers.core.OccurrenceParseResult; 009import org.gbif.common.parsers.core.ParseResult; 010import org.gbif.common.parsers.utils.DelimiterUtils; 011 012import java.io.Serializable; 013import java.time.LocalDate; 014import java.time.LocalDateTime; 015import java.time.OffsetDateTime; 016import java.time.Year; 017import java.time.YearMonth; 018import java.time.ZonedDateTime; 019import java.time.temporal.ChronoField; 020import java.time.temporal.ChronoUnit; 021import java.time.temporal.Temporal; 022import java.time.temporal.TemporalAccessor; 023import java.time.temporal.TemporalUnit; 024import java.util.HashSet; 025import java.util.Optional; 026import java.util.Set; 027 028@Slf4j 029public class TemporalRangeParser implements Serializable { 030 031 private final MultiinputTemporalParser temporalParser; 032 033 @Builder(buildMethodName = "create") 034 private TemporalRangeParser(MultiinputTemporalParser temporalParser) { 035 if (temporalParser != null) { 036 this.temporalParser = temporalParser; 037 } else { 038 this.temporalParser = MultiinputTemporalParser.create(); 039 } 040 } 041 042 public OccurrenceParseResult<IsoDateInterval> parse(String dateRange) { 043 return parse(null, null, null, dateRange, null, null); 044 } 045 046 public OccurrenceParseResult<IsoDateInterval> parse(String year, String month, String day, String dateRange) { 047 return parse(year, month, day, dateRange, null, null); 048 } 049 050 public OccurrenceParseResult<IsoDateInterval> parse( 051 String year, 052 String month, 053 String day, 054 String dateRange, 055 String startDayOfYear, 056 String endDayOfYear) { 057 058 Set<OccurrenceIssue> issues = new HashSet<>(); 059 060 try { 061 // Even a single date will be split to two 062 final String[] rawPeriod = DelimiterUtils.splitPeriod(dateRange); 063 064 Temporal from; 065 Temporal to; 066 067 // If eventDate and year are present 068 if (StringUtils.isNotBlank(dateRange) && StringUtils.isNotBlank(year)) { 069 OccurrenceParseResult<TemporalAccessor> dateRangeOnlyStart = temporalParser.parseRecordedDate(null, null, null, rawPeriod[0], null); 070 OccurrenceParseResult<TemporalAccessor> dateRangeOnlyEnd = temporalParser.parseRecordedDate(null, null, null, rawPeriod[1], null); 071 OccurrenceParseResult<TemporalAccessor> ymdOnly = temporalParser.parseRecordedDate(year, month, day, null, null); 072 073 if (ymdOnly.isSuccessful()) { 074 if (dateRangeOnlyStart.isSuccessful() && dateRangeOnlyEnd.isSuccessful()) { 075 // If eventDate is a multi-day range, with at least day precision, and year+month+day are set, we must test 076 // whether year+month+day falls within this range. 077 if (StringUtils.isNotBlank(month) && StringUtils.isNotBlank(day)) { 078 if (dateRangeOnlyStart.getPayload().isSupported(ChronoField.DAY_OF_YEAR) 079 && dateRangeOnlyEnd.getPayload().isSupported(ChronoField.DAY_OF_YEAR) 080 && ymdOnly.getPayload().isSupported(ChronoField.DAY_OF_YEAR)) { 081 if (TemporalAccessorUtils.withinRange(dateRangeOnlyStart.getPayload(), dateRangeOnlyEnd.getPayload(), ymdOnly.getPayload()) || 082 // Also if the range is backwards 083 TemporalAccessorUtils.withinRange(dateRangeOnlyEnd.getPayload(), dateRangeOnlyStart.getPayload(), ymdOnly.getPayload())) { 084 // Then we can just check the startDayOfYear and endDayOfYear fields match. 085 from = parseAndSet(null, null, null, rawPeriod[0], startDayOfYear, issues); 086 to = parseAndSet(null, null, null, rawPeriod[1], endDayOfYear, issues); 087 log.trace("Range {}|{}|{}|{}|{}|{} succeeds with ymd within range {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to); 088 return OccurrenceParseResult.success(ParseResult.CONFIDENCE.DEFINITE, finalChecks(from, to, issues), issues); 089 } 090 } 091 } 092 } 093 094 // If that didn't work, test whether year+month+day are set according to the constant parts of eventDate. 095 Optional<TemporalAccessor> dateRangeConstant = TemporalAccessorUtils.nonConflictingDateParts(dateRangeOnlyStart.getPayload(), dateRangeOnlyEnd.getPayload(), null); 096 if (dateRangeConstant.isPresent() && ymdOnly.getPayload().equals(dateRangeConstant.get())) { 097 // Then we can just check the startDayOfYear and endDayOfYear fields match. 098 from = parseAndSet(null, null, null, rawPeriod[0], startDayOfYear, issues); 099 to = parseAndSet(null, null, null, rawPeriod[1], endDayOfYear, issues); 100 log.trace("Range {}|{}|{}|{}|{}|{} succeeds with correct ymd parts {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to); 101 return OccurrenceParseResult.success(ParseResult.CONFIDENCE.DEFINITE, finalChecks(from, to, issues), issues); 102 } 103 } 104 } 105 106 // Otherwise, we will reduce the precision of the given dates until they all agree. 107 108 // Year+month+day, first part of eventDate, and startDayOfYear to the best we can get. 109 from = parseAndSet(year, month, day, rawPeriod[0], startDayOfYear, issues); 110 // Year+month+day, second part of eventDate, and endDayOfYear to the best we can get. 111 to = parseAndSet(year, month, day, rawPeriod[1], endDayOfYear, issues); 112 log.trace("Range {}|{}|{}|{}|{}|{} parsed to {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to); 113 114 // Neither could be parsed, or else the year is outside the range. 115 if (from == null && to == null) { 116 log.debug("Range {}|{}|{}|{}|{}|{} could not be parsed", year, month, day, dateRange, startDayOfYear, endDayOfYear); 117 return OccurrenceParseResult.fail(issues); 118 } 119 120 // The year is outside one part of the date range. 121 if (from != null ^ to != null) { 122 log.debug("Range {}|{}|{}|{}|{}|{} fails due to missing start xor end {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to); 123 issues.add(OccurrenceIssue.RECORDED_DATE_MISMATCH); 124 return OccurrenceParseResult.fail(issues); 125 } 126 127 // Apply final checks 128 log.trace("Range {}|{}|{}|{}|{}|{} succeeds {}→{}", year, month, day, dateRange, startDayOfYear, endDayOfYear, from, to); 129 return OccurrenceParseResult.success(ParseResult.CONFIDENCE.DEFINITE, finalChecks(from, to, issues), issues); 130 } catch (Exception e) { 131 log.error("Exception when parsing dates: {}, {}, {}, {}, {}, {}, {}", year, month, day, dateRange, startDayOfYear, endDayOfYear); 132 log.error("Exception is "+e.getMessage(), e); 133 issues.add(OccurrenceIssue.RECORDED_DATE_INVALID); 134 issues.add(OccurrenceIssue.INTERPRETATION_ERROR); 135 return OccurrenceParseResult.fail(issues); 136 } 137 } 138 139 private Temporal parseAndSet( 140 String year, 141 String month, 142 String day, 143 String rawDate, 144 String dayOfYear, 145 Set<OccurrenceIssue> issues) { 146 OccurrenceParseResult<TemporalAccessor> result = 147 temporalParser.parseRecordedDate(year, month, day, rawDate, dayOfYear); 148 issues.addAll(result.getIssues()); 149 if (result.isSuccessful()) { 150 return (Temporal) result.getPayload(); 151 } else { 152 return null; 153 } 154 } 155 156 /** Compare dates and returns difference between FROM and TO dates in milliseconds */ 157 private static long getRangeDiff(Temporal from, Temporal to) { 158 if (from == null || to == null) { 159 return 1L; 160 } 161 TemporalUnit unit = null; 162 if (from instanceof Year) { 163 unit = ChronoUnit.YEARS; 164 } else if (from instanceof YearMonth) { 165 unit = ChronoUnit.MONTHS; 166 } else if (from instanceof LocalDate) { 167 unit = ChronoUnit.DAYS; 168 } else if (from instanceof LocalDateTime 169 || from instanceof OffsetDateTime 170 || from instanceof ZonedDateTime) { 171 unit = ChronoUnit.SECONDS; 172 } 173 return from.until(to, unit); 174 } 175 176 /** 177 * Make sure the range is the correct way around, has equal resolutions, and matching time zones. 178 */ 179 private IsoDateInterval finalChecks(Temporal from, Temporal to, Set<OccurrenceIssue> issues) { 180 if (from != null && to != null) { 181 // If the dateRange has different resolutions on either side, truncate it. 182 if (TemporalAccessorUtils.resolutionToSeconds(from) != TemporalAccessorUtils.resolutionToSeconds(to)) { 183 log.trace("Resolutions don't match, truncate."); 184 int requiredResolution = Math.min(TemporalAccessorUtils.resolutionToSeconds(from), TemporalAccessorUtils.resolutionToSeconds(to)); 185 from = TemporalAccessorUtils.limitToResolution(from, requiredResolution); 186 to = TemporalAccessorUtils.limitToResolution(to, requiredResolution); 187 issues.add(OccurrenceIssue.RECORDED_DATE_MISMATCH); 188 } 189 190 // If one side has a time zone and the other doesn't, remove it. 191 if (from.isSupported(ChronoField.OFFSET_SECONDS) ^ to.isSupported(ChronoField.OFFSET_SECONDS)) { 192 from = from.query(LocalDateTime::from); 193 to = to.query(LocalDateTime::from); 194 } 195 196 // Reverse order if needed 197 if (from.getClass() == to.getClass()) { 198 long rangeDiff = getRangeDiff(from, to); 199 if (rangeDiff < 0) { 200 log.trace("Range was inverted."); 201 issues.add(OccurrenceIssue.RECORDED_DATE_INVALID); 202 return new IsoDateInterval(to, from); 203 } 204 } else { 205 issues.add(OccurrenceIssue.RECORDED_DATE_UNLIKELY); 206 } 207 } 208 return new IsoDateInterval(from, to); 209 } 210}