diff --git a/src/calendar-app/calendar/export/CalendarExporter.ts b/src/calendar-app/calendar/export/CalendarExporter.ts index bb6dd583b7df..6368db046b6a 100644 --- a/src/calendar-app/calendar/export/CalendarExporter.ts +++ b/src/calendar-app/calendar/export/CalendarExporter.ts @@ -116,7 +116,7 @@ function serializeAdvancedRepeatRules(advancedRules: CalendarAdvancedRepeatRule[ const type = byRuleValueToKey[r.ruleType as ByRule] BYRULES.set(type, BYRULES.get(type) ? `${BYRULES.get(type)},${r.interval}` : r.interval) } - for (const [interval, type] of BYRULES) { + for (const [type, interval] of BYRULES) { advancedRepeatRules += `;${type.toUpperCase()}=${interval}` } } @@ -167,7 +167,7 @@ export function serializeRepeatRule(repeatRule: RepeatRule | null, isAllDayEvent `RRULE:FREQ=${repeatPeriodToIcalFrequency(assertEnumValue(RepeatPeriod, repeatRule.frequency))}` + `;INTERVAL=${repeatRule.interval}` + endType + - advancedRepeatRules.trim(), + advancedRepeatRules, ].concat(excludedDates) } else { return [] diff --git a/src/calendar-app/calendar/gui/eventeditor-view/RepeatRuleEditor.ts b/src/calendar-app/calendar/gui/eventeditor-view/RepeatRuleEditor.ts index babb1980d2b4..b32858b2dbe4 100644 --- a/src/calendar-app/calendar/gui/eventeditor-view/RepeatRuleEditor.ts +++ b/src/calendar-app/calendar/gui/eventeditor-view/RepeatRuleEditor.ts @@ -17,7 +17,8 @@ import { theme } from "../../../../common/gui/theme.js" import { isApp } from "../../../../common/api/common/Env.js" import { BannerType, InfoBanner, InfoBannerAttrs } from "../../../../common/gui/base/InfoBanner.js" import { Icons } from "../../../../common/gui/base/icons/Icons.js" -import { ByRule } from "../../../../common/calendar/date/CalendarUtils.js" +import { areAllAdvancedRepeatRulesValid, ByRule } from "../../../../common/calendar/date/CalendarUtils.js" +import { isNotEmpty } from "@tutao/tutanota-utils" export type RepeatRuleEditorAttrs = { model: CalendarEventWhenModel @@ -50,11 +51,8 @@ export class RepeatRuleEditor implements Component { this.repeatInterval = attrs.model.repeatInterval this.repeatOccurrences = attrs.model.repeatEndOccurrences - this.hasUnsupportedRules = attrs.model.advancedRules.some((rule) => { - const isValidRule = - (attrs.model.repeatPeriod === RepeatPeriod.WEEKLY || attrs.model.repeatPeriod === RepeatPeriod.MONTHLY) && rule.ruleType === ByRule.BYDAY - return !isValidRule - }) + + this.hasUnsupportedRules = !areAllAdvancedRepeatRulesValid(attrs.model.advancedRules, attrs.model.repeatPeriod) } private getRepeatType(period: RepeatPeriod, interval: number, endTime: EndType) { diff --git a/src/calendar-app/calendar/gui/eventpopup/EventPreviewView.ts b/src/calendar-app/calendar/gui/eventpopup/EventPreviewView.ts index 3a558b0dfe3b..720c4b702bd6 100644 --- a/src/calendar-app/calendar/gui/eventpopup/EventPreviewView.ts +++ b/src/calendar-app/calendar/gui/eventpopup/EventPreviewView.ts @@ -11,7 +11,14 @@ import { AllIcons, Icon, IconSize } from "../../../../common/gui/base/Icon.js" import { theme } from "../../../../common/gui/theme.js" import { BootIcons } from "../../../../common/gui/base/icons/BootIcons.js" import { Icons } from "../../../../common/gui/base/icons/Icons.js" -import { ByRule, getRepeatEndTimeForDisplay, getTimeZone, RENDER_TYPE_TRANSLATION_MAP, RenderType } from "../../../../common/calendar/date/CalendarUtils.js" +import { + areAllAdvancedRepeatRulesValid, + ByRule, + getRepeatEndTimeForDisplay, + getTimeZone, + RENDER_TYPE_TRANSLATION_MAP, + RenderType, +} from "../../../../common/calendar/date/CalendarUtils.js" import { CalendarAttendeeStatus, EndType, getAttendeeStatus, RepeatPeriod } from "../../../../common/api/common/TutanotaConstants.js" import { downcast, memoized } from "@tutao/tutanota-utils" import { lang, TranslationKey } from "../../../../common/misc/LanguageViewModel.js" @@ -265,7 +272,7 @@ export function formatRepetitionFrequency(repeatRule: RepeatRule): string | null if (frequency) { const freq = frequency.name - const readable = buildReadableAdvancedRepetitionRule(repeatRule.advancedRules, downcast(repeatRule.frequency)) + const readable = buildAdvancedRepetitionRuleDescription(repeatRule.advancedRules, downcast(repeatRule.frequency)) return `${freq}. ${readable}`.trim() } @@ -275,7 +282,7 @@ export function formatRepetitionFrequency(repeatRule: RepeatRule): string | null "{timeUnit}": getFrequencyTimeUnit(downcast(repeatRule.frequency)), }) - const advancedRule = buildReadableAdvancedRepetitionRule(repeatRule.advancedRules, downcast(repeatRule.frequency)) + const advancedRule = buildAdvancedRepetitionRuleDescription(repeatRule.advancedRules, downcast(repeatRule.frequency)) return `${repeatMessage}. ${advancedRule}`.trim() } @@ -283,10 +290,8 @@ export function formatRepetitionFrequency(repeatRule: RepeatRule): string | null return null } -function buildReadableAdvancedRepetitionRule(advancedRule: AdvancedRepeatRule[], frequency: RepeatPeriod): string { - const hasInvalidRules = advancedRule.some( - (rule) => !((frequency === RepeatPeriod.WEEKLY || frequency === RepeatPeriod.MONTHLY) && rule.ruleType === ByRule.BYDAY), - ) +function buildAdvancedRepetitionRuleDescription(advancedRules: AdvancedRepeatRule[], frequency: RepeatPeriod): string { + const hasInvalidRules = !areAllAdvancedRepeatRulesValid(advancedRules, frequency) let translationKey: TranslationKey = "withCustomRules_label" if (hasInvalidRules) { @@ -295,7 +300,7 @@ function buildReadableAdvancedRepetitionRule(advancedRule: AdvancedRepeatRule[], const days: string[] = [] - for (const item of advancedRule) { + for (const item of advancedRules) { switch (item.ruleType) { case ByRule.BYDAY: days.push(item.interval) @@ -308,7 +313,9 @@ function buildReadableAdvancedRepetitionRule(advancedRule: AdvancedRepeatRule[], if (days.length === 0) return "" if (frequency === RepeatPeriod.MONTHLY) { - const ruleRegex = /^([-+]?\d{0,3})([a-zA-Z]{2})?$/g + // Gets the number and the day of the week for a given rule value + // e.g. 2TH would return ["2TH", "2", "TH"] + const ruleRegex = /^([-+]?\d{0,2})([a-zA-Z]{2})?$/g const parsedRuleValue = Array.from(days[0].matchAll(ruleRegex)).flat() @@ -332,7 +339,7 @@ function buildReadableAdvancedRepetitionRule(advancedRule: AdvancedRepeatRule[], } return lang.get("onDays_label", { - "{days}": joinWithAnd( + "{days}": joinAndEndWithString( days.map((day) => parseShortDay(day)), ", ", lang.get("and_label"), @@ -340,7 +347,11 @@ function buildReadableAdvancedRepetitionRule(advancedRule: AdvancedRepeatRule[], }) } -function joinWithAnd(items: any[], separator: string, lastSeparator: string) { +/* + * Concatenates elements of an array using a specified separator, and + * appends the final element with an alternative separator + */ +function joinAndEndWithString(items: any[], separator: string, lastSeparator: string) { if (items.length > 1) { const last = items.pop() const joinedString = items.join(separator) @@ -398,24 +409,17 @@ function getFrequencyTimeUnit(frequency: RepeatPeriod): string { } function parseShortDay(day: string) { - switch (day) { - case "MO": - return lang.get("monday_label") - case "TU": - return lang.get("tuesday_label") - case "WE": - return lang.get("wednesday_label") - case "TH": - return lang.get("thursday_label") - case "FR": - return lang.get("friday_label") - case "SA": - return lang.get("saturday_label") - case "SU": - return lang.get("sunday_label") - default: - return "" + const days: Record = { + MO: "monday_label", + TU: "tuesday_label", + WE: "wednesday_label", + TH: "thursday_label", + FR: "friday_label", + SA: "saturday_label", + SU: "sunday_label", } + + return lang.get(days[day]) || "" } function prepareAttendees(attendees: Array, organizer: EncryptedMailAddress | null): Array { diff --git a/src/common/calendar/date/CalendarUtils.ts b/src/common/calendar/date/CalendarUtils.ts index b22dd3f6916b..2a0ccadbfdb0 100644 --- a/src/common/calendar/date/CalendarUtils.ts +++ b/src/common/calendar/date/CalendarUtils.ts @@ -30,8 +30,9 @@ import { TimeFormat, WeekStart, } from "../../api/common/TutanotaConstants" -import { DateTime, DurationLikeObject, FixedOffsetZone, IANAZone, MonthNumbers, WeekdayNumbers } from "luxon" +import { DateTime, Duration, DurationLikeObject, FixedOffsetZone, IANAZone, MonthNumbers, WeekdayNumbers } from "luxon" import { + AdvancedRepeatRule, CalendarEvent, CalendarEventTypeRef, CalendarGroupRoot, @@ -181,6 +182,192 @@ const WEEKDAY_TO_NUMBER = { SU: 7, } as Record +function expandByDayRuleForWeeklyEvents(targetWeekDay: any, date: DateTime, wkst: WeekdayNumbers, validMonths: number[], newDates: DateTime[]) { + // BYMONTH => BYDAY(expand) + if (!targetWeekDay) { + return + } + + // Go back to week start, so we don't miss any events + let intervalStart = clone(date) + while (intervalStart.weekday !== wkst) { + intervalStart = intervalStart.minus({ day: 1 }) + } + + // Move forward until we reach the target day + let newDate = clone(intervalStart) + while (newDate.weekday !== targetWeekDay) { + newDate = newDate.plus({ day: 1 }) + } + + // Calculate next event to avoid creating events too ahead in the future + const nextEvent = date.plus({ week: 1 }).toMillis() + if (newDate.toMillis() >= intervalStart.plus({ week: 1 }).toMillis()) { + // The event is actually next week, so discard + return + } else if (newDate.toMillis() < date.toMillis()) { + // Event is behind progenitor, go forward one week + newDate = newDate.plus({ weeks: 1 }) + } + + if (newDate.toMillis() >= nextEvent || (wkst != WeekDaysJsValue.MO && newDate.toMillis() >= intervalStart.plus({ weeks: 1 }).toMillis())) { + // Or we created an event after the first event or within the next week + return + } + + if (validMonths.length === 0 || validMonths.includes(newDate.month)) { + newDates.push(newDate) + } +} + +function expandByDayRuleForMonthlyEvents( + targetWeekDay: any, + leadingValue: number | null, + date: DateTime, + monthDays: number[] | undefined, + newDates: DateTime[], + validMonths: number[], +) { + if (!targetWeekDay) { + return + } + + const allowedDays: number[] = [] + const weekChange = leadingValue ?? 0 + const stopCondition = date.plus({ month: 1 }).set({ day: 1 }) + const baseDate = date.set({ day: 1 }) + + // Calculate allowed days parsing negative values + // to valid days in the month. e.g -1 to 31 in JAN + for (const allowedDay of monthDays ?? []) { + if (allowedDay > 0) { + allowedDays.push(allowedDay) + continue + } + + const day = baseDate.daysInMonth! - Math.abs(allowedDay) + 1 + allowedDays.push(day) + } + + // Simply checks if there's a list with allowed day and check if it includes a given day + const isAllowedInMonthDayRule = (day: number) => { + return allowedDays.length === 0 ? true : allowedDays.includes(day) + } + + // If there's a leading value in the rule we have to change the week. + // e.g. 2TH means second thursday, consequently, second week of the month + if (weekChange != 0) { + let dt = baseDate + + // Check for negative week changes e.g -1TH last thursday + if (weekChange < 0) { + dt = dt + .set({ day: dt.daysInMonth }) + .set({ weekday: targetWeekDay }) + .minus({ week: Math.abs(weekChange) - 1 }) + } else { + while (dt.weekday != targetWeekDay) { + dt = dt.plus({ day: 1 }) + } + dt = dt.plus({ week: weekChange - 1 }) + } + + if (dt.toMillis() >= baseDate.toMillis() && dt.toMillis() < stopCondition.toMillis() && isAllowedInMonthDayRule(dt.day)) { + newDates.push(dt) + } + } else { + // If there's no week change, just iterate to the target day + let currentDate = baseDate + while (currentDate < stopCondition) { + const dt = currentDate.set({ weekday: targetWeekDay }) + if (dt.toMillis() >= baseDate.toMillis() && isAllowedInMonthDayRule(dt.day)) { + if (validMonths.length > 0 && validMonths.includes(dt.month)) { + newDates.push(dt) + } else if (validMonths.length === 0) { + newDates.push(dt) + } + } + currentDate = dt.plus({ week: 1 }) + } + } +} + +function expandByDayRuleForAnnuallyEvents( + leadingValue: number | null, + hasWeekNo: boolean | undefined, + targetWeekDay: any, + date: DateTime, + newDates: DateTime[], + wkst: WeekdayNumbers, +) { + const weekChangeValue = leadingValue ?? 0 + if (hasWeekNo && weekChangeValue !== 0) { + console.warn("Invalid repeat rule, can't use BYWEEKNO with Week Offset on BYDAY") + return + } + + if (weekChangeValue !== 0 && !hasWeekNo) { + // If there's no target week day, we just set the day of the year. + if (!targetWeekDay) { + let dt: DateTime + if (weekChangeValue > 0) { + dt = date.set({ day: 1, month: 1 }).plus({ day: weekChangeValue - 1 }) + } else { + dt = date.set({ day: 31, month: 12 }).minus({ day: Math.abs(weekChangeValue) - 1 }) + } + + // The event is in the past so it should be moved to next year + if (dt.toMillis() < date.toMillis()) { + newDates.push(dt.plus({ year: 1 })) + } else { + newDates.push(dt) + } + } else { + // There's a target week day so the occurrenceNumber indicates the week of the year + // that the event will happen + const absWeeks = weekChangeValue > 0 ? weekChangeValue : Math.ceil(date.daysInMonth! / 7) - Math.abs(weekChangeValue) + 1 + + const dt = date.set({ day: 1 }).set({ weekday: targetWeekDay }).plus({ week: absWeeks }) + if (dt.toMillis() >= date.toMillis()) { + newDates.push(dt) + } + } + } else if (hasWeekNo) { + if (!targetWeekDay) { + return + } + const dt = date.set({ weekday: targetWeekDay }) + const intervalStart = date.set({ weekday: wkst }) + if (dt.toMillis() > intervalStart.plus({ week: 1 }).toMillis() || dt.toMillis() < date.toMillis()) { + // Too ahead in the future or before progenitor + } else if (dt.toMillis() < intervalStart.toMillis()) { + newDates.push(intervalStart.plus({ week: 1 })) + } else { + newDates.push(dt) + } + } else if (!hasWeekNo && weekChangeValue === 0) { + // There's no week number or occurrenceNumber, so it will happen on all + // weekdays that are the same as targetWeekDay + if (!targetWeekDay) { + return + } + + const stopCondition = date.set({ day: 1 }).plus({ year: 1 }) + let currentDate = date.set({ day: 1, weekday: targetWeekDay }) + + if (currentDate.toMillis() >= date.set({ day: 1 }).toMillis()) { + newDates.push(currentDate) + } + + currentDate = currentDate.plus({ week: 1 }) + + while (currentDate.toMillis() < stopCondition.toMillis()) { + newDates.push(currentDate) + currentDate = currentDate.plus({ week: 1 }) + } + } +} + function applyByDayRules( dates: DateTime[], parsedRules: CalendarAdvancedRepeatRule[], @@ -195,6 +382,8 @@ function applyByDayRules( return dates } + // Gets the nth number and the day of the week for a given rule value + // e.g. 312TH would return ["312TH", "312", "TH"] const ruleRegex = /^([-+]?\d{0,3})([a-zA-Z]{2})?$/g const newDates: DateTime[] = [] @@ -215,145 +404,17 @@ function applyByDayRules( const leadingValue = parsedRuleValue[1] !== "" ? Number.parseInt(parsedRuleValue[1]) : null if (frequency === RepeatPeriod.DAILY) { - // BYMONTH => BYMONTHDAY => BYDAY + // Only filters weekdays that don't match the rule if (date.weekday !== targetWeekDay) { continue } newDates.push(date) } else if (frequency === RepeatPeriod.WEEKLY) { - // BYMONTH => BYDAY(expand) - if (!targetWeekDay) { - continue - } - - let dt = date.set({ weekday: targetWeekDay }) - const intervalStart = date.set({ weekday: wkst }) - if (dt.toMillis() > intervalStart.plus({ week: 1 }).toMillis()) { - // Do nothing - continue - } else if (dt.toMillis() < intervalStart.toMillis()) { - dt = dt.plus({ week: 1 }) - } - - if (validMonths.length === 0 || validMonths.includes(dt.month)) { - newDates.push(dt) - } + expandByDayRuleForWeeklyEvents(targetWeekDay, date, wkst, validMonths, newDates) } else if (frequency === RepeatPeriod.MONTHLY) { - if (!targetWeekDay) { - continue - } - - const allowedDays: number[] = [] - const weekChange = leadingValue ?? 0 - const stopCondition = date.plus({ month: 1 }).set({ day: 1 }) - const baseDate = date.set({ day: 1 }) - - for (const allowedDay of monthDays ?? []) { - if (allowedDay > 0) { - allowedDays.push(allowedDay) - continue - } - - const day = baseDate.daysInMonth! - Math.abs(allowedDay) + 1 - allowedDays.push(day) - } - - const isAllowedInMonthDayRule = (day: number) => { - return allowedDays.length === 0 ? true : allowedDays.includes(day) - } - - if (weekChange != 0) { - let dt = baseDate - - if (weekChange < 0) { - dt = dt - .set({ day: dt.daysInMonth }) - .set({ weekday: targetWeekDay }) - .minus({ week: Math.abs(weekChange) - 1 }) - } else { - while (dt.weekday != targetWeekDay) { - dt = dt.plus({ day: 1 }) - } - dt = dt.plus({ week: weekChange - 1 }) - } - - if (dt.toMillis() >= baseDate.toMillis() && dt.toMillis() < stopCondition.toMillis() && isAllowedInMonthDayRule(dt.day)) { - newDates.push(dt) - } - } else { - let currentDate = baseDate - while (currentDate < stopCondition) { - const dt = currentDate.set({ weekday: targetWeekDay }) - if (dt.toMillis() >= baseDate.toMillis() && isAllowedInMonthDayRule(dt.day)) { - if (validMonths.length > 0 && validMonths.includes(dt.month)) { - newDates.push(dt) - } else if (validMonths.length === 0) { - newDates.push(dt) - } - } - currentDate = dt.plus({ week: 1 }) - } - } + expandByDayRuleForMonthlyEvents(targetWeekDay, leadingValue, date, monthDays, newDates, validMonths) } else if (frequency === RepeatPeriod.ANNUALLY) { - const weekChange = leadingValue ?? 0 - if (hasWeekNo && weekChange !== 0) { - console.warn("Invalid repeat rule, can't use BYWEEKNO with Week Offset on BYDAY") - continue - } - - if (weekChange !== 0 && !hasWeekNo) { - if (!targetWeekDay) { - let dt: DateTime - if (weekChange > 0) { - dt = date.set({ day: 1, month: 1 }).plus({ day: weekChange - 1 }) - } else { - dt = date.set({ day: 31, month: 12 }).minus({ day: Math.abs(weekChange) - 1 }) - } - if (dt.toMillis() < date.toMillis()) { - newDates.push(dt.plus({ year: 1 })) - } else { - newDates.push(dt) - } - } else { - const absWeeks = weekChange > 0 ? weekChange : Math.ceil(date.daysInMonth! / 7) - Math.abs(weekChange) + 1 - const dt = date.set({ day: 1 }).set({ weekday: targetWeekDay }).plus({ week: absWeeks }) - if (dt.toMillis() >= date.toMillis()) { - newDates.push(dt) - } - } - } else if (hasWeekNo) { - // Handle WKST - if (!targetWeekDay) { - continue - } - const dt = date.set({ weekday: targetWeekDay }) - const intervalStart = date.set({ weekday: wkst }) - if (dt.toMillis() > intervalStart.plus({ week: 1 }).toMillis() || dt.toMillis() < date.toMillis()) { - // Do nothing - } else if (dt.toMillis() < intervalStart.toMillis()) { - newDates.push(intervalStart.plus({ week: 1 })) - } else { - newDates.push(dt) - } - } else if (!hasWeekNo && weekChange === 0) { - if (!targetWeekDay) { - continue - } - - const stopCondition = date.set({ day: 1 }).plus({ year: 1 }) - let currentDate = date.set({ day: 1, weekday: targetWeekDay }) - - if (currentDate.toMillis() >= date.set({ day: 1 }).toMillis()) { - newDates.push(currentDate) - } - - currentDate = currentDate.plus({ week: 1 }) - - while (currentDate.toMillis() < stopCondition.toMillis()) { - newDates.push(currentDate) - currentDate = currentDate.plus({ week: 1 }) - } - } + expandByDayRuleForAnnuallyEvents(leadingValue, hasWeekNo, targetWeekDay, date, newDates, wkst) } } } @@ -882,9 +943,10 @@ export function addDaysForEventInstance(daysToEvents: Map createDateWrapper({ date: getAllDayDateForTimezone(date, timeZone) })) : repeatRule.excludedDates - const generatedEvents = generateEventOccurrences(event, timeZone, new Date(range.end)) + const generatedEvents = eventOccurencesGenerator(event, timeZone, new Date(range.end)) for (const { startTime, endTime } of generatedEvents) { if (startTime.getTime() > range.end) break @@ -1043,7 +1108,7 @@ export function generateCalendarInstancesInRange( nextCandidate: CalendarEvent }> = progenitors .map((p) => { - const generator = generateEventOccurrences(p, timeZone, new Date(range.end)) + const generator = eventOccurencesGenerator(p, timeZone, new Date(range.end)) const excludedDates = p.repeatRule?.excludedDates ?? [] const nextCandidate = getNextCandidate(p, generator, excludedDates) if (nextCandidate == null) return null @@ -1108,7 +1173,7 @@ export function getRepeatEndTimeForDisplay(repeatRule: RepeatRule, isAllDay: boo * @param timeZone * @param maxDate */ -function* generateEventOccurrences(event: CalendarEvent, timeZone: string, maxDate: Date): Generator<{ startTime: Date; endTime: Date }> { +function* eventOccurencesGenerator(event: CalendarEvent, timeZone: string, maxDate: Date): Generator<{ startTime: Date; endTime: Date }> { const { repeatRule } = event if (repeatRule == null) { @@ -1209,7 +1274,7 @@ function* generateEventOccurrences(event: CalendarEvent, timeZone: string, maxDa ? incrementByRepeatPeriod(newStartTime, RepeatPeriod.DAILY, calcDuration, repeatTimeZone) : DateTime.fromJSDate(newStartTime).plus(calcDuration).toJSDate() - if (shouldApplySetPos && !bySetPosContainsEventOccurance(setPosRulesValues, downcast(repeatRule?.frequency), ++eventCount, events)) { + if (shouldApplySetPos && !filterEventOccurancesBySetPos(setPosRulesValues, downcast(repeatRule?.frequency), ++eventCount, events)) { continue } @@ -1268,7 +1333,7 @@ export function calendarEventHasMoreThanOneOccurrencesLeft({ progenitor, altered let occurrencesFound = alteredInstances.length - for (const { startTime } of generateEventOccurrences(progenitor, getTimeZone(), maxDate)) { + for (const { startTime } of eventOccurencesGenerator(progenitor, getTimeZone(), maxDate)) { const startTimestamp = startTime.getTime() while (i < excludedTimestamps.length && startTimestamp > excludedTimestamps[i]) { // exclusions are sorted @@ -1336,7 +1401,7 @@ export function findNextAlarmOccurrence( return null } - const eventGenerator = generateEventOccurrences( + const eventGenerator = eventOccurencesGenerator( createCalendarEvent({ startTime: eventStart, endTime: eventEnd, @@ -1542,6 +1607,19 @@ export function areRepeatRulesEqual(r1: CalendarRepeatRule | null, r2: CalendarR ) } +/* + * Checks if all Advanced Rules whithin a set are valid. Return true if we support all rules present in the array + */ +export function areAllAdvancedRepeatRulesValid(advancedRules: AdvancedRepeatRule[], repeatPeriod: RepeatPeriod | null) { + const isDailyOrYearly = repeatPeriod === RepeatPeriod.ANNUALLY || repeatPeriod === RepeatPeriod.DAILY + + if (repeatPeriod == null && isNotEmpty(advancedRules)) return false + else if (isDailyOrYearly && isNotEmpty(advancedRules)) return false + else if (advancedRules.some((rule) => rule.ruleType !== ByRule.BYDAY)) return false + + return true +} + /** * Converts db representation of alarm to a runtime one. */ diff --git a/test/tests/calendar/AlarmSchedulerTest.ts b/test/tests/calendar/AlarmSchedulerTest.ts index b8c0556fcda5..8c2267a14889 100644 --- a/test/tests/calendar/AlarmSchedulerTest.ts +++ b/test/tests/calendar/AlarmSchedulerTest.ts @@ -6,6 +6,8 @@ import { EndType, RepeatPeriod } from "../../../src/common/api/common/TutanotaCo import { DateProvider } from "../../../src/common/api/common/DateProvider.js" import { createTestEntity, SchedulerMock } from "../TestUtils.js" import { spy } from "@tutao/tutanota-test-utils" +import { AdvancedRepeatRuleTypeRef } from "../../../src/common/api/entities/tutanota/TypeRefs" +import { ByRule } from "../../../src/common/calendar/date/CalendarUtils" o.spec("AlarmScheduler", function () { let alarmScheduler: AlarmScheduler diff --git a/tuta-sdk/rust/sdk/Cargo.toml b/tuta-sdk/rust/sdk/Cargo.toml index e2abc8fd387d..41a8f8347741 100644 --- a/tuta-sdk/rust/sdk/Cargo.toml +++ b/tuta-sdk/rust/sdk/Cargo.toml @@ -53,7 +53,6 @@ form_urlencoded = "1" # allow initializing a simple_logger if the consuming application (or examples) want to do that. simple_logger = { version = "5.0.0", optional = true } -sha3 = "0.10.8" time = { version = "0.3.37", features = ["serde", "macros"] } regex = "1.11.1" diff --git a/tuta-sdk/rust/sdk/src/date/event_facade.rs b/tuta-sdk/rust/sdk/src/date/event_facade.rs index 066024eca4c5..46f2bd4fa2d9 100644 --- a/tuta-sdk/rust/sdk/src/date/event_facade.rs +++ b/tuta-sdk/rust/sdk/src/date/event_facade.rs @@ -1,6 +1,6 @@ use std::ops::{Add, Sub}; -use regex::Regex; +use regex::{Match, Regex}; use time::util::weeks_in_year; use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Weekday}; @@ -522,6 +522,9 @@ impl EventFacade { } let mut new_dates: Vec = Vec::new(); + + // Gets the nth number and the day of the week for a given rule value + // e.g. 312TH would return ["312TH", "312", "TH"] let regex = Regex::new(r"^([-+]?\d{0,3})([a-zA-Z]{2})?$").unwrap(); for &rule in rules { @@ -536,313 +539,346 @@ impl EventFacade { && target_week_day.is_some() && date.weekday() == Weekday::from_short(target_week_day.unwrap().as_str()) { + // Only filters weekdays that don't match the rule new_dates.push(*date) } else if frequency == &RepeatPeriod::Weekly && target_week_day.is_some() { - let parsed_target_week_day = - Weekday::from_short(target_week_day.unwrap().as_str()); - let mut interval_start = *date; - while interval_start.date().weekday() != week_start { - interval_start = interval_start.sub(Duration::days(1)); - } + self.expand_by_day_rules_for_weekly_events( + &valid_months, + week_start, + &mut new_dates, + date, + target_week_day, + ) + } else if frequency == &RepeatPeriod::Monthly && target_week_day.is_some() { + self.expand_by_day_rule_for_monthly_events( + &valid_months, + &valid_month_days, + &mut new_dates, + date, + target_week_day, + leading_value, + ); + } else if frequency == &RepeatPeriod::Annually { + self.expand_by_day_rule_for_annually_events( + week_start, + has_week_no, + &mut new_dates, + date, + target_week_day, + leading_value, + ) + } + } + } - let mut new_date = interval_start; - while new_date.weekday() != parsed_target_week_day { - new_date = new_date.add(Duration::days(1)) - } + if frequency == &RepeatPeriod::Annually { + return new_dates + .iter() + .filter(|date| self.is_valid_day_in_year(**date, valid_year_days.clone())) + .copied() + .collect(); + } - /* - if interval_start.assume_utc().unix_timestamp() - < date.assume_utc().unix_timestamp() - { - interval_start = interval_start.add(Duration::weeks(1)) - } - */ - let next_event = date.add(Duration::weeks(1)).assume_utc().unix_timestamp(); - - if new_date.assume_utc().unix_timestamp() - >= interval_start - .add(Duration::weeks(1)) - .assume_utc() - .unix_timestamp() - { - continue; - } else if new_date.assume_utc().unix_timestamp() - < date.assume_utc().unix_timestamp() - { - new_date = new_date.add(Duration::weeks(1)); - } + new_dates + } - if (new_date.assume_utc().unix_timestamp() >= next_event) - || (week_start != Weekday::Monday // We have WKST - && new_date.assume_utc().unix_timestamp() - >= interval_start - .add(Duration::weeks(1)) - .assume_utc() - .unix_timestamp()) - { - continue; - } + fn expand_by_day_rule_for_annually_events( + &self, + week_start: Weekday, + has_week_no: bool, + new_dates: &mut Vec, + date: &PrimitiveDateTime, + target_week_day: Option, + leading_value: Option, + ) { + let week_change = leading_value + .map_or(Ok(0), |m| m.as_str().parse::()) + .unwrap_or_default(); + + if has_week_no && week_change != 0 { + println!("Invalid repeat rule, can't use BYWEEKNO with Week Offset on BYDAY"); + return; + } - if valid_months.is_empty() - || valid_months.contains(&new_date.month().to_number()) - { - new_dates.push(new_date) + if week_change != 0 && !has_week_no { + let mut new_date: PrimitiveDateTime; + + // If there's no target week day, we just set the day of the year. + if target_week_day.is_none() { + if week_change > 0 { + new_date = date + .replace_day(1) + .unwrap() + .replace_month(Month::January) + .unwrap() + .add(Duration::days(week_change - 1)) + } else { + new_date = date + .replace_month(Month::December) + .unwrap() + .replace_day(31) + .unwrap() + .sub(Duration::days(week_change.abs() - 1)) + } + } else { + let parsed_weekday = Weekday::from_short(target_week_day.unwrap().as_str()); + + // There's a target week day so the occurrenceNumber indicates the week of the year + // that the event will happen + if week_change > 0 { + new_date = date + .replace_day(1) + .unwrap() + .replace_month(Month::January) + .unwrap() + .add(Duration::weeks(week_change - 1)); + + while new_date.weekday() != parsed_weekday { + new_date = new_date.add(Duration::days(1)); } - } else if frequency == &RepeatPeriod::Monthly && target_week_day.is_some() { - let mut allowed_days: Vec = Vec::new(); + } else { + new_date = date + .replace_month(Month::December) + .unwrap() + .replace_day(31) + .unwrap() + .sub(Duration::weeks(week_change.abs() - 1)); + while new_date.weekday() != parsed_weekday { + new_date = new_date.sub(Duration::days(1)); + } + } + } - let week_change = leading_value - .map_or(Ok(0), |m| m.as_str().parse::()) - .unwrap_or_default(); + if new_date.assume_utc().unix_timestamp() < date.assume_utc().unix_timestamp() { + if let Ok(dt) = new_date.replace_year(new_date.year() + 1) { + new_dates.push(dt) + } + } else { + new_dates.push(new_date) + } + } else if has_week_no { + // There's no week number or occurrenceNumber, so it will happen on all + // weekdays that are the same as targetWeekDay - let base_date = date.replace_day(1).unwrap(); - let stop_condition = - PrimitiveDateTime::new(base_date.date().add_month(), base_date.time()); + if target_week_day.is_none() { + return; + } - for allowed_day in &valid_month_days { - if allowed_day.is_positive() { - allowed_days.push(allowed_day.unsigned_abs()); - continue; - } + let parsed_weekday = Weekday::from_short(target_week_day.unwrap().as_str()); + let new_date = date.replace_date( + Date::from_iso_week_date(date.year(), date.iso_week(), parsed_weekday).unwrap(), + ); - let day = - base_date.month().length(date.year()) - allowed_day.unsigned_abs() + 1; - allowed_days.push(day); - } + let interval_start = date.replace_date( + Date::from_iso_week_date(date.year(), date.iso_week(), week_start).unwrap(), + ); + let week_ahead = interval_start.add(Duration::days(7)); - let is_allowed_in_month_day = |day: u8| -> bool { - if allowed_days.is_empty() { - return true; - } + if new_date.assume_utc().unix_timestamp() > week_ahead.assume_utc().unix_timestamp() + || new_date.assume_utc().unix_timestamp() < date.assume_utc().unix_timestamp() + { + } else if new_date.assume_utc().unix_timestamp() + < interval_start.assume_utc().unix_timestamp() + { + new_dates.push(interval_start.add(Duration::days(7))); + } else { + new_dates.push(new_date); + } + } else { + if target_week_day.is_none() { + return; + } - allowed_days.contains(&day) - }; + let day_one = date.replace_day(1).unwrap(); + let parsed_weekday = Weekday::from_short(target_week_day.unwrap().as_str()); - let parsed_weekday = Weekday::from_short(target_week_day.unwrap().as_str()); - - if week_change != 0 { - let mut new_date = base_date; - if week_change.is_negative() { - new_date = new_date - .replace_day(new_date.month().length(new_date.year())) - .unwrap(); - new_date = new_date.replace_date( - Date::from_iso_week_date( - new_date.year(), - new_date.iso_week(), - parsed_weekday, - ) - .unwrap(), - ); - - let new_week = new_date.iso_week() - week_change.unsigned_abs() + 1; - new_date = new_date.replace_date( - Date::from_iso_week_date( - new_date.year(), - new_week, - new_date.weekday(), - ) - .unwrap(), - ) - } else { - while new_date.weekday() != parsed_weekday { - new_date = new_date.add(Duration::days(1)); - } - - new_date = new_date.replace_date( - Date::from_iso_week_date( - new_date.year(), - new_date.iso_week() + week_change.unsigned_abs() - 1, - new_date.weekday(), - ) - .unwrap(), - ) - } - - if new_date.assume_utc().unix_timestamp() - >= base_date.assume_utc().unix_timestamp() - && new_date.assume_utc().unix_timestamp() - <= stop_condition.assume_utc().unix_timestamp() - && is_allowed_in_month_day(new_date.day()) - { - new_dates.push(new_date) - } - } else { - let mut current_date = base_date; - while current_date.assume_utc().unix_timestamp() - < stop_condition.assume_utc().unix_timestamp() - { - let new_date = current_date.replace_date( - Date::from_iso_week_date( - current_date.year(), - current_date.iso_week(), - parsed_weekday, - ) - .unwrap(), - ); - if new_date.assume_utc().unix_timestamp() - >= base_date.assume_utc().unix_timestamp() - && is_allowed_in_month_day(new_date.day()) - && ((!valid_months.is_empty() - && valid_months.contains(&new_date.month().to_number())) - || valid_months.is_empty()) - { - new_dates.push(new_date) - } - - current_date = new_date.add(Duration::days(7)); - } - } - } else if frequency == &RepeatPeriod::Annually { - let week_change = leading_value - .map_or(Ok(0), |m| m.as_str().parse::()) - .unwrap_or_default(); - - if has_week_no && week_change != 0 { - println!( - "Invalid repeat rule, can't use BYWEEKNO with Week Offset on BYDAY" - ); - continue; - } + let Ok(stop_date) = Date::from_calendar_date(date.year() + 1, date.month(), date.day()) + else { + return; + }; - if week_change != 0 && !has_week_no { - let mut new_date: PrimitiveDateTime; - - if target_week_day.is_none() { - if week_change > 0 { - new_date = date - .replace_day(1) - .unwrap() - .replace_month(Month::January) - .unwrap() - .add(Duration::days(week_change - 1)) - } else { - new_date = date - .replace_month(Month::December) - .unwrap() - .replace_day(31) - .unwrap() - .sub(Duration::days(week_change.abs() - 1)) - } - } else { - let parsed_weekday = - Weekday::from_short(target_week_day.unwrap().as_str()); - - if week_change > 0 { - new_date = date - .replace_day(1) - .unwrap() - .replace_month(Month::January) - .unwrap() - .add(Duration::weeks(week_change - 1)); - - while new_date.weekday() != parsed_weekday { - new_date = new_date.add(Duration::days(1)); - } - } else { - new_date = date - .replace_month(Month::December) - .unwrap() - .replace_day(31) - .unwrap() - .sub(Duration::weeks(week_change.abs() - 1)); - while new_date.weekday() != parsed_weekday { - new_date = new_date.sub(Duration::days(1)); - } - } - } - - if new_date.assume_utc().unix_timestamp() - < date.assume_utc().unix_timestamp() - { - match new_date.replace_year(new_date.year() + 1) { - Ok(dt) => new_dates.push(dt), - _ => continue, - } - } else { - new_dates.push(new_date) - } - } else if has_week_no { - if target_week_day.is_none() { - continue; - } - - let parsed_weekday = Weekday::from_short(target_week_day.unwrap().as_str()); - let new_date = date.replace_date( - Date::from_iso_week_date(date.year(), date.iso_week(), parsed_weekday) - .unwrap(), - ); - - let interval_start = date.replace_date( - Date::from_iso_week_date(date.year(), date.iso_week(), week_start) - .unwrap(), - ); - let week_ahead = interval_start.add(Duration::days(7)); - - if new_date.assume_utc().unix_timestamp() - > week_ahead.assume_utc().unix_timestamp() - || new_date.assume_utc().unix_timestamp() - < date.assume_utc().unix_timestamp() - { - } else if new_date.assume_utc().unix_timestamp() - < interval_start.assume_utc().unix_timestamp() - { - new_dates.push(interval_start.add(Duration::days(7))); - } else { - new_dates.push(new_date); - } - } else { - if target_week_day.is_none() { - continue; - } - - let day_one = date.replace_day(1).unwrap(); - let parsed_weekday = Weekday::from_short(target_week_day.unwrap().as_str()); - - let Ok(stop_date) = - Date::from_calendar_date(date.year() + 1, date.month(), date.day()) - else { - continue; - }; - - let stop_condition = date.replace_date(stop_date); - let mut current_date = date.replace_date( - Date::from_iso_week_date( - date.year(), - day_one.iso_week(), - parsed_weekday, - ) - .unwrap(), - ); - - if current_date.assume_utc().unix_timestamp() - >= day_one.assume_utc().unix_timestamp() - { - new_dates.push(current_date); - } - - current_date = current_date.add(Duration::days(7)); - - while current_date.assume_utc().unix_timestamp() - < stop_condition.assume_utc().unix_timestamp() - { - new_dates.push(current_date); - current_date = current_date.add(Duration::days(7)); - } - } + let stop_condition = date.replace_date(stop_date); + let mut current_date = date.replace_date( + Date::from_iso_week_date(date.year(), day_one.iso_week(), parsed_weekday).unwrap(), + ); + + if current_date.assume_utc().unix_timestamp() >= day_one.assume_utc().unix_timestamp() { + new_dates.push(current_date); + } + + current_date = current_date.add(Duration::days(7)); + + while current_date.assume_utc().unix_timestamp() + < stop_condition.assume_utc().unix_timestamp() + { + new_dates.push(current_date); + current_date = current_date.add(Duration::days(7)); + } + } + } + + fn expand_by_day_rule_for_monthly_events( + &self, + valid_months: &[u8], + valid_month_days: &Vec, + new_dates: &mut Vec, + date: &PrimitiveDateTime, + target_week_day: Option, + leading_value: Option, + ) { + let mut allowed_days: Vec = Vec::new(); + + let week_change = leading_value + .map_or(Ok(0), |m| m.as_str().parse::()) + .unwrap_or_default(); + + let base_date = date.replace_day(1).unwrap(); + let stop_condition = PrimitiveDateTime::new(base_date.date().add_month(), base_date.time()); + + // Calculate allowed days parsing negative values + // to valid days in the month. e.g -1 to 31 in JAN + for allowed_day in valid_month_days { + if allowed_day.is_positive() { + allowed_days.push(allowed_day.unsigned_abs()); + continue; + } + + let day = base_date.month().length(date.year()) - allowed_day.unsigned_abs() + 1; + allowed_days.push(day); + } + + // Simply checks if there's a list with allowed day and check if it includes a given day + let is_allowed_in_month_day = |day: u8| -> bool { + if allowed_days.is_empty() { + return true; + } + + allowed_days.contains(&day) + }; + + let parsed_weekday = Weekday::from_short(target_week_day.unwrap().as_str()); + + // If there's a leading value in the rule we have to change the week. + // e.g. 2TH means second thursday, consequently, second week of the month + if week_change != 0 { + let mut new_date = base_date; + if week_change.is_negative() { + new_date = new_date + .replace_day(new_date.month().length(new_date.year())) + .unwrap(); + new_date = new_date.replace_date( + Date::from_iso_week_date(new_date.year(), new_date.iso_week(), parsed_weekday) + .unwrap(), + ); + + let new_week = new_date.iso_week() - week_change.unsigned_abs() + 1; + new_date = new_date.replace_date( + Date::from_iso_week_date(new_date.year(), new_week, new_date.weekday()) + .unwrap(), + ) + } else { + while new_date.weekday() != parsed_weekday { + new_date = new_date.add(Duration::days(1)); + } + + new_date = new_date.replace_date( + Date::from_iso_week_date( + new_date.year(), + new_date.iso_week() + week_change.unsigned_abs() - 1, + new_date.weekday(), + ) + .unwrap(), + ) + } + + if new_date.assume_utc().unix_timestamp() >= base_date.assume_utc().unix_timestamp() + && new_date.assume_utc().unix_timestamp() + <= stop_condition.assume_utc().unix_timestamp() + && is_allowed_in_month_day(new_date.day()) + { + new_dates.push(new_date) + } + } else { + // If there's no week change, just iterate to the target day + let mut current_date = base_date; + while current_date.assume_utc().unix_timestamp() + < stop_condition.assume_utc().unix_timestamp() + { + let new_date = current_date.replace_date( + Date::from_iso_week_date( + current_date.year(), + current_date.iso_week(), + parsed_weekday, + ) + .unwrap(), + ); + if new_date.assume_utc().unix_timestamp() >= base_date.assume_utc().unix_timestamp() + && is_allowed_in_month_day(new_date.day()) + && ((!valid_months.is_empty() + && valid_months.contains(&new_date.month().to_number())) + || valid_months.is_empty()) + { + new_dates.push(new_date) } + + current_date = new_date.add(Duration::days(7)); } } + } - if frequency == &RepeatPeriod::Annually { - return new_dates - .iter() - .filter(|date| self.is_valid_day_in_year(**date, valid_year_days.clone())) - .copied() - .collect(); + fn expand_by_day_rules_for_weekly_events( + &self, + valid_months: &[u8], + week_start: Weekday, + new_dates: &mut Vec, + date: &PrimitiveDateTime, + target_week_day: Option, + ) { + let parsed_target_week_day = Weekday::from_short(target_week_day.unwrap().as_str()); + + // Go back to week start, so we don't miss any events + let mut interval_start = *date; + while interval_start.date().weekday() != week_start { + interval_start = interval_start.sub(Duration::days(1)); } - new_dates + // Move forward until we reach the target day + let mut new_date = interval_start; + while new_date.weekday() != parsed_target_week_day { + new_date = new_date.add(Duration::days(1)) + } + + // Calculate next event to avoid creating events too ahead in the future + let next_event = date.add(Duration::weeks(1)).assume_utc().unix_timestamp(); + + if new_date.assume_utc().unix_timestamp() + >= interval_start + .add(Duration::weeks(1)) + .assume_utc() + .unix_timestamp() + { + // The event is actually next week, so discard + return; + } else if new_date.assume_utc().unix_timestamp() < date.assume_utc().unix_timestamp() { + // Event is behind progenitor, go forward one week + new_date = new_date.add(Duration::weeks(1)); + } + + if (new_date.assume_utc().unix_timestamp() >= next_event) + || (week_start != Weekday::Monday // We have WKST + && new_date.assume_utc().unix_timestamp() + >= interval_start + .add(Duration::weeks(1)) + .assume_utc() + .unix_timestamp()) + { + // Or we created an event after the first event or within the next week + return; + } + + if valid_months.is_empty() || valid_months.contains(&new_date.month().to_number()) { + new_dates.push(new_date) + } } fn get_valid_days_in_year(&self, year: i32, valid_year_days: &Vec) -> Vec {