+
+ {{ $t('calendar', 'The recurrence definition of this event is not fully supported by Nextcloud. If you edit the recurrence-options, certain recurrences may be lost.') }}
+
+
+
+
+
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..242c793c4 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..83192f2ab 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..90bf3ec28
--- /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'), {})
task.complete = 100
@@ -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()
+ })
+ })
})