001/* 002 * Licensed under the Apache License, Version 2.0 (the "License"); 003 * you may not use this file except in compliance with the License. 004 * You may obtain a copy of the License at 005 * 006 * http://www.apache.org/licenses/LICENSE-2.0 007 * 008 * Unless required by applicable law or agreed to in writing, software 009 * distributed under the License is distributed on an "AS IS" BASIS, 010 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 011 * See the License for the specific language governing permissions and 012 * limitations under the License. 013 */ 014package org.gbif.api.util; 015 016import java.text.ParseException; 017import java.time.LocalDate; 018import java.time.LocalDateTime; 019import java.time.OffsetDateTime; 020import java.time.Year; 021import java.time.YearMonth; 022import java.time.ZonedDateTime; 023import java.time.temporal.ChronoUnit; 024import java.time.temporal.Temporal; 025import java.time.temporal.TemporalUnit; 026 027import javax.annotation.Nullable; 028 029import io.swagger.v3.oas.annotations.media.Schema; 030 031/** 032 * <p>Represents an ISO 8601:2019 date, date-time or date/date-time interval.</p> 033 * <p>Valid serializations include 2023, 2023-08, 2023-08-29, 2023/2024, 2023-08/2023-09 and so on.</p> 034 */ 035public class IsoDateInterval { 036 037 /** 038 * Lower bound. 039 */ 040 @Schema(description = "The lower bound.") 041 private Temporal from; 042 043 /** 044 * Upper bound. 045 */ 046 @Schema(description = "The upper bound.") 047 private Temporal to; 048 049 /** 050 * Create an empty DateRange. 051 */ 052 public IsoDateInterval() { 053 } 054 055 /** 056 * Create a range with bounds of {@code date}. 057 */ 058 public IsoDateInterval(Temporal date) { 059 this(date, null); 060 } 061 062 /** 063 * Create a range with bounds {@code from} and {@code to}. 064 * 065 * from and to must have the same type. 066 * 067 * @throws IllegalArgumentException if {@code from} is greater than {@code to} 068 */ 069 public IsoDateInterval(Temporal from, Temporal to) { 070 if (from != null && to != null && getRangeDiff(from, to) < 0) { 071 throw new IllegalArgumentException(String.format("Invalid range: (%s, %s)", from, to)); 072 } 073 setFrom(from); 074 setTo(to); 075 } 076 077 /** Compare dates and return difference between FROM and TO dates in milliseconds */ 078 private static long getRangeDiff(Temporal from, Temporal to) { 079 TemporalUnit unit = null; 080 if (from instanceof Year) { 081 unit = ChronoUnit.YEARS; 082 } else if (from instanceof YearMonth) { 083 unit = ChronoUnit.MONTHS; 084 } else if (from instanceof LocalDate) { 085 unit = ChronoUnit.DAYS; 086 } else if (from instanceof LocalDateTime 087 || from instanceof OffsetDateTime 088 || from instanceof ZonedDateTime) { 089 unit = ChronoUnit.SECONDS; 090 } 091 return from.until(to, unit); 092 } 093 094 @Nullable 095 public Temporal getFrom() { 096 return from; 097 } 098 099 public void setFrom(Temporal from) { 100 if (this.to != null && from != null) { 101 if (!this.to.getClass().isAssignableFrom(from.getClass())) { 102 throw new IllegalArgumentException("From and to dates must be of compatible types."); 103 } 104 if (getRangeDiff(from, to) < 0) { 105 throw new IllegalArgumentException(String.format("Invalid range: (%s, %s)", from, to)); 106 } 107 } 108 this.from = from; 109 } 110 111 public void setFrom(String textFrom) { 112 this.setFrom(IsoDateParsingUtils.parseTemporal(textFrom)); 113 } 114 115 @Nullable 116 public Temporal getTo() { 117 return to; 118 } 119 120 public void setTo(Temporal to) { 121 if (this.from != null && to != null) { 122 if (!this.from.getClass().isAssignableFrom(to.getClass())) { 123 throw new IllegalArgumentException("From and to dates must be of compatible types."); 124 } 125 if (getRangeDiff(from, to) < 0) { 126 throw new IllegalArgumentException(String.format("Invalid range: (%s, %s)", from, to)); 127 } 128 } 129 this.to = to; 130 } 131 132 public void setTo(String textTo) { 133 this.setTo(IsoDateParsingUtils.parseTemporal(textTo)); 134 } 135 136 /** 137 * Returns the date-time interval formatted as a single value where the from and to values are the same 138 * (e.g. "2023"), or as unabbreviated date-times at the defined accuracy otherwise ("2023-08-29/2023-08-30" rather 139 * than "2023-08-29/30"). 140 */ 141 @Override 142 public String toString() { 143 return toString(false); 144 } 145 146 /** 147 * Returns the date-time interval formatted as a single value where the from and to values are the same 148 * (e.g. "2023"), or as unabbreviated date-times at the defined accuracy otherwise ("2023-08-29/2023-08-30" rather 149 * than "2023-08-29/30"). 150 * 151 * Optionally ignore a non-UTC offset. 152 */ 153 public String toString(boolean ignoreNonUTCOffset) { 154 if (this.getFrom() == null) { 155 return null; 156 } 157 158 StringBuilder s = new StringBuilder(); 159 if (ignoreNonUTCOffset) { 160 s.append(IsoDateParsingUtils.stripOffsetOrZoneExceptUTC(this.getFrom(), true).toString()); 161 } else { 162 s.append(this.getFrom().toString()); 163 } 164 165 if (this.getTo() != null && !this.getFrom().equals(this.getTo())) { 166 s.append('/'); 167 if (ignoreNonUTCOffset) { 168 s.append(IsoDateParsingUtils.stripOffsetOrZoneExceptUTC(this.getTo(), true).toString()); 169 } else { 170 s.append(this.getTo().toString()); 171 } 172 } 173 174 return s.toString(); 175 } 176 177 /** 178 * Parses a well-formatted IsoDateInterval from the text representation. 179 */ 180 public static IsoDateInterval fromString(String text) throws ParseException { 181 int index; 182 if ((index = text.indexOf("/")) >= 0) { 183 if (index + 1 < text.length()) { 184 // Explicit range 185 return new IsoDateInterval(IsoDateParsingUtils.parseTemporal(text.substring(0, index)), IsoDateParsingUtils.parseTemporal(text.substring(index + 1))); 186 } else { 187 throw new ParseException("Missing 'to' end of IsoDateInterval in " + text, index); 188 } 189 } else { 190 return new IsoDateInterval(IsoDateParsingUtils.parseTemporal(text)); 191 } 192 } 193 194 /** 195 * Parses a well-formatted IsoDateInterval from the text representation. 196 */ 197 public static IsoDateInterval fromString(String textFrom, String textTo) throws ParseException { 198 return new IsoDateInterval(IsoDateParsingUtils.parseTemporal(textFrom), IsoDateParsingUtils.parseTemporal(textTo)); 199 } 200}