From cf4a3895f0efd96c2936a928a042c1ea597cbe51 Mon Sep 17 00:00:00 2001 From: Martin Schulze Date: Thu, 8 Jan 2026 15:11:16 +0100 Subject: [PATCH 1/2] First working version of recurring tasks Fixes #34 --- src/components/AppSidebar/RecurrenceItem.vue | 374 +++++++++++++ src/components/Repeat/Repeat.vue | 510 +++++++++++++++++ src/components/Repeat/RepeatEndRepeat.vue | 209 +++++++ .../Repeat/RepeatExceptionWarning.vue | 18 + .../Repeat/RepeatFirstLastSelect.vue | 72 +++ src/components/Repeat/RepeatForkWarning.vue | 19 + src/components/Repeat/RepeatFreqInterval.vue | 91 +++ .../Repeat/RepeatFreqMonthlyOptions.vue | 168 ++++++ src/components/Repeat/RepeatFreqSelect.vue | 73 +++ .../Repeat/RepeatFreqWeeklyOptions.vue | 89 +++ .../Repeat/RepeatFreqYearlyOptions.vue | 212 +++++++ src/components/Repeat/RepeatOnTheSelect.vue | 96 ++++ src/components/Repeat/RepeatSummary.vue | 52 ++ .../Repeat/RepeatUnsupportedWarning.vue | 18 + src/components/TaskBody.vue | 12 +- src/models/recurrenceRule.js | 518 ++++++++++++++++++ src/models/task.js | 121 ++++ src/store/tasks.js | 274 ++++++++- src/utils/recurrence.js | 55 ++ src/views/AppSidebar.vue | 5 + tests/assets/loadAsset.js | 95 ++++ tests/javascript/unit/models/task.spec.js | 160 ++++++ 22 files changed, 3233 insertions(+), 8 deletions(-) create mode 100644 src/components/AppSidebar/RecurrenceItem.vue create mode 100644 src/components/Repeat/Repeat.vue create mode 100644 src/components/Repeat/RepeatEndRepeat.vue create mode 100644 src/components/Repeat/RepeatExceptionWarning.vue create mode 100644 src/components/Repeat/RepeatFirstLastSelect.vue create mode 100644 src/components/Repeat/RepeatForkWarning.vue create mode 100644 src/components/Repeat/RepeatFreqInterval.vue create mode 100644 src/components/Repeat/RepeatFreqMonthlyOptions.vue create mode 100644 src/components/Repeat/RepeatFreqSelect.vue create mode 100644 src/components/Repeat/RepeatFreqWeeklyOptions.vue create mode 100644 src/components/Repeat/RepeatFreqYearlyOptions.vue create mode 100644 src/components/Repeat/RepeatOnTheSelect.vue create mode 100644 src/components/Repeat/RepeatSummary.vue create mode 100644 src/components/Repeat/RepeatUnsupportedWarning.vue create mode 100644 src/models/recurrenceRule.js create mode 100644 src/utils/recurrence.js diff --git a/src/components/AppSidebar/RecurrenceItem.vue b/src/components/AppSidebar/RecurrenceItem.vue new file mode 100644 index 000000000..87177642d --- /dev/null +++ b/src/components/AppSidebar/RecurrenceItem.vue @@ -0,0 +1,374 @@ + + + + + + + diff --git a/src/components/Repeat/Repeat.vue b/src/components/Repeat/Repeat.vue new file mode 100644 index 000000000..44a9d7b1d --- /dev/null +++ b/src/components/Repeat/Repeat.vue @@ -0,0 +1,510 @@ + + + + + + + diff --git a/src/components/Repeat/RepeatEndRepeat.vue b/src/components/Repeat/RepeatEndRepeat.vue new file mode 100644 index 000000000..7d66b4978 --- /dev/null +++ b/src/components/Repeat/RepeatEndRepeat.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/src/components/Repeat/RepeatExceptionWarning.vue b/src/components/Repeat/RepeatExceptionWarning.vue new file mode 100644 index 000000000..56e47d8f2 --- /dev/null +++ b/src/components/Repeat/RepeatExceptionWarning.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/src/components/Repeat/RepeatFirstLastSelect.vue b/src/components/Repeat/RepeatFirstLastSelect.vue new file mode 100644 index 000000000..df7a8ce72 --- /dev/null +++ b/src/components/Repeat/RepeatFirstLastSelect.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/src/components/Repeat/RepeatForkWarning.vue b/src/components/Repeat/RepeatForkWarning.vue new file mode 100644 index 000000000..caad66143 --- /dev/null +++ b/src/components/Repeat/RepeatForkWarning.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/src/components/Repeat/RepeatFreqInterval.vue b/src/components/Repeat/RepeatFreqInterval.vue new file mode 100644 index 000000000..183d9044d --- /dev/null +++ b/src/components/Repeat/RepeatFreqInterval.vue @@ -0,0 +1,91 @@ + + + + + + + diff --git a/src/components/Repeat/RepeatFreqMonthlyOptions.vue b/src/components/Repeat/RepeatFreqMonthlyOptions.vue new file mode 100644 index 000000000..b09f85c75 --- /dev/null +++ b/src/components/Repeat/RepeatFreqMonthlyOptions.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/components/Repeat/RepeatFreqSelect.vue b/src/components/Repeat/RepeatFreqSelect.vue new file mode 100644 index 000000000..e0196125f --- /dev/null +++ b/src/components/Repeat/RepeatFreqSelect.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/src/components/Repeat/RepeatFreqWeeklyOptions.vue b/src/components/Repeat/RepeatFreqWeeklyOptions.vue new file mode 100644 index 000000000..80e645423 --- /dev/null +++ b/src/components/Repeat/RepeatFreqWeeklyOptions.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/components/Repeat/RepeatFreqYearlyOptions.vue b/src/components/Repeat/RepeatFreqYearlyOptions.vue new file mode 100644 index 000000000..eb2e89510 --- /dev/null +++ b/src/components/Repeat/RepeatFreqYearlyOptions.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/src/components/Repeat/RepeatOnTheSelect.vue b/src/components/Repeat/RepeatOnTheSelect.vue new file mode 100644 index 000000000..1f61ec8b5 --- /dev/null +++ b/src/components/Repeat/RepeatOnTheSelect.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/src/components/Repeat/RepeatSummary.vue b/src/components/Repeat/RepeatSummary.vue new file mode 100644 index 000000000..f64a0302e --- /dev/null +++ b/src/components/Repeat/RepeatSummary.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/src/components/Repeat/RepeatUnsupportedWarning.vue b/src/components/Repeat/RepeatUnsupportedWarning.vue new file mode 100644 index 000000000..e67310a2e --- /dev/null +++ b/src/components/Repeat/RepeatUnsupportedWarning.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/src/components/TaskBody.vue b/src/components/TaskBody.vue index 6b0848504..828e2f769 100644 --- a/src/components/TaskBody.vue +++ b/src/components/TaskBody.vue @@ -81,7 +81,7 @@ License along with this library. If not, see . :title="t('tasks', 'Task has a note')" @click="openAppSidebarTab($event, 'app-sidebar-tab-notes')" @dblclick.stop="openAppSidebarTab($event, 'app-sidebar-tab-notes', true)" /> -
+
{{ dueDateShort }} {{ dueDateLong }}
@@ -275,9 +275,10 @@ export default { }), dueDateShort() { + const taskDate = this.task.startMoment.isValid() ? this.task.startMoment : this.task.dueMoment if (!this.task.completed) { - return this.task.dueMoment.isValid() - ? this.task.dueMoment.calendar(null, { + return taskDate.isValid() + ? taskDate.calendar(null, { // TRANSLATORS This is a string for moment.js. The square brackets escape the string from moment.js. Please translate the string and keep the brackets. lastDay: t('tasks', '[Yesterday]'), // TRANSLATORS This is a string for moment.js. The square brackets escape the string from moment.js. Please translate the string and keep the brackets. @@ -313,9 +314,10 @@ export default { if (this.task.allDay) { return this.dueDateShort } + const taskDate = this.task.startMoment.isValid() ? this.task.startMoment : this.task.dueMoment if (!this.task.completed) { - return this.task.dueMoment.isValid() - ? this.task.dueMoment.calendar(null, { + return taskDate.isValid() + ? taskDate.calendar(null, { // TRANSLATORS This is a string for moment.js. The square brackets escape the string from moment.js. Please translate the string and keep the brackets. lastDay: t('tasks', '[Yesterday at] LT'), // TRANSLATORS This is a string for moment.js. The square brackets escape the string from moment.js. Please translate the string and keep the brackets. diff --git a/src/models/recurrenceRule.js b/src/models/recurrenceRule.js new file mode 100644 index 000000000..1a0d893ca --- /dev/null +++ b/src/models/recurrenceRule.js @@ -0,0 +1,518 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { getWeekDayFromDate } from '../utils/recurrence.js' + +/** + * Converts a DateTimeValue to a JavaScript Date object + * + * @param {object} dateTimeValue The DateTimeValue object + * @return {Date} + */ +function getDateFromDateTimeValue(dateTimeValue) { + return new Date( + dateTimeValue.year, + dateTimeValue.month - 1, + dateTimeValue.day, + dateTimeValue.hour, + dateTimeValue.minute, + 0, + 0, + ) +} + +/** + * Creates a complete recurrence-rule-object based on given props + * + * @param {object} props Recurrence-rule-object-props already provided + * @return {object} + */ +function getDefaultRecurrenceRuleObject(props = {}) { + return { // The calendar-js recurrence-rule value + recurrenceRuleValue: null, + // The frequency of the recurrence-rule (DAILY, WEEKLY, ...) + frequency: 'NONE', + // The interval of the recurrence-rule, must be a positive integer + interval: 1, + // Positive integer if recurrence-rule limited by count, null otherwise + count: null, + // Date if recurrence-rule limited by date, null otherwise + // We do not store a timezone here, since we only care about the date part + until: null, + // List of byDay components to limit/expand the recurrence-rule + byDay: [], + // List of byMonth components to limit/expand the recurrence-rule + byMonth: [], + // List of byMonthDay components to limit/expand the recurrence-rule + byMonthDay: [], + // A position to limit the recurrence-rule (e.g. -1 for last Friday) + bySetPosition: null, + // Whether or not the rule is not supported for editing + isUnsupported: false, + ...props, + } +} + +/** + * Maps a calendar-js recurrence-rule-value to an recurrence-rule-object + * + * @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value + * @param {DateTimeValue} baseDate The base-date used to fill unset values + * @return {object} + */ +function mapRecurrenceRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) { + switch (recurrenceRuleValue.frequency) { + case 'DAILY': + return mapDailyRuleValueToRecurrenceRuleObject(recurrenceRuleValue) + + case 'WEEKLY': + return mapWeeklyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) + + case 'MONTHLY': + return mapMonthlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) + + case 'YEARLY': + return mapYearlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) + + default: // SECONDLY, MINUTELY, HOURLY + return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, { + isUnsupported: true, + }) + } +} + +const FORBIDDEN_BY_PARTS_DAILY = [ + 'BYSECOND', + 'BYMINUTE', + 'BYHOUR', + 'BYDAY', + 'BYMONTHDAY', + 'BYYEARDAY', + 'BYWEEKNO', + 'BYMONTH', + 'BYSETPOS', +] +const FORBIDDEN_BY_PARTS_WEEKLY = [ + 'BYSECOND', + 'BYMINUTE', + 'BYHOUR', + 'BYMONTHDAY', + 'BYYEARDAY', + 'BYWEEKNO', + 'BYMONTH', + 'BYSETPOS', +] +const FORBIDDEN_BY_PARTS_MONTHLY = [ + 'BYSECOND', + 'BYMINUTE', + 'BYHOUR', + 'BYYEARDAY', + 'BYWEEKNO', + 'BYMONTH', +] +const FORBIDDEN_BY_PARTS_YEARLY = [ + 'BYSECOND', + 'BYMINUTE', + 'BYHOUR', + 'BYYEARDAY', + 'BYWEEKNO', +] + +const SUPPORTED_BY_DAY_WEEKLY = [ + 'SU', + 'MO', + 'TU', + 'WE', + 'TH', + 'FR', + 'SA', +] + +const SUPPORTED_BY_MONTHDAY_MONTHLY = [...Array(31).keys().map((i) => i + 1)] + +const SUPPORTED_BY_MONTH_YEARLY = [...Array(12).keys().map((i) => i + 1)] + +/** + * Maps a daily calendar-js recurrence-rule-value to an recurrence-rule-object + * + * @param recurrenceRuleValue + * @return {object} + */ +function mapDailyRuleValueToRecurrenceRuleObject(recurrenceRuleValue) { + /** + * We only support DAILY rules without any by-parts in the editor. + * If the recurrence-rule contains any by-parts, mark it as unsupported. + */ + const isUnsupported = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_DAILY) + + return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, { + isUnsupported, + }) +} + +/** + * Maps a weekly calendar-js recurrence-rule-value to an recurrence-rule-object + * + * @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value + * @param {DateTimeValue} baseDate The base-date used to fill unset values + * @return {object} + */ +function mapWeeklyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) { + /** + * For WEEKLY recurrences, our editor only allows BYDAY + * + * As defined in RFC5545 3.3.10. Recurrence Rule: + * > Each BYDAY value can also be preceded by a positive (+n) or + * > negative (-n) integer. If present, this indicates the nth + * > occurrence of a specific day within the MONTHLY or YEARLY "RRULE". + * + * RFC 5545 specifies other components, which can be used along WEEKLY. + * Among them are BYMONTH and BYSETPOS. We don't support those. + */ + const containsUnsupportedByParts = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_WEEKLY) + const containsInvalidByDayPart = recurrenceRuleValue.getComponent('BYDAY') + .some((weekday) => !SUPPORTED_BY_DAY_WEEKLY.includes(weekday)) + + const isUnsupported = containsUnsupportedByParts || containsInvalidByDayPart + + const byDay = recurrenceRuleValue.getComponent('BYDAY') + .filter((weekday) => SUPPORTED_BY_DAY_WEEKLY.includes(weekday)) + + // If the BYDAY is empty, add the day that the task occurs in + // E.g. if the task is on a Wednesday, automatically set BYDAY:WE + if (byDay.length === 0) { + byDay.push(getWeekDayFromDate(baseDate.jsDate)) + } + + return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, { + byDay, + isUnsupported, + }) +} + +/** + * Maps a monthly calendar-js recurrence-rule-value to an recurrence-rule-object + * + * @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value + * @param {DateTimeValue} baseDate The base-date used to fill unset values + * @return {object} + */ +function mapMonthlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) { + /** + * We only supports BYMONTHDAY, BYDAY, BYSETPOS in order to expand the monthly rule. + * It supports either BYMONTHDAY or the combination of BYDAY and BYSETPOS. They have to be used exclusively + * and cannot be combined. + * + * We do not support other BY-parts like BYMONTH + * + * For monthly recurrence-rules, BYDAY components are allowed to be preceded by positive or negative integers. + * The Nextcloud-editor supports at most one BYDAY component with an integer. + * If it's presented with such a BYDAY component, it will internally be converted to BYDAY without integer and BYSETPOS. + * e.g. + * BYDAY=3WE => BYDAY=WE,BYSETPOS=3 + * + * BYSETPOS is limited to -2, -1, 1, 2, 3, 4, 5 + * Other values are not supported + * + * BYDAY is limited to "MO", "TU", "WE", "TH", "FR", "SA", "SU", + * "MO,TU,WE,TH,FR,SA,SU", "MO,TU,WE,TH,FR", "SA,SU" + * + * BYMONTHDAY is limited to "1", "2", ..., "31" + */ + let isUnsupported = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_MONTHLY) + + let byDay = [] + let bySetPosition = null + let byMonthDay = [] + + // This handles the first case, where we have a BYMONTHDAY rule + if (containsRecurrenceComponent(recurrenceRuleValue, ['BYMONTHDAY'])) { + // verify there is no BYDAY or BYSETPOS at the same time + if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY', 'BYSETPOS'])) { + isUnsupported = true + } + + const containsInvalidByMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY') + .some((monthDay) => !SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay)) + isUnsupported = isUnsupported || containsInvalidByMonthDay + + byMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY') + .filter((monthDay) => SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay)) + .map((monthDay) => monthDay) + + // This handles cases where we have both BYDAY and BYSETPOS + } else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY']) && containsRecurrenceComponent(recurrenceRuleValue, ['BYSETPOS'])) { + if (isAllowedByDay(recurrenceRuleValue.getComponent('BYDAY'))) { + byDay = recurrenceRuleValue.getComponent('BYDAY') + } else { + byDay = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] + isUnsupported = true + } + + const setPositionArray = recurrenceRuleValue.getComponent('BYSETPOS') + if (setPositionArray.length === 1 && isAllowedBySetPos(setPositionArray[0])) { + bySetPosition = setPositionArray[0] + } else { + bySetPosition = 1 + isUnsupported = true + } + + // This handles cases where we only have a BYDAY + } else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY'])) { + const byDayArray = recurrenceRuleValue.getComponent('BYDAY') + + if (byDayArray.length > 1) { + byMonthDay.push(baseDate.day) + isUnsupported = true + } else { + const firstElement = byDayArray[0] + + const match = /^(-?\d)([A-Z]{2})$/.exec(firstElement) + if (match) { + const matchedBySetPosition = match[1] + const matchedByDay = match[2] + + if (isAllowedBySetPos(matchedBySetPosition)) { + byDay = [matchedByDay] + bySetPosition = parseInt(matchedBySetPosition, 10) + } else { + byDay = [matchedByDay] + bySetPosition = 1 + isUnsupported = true + } + } else { + byMonthDay.push(baseDate.day) + isUnsupported = true + } + } + + // This is a fallback where we just default BYMONTHDAY to the start date of the event + } else { + byMonthDay.push(baseDate.day) + } + + return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, { + byDay, + bySetPosition, + byMonthDay, + isUnsupported, + }) +} + +/** + * Maps a yearly calendar-js recurrence-rule-value to an recurrence-rule-object + * + * @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value + * @param {DateTimeValue} baseDate The base-date used to fill unset values + * @return {object} + */ +function mapYearlyRuleValueToRecurrenceRuleObject(recurrenceRuleValue, baseDate) { + /** + * We only supports BYMONTH, BYDAY, BYSETPOS in order to expand the yearly rule. + * It supports a combination of them. + * + * We do not support other BY-parts. + * + * For yearly recurrence-rules, BYDAY components are allowed to be preceded by positive or negative integers. + * The Nextcloud-editor supports at most one BYDAY component with an integer. + * If it's presented with such a BYDAY component, it will internally be converted to BYDAY without integer and BYSETPOS. + * e.g. + * BYDAY=3WE => BYDAY=WE,BYSETPOS=3 + * + * BYSETPOS is limited to -2, -1, 1, 2, 3, 4, 5 + * Other values are not supported + * + * BYDAY is limited to "MO", "TU", "WE", "TH", "FR", "SA", "SU", + * "MO,TU,WE,TH,FR,SA,SU", "MO,TU,WE,TH,FR", "SA,SU" + */ + let isUnsupported = containsRecurrenceComponent(recurrenceRuleValue, FORBIDDEN_BY_PARTS_YEARLY) + + let byDay = [] + let bySetPosition = null + let byMonth = [] + let byMonthDay = [] + + if (containsRecurrenceComponent(recurrenceRuleValue, ['BYMONTH'])) { + // This handles the first case, where we have a BYMONTH rule + + const containsInvalidByMonth = recurrenceRuleValue.getComponent('BYMONTH') + .some((month) => !SUPPORTED_BY_MONTH_YEARLY.includes(month)) + isUnsupported = isUnsupported || containsInvalidByMonth + + byMonth = recurrenceRuleValue.getComponent('BYMONTH') + .filter((month) => SUPPORTED_BY_MONTH_YEARLY.includes(month)) + .map((month) => month) + } else { + // This is a fallback where we just default BYMONTH to the start date of the event + + byMonth.push(baseDate.month) + } + + if (containsRecurrenceComponent(recurrenceRuleValue, ['BYMONTHDAY'])) { + // This handles the first case, where we have a BYMONTHDAY rule + + // verify there is no BYDAY or BYSETPOS at the same time + if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY', 'BYSETPOS'])) { + isUnsupported = true + } + + const containsInvalidByMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY') + .some((monthDay) => !SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay)) + isUnsupported = isUnsupported || containsInvalidByMonthDay + + byMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY') + .filter((monthDay) => SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay)) + .map((monthDay) => monthDay) + } else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY']) && containsRecurrenceComponent(recurrenceRuleValue, ['BYSETPOS'])) { + // This handles cases where we have both BYDAY and BYSETPOS + + if (isAllowedByDay(recurrenceRuleValue.getComponent('BYDAY'))) { + byDay = recurrenceRuleValue.getComponent('BYDAY') + } else { + byDay = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] + isUnsupported = true + } + + const setPositionArray = recurrenceRuleValue.getComponent('BYSETPOS') + if (setPositionArray.length === 1 && isAllowedBySetPos(setPositionArray[0])) { + bySetPosition = setPositionArray[0] + } else { + bySetPosition = 1 + isUnsupported = true + } + } else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY'])) { + // This handles cases where we only have a BYDAY + + const byDayArray = recurrenceRuleValue.getComponent('BYDAY') + + if (byDayArray.length > 1) { + byMonthDay.push(baseDate.day) + isUnsupported = true + } else { + const firstElement = byDayArray[0] + + const match = /^(-?\d)([A-Z]{2})$/.exec(firstElement) + if (match) { + const matchedBySetPosition = match[1] + const matchedByDay = match[2] + + if (isAllowedBySetPos(matchedBySetPosition)) { + byDay = [matchedByDay] + bySetPosition = parseInt(matchedBySetPosition, 10) + } else { + byDay = [matchedByDay] + bySetPosition = 1 + isUnsupported = true + } + } else { + byMonthDay.push(baseDate.day) + isUnsupported = true + } + } + } else { + // This is a fallback where we just default BYMONTHDAY to the start date of the event + byMonthDay.push(baseDate.day) + } + + return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, { + byDay, + bySetPosition, + byMonth, + byMonthDay, + isUnsupported, + }) +} + +/** + * Checks if the given parameter is a supported BYDAY value + * + * @param {string[]} byDay The byDay component to check + * @return {boolean} + */ +function isAllowedByDay(byDay) { + return [ + 'MO', + 'TU', + 'WE', + 'TH', + 'FR', + 'SA', + 'SU', + 'FR,MO,SA,SU,TH,TU,WE', + 'FR,MO,TH,TU,WE', + 'SA,SU', + ].includes(byDay.slice().sort().join(',')) +} + +/** + * Checks if the given parameter is a supported BYSETPOS value + * + * @param {string} bySetPos The bySetPos component to check + * @return {boolean} + */ +function isAllowedBySetPos(bySetPos) { + return [ + '-2', + '-1', + '1', + '2', + '3', + '4', + '5', + ].includes(bySetPos.toString()) +} + +/** + * Checks if the recurrence-rule contains any of the given components + * + * @param {RecurValue} recurrenceRule The recurrence-rule value to check for the given components + * @param {string[]} components List of components to check for + * @return {boolean} + */ +function containsRecurrenceComponent(recurrenceRule, components) { + for (const component of components) { + const componentValue = recurrenceRule.getComponent(component) + if (componentValue.length > 0) { + return true + } + } + + return false +} + +/** + * Returns a full recurrence-rule-object with default values derived from recurrenceRuleValue + * and additional props + * + * @param {RecurValue} recurrenceRuleValue The recurrence-rule value to get default values from + * @param {object} props The properties to provide on top of default one + * @return {object} + */ +function getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, props) { + const isUnsupported = recurrenceRuleValue.count !== null && recurrenceRuleValue.until !== null + let isUnsupportedProps = {} + + if (isUnsupported) { + isUnsupportedProps = { + isUnsupported, + } + } + + return getDefaultRecurrenceRuleObject({ + recurrenceRuleValue, + frequency: recurrenceRuleValue.frequency, + interval: parseInt(recurrenceRuleValue.interval, 10) || 1, + count: recurrenceRuleValue.count, + until: recurrenceRuleValue.until + ? getDateFromDateTimeValue(recurrenceRuleValue.until) + : null, + ...props, + ...isUnsupportedProps, + }) +} + +export { + getDefaultRecurrenceRuleObject, + mapRecurrenceRuleValueToRecurrenceRuleObject, +} diff --git a/src/models/task.js b/src/models/task.js index e3652b25b..e8444be53 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -27,7 +27,12 @@ import moment from '@nextcloud/moment' import ICAL from 'ical.js' +import { RecurValue, DateTimeValue } from '@nextcloud/calendar-js' import { randomUUID } from '../utils/crypto.js' +import { + getDefaultRecurrenceRuleObject, + mapRecurrenceRuleValueToRecurrenceRuleObject, +} from './recurrenceRule.js' export default class Task { @@ -121,6 +126,40 @@ export default class Task { this._location = this.vtodo.getFirstPropertyValue('location') || '' this._customUrl = this.vtodo.getFirstPropertyValue('url') || '' + // Check for RECURRENCE-ID property (this is an exception instance) + this._recurrenceId = this.vtodo.getFirstPropertyValue('recurrence-id') + + // Extract recurrence-rule only if this is NOT an exception instance + if (this.vtodo && !this._recurrenceId) { + const recurrenceRules = this.vtodo.getAllProperties('rrule') + const firstRecurrenceRule = recurrenceRules?.[0] + + if (firstRecurrenceRule) { + try { + // Get the ICAL.Recur value and convert directly to RecurValue + const icalRecur = firstRecurrenceRule.getFirstValue() + const recurValue = RecurValue.fromICALJs(icalRecur) + + // Get reference date for the mapping function + const referenceDate = this._due || this._start + const jsDate = referenceDate?.toJSDate() || null + + this._recurrenceRule = mapRecurrenceRuleValueToRecurrenceRuleObject(recurValue, jsDate) + this._hasMultipleRRules = recurrenceRules.length > 1 + } catch (e) { + console.warn('Failed to parse recurrence rule:', e) + this._recurrenceRule = getDefaultRecurrenceRuleObject() + this._hasMultipleRRules = false + } + } + } + + // Set default if not already set + if (!this._recurrenceRule) { + this._recurrenceRule = getDefaultRecurrenceRuleObject() + this._hasMultipleRRules = false + } + let sortOrder = this.vtodo.getFirstPropertyValue('x-apple-sort-order') if (sortOrder === null) { sortOrder = this.getSortOrder() @@ -502,6 +541,12 @@ export default class Task { this.vtodo.updatePropertyWithValue('dtstart', start) } else { this.vtodo.removeProperty('dtstart') + // Remove RRULE when start date is removed (if no due date exists) + if (!this._due) { + this.vtodo.removeAllProperties('rrule') + this._recurrenceRule = getDefaultRecurrenceRuleObject() + this._hasMultipleRRules = false + } } this._start = start this._startMoment = moment(start, 'YYYYMMDDTHHmmssZ') @@ -528,6 +573,12 @@ export default class Task { this.vtodo.updatePropertyWithValue('due', due) } else { this.vtodo.removeProperty('due') + // Remove RRULE when due date is removed (if no start date exists) + if (!this._start) { + this.vtodo.removeAllProperties('rrule') + this._recurrenceRule = getDefaultRecurrenceRuleObject() + this._hasMultipleRRules = false + } } this._due = due this._dueMoment = moment(due, 'YYYYMMDDTHHmmssZ') @@ -771,6 +822,76 @@ export default class Task { this._sortOrder = sortOrder } + /** + * Gets the recurrence rule + * + * @return {object} The recurrence rule + */ + get recurrenceRule() { + return this._recurrenceRule + } + + /** + * Sets the recurrence rule + * + * @param {object} recurrenceRule The recurrence rule + */ + set recurrenceRule(recurrenceRule) { + // Auto-set DTSTART if no date exists (Thunderbird compatibility) + if (!this._start && !this._due && recurrenceRule.frequency !== 'NONE') { + const now = ICAL.Time.now() + now.isDate = true // Make it all-day by default + this.setStart(now) + } + this._recurrenceRule = recurrenceRule + } + + /** + * Checks if the task is recurring + * + * @return {boolean} True if recurring + */ + get isRecurring() { + return this._recurrenceRule && this._recurrenceRule.frequency !== 'NONE' + } + + /** + * Checks if the task has multiple recurrence rules + * + * @return {boolean} True if has multiple RRULEs + */ + get hasMultipleRRules() { + return this._hasMultipleRRules + } + + /** + * Returns the recurrence ID of this task (if it's an exception instance) + * + * @return {ICAL.Time|null} + */ + get recurrenceId() { + return this._recurrenceId + } + + /** + * Checks if this task is a recurring exception instance + * + * @return {boolean} + */ + get isRecurrenceException() { + return this._recurrenceId !== null + } + + /** + * Checks if a recurrence exception can be created for this task + * + * @return {boolean} True if exception can be created + */ + get canCreateRecurrenceException() { + // Can create exception if task is recurring and not completed + return this.isRecurring && !this.completed + } + /** * Construct the default value for the sort order * from the created date. diff --git a/src/store/tasks.js b/src/store/tasks.js index 4b33b7dc4..41d003e19 100644 --- a/src/store/tasks.js +++ b/src/store/tasks.js @@ -1144,6 +1144,12 @@ const actions = { await context.dispatch('setPercentComplete', { task: subTask, complete: 100 }) } })) + + // Handle recurring tasks + if (task.isRecurring && task.recurrenceRule.recurrenceRuleValue) { + await context.dispatch('handleRecurringTaskCompletion', { task }) + return // The handler will update the task + } } context.commit('setComplete', { task, complete }) context.dispatch('updateTask', task) @@ -1445,7 +1451,7 @@ const actions = { context.commit('setStart', { task, start: newStart }) context.dispatch('updateTask', task) } - // Adjust due date + // Adjust due date if start is not set but due is } else if (due.isValid()) { diff = due.diff(moment().startOf('day'), 'days') diff = diff < 0 ? 0 : diff @@ -1454,9 +1460,9 @@ const actions = { context.commit('setDue', { task, due: newDue }) context.dispatch('updateTask', task) } - // Set the due date to appropriate value + // Set the start date to appropriate value (make start the default) } else { - context.commit('setDue', { task, due: day }) + context.commit('setStart', { task, start: day }) context.dispatch('updateTask', task) } }, @@ -1535,6 +1541,268 @@ const actions = { } }, + /** + * Sets the recurrence rule for a task + * + * @param {object} context The store context + * @param {object} data Destructuring object + * @param {Task} data.task The task to update + * @param {object} data.recurrenceRule The recurrence rule data + */ + async setRecurrenceRule(context, { task, recurrenceRule }) { + // Import required classes + const { RecurValue } = await import('@nextcloud/calendar-js') + + // Create or update the RRULE property + const recurrenceValue = RecurValue.fromData({ + freq: recurrenceRule.frequency, + interval: recurrenceRule.interval || 1, + }) + + // Set end condition + if (recurrenceRule.until) { + const { DateTimeValue } = await import('@nextcloud/calendar-js') + recurrenceValue.until = DateTimeValue.fromJSDate(new Date(recurrenceRule.until), { zone: 'utc' }) + } else if (recurrenceRule.count) { + recurrenceValue.count = recurrenceRule.count + } + + // Convert RecurValue to ICAL.Recur + const icalRecur = recurrenceValue.toICALJs() + + // Add or update the RRULE property on the vtodo + task.vtodo.removeAllProperties('rrule') + task.vtodo.updatePropertyWithValue('rrule', icalRecur) + + // Update the task model + task._recurrenceRule = { + recurrenceRuleValue: recurrenceValue, + frequency: recurrenceRule.frequency, + interval: recurrenceRule.interval || 1, + count: recurrenceRule.count || null, + until: recurrenceRule.until || null, + byDay: [], + byMonth: [], + byMonthDay: [], + bySetPosition: null, + isUnsupported: false, + } + task._hasMultipleRRules = false + + await context.dispatch('updateTask', task) + }, + + /** + * Removes the recurrence rule from a task + * + * @param {object} context The store context + * @param {object} data Destructuring object + * @param {Task} data.task The task to update + */ + async removeRecurrenceRule(context, { task }) { + const { getDefaultRecurrenceRuleObject } = await import('../models/recurrenceRule.js') + + // Remove the RRULE property from vtodo + task.vtodo.removeAllProperties('rrule') + + // Reset the recurrence rule in the task model + task._recurrenceRule = getDefaultRecurrenceRuleObject() + task._hasMultipleRRules = false + + await context.dispatch('updateTask', task) + }, + + /** + * Handles completion of a recurring task by creating an exception instance with RECURRENCE-ID + * This is compatible with Thunderbird and other CalDAV clients + * + * @param {object} context The store context + * @param {object} data Destructuring object + * @param {Task} data.task The task that was completed + */ + async handleRecurringTaskCompletion(context, { task }) { + // Only process if task is recurring + if (!task.isRecurring || !task.recurrenceRule.recurrenceRuleValue) { + return + } + + try { + // Get the instance date (the current due/start date) + const instanceDate = task.due || task.start + + if (!instanceDate) { + // Task has no date - just mark it complete without creating exception + console.warn('Recurring task has no due/start date - cannot create exception or advance to next occurrence') + context.commit('setComplete', { task, complete: 100 }) + context.dispatch('updateTask', task) + return + } + + // Get the calendar + const calendar = task.calendar + if (!calendar) { + console.error('Cannot find calendar for task') + return + } + + // Create a new exception VTODO for this completed instance + const comp = new ICAL.Component('vcalendar') + comp.updatePropertyWithValue('prodid', '-//Nextcloud Tasks') + comp.updatePropertyWithValue('version', '2.0') + + const vtodo = new ICAL.Component('vtodo') + comp.addSubcomponent(vtodo) + + // Copy properties from master task + vtodo.updatePropertyWithValue('uid', task.uid) + vtodo.updatePropertyWithValue('summary', task.summary) + + if (task.description) { + vtodo.updatePropertyWithValue('description', task.description) + } + if (task.location) { + vtodo.updatePropertyWithValue('location', task.location) + } + if (task.url) { + vtodo.updatePropertyWithValue('url', task.url) + } + if (task.priority) { + vtodo.updatePropertyWithValue('priority', task.priority) + } + if (task.class) { + vtodo.updatePropertyWithValue('class', task.class) + } + + // Set completion properties + vtodo.updatePropertyWithValue('status', 'COMPLETED') + vtodo.updatePropertyWithValue('percent-complete', 100) + const completedDate = ICAL.Time.now() + vtodo.updatePropertyWithValue('completed', completedDate) + + // Set RECURRENCE-ID to mark this as an exception instance + // Use the ICAL.Time directly (don't convert to JS Date and back) + const recurrenceId = instanceDate.clone() + vtodo.updatePropertyWithValue('recurrence-id', recurrenceId) + + // Set the original due/start date for this instance + if (task.due) { + const dueTime = task.due.clone() + if (task.allDay) { + dueTime.isDate = true + } + vtodo.updatePropertyWithValue('due', dueTime) + } + if (task.start) { + const startTime = task.start.clone() + if (task.allDay) { + startTime.isDate = true + } + vtodo.updatePropertyWithValue('dtstart', startTime) + } + + // Set created and last-modified + vtodo.updatePropertyWithValue('created', ICAL.Time.now()) + vtodo.updatePropertyWithValue('last-modified', ICAL.Time.now()) + vtodo.updatePropertyWithValue('dtstamp', ICAL.Time.now()) + + // Create the exception task on the server + const vData = comp.toString() + await context.dispatch('appendTaskToCalendar', { + calendar, + taskData: vData, + }) + + // Reload the task to get fresh ETag after exception was created + const freshTask = context.getters.getTaskByUri(task.uri) + if (!freshTask) { + console.error('Cannot find task after creating exception') + return + } + + // Now update the master task to show next occurrence + if (!freshTask.recurrenceRule?.recurrenceRuleValue) { + console.warn('Cannot advance recurring task: no recurrence rule value') + return + } + + // Convert RecurValue to ICAL.Recur to get the iterator + const icalRecur = freshTask.recurrenceRule.recurrenceRuleValue.toICALJs() + + // Create a floating time (no timezone) from instanceDate to avoid timezone issues + const startTime = new ICAL.Time({ + year: instanceDate.year, + month: instanceDate.month, + day: instanceDate.day, + hour: instanceDate.hour, + minute: instanceDate.minute, + second: instanceDate.second, + isDate: instanceDate.isDate, + }) + + const iterator = icalRecur.iterator(startTime) + + // Skip current occurrence + iterator.next() + + // Get next occurrence (ical.js iterator returns ICAL.Time directly, or null when done) + const nextOccurrence = iterator.next() + if (nextOccurrence) { + const nextDate = nextOccurrence.toJSDate() + const nextMoment = moment(nextDate) + + // Calculate offset between start and due if both are set + let startOffset = null + if (freshTask.start && freshTask.due) { + const currentStart = freshTask.startMoment + const currentDue = freshTask.dueMoment + if (currentStart.isValid() && currentDue.isValid()) { + startOffset = currentDue.diff(currentStart) + } + } + + // Update the appropriate date field + if (freshTask.due) { + context.commit('setDue', { + task: freshTask, + due: nextMoment, + allDay: freshTask.allDay + }) + } else if (freshTask.start) { + // If only start date exists, update that + context.commit('setStart', { + task: freshTask, + start: nextMoment, + allDay: freshTask.allDay + }) + } + + // Update start date if both dates were set, maintaining the offset + if (freshTask.due && startOffset !== null) { + const nextStart = nextMoment.clone().subtract(startOffset, 'ms') + context.commit('setStart', { + task: freshTask, + start: nextStart, + allDay: freshTask.allDay + }) + } + + // Reset completion status on master task + freshTask.setComplete(0) + freshTask.setCompleted(false) + freshTask.setStatus(null) + + // Save the updated master task + await context.dispatch('updateTask', freshTask) + } else { + // No more occurrences - keep task completed + // The exception will show as completed, master can be left as-is + } + } catch (error) { + console.error('Error handling recurring task completion:', error) + showError(t('tasks', 'Error processing recurring task')) + } + }, + /** * Moves a task to a new calendar or parent task * diff --git a/src/utils/recurrence.js b/src/utils/recurrence.js new file mode 100644 index 000000000..fbecc6d14 --- /dev/null +++ b/src/utils/recurrence.js @@ -0,0 +1,55 @@ +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Gets the ByDay and BySetPosition + * + * @param {Date} jsDate The date to get the weekday of + * @return {object} + */ +export function getBySetPositionAndBySetFromDate(jsDate) { + const byDay = getWeekDayFromDate(jsDate) + let bySetPosition = 1 + let dayOfMonth = jsDate.getDate() + for (; dayOfMonth > 7; dayOfMonth -= 7) { + bySetPosition++ + } + + return { + byDay, + bySetPosition, + } +} + +/** + * Gets the string-representation of the weekday of a given date + * + * @param {Date} jsDate The date to get the weekday of + * @return {string} + */ +export function getWeekDayFromDate(jsDate) { + // If no date provided, default to Monday + if (!jsDate) { + return 'MO' + } + switch (jsDate.getDay()) { + case 0: + return 'SU' + case 1: + return 'MO' + case 2: + return 'TU' + case 3: + return 'WE' + case 4: + return 'TH' + case 5: + return 'FR' + case 6: + return 'SA' + default: + throw TypeError('Invalid date-object given') + } +} diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue index ca93aff9e..d179537f9 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -66,6 +66,9 @@ License along with this library. If not, see . :read-only="readOnly" :property-string="t('tasks', 'All day')" @set-checked="toggleAllDay(task)" /> + { 'use strict' + + it('RecurValue should be available', () => { + expect(RecurValue).toBeDefined() + expect(typeof RecurValue.fromData).toEqual('function') + + // Test creating a simple recurrence rule + const recurValue = RecurValue.fromData({ freq: 'DAILY', interval: 1 }) + expect(recurValue).toBeDefined() + expect(recurValue.frequency).toEqual('DAILY') + }) + + it('Should manually parse RRULE', () => { + const ics = loadICS('vcalendars/vcalendar-recurring-daily') + const jCal = ICAL.parse(ics) + const vCalendar = new ICAL.Component(jCal) + const vtodo = vCalendar.getFirstSubcomponent('vtodo') + const rruleProp = vtodo.getFirstProperty('rrule') + const icalRecur = rruleProp.getFirstValue() + + // Try to convert to RecurValue + const recurData = { + freq: icalRecur.freq, + interval: icalRecur.interval || 1, + } + const recurValue = RecurValue.fromData(recurData) + + expect(recurValue).toBeDefined() + expect(recurValue.frequency).toEqual('DAILY') + }) + + it('Should parse RRULE when task is created', () => { + // Log to see what's happening + const origWarn = console.warn + const warnings = [] + console.warn = (...args) => { warnings.push(args.join(' ')); origWarn(...args) } + + const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {}) + + console.warn = origWarn + + // Check if there were warnings + if (warnings.length > 0) { + console.log('Warnings during task creation:', warnings) + } + + // The task should have recurrence parsed + console.log('Task isRecurring:', task.isRecurring) + console.log('Task recurrenceRule:', JSON.stringify(task.recurrenceRule, null, 2)) + + expect(task.isRecurring).toEqual(true) + }) it('Should set status to "COMPLETED" on completion.', () => { const task = new Task(loadICS('vcalendars/vcalendar-default'), {}) @@ -279,4 +331,112 @@ describe('task', () => { task.customUrl = expected expect(task.customUrl).toEqual(expected) }) + + describe('Recurring Tasks', () => { + it('Should load RRULE from ICS file', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {}) + // Debug: check what we actually got + const rruleProp = task.vtodo.getFirstProperty('rrule') + console.log('RRULE property:', rruleProp) + if (rruleProp) { + const rruleValue = rruleProp.getFirstValue() + console.log('RRULE value:', rruleValue) + console.log('RRULE value.freq:', rruleValue.freq) + console.log('RRULE value.interval:', rruleValue.interval) + console.log('RRULE value.parts:', rruleValue.parts) + console.log('RRULE value.toString():', rruleValue.toString()) + } + console.log('Task due:', task.due) + console.log('Task _due:', task._due) + console.log('Task recurrenceRule:', task.recurrenceRule) + expect(rruleProp).toBeDefined() + }) + + it('Should parse daily recurrence rule', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {}) + expect(task.isRecurring).toEqual(true) + expect(task.recurrenceRule).toBeDefined() + expect(task.recurrenceRule.frequency).toEqual('DAILY') + expect(task.recurrenceRule.interval).toEqual(1) + }) + + it('Should parse weekly recurrence rule with interval', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-weekly'), {}) + expect(task.isRecurring).toEqual(true) + expect(task.recurrenceRule.frequency).toEqual('WEEKLY') + expect(task.recurrenceRule.interval).toEqual(2) + }) + + it('Should parse recurrence rule with count', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-with-count'), {}) + expect(task.isRecurring).toEqual(true) + expect(task.recurrenceRule.count).toEqual(5) + }) + + it('Should parse recurrence rule with until date', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-with-until'), {}) + expect(task.isRecurring).toEqual(true) + expect(task.recurrenceRule.until).toBeDefined() + }) + + it('Should not be recurring without RRULE', () => { + const task = new Task(loadICS('vcalendars/vcalendar-default'), {}) + expect(task.isRecurring).toEqual(false) + expect(task.recurrenceRule.frequency).toEqual('NONE') + }) + + it('Should have recurrenceRuleValue when recurring', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {}) + expect(task.isRecurring).toEqual(true) + expect(task.recurrenceRule.recurrenceRuleValue).toBeDefined() + expect(task.recurrenceRule.recurrenceRuleValue.frequency).toEqual('DAILY') + }) + + it('Should detect multiple RRULE properties', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {}) + expect(task.hasMultipleRRules).toEqual(false) + }) + + it('Should parse RRULE with DTSTART instead of DUE', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-dtstart'), {}) + expect(task.isRecurring).toEqual(true) + expect(task.recurrenceRule.frequency).toEqual('DAILY') + }) + + it('Should parse RRULE even without due or start date (Thunderbird compatibility)', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-no-date'), {}) + expect(task.isRecurring).toEqual(true) + expect(task.recurrenceRule.frequency).toEqual('DAILY') + }) + + it('Should be able to create recurrence exceptions when recurring', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurring-daily'), {}) + expect(task.canCreateRecurrenceException).toEqual(true) + }) + + it('Should not be able to create recurrence exceptions when not recurring', () => { + const task = new Task(loadICS('vcalendars/vcalendar-default'), {}) + expect(task.canCreateRecurrenceException).toEqual(false) + }) + }) + + describe('RECURRENCE-ID Exception Instances', () => { + it('Should parse RECURRENCE-ID property', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurrence-exception'), {}) + expect(task.isRecurrenceException).toEqual(true) + expect(task.recurrenceId).toBeTruthy() + }) + + it('Should NOT parse RRULE when RECURRENCE-ID is present', () => { + const task = new Task(loadICS('vcalendars/vcalendar-recurrence-exception'), {}) + expect(task.isRecurrenceException).toEqual(true) + expect(task.isRecurring).toEqual(false) + }) + + it('Should not have RECURRENCE-ID on normal tasks', () => { + const task = new Task(loadICS('vcalendars/vcalendar-default'), {}) + expect(task.isRecurrenceException).toEqual(false) + expect(task.recurrenceId).toBeNull() + }) + }) }) From 49e9a51ae65d8ed7d5f89361ba830fcc1ec6560d Mon Sep 17 00:00:00 2001 From: Martin Schulze Date: Fri, 9 Jan 2026 12:32:18 +0100 Subject: [PATCH 2/2] npm run lint -- --fix --- src/components/AppSidebar/RecurrenceItem.vue | 60 ++++++++++++------- src/components/Repeat/Repeat.vue | 26 +++----- src/components/Repeat/RepeatEndRepeat.vue | 30 ++++------ .../Repeat/RepeatFirstLastSelect.vue | 3 +- src/components/Repeat/RepeatFreqInterval.vue | 6 +- .../Repeat/RepeatFreqMonthlyOptions.vue | 19 +++--- src/components/Repeat/RepeatFreqSelect.vue | 3 +- .../Repeat/RepeatFreqWeeklyOptions.vue | 3 +- .../Repeat/RepeatFreqYearlyOptions.vue | 22 +++---- src/components/Repeat/RepeatOnTheSelect.vue | 3 +- src/models/task.js | 8 +-- src/store/tasks.js | 50 ++++++++-------- src/utils/recurrence.js | 32 +++++----- tests/javascript/unit/models/task.spec.js | 22 +++---- 14 files changed, 137 insertions(+), 150 deletions(-) diff --git a/src/components/AppSidebar/RecurrenceItem.vue b/src/components/AppSidebar/RecurrenceItem.vue index 87177642d..5eda6c2cb 100644 --- a/src/components/AppSidebar/RecurrenceItem.vue +++ b/src/components/AppSidebar/RecurrenceItem.vue @@ -54,11 +54,21 @@ License along with this library. If not, see .
@@ -74,9 +84,15 @@ License along with this library. If not, see .
@@ -160,25 +176,25 @@ export default { if (!this.isRecurring) { return t('tasks', 'Does not repeat') } - + const rule = this.task.recurrenceRule const frequency = rule.frequency.toLowerCase() const interval = rule.interval || 1 - + let summary = '' if (interval === 1) { summary = t('tasks', `Repeats ${frequency}`) } else { summary = t('tasks', `Every {interval} ${frequency}`, { interval }) } - + if (rule.until) { const date = new Date(rule.until).toLocaleDateString() summary += ' ' + t('tasks', 'until {date}', { date }) } else if (rule.count) { summary += ' ' + t('tasks', '{count} times', { count: rule.count }) } - + return summary }, }, @@ -194,24 +210,24 @@ export default { }, methods: { t, - + openEditor() { if (!this.readOnly) { this.loadFromRule(this.task.recurrenceRule) this.showEditor = true } }, - + closeEditor() { this.showEditor = false }, - + loadFromRule(rule) { this.localFrequency = rule.frequency || 'NONE' this.localInterval = rule.interval || 1 this.localUntil = rule.until ? new Date(rule.until) : null this.localCount = rule.count || null - + if (rule.until) { this.endType = 'until' } else if (rule.count) { @@ -220,35 +236,35 @@ export default { this.endType = 'never' } }, - + async saveRecurrence() { if (this.localFrequency === 'NONE') { await this.clearRecurrence() this.closeEditor() return } - + // Build the recurrence rule const recurrenceData = { frequency: this.localFrequency, interval: this.localInterval || 1, } - + if (this.endType === 'until' && this.localUntil) { recurrenceData.until = this.localUntil } else if (this.endType === 'count' && this.localCount) { recurrenceData.count = this.localCount } - + // Dispatch to store await this.$store.dispatch('setRecurrenceRule', { task: this.task, recurrenceRule: recurrenceData, }) - + this.closeEditor() }, - + async clearRecurrence() { await this.$store.dispatch('removeRecurrenceRule', { task: this.task, diff --git a/src/components/Repeat/Repeat.vue b/src/components/Repeat/Repeat.vue index 44a9d7b1d..81c1555f7 100644 --- a/src/components/Repeat/Repeat.vue +++ b/src/components/Repeat/Repeat.vue @@ -6,18 +6,15 @@