From d45db44b62a835c3fc948880e302f48beeaee253 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Tue, 13 Jun 2023 21:19:25 +0200 Subject: [PATCH 01/16] Show repeat icon for recurring tasks Signed-off-by: Sunik Kupfer --- src/components/TaskBody.vue | 1 + src/components/TaskCheckbox.vue | 7 +++++++ src/models/task.js | 6 ++++++ src/views/AppSidebar.vue | 9 +++++++++ 4 files changed, 23 insertions(+) diff --git a/src/components/TaskBody.vue b/src/components/TaskBody.vue index 70cd7f680..8db82e752 100644 --- a/src/components/TaskBody.vue +++ b/src/components/TaskBody.vue @@ -44,6 +44,7 @@ License along with this library. If not, see . class="no-nav" :cancelled="task.status === 'CANCELLED'" :read-only="readOnly" + :recurring="task.recurring" :priority-class="priorityClass" @toggle-completed="toggleCompleted(task)" /> diff --git a/src/components/TaskCheckbox.vue b/src/components/TaskCheckbox.vue index 7d86a9c62..b5b9e480d 100644 --- a/src/components/TaskCheckbox.vue +++ b/src/components/TaskCheckbox.vue @@ -33,6 +33,7 @@ License along with this library. If not, see . + @@ -45,6 +46,7 @@ import CheckboxBlank from 'vue-material-design-icons/CheckboxBlank.vue' import CheckboxBlankOffOutline from 'vue-material-design-icons/CheckboxBlankOffOutline.vue' import CheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue' import CheckboxOutline from 'vue-material-design-icons/CheckboxOutline.vue' +import Repeat from 'vue-material-design-icons/Repeat.vue' export default { components: { @@ -52,6 +54,7 @@ export default { CheckboxBlankOffOutline, CheckboxBlankOutline, CheckboxOutline, + Repeat, }, props: { completed: { @@ -66,6 +69,10 @@ export default { type: Boolean, required: true, }, + recurring: { + type: Boolean, + required: true + }, priorityClass: { type: String, default: '', diff --git a/src/models/task.js b/src/models/task.js index bc5a20da9..abc87324d 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -94,6 +94,8 @@ export default class Task { this._completedDate = this.vtodo.getFirstPropertyValue('completed') this._completedDateMoment = moment(this._completedDate, 'YYYYMMDDTHHmmssZ') this._completed = !!this._completedDate + const recur = this.vtodo.getFirstPropertyValue('rrule') + this._recurring = !!recur this._status = this.vtodo.getFirstPropertyValue('status') this._note = this.vtodo.getFirstPropertyValue('description') || '' this._related = this.getParent()?.getFirstValue() || null @@ -329,6 +331,10 @@ export default class Task { return this._completedDateMoment.clone() } + get recurring() { + return this._recurring + } + get status() { return this._status } diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue index 5fe9bc0b7..3cab5c1aa 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -138,6 +138,7 @@ License along with this library. If not, see . @@ -562,6 +563,14 @@ export default { readOnly() { return this.task.calendar.readOnly || (this.task.calendar.isSharedWithMe && this.task.class !== 'PUBLIC') }, + /** + * Whether this is a recurring task. + * + * @return {boolean} Is the task recurring + */ + recurring() { + return this.task.recurring + }, /** * Whether the dates of a task are all-day * When no dates are set, we consider the last used value. From f94756cb5ad9b4aae9bcca4cf5118ad5992defac Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Sun, 18 Jun 2023 15:10:11 +0200 Subject: [PATCH 02/16] Complete recurring tasks by setting their start date to the next recurrence date Signed-off-by: Sunik Kupfer Signed-off-by: Sunik Signed-off-by: Sunik Kupfer --- src/models/task.js | 35 ++++++++++++++++++++++++++++++++--- src/store/tasks.js | 6 ++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/models/task.js b/src/models/task.js index abc87324d..b49676745 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -94,8 +94,7 @@ export default class Task { this._completedDate = this.vtodo.getFirstPropertyValue('completed') this._completedDateMoment = moment(this._completedDate, 'YYYYMMDDTHHmmssZ') this._completed = !!this._completedDate - const recur = this.vtodo.getFirstPropertyValue('rrule') - this._recurring = !!recur + this._recurrence = this.vtodo.getFirstPropertyValue('rrule') this._status = this.vtodo.getFirstPropertyValue('status') this._note = this.vtodo.getFirstPropertyValue('description') || '' this._related = this.getParent()?.getFirstValue() || null @@ -331,8 +330,17 @@ export default class Task { return this._completedDateMoment.clone() } + get recurrence() { + return this._recurrence + } + get recurring() { - return this._recurring + if (this._start === null || this._recurrence === null) { + return false + } + const iter = this._recurrence.iterator(this.start) + iter.next() + return iter.next() !== null } get status() { @@ -723,6 +731,27 @@ export default class Task { ).toSeconds() } + /** + * For completing a recurring task, tries to set the task start date to the next recurrence date. + * + * Does nothing if we are at the end of the recurrence (RRULE:UNTIL was reached). + */ + completeRecurring() { + // Get recurrence iterator, starting at start date + const iter = this.recurrence.iterator(this.start) + // Skip the start date itself + iter.next() + // If there is a next recurrence, update the start date to next recurrence date + const nextRecurrence = iter.next() + if (nextRecurrence !== null) { + this.start = nextRecurrence + // If the due date now lies before start date, clear it + if (this.due !== null && this.due.compare(this.start) < 0) { + this.due = null + } + } + } + /** * Checks if the task matches the search query * diff --git a/src/store/tasks.js b/src/store/tasks.js index 15603a632..a2c64a5c3 100644 --- a/src/store/tasks.js +++ b/src/store/tasks.js @@ -1054,6 +1054,12 @@ const actions = { if (task.calendar.isSharedWithMe && task.class !== 'PUBLIC') { return } + // Don't complete a task if it is still recurring, but update its start date instead + if (task.recurring) { + task.completeRecurring() + await context.dispatch('updateTask', task) + return + } if (task.completed) { await context.dispatch('setPercentComplete', { task, complete: 0 }) } else { From 58b9638ffa5bd195d7b80b73976080c040a71d8e Mon Sep 17 00:00:00 2001 From: Sunik Date: Sat, 7 Oct 2023 12:21:10 +0200 Subject: [PATCH 03/16] Add initial unfinished repeat item to sidebar Signed-off-by: Sunik Signed-off-by: Sunik Kupfer --- src/components/AppSidebar/RepeatItem.vue | 78 ++++++++++++++++++++++++ src/main.js | 2 + src/models/task.js | 12 ++++ src/views/AppSidebar.vue | 8 +++ 4 files changed, 100 insertions(+) create mode 100644 src/components/AppSidebar/RepeatItem.vue diff --git a/src/components/AppSidebar/RepeatItem.vue b/src/components/AppSidebar/RepeatItem.vue new file mode 100644 index 000000000..62aae929a --- /dev/null +++ b/src/components/AppSidebar/RepeatItem.vue @@ -0,0 +1,78 @@ + + + + + + + diff --git a/src/main.js b/src/main.js index 3e8d4883d..d47d3ce9b 100644 --- a/src/main.js +++ b/src/main.js @@ -34,6 +34,7 @@ import Delete from 'vue-material-design-icons/Delete.vue' import Eye from 'vue-material-design-icons/Eye.vue' import EyeOff from 'vue-material-design-icons/EyeOff.vue' import Pulse from 'vue-material-design-icons/Pulse.vue' +import Repeat from 'vue-material-design-icons/Repeat.vue' import TrendingUp from 'vue-material-design-icons/TrendingUp.vue' import { createApp } from 'vue' @@ -59,6 +60,7 @@ const Tasks = createApp(App) .component('IconEye', Eye) .component('IconEyeOff', EyeOff) .component('IconPulse', Pulse) + .component('IconRepeat', Repeat) .component('IconTrendingUp', TrendingUp) .provide('$OCA', OCA) .provide('$appVersion', appVersion) diff --git a/src/models/task.js b/src/models/task.js index b49676745..b5ec94556 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -330,10 +330,22 @@ export default class Task { return this._completedDateMoment.clone() } + /** + * Return the recurrence + * + * @readonly + * @memberof Task + */ get recurrence() { return this._recurrence } + /** + * Whether this task repeats + * + * @readonly + * @memberof Task + */ get recurring() { if (this._start === null || this._recurrence === null) { return false diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue index 3cab5c1aa..62693bced 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -223,6 +223,11 @@ License along with this library. If not, see . icon="TagMultiple" @add-tag="updateTag" @set-tags="updateTags" /> + @@ -272,6 +277,7 @@ import MultiselectItem from '../components/AppSidebar/MultiselectItem.vue' import SliderItem from '../components/AppSidebar/SliderItem.vue' import TagsItem from '../components/AppSidebar/TagsItem.vue' import TextItem from '../components/AppSidebar/TextItem.vue' +import RepeatItem from '../components/AppSidebar/RepeatItem.vue' import NotesItem from '../components/AppSidebar/NotesItem.vue' import TaskCheckbox from '../components/TaskCheckbox.vue' // import TaskStatusDisplay from '../components/TaskStatusDisplay' @@ -338,6 +344,7 @@ export default { SliderItem, TagsItem, TextItem, + RepeatItem, CalendarPickerItem, NotesItem, TaskCheckbox, @@ -675,6 +682,7 @@ export default { getCalendarByRoute: 'getCalendarByRoute', calendars: 'getSortedCalendars', tags: 'tags', + recurrence: 'recurrence', showTaskInCalendar: 'showTaskInCalendar', calendarView: 'calendarView', }), From 9310ecb09fe56fbef936622cf56cf81c8a62e68b Mon Sep 17 00:00:00 2001 From: Sunik Date: Sun, 8 Oct 2023 19:11:57 +0200 Subject: [PATCH 04/16] Display recurrence summary Signed-off-by: Sunik Signed-off-by: Sunik Kupfer --- package-lock.json | 8 + src/components/AppSidebar/RepeatItem.vue | 17 +- .../AppSidebar/RepeatItem/RepeatSummary.vue | 66 +++ src/filters/recurrenceRuleFormat.js | 230 ++++++++ src/models/recurrenceRule.js | 503 ++++++++++++++++++ src/models/task.js | 17 +- src/utils/date.js | 134 +++++ src/utils/recurrence.js | 68 +++ src/views/AppSidebar.vue | 2 +- 9 files changed, 1033 insertions(+), 12 deletions(-) create mode 100644 src/components/AppSidebar/RepeatItem/RepeatSummary.vue create mode 100644 src/filters/recurrenceRuleFormat.js create mode 100644 src/models/recurrenceRule.js create mode 100644 src/utils/date.js create mode 100644 src/utils/recurrence.js diff --git a/package-lock.json b/package-lock.json index 450b945eb..eb666d066 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5325,6 +5325,14 @@ "node": ">=8" } }, + "node_modules/calendar-js": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/calendar-js/-/calendar-js-1.4.4.tgz", + "integrity": "sha512-KesBEbMZnjGzO/xl0ZcJVnezSRw3Xn/KaSUneY4LnANKlZwlllOtd6UGSyW8DSM+diUNi5dVVmSx4eZIMAA2Aw==", + "engines": { + "node": ">=4.6.0" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", diff --git a/src/components/AppSidebar/RepeatItem.vue b/src/components/AppSidebar/RepeatItem.vue index 62aae929a..d2e91a4b6 100644 --- a/src/components/AppSidebar/RepeatItem.vue +++ b/src/components/AppSidebar/RepeatItem.vue @@ -3,6 +3,8 @@ Nextcloud - Tasks @author Sunik Kupfer @copyright 2023 Sunik Kupfer +@author Raimund Schlüßler +@copyright 2018 Raimund Schlüßler This library is free software; you can redistribute it and/or modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE @@ -24,21 +26,24 @@ License along with this library. If not, see .
- {{ recurrence }} + diff --git a/src/filters/recurrenceRuleFormat.js b/src/filters/recurrenceRuleFormat.js new file mode 100644 index 000000000..815888ef4 --- /dev/null +++ b/src/filters/recurrenceRuleFormat.js @@ -0,0 +1,230 @@ +/** + * @copyright Copyright (c) 2019 Georg Ehrke + * + * @author Georg Ehrke + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +import { translate as t, translatePlural as n, getDayNames, getMonthNames } from '@nextcloud/l10n' +import moment from '@nextcloud/moment' + +/** + * Formats a recurrence-rule + * + * @param {object} recurrenceRule The recurrence-rule to format + * @return {string} + */ +export default (recurrenceRule) => { + if (recurrenceRule.frequency === 'NONE') { + return t('calendar', 'Does not repeat') + } + + let freqPart = '' + if (recurrenceRule.interval === 1) { + switch (recurrenceRule.frequency) { + case 'DAILY': + freqPart = t('calendar', 'Daily') + break + + case 'WEEKLY': + freqPart = t('calendar', 'Weekly') + break + + case 'MONTHLY': + freqPart = t('calendar', 'Monthly') + break + + case 'YEARLY': + freqPart = t('calendar', 'Yearly') + break + } + } else { + switch (recurrenceRule.frequency) { + case 'DAILY': + freqPart = n('calendar', 'Every %n day', 'Every %n days', recurrenceRule.interval) + break + + case 'WEEKLY': + freqPart = n('calendar', 'Every %n week', 'Every %n weeks', recurrenceRule.interval) + break + + case 'MONTHLY': + freqPart = n('calendar', 'Every %n month', 'Every %n months', recurrenceRule.interval) + break + + case 'YEARLY': + freqPart = n('calendar', 'Every %n year', 'Every %n years', recurrenceRule.interval) + break + } + } + + let limitPart = '' + if (recurrenceRule.frequency === 'WEEKLY' && recurrenceRule.byDay.length !== 0) { + const formattedDays = getTranslatedByDaySet(recurrenceRule.byDay) + + limitPart = n('calendar', 'on {weekday}', 'on {weekdays}', recurrenceRule.byDay.length, { + weekday: formattedDays, + weekdays: formattedDays, + }) + } else if (recurrenceRule.frequency === 'MONTHLY') { + if (recurrenceRule.byMonthDay.length !== 0) { + const dayOfMonthList = recurrenceRule.byMonthDay.join(', ') + + limitPart = n('calendar', 'on day {dayOfMonthList}', 'on days {dayOfMonthList}', recurrenceRule.byMonthDay.length, { + dayOfMonthList, + }) + } else { + const ordinalNumber = getTranslatedOrdinalNumber(recurrenceRule.bySetPosition) + const byDaySet = getTranslatedByDaySet(recurrenceRule.byDay) + + limitPart = t('calendar', 'on the {ordinalNumber} {byDaySet}', { + ordinalNumber, + byDaySet, + }) + } + } else if (recurrenceRule.frequency === 'YEARLY') { + const monthNames = getTranslatedMonths(recurrenceRule.byMonth) + + if (recurrenceRule.byDay.length === 0) { + limitPart = t('calendar', 'in {monthNames}', { + monthNames, + }) + } else { + const ordinalNumber = getTranslatedOrdinalNumber(recurrenceRule.bySetPosition) + const byDaySet = getTranslatedByDaySet(recurrenceRule.byDay) + + limitPart = t('calendar', 'in {monthNames} on the {ordinalNumber} {byDaySet}', { + monthNames, + ordinalNumber, + byDaySet, + }) + } + } + + let endPart = '' + if (recurrenceRule.until !== null) { + const untilDate = moment(recurrenceRule.until).format('L') + + endPart = t('calendar', 'until {untilDate}', { + untilDate, + }) + } else if (recurrenceRule.count !== null) { + endPart = n('calendar', '%n time', '%n times', recurrenceRule.count) + } + + return [ + freqPart, + limitPart, + endPart, + ].join(' ').replace(/\s{2,}/g, ' ').trim() +} + +/** + * Gets the byDay list as formatted list of translated weekdays + * + * @param {string[]} byDayList The by-day-list to get formatted + * @return {string} + */ +function getTranslatedByDaySet(byDayList) { + const byDayNames = [] + const allByDayNames = getDayNames() + + // TODO: This should be sorted by first day of week + // TODO: This should summarise: + // - SA, SU to weekend + // - MO, TU, WE, TH, FR to weekday + // - MO, TU, WE, TH, FR, SA, SU to day + + if (byDayList.includes('MO')) { + byDayNames.push(allByDayNames[1]) + } + if (byDayList.includes('TU')) { + byDayNames.push(allByDayNames[2]) + } + if (byDayList.includes('WE')) { + byDayNames.push(allByDayNames[3]) + } + if (byDayList.includes('TH')) { + byDayNames.push(allByDayNames[4]) + } + if (byDayList.includes('FR')) { + byDayNames.push(allByDayNames[5]) + } + if (byDayList.includes('SA')) { + byDayNames.push(allByDayNames[6]) + } + if (byDayList.includes('SU')) { + byDayNames.push(allByDayNames[0]) + } + + return byDayNames.join(', ') +} + +/** + * Gets the byMonth list as formatted list of translated month-names + * + * + * @param {string[]} byMonthList The by-month list to get formatted + * @return {string} + */ +function getTranslatedMonths(byMonthList) { + const sortedByMonth = byMonthList.slice().map((n) => parseInt(n, 10)) + sortedByMonth.sort((a, b) => a - b) + + const monthNames = [] + const allMonthNames = getMonthNames() + + for (const month of sortedByMonth) { + monthNames.push(allMonthNames[month - 1]) + } + + return monthNames.join(', ') +} + +/** + * Gets the translated ordinal number for by-set-position + * + * @param {number} bySetPositionNum The by-set-position number to get the translation of + * @return {string} + */ +function getTranslatedOrdinalNumber(bySetPositionNum) { + switch (bySetPositionNum) { + case 1: + return t('calendar', 'first') + + case 2: + return t('calendar', 'second') + + case 3: + return t('calendar', 'third') + + case 4: + return t('calendar', 'fourth') + + case 5: + return t('calendar', 'fifth') + + case -2: + return t('calendar', 'second to last') + + case -1: + return t('calendar', 'last') + + default: + return '' + } +} diff --git a/src/models/recurrenceRule.js b/src/models/recurrenceRule.js new file mode 100644 index 000000000..6749377b7 --- /dev/null +++ b/src/models/recurrenceRule.js @@ -0,0 +1,503 @@ +/** + * @copyright Copyright (c) 2020 Georg Ehrke + * + * @author Georg Ehrke + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +import { getWeekDayFromDate } from '../utils/recurrence.js' +import { getDateFromDateTimeValue } from '../utils/date.js' + +/** + * Creates a complete recurrence-rule-object based on given props + * + * @param {object} props Recurrence-rule-object-props already provided + * @return {object} + */ +const getDefaultRecurrenceRuleObject = (props = {}) => Object.assign({}, { + // 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} + */ +const 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', + 'BYMONTHDAY', + 'BYYEARDAY', + 'BYWEEKNO', +] + +const SUPPORTED_BY_DAY_WEEKLY = [ + 'SU', + 'MO', + 'TU', + 'WE', + 'TH', + 'FR', + 'SA', +] + +/** + * Get all numbers between start and end as strings + * + * @param {number} start Lower end of range + * @param {number} end Upper end of range + * @return {string[]} + */ +const getRangeAsStrings = (start, end) => { + return Array + .apply(null, Array((end - start) + 1)) + .map((_, n) => n + start) + .map((s) => s.toString()) +} + +const SUPPORTED_BY_MONTHDAY_MONTHLY = getRangeAsStrings(1, 31) + +const SUPPORTED_BY_MONTH_YEARLY = getRangeAsStrings(1, 12) + +/** + * Maps a daily calendar-js recurrence-rule-value to an recurrence-rule-object + * + * @param {RecurValue} recurrenceRuleValue The calendar-js recurrence rule value + * @return {object} + */ +const 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} + */ +const 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 event occurs in + // E.g. if the event 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} + */ +const 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.toString())) + isUnsupported = isUnsupported || containsInvalidByMonthDay + + byMonthDay = recurrenceRuleValue.getComponent('BYMONTHDAY') + .filter((monthDay) => SUPPORTED_BY_MONTHDAY_MONTHLY.includes(monthDay.toString())) + .map((monthDay) => monthDay.toString()) + + // 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.toString()) + 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.toString()) + isUnsupported = true + } + } + + // This is a fallback where we just default BYMONTHDAY to the start date of the event + } else { + byMonthDay.push(baseDate.day.toString()) + } + + 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} + */ +const 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 = [] + + if (containsRecurrenceComponent(recurrenceRuleValue, ['BYMONTH'])) { + const containsInvalidByMonthDay = recurrenceRuleValue.getComponent('BYMONTH') + .some((month) => !SUPPORTED_BY_MONTH_YEARLY.includes(month.toString())) + isUnsupported = isUnsupported || containsInvalidByMonthDay + + byMonth = recurrenceRuleValue.getComponent('BYMONTH') + .filter((monthDay) => SUPPORTED_BY_MONTH_YEARLY.includes(monthDay.toString())) + .map((month) => month.toString()) + } else { + byMonth.push(baseDate.month.toString()) + } + + 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 + } + + } else if (containsRecurrenceComponent(recurrenceRuleValue, ['BYDAY'])) { + + const byDayArray = recurrenceRuleValue.getComponent('BYDAY') + if (byDayArray.length > 1) { + 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 { + isUnsupported = true + } + } + } + + return getDefaultRecurrenceRuleObjectForRecurrenceValue(recurrenceRuleValue, { + byDay, + bySetPosition, + byMonth, + isUnsupported, + }) +} + +/** + * Checks if the given parameter is a supported BYDAY value + * + * @param {string[]} byDay The byDay component to check + * @return {boolean} + */ +const 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} + */ +const 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} + */ +const 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} + */ +const getDefaultRecurrenceRuleObjectForRecurrenceValue = (recurrenceRuleValue, props) => { + const isUnsupported = recurrenceRuleValue.count !== null && recurrenceRuleValue.until !== null + let isUnsupportedProps = {} + + if (isUnsupported) { + isUnsupportedProps = { + isUnsupported, + } + } + + return getDefaultRecurrenceRuleObject(Object.assign({}, { + 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 b5ec94556..416bb055d 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -28,6 +28,8 @@ import moment from '@nextcloud/moment' import { v4 as uuid } from 'uuid' import ICAL from 'ical.js' +import { getDefaultRecurrenceRuleObject, mapRecurrenceRuleValueToRecurrenceRuleObject } from './recurrenceRule.js' +import { ToDoComponent } from '@nextcloud/calendar-js' export default class Task { @@ -80,6 +82,7 @@ export default class Task { this.vtodo = new ICAL.Component('vtodo') this.vCalendar.addSubcomponent(this.vtodo) } + this.todoComponent = ToDoComponent.fromICALJs(this.vtodo) if (!this.vtodo.hasProperty('uid')) { console.debug('This task did not have a proper uid. Setting a new one for ', this) @@ -94,7 +97,7 @@ export default class Task { this._completedDate = this.vtodo.getFirstPropertyValue('completed') this._completedDateMoment = moment(this._completedDate, 'YYYYMMDDTHHmmssZ') this._completed = !!this._completedDate - this._recurrence = this.vtodo.getFirstPropertyValue('rrule') + this._recurrence = this.todoComponent.getPropertyIterator('RRULE').next().value this._status = this.vtodo.getFirstPropertyValue('status') this._note = this.vtodo.getFirstPropertyValue('description') || '' this._related = this.getParent()?.getFirstValue() || null @@ -337,7 +340,10 @@ export default class Task { * @memberof Task */ get recurrence() { - return this._recurrence + if (this._recurrence === undefined || this._recurrence === null) { + return getDefaultRecurrenceRuleObject() + } + return mapRecurrenceRuleValueToRecurrenceRuleObject(this._recurrence.getFirstValue(), this._start) } /** @@ -347,12 +353,7 @@ export default class Task { * @memberof Task */ get recurring() { - if (this._start === null || this._recurrence === null) { - return false - } - const iter = this._recurrence.iterator(this.start) - iter.next() - return iter.next() !== null + return this.todoComponent.isRecurring() } get status() { diff --git a/src/utils/date.js b/src/utils/date.js new file mode 100644 index 000000000..30ed9b45d --- /dev/null +++ b/src/utils/date.js @@ -0,0 +1,134 @@ +/** + * @copyright Copyright (c) 2019 Georg Ehrke + * + * @author Georg Ehrke + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import logger from './logger.js' + +/** + * returns a new Date object + * + * @return {Date} + */ +export function dateFactory() { + return new Date() +} + +/** + * formats a Date object as YYYYMMDD + * + * @param {Date} date Date to format + * @return {string} + */ +export function getYYYYMMDDFromDate(date) { + return new Date(date.getTime() - (date.getTimezoneOffset() * 60000)) + .toISOString() + .split('T')[0] +} + +/** + * get unix time from date object + * + * @param {Date} date Date to format + * @return {number} + */ +export function getUnixTimestampFromDate(date) { + return Math.floor(date.getTime() / 1000) +} + +/** + * Gets a Date-object based on the firstday param used in routes + * + * @param {string} firstDayParam The firstday param from the router + * @return {Date} + */ +export function getDateFromFirstdayParam(firstDayParam) { + if (firstDayParam === 'now') { + return dateFactory() + } + + const [year, month, date] = firstDayParam.split('-') + .map((str) => parseInt(str, 10)) + + if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(date)) { + logger.error('First day parameter contains non-numerical components, falling back to today') + return dateFactory() + } + + const dateObject = dateFactory() + dateObject.setFullYear(year, month - 1, date) + dateObject.setHours(0, 0, 0, 0) + + return dateObject +} + +/** + * formats firstday param as YYYYMMDD + * + * @param {string} firstDayParam The firstday param from the router + * @return {string} + */ +export function getYYYYMMDDFromFirstdayParam(firstDayParam) { + if (firstDayParam === 'now') { + return getYYYYMMDDFromDate(dateFactory()) + } + + return firstDayParam +} + +/** + * Gets a date object based on the given DateTimeValue + * Ignores given timezone-information + * + * @param {DateTimeValue} dateTimeValue Value to get date from + * @return {Date} + */ +export function getDateFromDateTimeValue(dateTimeValue) { + return new Date( + dateTimeValue.year, + dateTimeValue.month - 1, + dateTimeValue.day, + dateTimeValue.hour, + dateTimeValue.minute, + 0, + 0 + ) +} + +/** + * modifies a date + * + * @param {Date} date Date object to modify + * @param {object} data The destructuring object + * @param {number} data.day Number of days to add + * @param {number} data.week Number of weeks to add + * @param {number} data.month Number of months to add + * @param data.year + * @return {Date} + */ +export function modifyDate(date, { day = 0, week = 0, month = 0, year = 0 }) { + date = new Date(date.getTime()) + date.setDate(date.getDate() + day) + date.setDate(date.getDate() + week * 7) + date.setMonth(date.getMonth() + month) + date.setFullYear(date.getFullYear() + year) + + return date +} diff --git a/src/utils/recurrence.js b/src/utils/recurrence.js new file mode 100644 index 000000000..888d14bfc --- /dev/null +++ b/src/utils/recurrence.js @@ -0,0 +1,68 @@ +/** + * @copyright Copyright (c) 2019 Georg Ehrke + * + * @author Georg Ehrke + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +/** + * 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) { + 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 62693bced..6962439e1 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -224,7 +224,7 @@ License along with this library. If not, see . @add-tag="updateTag" @set-tags="updateTags" /> From 34435995b33531f2ae94d8a35cfd065fe6e637b8 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 10 Nov 2023 14:22:37 +0100 Subject: [PATCH 05/16] Add toggle options button Signed-off-by: Sunik Kupfer --- src/components/AppSidebar/RepeatItem.vue | 85 ++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 4 deletions(-) diff --git a/src/components/AppSidebar/RepeatItem.vue b/src/components/AppSidebar/RepeatItem.vue index d2e91a4b6..6fdb29caf 100644 --- a/src/components/AppSidebar/RepeatItem.vue +++ b/src/components/AppSidebar/RepeatItem.vue @@ -28,6 +28,18 @@ License along with this library. If not, see . + + + + {{ toggleTitle }} + + + +
+ options +
@@ -35,10 +47,17 @@ License along with this library. If not, see . import { translate as t } from '@nextcloud/l10n' import RepeatSummary from './RepeatItem/RepeatSummary.vue' +import Pencil from 'vue-material-design-icons/Pencil.vue' +import Check from 'vue-material-design-icons/Check.vue' +import { NcActions as Actions, NcActionButton as ActionButton } from '@nextcloud/vue' export default { components: { RepeatSummary, + Actions, + ActionButton, + Pencil, + Check, }, props: { recurrence: { @@ -58,8 +77,43 @@ export default { default: null, }, }, + data() { + return { + showOptions: false, + } + }, + computed: { + /** + * The name of the icon for the toggle options button + * + * @return {string} + */ + toggleIcon() { + if (this.showOptions) { + return 'Check' + } + return 'Pencil' + }, + /** + * The text of the toggle options button + * + * @return {string} + */ + toggleTitle() { + if (this.showOptions) { + return this.t('calendar', 'Save') + } + return this.t('calendar', 'Edit') + }, + }, methods: { t, + /** + * Toggle visibility of the options + */ + toggleOptions() { + this.showOptions = !this.showOptions + }, }, } @@ -80,10 +134,33 @@ export default { flex-shrink: 0; } - .property-repeat__summary__content { - display: flex; - align-items: center; - margin-bottom: 5px; + .property-repeat { + width: 100%; + + &__summary { + display: flex; + align-items: center; + margin-bottom: 5px; + + &__icon { + width: 34px; + height: 34px; + margin-left: -5px; + margin-right: 5px; + } + + &__content { + flex: 1 auto; + padding: 0 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + &__options { + margin-bottom: 5px; + } } } From 7a38fc0d1cbd1be908be7d91a9234f25be960bcc Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 5 Jan 2024 15:18:20 +0100 Subject: [PATCH 06/16] Update translations app param --- src/components/AppSidebar/RepeatItem.vue | 4 +-- src/filters/recurrenceRuleFormat.js | 46 ++++++++++++------------ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/components/AppSidebar/RepeatItem.vue b/src/components/AppSidebar/RepeatItem.vue index 6fdb29caf..ceb4f4c95 100644 --- a/src/components/AppSidebar/RepeatItem.vue +++ b/src/components/AppSidebar/RepeatItem.vue @@ -101,9 +101,9 @@ export default { */ toggleTitle() { if (this.showOptions) { - return this.t('calendar', 'Save') + return this.t('tasks', 'Save') } - return this.t('calendar', 'Edit') + return this.t('tasks', 'Edit') }, }, methods: { diff --git a/src/filters/recurrenceRuleFormat.js b/src/filters/recurrenceRuleFormat.js index 815888ef4..15344f0f9 100644 --- a/src/filters/recurrenceRuleFormat.js +++ b/src/filters/recurrenceRuleFormat.js @@ -30,44 +30,44 @@ import moment from '@nextcloud/moment' */ export default (recurrenceRule) => { if (recurrenceRule.frequency === 'NONE') { - return t('calendar', 'Does not repeat') + return t('tasks', 'Does not repeat') } let freqPart = '' if (recurrenceRule.interval === 1) { switch (recurrenceRule.frequency) { case 'DAILY': - freqPart = t('calendar', 'Daily') + freqPart = t('tasks', 'Daily') break case 'WEEKLY': - freqPart = t('calendar', 'Weekly') + freqPart = t('tasks', 'Weekly') break case 'MONTHLY': - freqPart = t('calendar', 'Monthly') + freqPart = t('tasks', 'Monthly') break case 'YEARLY': - freqPart = t('calendar', 'Yearly') + freqPart = t('tasks', 'Yearly') break } } else { switch (recurrenceRule.frequency) { case 'DAILY': - freqPart = n('calendar', 'Every %n day', 'Every %n days', recurrenceRule.interval) + freqPart = n('tasks', 'Every %n day', 'Every %n days', recurrenceRule.interval) break case 'WEEKLY': - freqPart = n('calendar', 'Every %n week', 'Every %n weeks', recurrenceRule.interval) + freqPart = n('tasks', 'Every %n week', 'Every %n weeks', recurrenceRule.interval) break case 'MONTHLY': - freqPart = n('calendar', 'Every %n month', 'Every %n months', recurrenceRule.interval) + freqPart = n('tasks', 'Every %n month', 'Every %n months', recurrenceRule.interval) break case 'YEARLY': - freqPart = n('calendar', 'Every %n year', 'Every %n years', recurrenceRule.interval) + freqPart = n('tasks', 'Every %n year', 'Every %n years', recurrenceRule.interval) break } } @@ -76,7 +76,7 @@ export default (recurrenceRule) => { if (recurrenceRule.frequency === 'WEEKLY' && recurrenceRule.byDay.length !== 0) { const formattedDays = getTranslatedByDaySet(recurrenceRule.byDay) - limitPart = n('calendar', 'on {weekday}', 'on {weekdays}', recurrenceRule.byDay.length, { + limitPart = n('tasks', 'on {weekday}', 'on {weekdays}', recurrenceRule.byDay.length, { weekday: formattedDays, weekdays: formattedDays, }) @@ -84,14 +84,14 @@ export default (recurrenceRule) => { if (recurrenceRule.byMonthDay.length !== 0) { const dayOfMonthList = recurrenceRule.byMonthDay.join(', ') - limitPart = n('calendar', 'on day {dayOfMonthList}', 'on days {dayOfMonthList}', recurrenceRule.byMonthDay.length, { + limitPart = n('tasks', 'on day {dayOfMonthList}', 'on days {dayOfMonthList}', recurrenceRule.byMonthDay.length, { dayOfMonthList, }) } else { const ordinalNumber = getTranslatedOrdinalNumber(recurrenceRule.bySetPosition) const byDaySet = getTranslatedByDaySet(recurrenceRule.byDay) - limitPart = t('calendar', 'on the {ordinalNumber} {byDaySet}', { + limitPart = t('tasks', 'on the {ordinalNumber} {byDaySet}', { ordinalNumber, byDaySet, }) @@ -100,14 +100,14 @@ export default (recurrenceRule) => { const monthNames = getTranslatedMonths(recurrenceRule.byMonth) if (recurrenceRule.byDay.length === 0) { - limitPart = t('calendar', 'in {monthNames}', { + limitPart = t('tasks', 'in {monthNames}', { monthNames, }) } else { const ordinalNumber = getTranslatedOrdinalNumber(recurrenceRule.bySetPosition) const byDaySet = getTranslatedByDaySet(recurrenceRule.byDay) - limitPart = t('calendar', 'in {monthNames} on the {ordinalNumber} {byDaySet}', { + limitPart = t('tasks', 'in {monthNames} on the {ordinalNumber} {byDaySet}', { monthNames, ordinalNumber, byDaySet, @@ -119,11 +119,11 @@ export default (recurrenceRule) => { if (recurrenceRule.until !== null) { const untilDate = moment(recurrenceRule.until).format('L') - endPart = t('calendar', 'until {untilDate}', { + endPart = t('tasks', 'until {untilDate}', { untilDate, }) } else if (recurrenceRule.count !== null) { - endPart = n('calendar', '%n time', '%n times', recurrenceRule.count) + endPart = n('tasks', '%n time', '%n times', recurrenceRule.count) } return [ @@ -204,25 +204,25 @@ function getTranslatedMonths(byMonthList) { function getTranslatedOrdinalNumber(bySetPositionNum) { switch (bySetPositionNum) { case 1: - return t('calendar', 'first') + return t('tasks', 'first') case 2: - return t('calendar', 'second') + return t('tasks', 'second') case 3: - return t('calendar', 'third') + return t('tasks', 'third') case 4: - return t('calendar', 'fourth') + return t('tasks', 'fourth') case 5: - return t('calendar', 'fifth') + return t('tasks', 'fifth') case -2: - return t('calendar', 'second to last') + return t('tasks', 'second to last') case -1: - return t('calendar', 'last') + return t('tasks', 'last') default: return '' From 1261a0e6e8e3e4503373f85752f48756f264aa65 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 5 Jan 2024 15:37:42 +0100 Subject: [PATCH 07/16] Move recurrenceRuleFormat.js from filter to utils --- src/components/AppSidebar/RepeatItem/RepeatSummary.vue | 8 +++----- src/{filters => utils}/recurrenceRuleFormat.js | 0 2 files changed, 3 insertions(+), 5 deletions(-) rename src/{filters => utils}/recurrenceRuleFormat.js (100%) diff --git a/src/components/AppSidebar/RepeatItem/RepeatSummary.vue b/src/components/AppSidebar/RepeatItem/RepeatSummary.vue index 43ab19c2e..aef084a4d 100644 --- a/src/components/AppSidebar/RepeatItem/RepeatSummary.vue +++ b/src/components/AppSidebar/RepeatItem/RepeatSummary.vue @@ -23,7 +23,7 @@ @@ -307,6 +307,7 @@ import Pencil from 'vue-material-design-icons/Pencil.vue' import Percent from 'vue-material-design-icons/Percent.vue' import Pin from 'vue-material-design-icons/Pin.vue' import PinOff from 'vue-material-design-icons/PinOff.vue' +import Repeat from 'vue-material-design-icons/Repeat.vue' import Star from 'vue-material-design-icons/Star.vue' import TextBoxOutline from 'vue-material-design-icons/TextBoxOutline.vue' import Undo from 'vue-material-design-icons/Undo.vue' @@ -335,6 +336,7 @@ export default { Percent, Pin, PinOff, + Repeat, Star, TextBoxOutline, Undo, From b26a7d11bdb7cb300f846c0645859e01a1526fc1 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 16 Feb 2024 15:41:50 +0100 Subject: [PATCH 11/16] Use editableItem mixin --- src/components/AppSidebar/RepeatItem.vue | 195 ++++++++++++++--------- src/views/AppSidebar.vue | 1 + 2 files changed, 118 insertions(+), 78 deletions(-) diff --git a/src/components/AppSidebar/RepeatItem.vue b/src/components/AppSidebar/RepeatItem.vue index 4799ef41b..eb2d82574 100644 --- a/src/components/AppSidebar/RepeatItem.vue +++ b/src/components/AppSidebar/RepeatItem.vue @@ -22,25 +22,43 @@ License along with this library. If not, see . --> @@ -53,8 +71,10 @@ import RepeatFreqInterval from './RepeatItem/RepeatFreqInterval.vue' import Pencil from 'vue-material-design-icons/Pencil.vue' import Check from 'vue-material-design-icons/Check.vue' import { NcActions as Actions, NcActionButton as ActionButton } from '@nextcloud/vue' +import editableItem from '../../mixins/editableItem.js' export default { + name: 'RepeatItem', components: { RepeatSummary, RepeatFreqInterval, @@ -63,6 +83,7 @@ export default { Pencil, Check, }, + mixins: [editableItem], props: { recurrenceRule: { type: Object, @@ -72,6 +93,10 @@ export default { type: Boolean, default: false, }, + readOnly: { + type: Boolean, + required: true, + }, placeholder: { type: String, default: '', @@ -83,40 +108,14 @@ export default { }, data() { return { - showOptions: false, + frequency: 'NONE', + interval: -1, } }, - computed: { - /** - * The name of the icon for the toggle options button - * - * @return {string} - */ - toggleIcon() { - if (this.showOptions) { - return 'Check' - } - return 'Pencil' - }, - /** - * The text of the toggle options button - * - * @return {string} - */ - toggleTitle() { - if (this.showOptions) { - return this.t('tasks', 'Save') - } - return this.t('tasks', 'Edit') - }, - }, methods: { t, - /** - * Toggle visibility of the options - */ - toggleOptions() { - this.showOptions = !this.showOptions + isRecurring() { + return this.recurrenceRule.frequency !== 'NONE' }, }, } @@ -124,47 +123,87 @@ export default { diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue index 935bbc186..15773d7b3 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -263,6 +263,7 @@ License along with this library. If not, see . From b0466efb48c398d2fad7d7e763b1528cb3155719 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 16 Feb 2024 16:35:06 +0100 Subject: [PATCH 12/16] Add RepeatFreqSelect --- src/components/AppSidebar/RepeatItem.vue | 14 ++- .../RepeatItem/RepeatFreqInterval.vue | 23 ++++- .../RepeatItem/RepeatFreqSelect.vue | 87 +++++++++++++++++++ 3 files changed, 120 insertions(+), 4 deletions(-) create mode 100644 src/components/AppSidebar/RepeatItem/RepeatFreqSelect.vue diff --git a/src/components/AppSidebar/RepeatItem.vue b/src/components/AppSidebar/RepeatItem.vue index eb2d82574..db32e5eb5 100644 --- a/src/components/AppSidebar/RepeatItem.vue +++ b/src/components/AppSidebar/RepeatItem.vue @@ -39,7 +39,9 @@ License along with this library. If not, see .
+ :interval="interval" + @change-frequency="changeFrequency" + @change-interval="changeInterval" />
@@ -109,7 +111,7 @@ export default { data() { return { frequency: 'NONE', - interval: -1, + interval: 0, } }, methods: { @@ -117,6 +119,14 @@ export default { isRecurring() { return this.recurrenceRule.frequency !== 'NONE' }, + changeFrequency(value) { + this.frequency = value + // this.setValue() + }, + changeInterval(value) { + this.interval = value + // this.setValue() + }, }, } diff --git a/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue b/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue index acee57e6d..989125622 100644 --- a/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue +++ b/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue @@ -25,18 +25,25 @@ {{ repeatEveryLabel }} - + :value="interval" + @input="changeInterval"> +
diff --git a/src/components/AppSidebar/RepeatItem/RepeatFreqSelect.vue b/src/components/AppSidebar/RepeatItem/RepeatFreqSelect.vue new file mode 100644 index 000000000..f28385e96 --- /dev/null +++ b/src/components/AppSidebar/RepeatItem/RepeatFreqSelect.vue @@ -0,0 +1,87 @@ + + + + + From 554c035ff04af14ff3c70a8821eb3974eb7638df Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Sun, 18 Feb 2024 16:18:22 +0100 Subject: [PATCH 13/16] Use v-model --- src/components/AppSidebar/RepeatItem.vue | 23 +++++++++++-------- .../RepeatItem/RepeatFreqInterval.vue | 12 ++++------ src/views/AppSidebar.vue | 5 ++-- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/src/components/AppSidebar/RepeatItem.vue b/src/components/AppSidebar/RepeatItem.vue index db32e5eb5..631afa772 100644 --- a/src/components/AppSidebar/RepeatItem.vue +++ b/src/components/AppSidebar/RepeatItem.vue @@ -34,12 +34,12 @@ License along with this library. If not, see . + :recurrence-rule="value" />
@@ -74,6 +74,7 @@ import Pencil from 'vue-material-design-icons/Pencil.vue' import Check from 'vue-material-design-icons/Check.vue' import { NcActions as Actions, NcActionButton as ActionButton } from '@nextcloud/vue' import editableItem from '../../mixins/editableItem.js' +import logger from '../../utils/logger.js' export default { name: 'RepeatItem', @@ -87,7 +88,10 @@ export default { }, mixins: [editableItem], props: { - recurrenceRule: { + /** + * The Recurrence object + */ + value: { type: Object, required: true, }, @@ -110,22 +114,23 @@ export default { }, data() { return { - frequency: 'NONE', - interval: 0, + frequency: this.value.frequency, + interval: this.value.interval, } }, methods: { t, isRecurring() { - return this.recurrenceRule.frequency !== 'NONE' + return this.value.frequency !== 'NONE' }, changeFrequency(value) { this.frequency = value - // this.setValue() + // this.newValue = this.value.copy(interval = value) }, changeInterval(value) { + logger.info('change Interval to ' + value) this.interval = value - // this.setValue() + // this.newValue = this.value.copy(interval = value) }, }, } diff --git a/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue b/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue index 989125622..ce527fe8b 100644 --- a/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue +++ b/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue @@ -26,12 +26,11 @@ {{ repeatEveryLabel }} + @input="$emit('update:interval', $event.target.value)"> @@ -54,7 +53,7 @@ export default { required: true, }, }, - emits: ['change-frequency', 'change-interval'], + emits: ['update:frequency', 'update:interval'], computed: { repeatEveryLabel() { if (this.frequency === 'NONE') { @@ -68,10 +67,7 @@ export default { }, methods: { changeFrequency(value) { - this.$emit('change-frequency', value) - }, - changeInterval(value) { - this.$emit('change-interval', value) + this.$emit('update:frequency', value) }, }, } diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue index 15773d7b3..fa5c98fcc 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -261,10 +261,11 @@ License along with this library. If not, see . - From e6f74a3deaf50dcff698c75ea9d75c73a1d227d5 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Sun, 3 Mar 2024 20:22:25 +0100 Subject: [PATCH 14/16] Add vuex mutation and commit --- src/components/AppSidebar/RepeatItem.vue | 7 +++-- .../RepeatItem/RepeatFreqInterval.vue | 6 ++--- src/models/task.js | 26 ++++++++++++++++--- src/store/tasks.js | 26 +++++++++++++++++++ src/views/AppSidebar.vue | 6 +++-- 5 files changed, 61 insertions(+), 10 deletions(-) diff --git a/src/components/AppSidebar/RepeatItem.vue b/src/components/AppSidebar/RepeatItem.vue index 631afa772..e082d4fb2 100644 --- a/src/components/AppSidebar/RepeatItem.vue +++ b/src/components/AppSidebar/RepeatItem.vue @@ -125,12 +125,15 @@ export default { }, changeFrequency(value) { this.frequency = value - // this.newValue = this.value.copy(interval = value) + // TODO: There should be a better way than using structuredClone + this.newValue = structuredClone(this.value) + this.newValue.frequency = value }, changeInterval(value) { logger.info('change Interval to ' + value) this.interval = value - // this.newValue = this.value.copy(interval = value) + this.newValue = structuredClone(this.value) + this.newValue.interval = value }, }, } diff --git a/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue b/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue index ce527fe8b..df74697ed 100644 --- a/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue +++ b/src/components/AppSidebar/RepeatItem/RepeatFreqInterval.vue @@ -30,7 +30,7 @@ type="number" min="1" max="366" - @input="$emit('update:interval', $event.target.value)"> + @input="$emit('change-interval', $event.target.value)"> @@ -53,7 +53,7 @@ export default { required: true, }, }, - emits: ['update:frequency', 'update:interval'], + emits: ['change-frequency', 'change-interval'], computed: { repeatEveryLabel() { if (this.frequency === 'NONE') { @@ -67,7 +67,7 @@ export default { }, methods: { changeFrequency(value) { - this.$emit('update:frequency', value) + this.$emit('change-frequency', value) }, }, } diff --git a/src/models/task.js b/src/models/task.js index 4e9a8f192..2137ac455 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -82,6 +82,8 @@ export default class Task { this.vtodo = new ICAL.Component('vtodo') this.vCalendar.addSubcomponent(this.vtodo) } + + // From NC calendar-js this.todoComponent = ToDoComponent.fromICALJs(this.vtodo) if (!this.vtodo.hasProperty('uid')) { @@ -334,18 +336,36 @@ export default class Task { } /** - * Return the recurrence + * Get the recurrence * * @readonly * @memberof Task */ - get recurrenceRule() { + get recurrenceRuleObject() { if (this._recurrence === undefined || this._recurrence === null) { return getDefaultRecurrenceRuleObject() } return mapRecurrenceRuleValueToRecurrenceRuleObject(this._recurrence.getFirstValue(), this._start) } + /** + * Set the recurrence + * + * @readonly + * @memberof Task + */ + set recurrenceRuleObject(rruleObject) { + if (rruleObject === null) { + this.vtodo.removeProperty('rrule') + } else { + // FIXME: rruleObject.recurrenceRuleValue should not be null + this.vtodo.updatePropertyWithValue('rrule', rruleObject.recurrenceRuleValue) + } + this.todoComponent = ToDoComponent.fromICALJs(this.vtodo) + this._recurrence = this.todoComponent.getPropertyIterator('RRULE').next().value + this.updateLastModified() + } + /** * Whether this task repeats * @@ -751,7 +771,7 @@ export default class Task { */ completeRecurring() { // Get recurrence iterator, starting at start date - const iter = this.recurrenceRule.iterator(this.start) + const iter = this.recurrenceRuleObject.iterator(this.start) // Skip the start date itself iter.next() // If there is a next recurrence, update the start date to next recurrence date diff --git a/src/store/tasks.js b/src/store/tasks.js index a2c64a5c3..18019afd3 100644 --- a/src/store/tasks.js +++ b/src/store/tasks.js @@ -495,6 +495,18 @@ const mutations = { task.location = location }, + /** + * Sets the recurrence rule of a task + * + * @param {object} state The store data + * @param {object} data Destructuring object + * @param {Task} data.task The task + * @param {string} data.rruleObject The recurrence rule object from NC calendar-js + */ + setRecurrence(state, { task, rruleObject }) { + task.recurrenceRuleObject = rruleObject + }, + /** * Sets the url of a task * @@ -1232,6 +1244,20 @@ const actions = { context.dispatch('updateTask', task) }, + /** + * Sets the location of a task + * + * @param {object} context The store context + * @param {Task} task The task to update + */ + async setRecurrence(context, { task, rruleObject }) { + if (rruleObject === task.recurrenceRuleObject) { + return + } + context.commit('setRecurrence', { task, rruleObject }) + context.dispatch('updateTask', task) + }, + /** * Sets the URL of a task * diff --git a/src/views/AppSidebar.vue b/src/views/AppSidebar.vue index fa5c98fcc..a694aba45 100644 --- a/src/views/AppSidebar.vue +++ b/src/views/AppSidebar.vue @@ -261,12 +261,13 @@ License along with this library. If not, see . - + icon="IconRepeat" + @set-value="({task, value}) => setRecurrence({ task, rruleObject: value })" /> @@ -724,6 +725,7 @@ export default { 'setNote', 'setPriority', 'setLocation', + 'setRecurrence', 'setUrl', 'setPercentComplete', 'setTags', From fdf3b3e6da82be88d9c1318561572633aab7d393 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Sun, 28 Jul 2024 18:48:10 +0200 Subject: [PATCH 15/16] Allow for daily frequency to be set --- src/models/task.js | 9 +++++---- src/store/tasks.js | 31 ++++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/models/task.js b/src/models/task.js index 2137ac455..82f06f086 100644 --- a/src/models/task.js +++ b/src/models/task.js @@ -340,9 +340,10 @@ export default class Task { * * @readonly * @memberof Task + * @return {object} */ get recurrenceRuleObject() { - if (this._recurrence === undefined || this._recurrence === null) { + if (this._recurrence == null) { return getDefaultRecurrenceRuleObject() } return mapRecurrenceRuleValueToRecurrenceRuleObject(this._recurrence.getFirstValue(), this._start) @@ -357,13 +358,13 @@ export default class Task { set recurrenceRuleObject(rruleObject) { if (rruleObject === null) { this.vtodo.removeProperty('rrule') + this._recurrence = null } else { - // FIXME: rruleObject.recurrenceRuleValue should not be null - this.vtodo.updatePropertyWithValue('rrule', rruleObject.recurrenceRuleValue) + this.vtodo.updatePropertyWithValue('rrule', rruleObject.recurrenceRuleValue.toICALJs()) } this.todoComponent = ToDoComponent.fromICALJs(this.vtodo) - this._recurrence = this.todoComponent.getPropertyIterator('RRULE').next().value this.updateLastModified() + this._recurrence = this.todoComponent.getPropertyIterator('RRULE').next().value } /** diff --git a/src/store/tasks.js b/src/store/tasks.js index 18019afd3..15a866432 100644 --- a/src/store/tasks.js +++ b/src/store/tasks.js @@ -33,6 +33,7 @@ import { translate as t } from '@nextcloud/l10n' import moment from '@nextcloud/moment' import ICAL from 'ical.js' +import { RecurValue } from '@nextcloud/calendar-js' const state = { tasks: {}, @@ -501,9 +502,37 @@ const mutations = { * @param {object} state The store data * @param {object} data Destructuring object * @param {Task} data.task The task - * @param {string} data.rruleObject The recurrence rule object from NC calendar-js + * @param {object} data.rruleObject The recurrence rule object from NC calendar-js */ setRecurrence(state, { task, rruleObject }) { + if (rruleObject == null) { + task.recurrenceRuleObject = null + return + } + // Set the ICAL recur value from changed params + const data = {} + if (rruleObject.frequency != null) { data.freq = rruleObject.frequency } + if (rruleObject.interval != null) { data.interval = rruleObject.interval } + // wkst + if (rruleObject.until != null) { data.until = rruleObject.until } + if (rruleObject.count != null) { data.count = rruleObject.count } + // bysecond + // byminute + // byhour + if (rruleObject.byDay != null) { data.byday = rruleObject.byDay } + if (rruleObject.bymonthday != null) { data.bymonthday = rruleObject.bymonthday } + // byyearday + // byweekno + if (rruleObject.byMonth != null) { data.bymonth = rruleObject.byMonth } + if (rruleObject.bySetPosition != null) { data.bysetpos = rruleObject.bySetPosition } + + rruleObject.recurrenceRuleValue = RecurValue.fromData(data) + if (!rruleObject.recurrenceRuleValue.isRuleValid()) { + // Don't save an invalid RRULE (For development, remove after) + // console.log('Invalid rrule') + // console.log(rruleObject.recurrenceRuleValue.toICALJs().toString()) + return + } task.recurrenceRuleObject = rruleObject }, From 62b7359f1b21b76a87d62eb908f1589af68c5a40 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Sun, 11 Aug 2024 12:20:25 +0200 Subject: [PATCH 16/16] Update invalid rrule check --- src/store/tasks.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/store/tasks.js b/src/store/tasks.js index 15a866432..3f0b96c67 100644 --- a/src/store/tasks.js +++ b/src/store/tasks.js @@ -527,12 +527,14 @@ const mutations = { if (rruleObject.bySetPosition != null) { data.bysetpos = rruleObject.bySetPosition } rruleObject.recurrenceRuleValue = RecurValue.fromData(data) - if (!rruleObject.recurrenceRuleValue.isRuleValid()) { - // Don't save an invalid RRULE (For development, remove after) - // console.log('Invalid rrule') - // console.log(rruleObject.recurrenceRuleValue.toICALJs().toString()) + // Don't save an invalid RRULE (For development, remove after) + if (!rruleObject.recurrenceRuleValue.isRuleValid() || rruleObject.recurrenceRuleValue.frequency === 'NONE') { + console.log('Rrule invalid or freq="NONE". Not saving.') + console.log(rruleObject.recurrenceRuleValue.toICALJs().toString()) return } + console.log('Saving rrule:') + console.log(rruleObject.recurrenceRuleValue.toICALJs().toString()) task.recurrenceRuleObject = rruleObject },