Java tutorial
/* * Copyright (C) 2000 - 2018 Silverpeas * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * As a special exception to the terms and conditions of version 3.0 of * the GPL, you may redistribute this Program in connection with Free/Libre * Open Source Software ("FLOSS") applications as described in Silverpeas's * FLOSS exception. You should have received a copy of the text describing * the FLOSS exception, and it is also available here: * "https://www.silverpeas.org/legal/floss_exception.html" * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.silverpeas.core.calendar; import org.apache.commons.lang3.builder.HashCodeBuilder; import org.silverpeas.core.SilverpeasRuntimeException; import org.silverpeas.core.date.TemporalConverter; import org.silverpeas.core.date.TimeUnit; import javax.persistence.*; import java.time.DayOfWeek; import java.time.LocalDate; import java.time.LocalTime; import java.time.Month; import java.time.MonthDay; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoField; import java.time.temporal.ChronoUnit; import java.time.temporal.Temporal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import static org.silverpeas.core.date.TemporalConverter.asOffsetDateTime; /** * It defines the rules of the recurrence of a {@link Plannable} in its planning in a calendar. * A {@link Plannable} recurrence is defined by a recurrence period, id est a frequency (hourly, * daily, weekly, monthly, or yearly), and optionally by some of the following properties: * * <ul> * <li>some days of week on which the {@link Plannable} should regularly occur</li> * <li>some exceptions in the recurrence period of the {@link Plannable}</li> * <li>a termination condition.</li> * </ul> */ @Entity @Table(name = "sb_cal_recurrence") public class Recurrence implements Cloneable { /** * A constant that defines a specific value for an empty recurrence. */ public static final Recurrence NO_RECURRENCE = null; /** * A constant that defines a specific value for no recurrence count limit. */ public static final int NO_RECURRENCE_COUNT = 0; /** * A constant that defines a specific value for no recurrence end date. */ @SuppressWarnings("WeakerAccess") public static final OffsetDateTime NO_RECURRENCE_END_DATE = null; /** * Identifier of the recurrent object planned in a calendar. This identifier is mapped to the * recurrent object's identifier in a one to one relationship between the recurrent object and * its recurrence. */ @SuppressWarnings("unused") @Id private String id; @Embedded private RecurrencePeriod frequency; @Column(name = "recur_count") private int count = NO_RECURRENCE_COUNT; @Column(name = "recur_endDate") private OffsetDateTime endDateTime = NO_RECURRENCE_END_DATE; @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "sb_cal_recurrence_dayofweek", joinColumns = { @JoinColumn(name = "recurrenceId") }) private Set<DayOfWeekOccurrence> daysOfWeek = new HashSet<>(); @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "sb_cal_recurrence_exception", joinColumns = { @JoinColumn(name = "recurrenceId") }) @Column(name = "recur_exceptionDate") private Set<OffsetDateTime> exceptionDates = new HashSet<>(); @Transient private Temporal startDate; /** * Constructs an empty recurrence for the persistence engine. */ protected Recurrence() { // empty for JPA } /** * Constructs a new recurrence instance from the specified recurrence period. * @param frequency the frequency of the recurrence. */ private Recurrence(final RecurrencePeriod frequency) { withFrequency(frequency); } /** * Creates a new recurrence from the specified frequency. * @param frequencyUnit the unit of the frequency: DAY means DAILY, WEEK means weekly, MONTH * means monthly or YEAR means YEARLY. * @return the event recurrence instance. */ public static Recurrence every(TimeUnit frequencyUnit) { return new Recurrence(RecurrencePeriod.every(1, frequencyUnit)); } /** * Creates a new recurrence from the specified frequency. For example every(2, MONTH) means * every 2 month. * @param frequencyValue a positive number indicating how many times the {@link Plannable} occurs. * @param frequencyUnit the frequency unit: DAY, WEEK, MONTH, or YEAR. * @return the event recurrence instance. */ public static Recurrence every(int frequencyValue, TimeUnit frequencyUnit) { return new Recurrence(RecurrencePeriod.every(frequencyValue, frequencyUnit)); } /** * Creates a new recurrence by specifying the recurrence period at which a {@link Plannable} * should recur. * @param period the recurrence period of the event. * @return the event recurrence instance. */ public static Recurrence from(final RecurrencePeriod period) { return new Recurrence(period); } /** * Excludes from this recurrence rule the occurrences originally starting at the specified date * or datetime. In the case the argument is one or more {@link OffsetDateTime}, their time is set * with the time of the start datetime of the calendar component concerned by this recurrence. * * If the calendar component from which the occurrences come is on all day, the specified * temporal instance is then converted into a {@link LocalDate} instance. Otherwise, if the * the temporal instance is a {@link LocalDate} it is then converted into a {@link OffsetDateTime} * with the time the one of the calendar component's start datetime; you have to ensure then the * {@link LocalDate} you pass comes from a value in UTC. If the temporal instance is already * an {@link OffsetDateTime}, then it is converted in UTC and its time set with the one * of the calendar component's start datetime. * @param temporal a list of either {@link LocalDate} or {@link OffsetDateTime} at which * originally start the occurrences to exclude. * @return itself. */ public Recurrence excludeEventOccurrencesStartingAt(final Temporal... temporal) { this.exceptionDates .addAll(Arrays.stream(temporal).map(this::normalize).sorted().collect(Collectors.toList())); return this; } /** * Sets some specific days of week at which a {@link Plannable} should periodically occur. * For a weekly recurrence, the specified days of week are the first one in the week. For other * frequency, the specified days of week will be all the occurrences of those days of week in the * recurrence period. For example, recur every weeks on monday and on tuesday or recur every month * on all saturdays and on all tuesdays. * This method can only be applied on recurrence period higher than the day, otherwise an * {@link IllegalStateException} will be thrown. * @param days the days of week at which a {@link Plannable} should occur. Theses days replace the * ones already set in the recurrence. * @return itself. */ public Recurrence on(DayOfWeek... days) { checkRecurrenceStateForSpecificDaySetting(); List<DayOfWeekOccurrence> dayOccurrences = new ArrayList<>(); int nth = getFrequency().isWeekly() ? 1 : DayOfWeekOccurrence.ALL_OCCURRENCES; for (DayOfWeek dayOfWeek : days) { dayOccurrences.add(DayOfWeekOccurrence.nth(nth, dayOfWeek)); } this.daysOfWeek.clear(); this.daysOfWeek.addAll(dayOccurrences); return this; } /** * Sets some specific occurrences of day of week at which a {@link Plannable} should periodically * occur within a monthly or a yearly period. For example, recur every month on the third monday * and on the first tuesday. The days of week for a weekly recurrence can also be indicated if, * and only if, the nth occurrence of the day is the first one or all occurrences (as there is * actually only one possible occurrence of a day in a week); any value other than 1 or * {@code ALL_OCCURRENCES} is considered as an error and an IllegalArgumentException is thrown. * This method can only be applied on recurrence period higher than the day, otherwise an * {@link IllegalStateException} will be thrown. * @param days the occurrences of day of week at which an event should occur. Theses days replace * the ones already set in the recurrence. * @return itself. */ public Recurrence on(DayOfWeekOccurrence... days) { return on(Arrays.asList(days)); } /** * Sets some specific occurrences of day of week at which a {@link Plannable} should periodically * occur within monthly or yearly period. For example, recur every month on the third monday and * on the first tuesday. The days of week for a weekly recurrence can also be indicated if, and * only if, the nth occurrence of the day is the first one or all occurrences (as there is * actually only one possible occurrence of a day in a week); any value other than 1 or * {@code ALL_OCCURRENCES} is considered as an error and an IllegalArgumentException is thrown. * This method can only be applied on recurrence period higher than the day, otherwise an * {@link IllegalStateException} will be thrown. * @param days a list of days of week at which a {@link Plannable} should occur. Theses days * replace * the ones already set in the recurrence. * @return itself. */ public Recurrence on(final List<DayOfWeekOccurrence> days) { checkRecurrenceStateForSpecificDaySetting(); if (frequency.getUnit() == TimeUnit.WEEK) { for (DayOfWeekOccurrence dayOfWeekOccurrence : days) { if (dayOfWeekOccurrence.nth() != 1 && dayOfWeekOccurrence.nth() != DayOfWeekOccurrence.ALL_OCCURRENCES) { throw new IllegalArgumentException( "The occurrence of the day of week " + dayOfWeekOccurrence.dayOfWeek().name() + " cannot be possible with a weekly " + "recurrence"); } } } this.daysOfWeek.clear(); this.daysOfWeek.addAll(days); return this; } /** * Sets that the recurrence is not linked to a specific day. So the occurrence generation will * take into account only the start datetime of the event. * @return itself. */ public Recurrence onNoSpecificDay() { this.daysOfWeek.clear(); return this; } /** * Sets a termination to this recurrence by specifying the count of time a {@link Plannable} * should occur. * Settings this termination unset the recurrence end date/datetime. * @param recurrenceCount the number of time a {@link Plannable} should occur. * @return itself. */ public Recurrence until(int recurrenceCount) { if (recurrenceCount <= 0) { throw new IllegalArgumentException( "The number of time the event has to recur should be a" + " positive value"); } this.endDateTime = NO_RECURRENCE_END_DATE; this.count = recurrenceCount; clearsUnnecessaryExceptionDates(); return this; } /** * Sets a termination to this recurrence by specifying an inclusive date or datetime. * * If a datetime is passed, it is set in UTC/Greenwich and then the time is overridden by the one * of the start date time of the calendar component concerned by this recurrence. In the case * the calendar component is on all day(s), then the specified datetime is converted into a date. * * Settings this termination unset the number of time a {@link Plannable} should occur. * @param endDate the inclusive date or datetime at which the recurrence ends. * @return itself. */ public Recurrence until(final Temporal endDate) { this.endDateTime = normalize(endDate); this.count = NO_RECURRENCE_COUNT; clearsUnnecessaryExceptionDates(); return this; } /** * Sets that the recurrence never ends. * @return itself. */ public Recurrence endless() { this.count = NO_RECURRENCE_COUNT; this.endDateTime = NO_RECURRENCE_END_DATE; return this; } /** * Sets a frequency to this recurrence by specifying a recurrence period.<br> * When the new frequency is a daily or a yearly one, days of weeks are reset. * @param frequency the frequency to set. * @return itself. */ public Recurrence withFrequency(final RecurrencePeriod frequency) { this.frequency = frequency; if (getFrequency().isDaily() || getFrequency().isYearly()) { this.daysOfWeek.clear(); } return this; } /** * Is this recurrence endless? * @return true if this recurrence has no upper bound defined. False otherwise. */ @SuppressWarnings("WeakerAccess") public boolean isEndless() { return !getRecurrenceEndDate().isPresent() && getRecurrenceCount() == NO_RECURRENCE_COUNT; } /** * Gets the frequency at which the {@link Plannable} should recur. * @return the frequency as a RecurrencePeriod instance. */ public RecurrencePeriod getFrequency() { return frequency; } /** * Gets the number of time the {@link Plannable} should occur. If NO_RECURRENCE_COUNT is * returned, * then no termination by recurrence count is defined. * @return the recurrence count or NO_RECURRENCE_COUNT if no such termination is defined. */ public int getRecurrenceCount() { return count; } /** * Gets the end date of the recurrence. The end date of the recurrence can be unspecified, in that * case the returned end date is empty. * @return an optional recurrence end date. The optional is empty if the end date of the * recurrence is unspecified, otherwise the recurrence termination date or datetime can be get * from the {@link Optional}. The returned datetime is from UTC/Greenwich. */ public Optional<Temporal> getRecurrenceEndDate() { if (this.endDateTime != NO_RECURRENCE_END_DATE) { return Optional .of(getStartDate() instanceof LocalDate ? this.endDateTime.toLocalDate() : this.endDateTime); } return Optional.empty(); } /** * Gets the end date of the period over which this recurrence is played by taking into account * either the number of time he recurrent {@link Plannable} occurs or the end date of its * recurrence. The computed date can match the date of the last occurrence of the recurrent * {@link Plannable} for a finite recurrence without an end date explicitly set. It can be also * a date after the last occurrence. The exception dates in the recurrence rule aren't taken * into account. * * If this recurrence isn't yet applied to any recurrence calendar component, then an * {@link IllegalStateException} exception is thrown. * @return an optional recurrence actual end date. The optional is empty if the recurrence is * endless. */ public Optional<Temporal> getEndDate() { if (!isEndless()) { return Optional.of(getRecurrenceEndDate().orElse(computeEndDate())); } return Optional.empty(); } /** * Gets the start date of the period over which this recurrence is played. It is the date of the * first occurrence of the recurrent {@link Plannable} on which this recurrence is applied. * <p> * If this recurrence isn't yet applied to any recurrence calendar component, then an * {@link IllegalStateException} exception is thrown. * @return the start date of this recurrence. */ public Temporal getStartDate() { if (this.startDate == null) { throw new IllegalStateException("The recurrence isn't applied to any recurrent calendar component!"); } return this.startDate; } /** * Gets the days of week on which the {@link Plannable} should recur each time. * @return an unmodifiable set of days of week or an empty set if no days of week are set to this * recurrence. */ public Set<DayOfWeekOccurrence> getDaysOfWeek() { return Collections.unmodifiableSet(daysOfWeek); } /** * Gets the datetime exceptions to this recurrence rule. * * The returned datetime are the start datetime of the occurrences that are excluded * from this recurrence rule. They are the exception in the application of the recurrence rule. * @return a set of either {@link LocalDate} or {@link OffsetDateTime} instances, or an empty set * if there is no exception dates to this recurrence. */ public Set<Temporal> getExceptionDates() { return exceptionDates.stream().map(this::decode).collect(Collectors.toSet()); } @Override public boolean equals(final Object o) { if (this == o) { return true; } if (!(o instanceof Recurrence)) { return false; } final Recurrence that = (Recurrence) o; if (this.count != that.count || !frequency.equals(that.frequency)) { return false; } if (this.endDateTime != null) { if (!this.endDateTime.equals(that.endDateTime)) { return false; } } else if (that.endDateTime != null) { return false; } return daysOfWeek.equals(that.daysOfWeek) && exceptionDates.equals(that.exceptionDates); } @Override public int hashCode() { return new HashCodeBuilder().append(count).append(endDateTime).append(daysOfWeek).append(exceptionDates) .toHashCode(); } @Override public Recurrence clone() { try { Recurrence clone = (Recurrence) super.clone(); clone.id = null; clone.daysOfWeek = new HashSet<>(daysOfWeek); clone.exceptionDates = new HashSet<>(exceptionDates); return clone; } catch (CloneNotSupportedException e) { throw new SilverpeasRuntimeException(e); } } /** * Is this recurrence identical in value than the specified one. * @param recurrence the recurrence with which this recurrence is compared to. * @return true if this recurrence is same as the given one, false otherwise. */ boolean sameAs(final Recurrence recurrence) { if (this.equals(recurrence)) { return true; } if (recurrence == null) { return false; } if (recurrence.count != this.count || !recurrence.daysOfWeek.equals(this.daysOfWeek) || !recurrence.startDate.equals(this.startDate) || !sameFrequencyAs(recurrence)) { return false; } return sameEndTimeAs(recurrence); } private boolean sameFrequencyAs(final Recurrence recurrence) { return recurrence.frequency.getUnit() == this.frequency.getUnit() && recurrence.frequency.getInterval() == this.frequency.getInterval(); } private boolean sameEndTimeAs(final Recurrence recurrence) { return (recurrence.endDateTime == NO_RECURRENCE_END_DATE && this.endDateTime == NO_RECURRENCE_END_DATE) || (recurrence.endDateTime != NO_RECURRENCE_END_DATE && recurrence.endDateTime.equals(this.endDateTime)); } /** * Sets the date or datetime at which this recurrence starts. The date or datetime should be the * one at which the concerned recurrent component calendar is planned. * <p> * This method is dedicated to the recurrent component calendar to set its own start date or * datetime. The start date or datetime is used to compute the correct value of both the * recurrence end date or datetime (in case it is set) and the exception date or datetime (when * they are set). * @param date a temporal instance of either {@link LocalDate} for a date or * {@link OffsetDateTime} for a datetime. * @return itself. */ Recurrence startingAt(final Temporal date) { this.startDate = date; if (this.endDateTime != null) { this.until(this.endDateTime.toLocalDate()); } if (!this.exceptionDates.isEmpty()) { Temporal[] exceptions = this.exceptionDates.toArray(new Temporal[0]); this.exceptionDates.clear(); this.excludeEventOccurrencesStartingAt(exceptions); } return this; } /** * Clears all the registered exception dates. */ void clearsAllExceptionDates() { exceptionDates.clear(); } @PrePersist protected void generateId() { this.id = UUID.randomUUID().toString(); } private void checkRecurrenceStateForSpecificDaySetting() { if (getFrequency().isDaily()) { throw new IllegalStateException("Some specific days cannot be set for a daily recurrence"); } } /** * Clears all the registered exception dates which are after the end datetime of the recurrence. */ private void clearsUnnecessaryExceptionDates() { if (!this.exceptionDates.isEmpty()) { getEndDate().ifPresent( e -> exceptionDates.removeIf(exceptionDate -> asOffsetDateTime(e).isBefore(exceptionDate))); } } private OffsetDateTime normalize(final Temporal temporal) { OffsetDateTime dateTime = asOffsetDateTime(temporal); if (this.startDate != null) { return TemporalConverter.applyByType(this.startDate, t -> dateTime.with(LocalTime.MIDNIGHT.atOffset(ZoneOffset.UTC)), t -> dateTime.with(t.toOffsetTime())); } return dateTime; } private Temporal decode(final OffsetDateTime dateTime) { return getStartDate() instanceof LocalDate ? dateTime.toLocalDate() : dateTime; } private Temporal computeDateForMonthlyFrequencyFrom(final Temporal source, DayOfWeekOccurrence dayOfWeek) { Temporal current = source; if (dayOfWeek.nth() > 1) { current = current.with(ChronoField.ALIGNED_WEEK_OF_MONTH, dayOfWeek.nth()); } else if (dayOfWeek.nth() < 0) { current = current.with(ChronoField.DAY_OF_MONTH, 1).plus(1, ChronoUnit.MONTHS).minus(1, ChronoUnit.DAYS) .plus(dayOfWeek.nth(), ChronoUnit.WEEKS).with(dayOfWeek.dayOfWeek()); } return current; } private Temporal computeDateForYearlyFrequencyFrom(final Temporal source, DayOfWeekOccurrence dayOfWeek) { final int lastDayOfYear = 31; Temporal current = source; if (dayOfWeek.nth() > 1) { current = current.with(ChronoField.ALIGNED_WEEK_OF_YEAR, dayOfWeek.nth()); } else if (dayOfWeek.nth() < 0) { current = current.with(MonthDay.of(Month.DECEMBER, lastDayOfYear)) .plus(dayOfWeek.nth(), ChronoUnit.WEEKS).with(dayOfWeek.dayOfWeek()); } return current; } private Temporal computeEndDate() { Temporal date = this.getStartDate(); if (getRecurrenceCount() == 1) { return date; } final long interval = (long) getRecurrenceCount() * (getFrequency().getInterval() >= 1 ? getFrequency().getInterval() : 1); date = date.plus(interval, getFrequency().getUnit().toChronoUnit()); boolean firstDayOfWeekSet = false; for (DayOfWeekOccurrence dayOfWeek : daysOfWeek) { Temporal current = date.with(dayOfWeek.dayOfWeek()); if (getFrequency().isMonthly()) { current = computeDateForMonthlyFrequencyFrom(current, dayOfWeek); } else if (getFrequency().isYearly()) { current = computeDateForYearlyFrequencyFrom(current, dayOfWeek); } if (!firstDayOfWeekSet || LocalDate.from(current).isAfter(LocalDate.from(date))) { date = current; firstDayOfWeekSet = true; } } return date; } }