|
| 1 | +// Copyright (c) Microsoft Corporation. |
| 2 | +// Licensed under the MIT license. |
| 3 | + |
| 4 | +import { RecurrenceParameter } from "../timeWindowFilter.js"; |
| 5 | +import { VALUE_OUT_OF_RANGE_ERROR_MESSAGE, UNRECOGNIZABLE_VALUE_ERROR_MESSAGE, REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE, buildInvalidParameterErrorMessage } from "../utils.js"; |
| 6 | +import { DayOfWeek, Recurrence, RecurrencePattern, RecurrenceRange, RecurrencePatternType, RecurrenceRangeType, DAYS_PER_WEEK, ONE_DAY_IN_MILLISECONDS } from "./model.js"; |
| 7 | +import { calculateWeeklyDayOffset, sortDaysOfWeek } from "./utils.js"; |
| 8 | + |
| 9 | +const START_NOT_MATCHED_ERROR_MESSAGE = "Start date is not a valid first occurrence."; |
| 10 | +const TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE = "Time window duration cannot be longer than how frequently it occurs or be longer than 10 years."; |
| 11 | + |
| 12 | +/** |
| 13 | + * Parses @see RecurrenceParameter into a @see Recurrence object. If the parameter is invalid, an error will be thrown. |
| 14 | + * @param startTime The start time of the base time window |
| 15 | + * @param day2 The end time of the base time window |
| 16 | + * @param recurrenceParameter The @see RecurrenceParameter to parse |
| 17 | + * @param TimeZoneOffset The time zone offset in milliseconds, by default 0 |
| 18 | + * @returns A @see Recurrence object |
| 19 | + */ |
| 20 | +export function parseRecurrenceParameter(startTime: Date | undefined, endTime: Date | undefined, recurrenceParameter: RecurrenceParameter, TimeZoneOffset: number = 0): Recurrence { |
| 21 | + if (startTime === undefined) { |
| 22 | + throw new Error(buildInvalidParameterErrorMessage("Start", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); |
| 23 | + } |
| 24 | + if (endTime === undefined) { |
| 25 | + throw new Error(buildInvalidParameterErrorMessage("End", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); |
| 26 | + } |
| 27 | + if (startTime >= endTime) { |
| 28 | + throw new Error(buildInvalidParameterErrorMessage("End", VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); |
| 29 | + } |
| 30 | + const timeWindowDuration = endTime.getTime() - startTime.getTime(); |
| 31 | + if (timeWindowDuration > 10 * 365 * ONE_DAY_IN_MILLISECONDS) { // time window duration cannot be longer than 10 years |
| 32 | + throw new Error(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); |
| 33 | + } |
| 34 | + |
| 35 | + return { |
| 36 | + StartTime: startTime, |
| 37 | + EndTime: endTime, |
| 38 | + Pattern: parseRecurrencePattern(startTime, endTime, recurrenceParameter, TimeZoneOffset), |
| 39 | + Range: parseRecurrenceRange(startTime, recurrenceParameter), |
| 40 | + TimeZoneOffset: TimeZoneOffset |
| 41 | + }; |
| 42 | +} |
| 43 | + |
| 44 | +function parseRecurrencePattern(startTime: Date, endTime: Date, recurrenceParameter: RecurrenceParameter, TimeZoneOffset: number): RecurrencePattern { |
| 45 | + const rawPattern = recurrenceParameter.Pattern; |
| 46 | + if (rawPattern === undefined) { |
| 47 | + throw new Error(buildInvalidParameterErrorMessage("Pattern", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); |
| 48 | + } |
| 49 | + if (rawPattern.Type === undefined) { |
| 50 | + throw new Error(buildInvalidParameterErrorMessage("Pattern.Type", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); |
| 51 | + } |
| 52 | + const patternType = RecurrencePatternType[rawPattern.Type]; |
| 53 | + if (patternType === undefined) { |
| 54 | + throw new Error(buildInvalidParameterErrorMessage("Pattern.Type", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); |
| 55 | + } |
| 56 | + let interval = rawPattern.Interval; |
| 57 | + if (interval !== undefined) { |
| 58 | + if (typeof interval !== "number") { |
| 59 | + throw new Error(buildInvalidParameterErrorMessage("Pattern.Interval", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); |
| 60 | + } else if (interval <= 0 || !Number.isInteger(interval)) { |
| 61 | + throw new Error(buildInvalidParameterErrorMessage("Pattern.Interval", VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); |
| 62 | + } |
| 63 | + } else { |
| 64 | + interval = 1; |
| 65 | + } |
| 66 | + const parsedPattern: RecurrencePattern = { |
| 67 | + Type: patternType, |
| 68 | + Interval: interval |
| 69 | + }; |
| 70 | + const timeWindowDuration = endTime.getTime() - startTime.getTime(); |
| 71 | + if (patternType === RecurrencePatternType.Daily) { |
| 72 | + if (timeWindowDuration > interval * ONE_DAY_IN_MILLISECONDS) { |
| 73 | + throw new Error(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); |
| 74 | + } |
| 75 | + } else if (patternType === RecurrencePatternType.Weekly) { |
| 76 | + let firstDayOfWeek: DayOfWeek; |
| 77 | + if (rawPattern.FirstDayOfWeek !== undefined) { |
| 78 | + firstDayOfWeek = DayOfWeek[rawPattern.FirstDayOfWeek]; |
| 79 | + if (firstDayOfWeek === undefined) { |
| 80 | + throw new Error(buildInvalidParameterErrorMessage("Pattern.FirstDayOfWeek", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); |
| 81 | + } |
| 82 | + } |
| 83 | + else { |
| 84 | + firstDayOfWeek = DayOfWeek.Sunday; |
| 85 | + } |
| 86 | + parsedPattern.FirstDayOfWeek = firstDayOfWeek; |
| 87 | + |
| 88 | + if (rawPattern.DaysOfWeek === undefined || rawPattern.DaysOfWeek.length === 0) { |
| 89 | + throw new Error(buildInvalidParameterErrorMessage("Pattern.DaysOfWeek", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); |
| 90 | + } |
| 91 | + const daysOfWeek = [...new Set(rawPattern.DaysOfWeek.map(day => DayOfWeek[day]))]; // dedup array |
| 92 | + if (daysOfWeek.some(day => day === undefined)) { |
| 93 | + throw new Error(buildInvalidParameterErrorMessage("Pattern.DaysOfWeek", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); |
| 94 | + } |
| 95 | + if (timeWindowDuration > interval * DAYS_PER_WEEK * ONE_DAY_IN_MILLISECONDS || |
| 96 | + !IsDurationCompliantWithDaysOfWeek(timeWindowDuration, interval, daysOfWeek, firstDayOfWeek)) { |
| 97 | + throw new Error(buildInvalidParameterErrorMessage("End", TIME_WINDOW_DURATION_OUT_OF_RANGE_ERROR_MESSAGE)); |
| 98 | + } |
| 99 | + parsedPattern.DaysOfWeek = daysOfWeek; |
| 100 | + |
| 101 | + // check whether "Start" is a valid first occurrence |
| 102 | + const alignedStartTime = new Date(startTime); |
| 103 | + alignedStartTime.setUTCMilliseconds(alignedStartTime.getUTCMilliseconds() + TimeZoneOffset); |
| 104 | + if (!daysOfWeek.find(day => day === alignedStartTime.getUTCDay())) { |
| 105 | + throw new Error(buildInvalidParameterErrorMessage("Start", START_NOT_MATCHED_ERROR_MESSAGE)); |
| 106 | + } |
| 107 | + } |
| 108 | + return parsedPattern; |
| 109 | +} |
| 110 | + |
| 111 | +function parseRecurrenceRange(startTime: Date, recurrenceParameter: RecurrenceParameter): RecurrenceRange { |
| 112 | + const rawRange = recurrenceParameter.Range; |
| 113 | + if (rawRange === undefined) { |
| 114 | + throw new Error(buildInvalidParameterErrorMessage("Range", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); |
| 115 | + } |
| 116 | + if (rawRange.Type === undefined) { |
| 117 | + throw new Error(buildInvalidParameterErrorMessage("Range.Type", REQUIRED_PARAMETER_MISSING_ERROR_MESSAGE)); |
| 118 | + } |
| 119 | + const rangeType = RecurrenceRangeType[rawRange.Type]; |
| 120 | + if (rangeType === undefined) { |
| 121 | + throw new Error(buildInvalidParameterErrorMessage("Range.Type", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); |
| 122 | + } |
| 123 | + const parsedRange: RecurrenceRange = { Type: rangeType }; |
| 124 | + if (rangeType === RecurrenceRangeType.EndDate) { |
| 125 | + let endDate: Date; |
| 126 | + if (rawRange.EndDate !== undefined) { |
| 127 | + endDate = new Date(rawRange.EndDate); |
| 128 | + if (isNaN(endDate.getTime())) { |
| 129 | + throw new Error(buildInvalidParameterErrorMessage("Range.EndDate", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); |
| 130 | + } |
| 131 | + if (endDate < startTime) { |
| 132 | + throw new Error(buildInvalidParameterErrorMessage("Range.EndDate", VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); |
| 133 | + } |
| 134 | + } else { |
| 135 | + endDate = new Date(8.64e15); // the maximum date in ECMAScript: https://262.ecma-international.org/5.1/#sec-15.9.1.1 |
| 136 | + } |
| 137 | + parsedRange.EndDate = endDate; |
| 138 | + } else if (rangeType === RecurrenceRangeType.Numbered) { |
| 139 | + let numberOfOccurrences = rawRange.NumberOfOccurrences; |
| 140 | + if (numberOfOccurrences !== undefined) { |
| 141 | + if (typeof numberOfOccurrences !== "number") { |
| 142 | + throw new Error(buildInvalidParameterErrorMessage("Range.NumberOfOccurrences", UNRECOGNIZABLE_VALUE_ERROR_MESSAGE)); |
| 143 | + } else if (numberOfOccurrences <= 0 || !Number.isInteger(numberOfOccurrences)) { |
| 144 | + throw new Error(buildInvalidParameterErrorMessage("Range.NumberOfOccurrences", VALUE_OUT_OF_RANGE_ERROR_MESSAGE)); |
| 145 | + } |
| 146 | + } else { |
| 147 | + numberOfOccurrences = Number.MAX_SAFE_INTEGER; |
| 148 | + } |
| 149 | + parsedRange.NumberOfOccurrences = numberOfOccurrences; |
| 150 | + } |
| 151 | + return parsedRange; |
| 152 | +} |
| 153 | + |
| 154 | +function IsDurationCompliantWithDaysOfWeek(duration: number, interval: number, daysOfWeek: DayOfWeek[], firstDayOfWeek: DayOfWeek): boolean { |
| 155 | + if (daysOfWeek.length === 1) { |
| 156 | + return true; |
| 157 | + } |
| 158 | + const sortedDaysOfWeek = sortDaysOfWeek(daysOfWeek, firstDayOfWeek); |
| 159 | + let prev = sortedDaysOfWeek[0]; // the closest occurrence day to the first day of week |
| 160 | + let minGap = DAYS_PER_WEEK * ONE_DAY_IN_MILLISECONDS; |
| 161 | + for (let i = 1; i < sortedDaysOfWeek.length; i++) { // skip the first day |
| 162 | + const gap = calculateWeeklyDayOffset(sortedDaysOfWeek[i], prev) * ONE_DAY_IN_MILLISECONDS; |
| 163 | + minGap = gap < minGap ? gap : minGap; |
| 164 | + prev = sortedDaysOfWeek[i]; |
| 165 | + } |
| 166 | + // It may across weeks. Check the next week if the interval is one week. |
| 167 | + if (interval == 1) { |
| 168 | + const gap = calculateWeeklyDayOffset(sortedDaysOfWeek[0], prev) * ONE_DAY_IN_MILLISECONDS; |
| 169 | + minGap = gap < minGap ? gap : minGap; |
| 170 | + } |
| 171 | + return minGap >= duration; |
| 172 | +} |
0 commit comments