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}