diff --git a/docs/content/doc/usage/caldav.md b/docs/content/doc/usage/caldav.md index e43ba04368..60d2612997 100644 --- a/docs/content/doc/usage/caldav.md +++ b/docs/content/doc/usage/caldav.md @@ -39,30 +39,31 @@ Vikunja currently supports the following properties: * `PRIORITY` * `CATEGORIES` * `COMPLETED` +* `CREATED` (only Vikunja -> Client) * `DUE` -* `DTSTART` * `DURATION` -* `ORGANIZER` -* `RELATED-TO` -* `CREATED` * `DTSTAMP` -* `LAST-MODIFIED` -* Recurrence +* `DTSTART` +* `LAST-MODIFIED` (only Vikunja -> Client) +* `RRULE` (Recurrence) (only Vikunja -> Client) +* `VALARM` (Reminders) Vikunja **currently does not** support these properties: * `ATTACH` * `CLASS` * `COMMENT` +* `CONTACT` * `GEO` * `LOCATION` +* `ORGANIZER` (disabled) * `PERCENT-COMPLETE` -* `RESOURCES` -* `STATUS` -* `CONTACT` * `RECURRENCE-ID` -* `URL` +* `RELATED-TO` +* `RESOURCES` * `SEQUENCE` +* `STATUS` +* `URL` ## Tested Clients diff --git a/pkg/caldav/caldav.go b/pkg/caldav/caldav.go index 0f324f1c80..0f53f5c1cc 100644 --- a/pkg/caldav/caldav.go +++ b/pkg/caldav/caldav.go @@ -31,19 +31,6 @@ import ( // DateFormat is the caldav date format const DateFormat = `20060102T150405` -// Event holds a single caldav event -type Event struct { - Summary string - Description string - UID string - Alarms []Alarm - Color string - - Timestamp time.Time - Start time.Time - End time.Time -} - // Todo holds a single VTODO type Todo struct { // Required @@ -65,6 +52,7 @@ type Todo struct { Duration time.Duration RepeatAfter int64 RepeatMode models.TaskRepeatMode + Alarms []Alarm Created time.Time Updated time.Time // last-mod @@ -73,6 +61,8 @@ type Todo struct { // Alarm holds infos about an alarm from a caldav event type Alarm struct { Time time.Time + Duration time.Duration + RelativeTo models.ReminderRelation Description string } @@ -100,58 +90,6 @@ X-OUTLOOK-COLOR:` + color + ` X-FUNAMBOL-COLOR:` + color } -// ParseEvents parses an array of caldav events and gives them back as string -func ParseEvents(config *Config, events []*Event) (caldavevents string) { - caldavevents += `BEGIN:VCALENDAR -VERSION:2.0 -METHOD:PUBLISH -X-PUBLISHED-TTL:PT4H -X-WR-CALNAME:` + config.Name + ` -PRODID:-//` + config.ProdID + `//EN` + getCaldavColor(config.Color) - - for _, e := range events { - - if e.UID == "" { - e.UID = makeCalDavTimeFromTimeStamp(e.Timestamp) + utils.Sha256(e.Summary) - } - - formattedDescription := "" - if e.Description != "" { - re := regexp.MustCompile(`\r?\n`) - formattedDescription = re.ReplaceAllString(e.Description, "\\n") - } - - caldavevents += ` -BEGIN:VEVENT -UID:` + e.UID + ` -SUMMARY:` + e.Summary + getCaldavColor(e.Color) + ` -DESCRIPTION:` + formattedDescription + ` -DTSTAMP:` + makeCalDavTimeFromTimeStamp(e.Timestamp) + ` -DTSTART:` + makeCalDavTimeFromTimeStamp(e.Start) + ` -DTEND:` + makeCalDavTimeFromTimeStamp(e.End) - - for _, a := range e.Alarms { - if a.Description == "" { - a.Description = e.Summary - } - - caldavevents += ` -BEGIN:VALARM -TRIGGER:` + calcAlarmDateFromReminder(e.Start, a.Time) + ` -ACTION:DISPLAY -DESCRIPTION:` + a.Description + ` -END:VALARM` - } - caldavevents += ` -END:VEVENT` - } - - caldavevents += ` -END:VCALENDAR` // Need a line break - - return -} - func formatDuration(duration time.Duration) string { seconds := duration.Seconds() - duration.Minutes()*60 minutes := duration.Minutes() - duration.Hours()*60 @@ -246,7 +184,7 @@ CATEGORIES:` + strings.Join(t.Categories, ",") caldavtodos += ` LAST-MODIFIED:` + makeCalDavTimeFromTimeStamp(t.Updated) - + caldavtodos += ParseAlarms(t.Alarms, t.Summary) caldavtodos += ` END:VTODO` } @@ -257,19 +195,42 @@ END:VCALENDAR` // Need a line break return } +func ParseAlarms(alarms []Alarm, taskDescription string) (caldavalarms string) { + for _, a := range alarms { + if a.Description == "" { + a.Description = taskDescription + } + + caldavalarms += ` +BEGIN:VALARM` + switch a.RelativeTo { + case models.ReminderRelationStartDate: + caldavalarms += ` +TRIGGER;RELATED=START:` + makeCalDavDuration(a.Duration) + case models.ReminderRelationEndDate, models.ReminderRelationDueDate: + caldavalarms += ` +TRIGGER;RELATED=END:` + makeCalDavDuration(a.Duration) + default: + caldavalarms += ` +TRIGGER;VALUE=DATE-TIME:` + makeCalDavTimeFromTimeStamp(a.Time) + } + caldavalarms += ` +ACTION:DISPLAY +DESCRIPTION:` + a.Description + ` +END:VALARM` + } + return caldavalarms +} + func makeCalDavTimeFromTimeStamp(ts time.Time) (caldavtime string) { return ts.In(time.UTC).Format(DateFormat) + "Z" } -func calcAlarmDateFromReminder(eventStart, reminder time.Time) (alarmTime string) { - diff := reminder.Sub(eventStart) - diffStr := strings.ToUpper(diff.String()) - if diff < 0 { - alarmTime += `-` - // We append the - at the beginning of the caldav flag, that would get in the way if the minutes - // themselves are also containing it - diffStr = diffStr[1:] +func makeCalDavDuration(duration time.Duration) (caldavtime string) { + if duration < 0 { + duration = duration.Abs() + caldavtime = "-" } - alarmTime += `PT` + diffStr + caldavtime += "PT" + strings.ToUpper(duration.Truncate(time.Millisecond).String()) return } diff --git a/pkg/caldav/caldav_test.go b/pkg/caldav/caldav_test.go index a8da6e2400..e1de0d9ca6 100644 --- a/pkg/caldav/caldav_test.go +++ b/pkg/caldav/caldav_test.go @@ -26,275 +26,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestParseEvents(t *testing.T) { - type args struct { - config *Config - events []*Event - } - tests := []struct { - name string - args args - wantCaldavevents string - }{ - { - name: "Test caldavparsing without reminders", - args: args{ - config: &Config{ - Name: "test", - ProdID: "RandomProdID which is not random", - Color: "ffffff", - }, - events: []*Event{ - { - Summary: "Event #1", - Description: "Lorem Ipsum", - UID: "randommduid", - Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()), - Start: time.Unix(1543626724, 0).In(config.GetTimeZone()), - End: time.Unix(1543627824, 0).In(config.GetTimeZone()), - Color: "affffe", - }, - { - Summary: "Event #2", - UID: "randommduidd", - Timestamp: time.Unix(1543726724, 0).In(config.GetTimeZone()), - Start: time.Unix(1543726724, 0).In(config.GetTimeZone()), - End: time.Unix(1543738724, 0).In(config.GetTimeZone()), - }, - { - Summary: "Event #3 with empty uid", - UID: "20181202T0600242aaef4a81d770c1e775e26bc5abebc87f1d3d7bffaa83", - Timestamp: time.Unix(1543726824, 0).In(config.GetTimeZone()), - Start: time.Unix(1543726824, 0).In(config.GetTimeZone()), - End: time.Unix(1543727000, 0).In(config.GetTimeZone()), - }, - }, - }, - wantCaldavevents: `BEGIN:VCALENDAR -VERSION:2.0 -METHOD:PUBLISH -X-PUBLISHED-TTL:PT4H -X-WR-CALNAME:test -PRODID:-//RandomProdID which is not random//EN -X-APPLE-CALENDAR-COLOR:#ffffffFF -X-OUTLOOK-COLOR:#ffffffFF -X-FUNAMBOL-COLOR:#ffffffFF -BEGIN:VEVENT -UID:randommduid -SUMMARY:Event #1 -X-APPLE-CALENDAR-COLOR:#affffeFF -X-OUTLOOK-COLOR:#affffeFF -X-FUNAMBOL-COLOR:#affffeFF -DESCRIPTION:Lorem Ipsum -DTSTAMP:20181201T011204Z -DTSTART:20181201T011204Z -DTEND:20181201T013024Z -END:VEVENT -BEGIN:VEVENT -UID:randommduidd -SUMMARY:Event #2 -DESCRIPTION: -DTSTAMP:20181202T045844Z -DTSTART:20181202T045844Z -DTEND:20181202T081844Z -END:VEVENT -BEGIN:VEVENT -UID:20181202T0600242aaef4a81d770c1e775e26bc5abebc87f1d3d7bffaa83 -SUMMARY:Event #3 with empty uid -DESCRIPTION: -DTSTAMP:20181202T050024Z -DTSTART:20181202T050024Z -DTEND:20181202T050320Z -END:VEVENT -END:VCALENDAR`, - }, - { - name: "Test caldavparsing with reminders", - args: args{ - config: &Config{ - Name: "test2", - ProdID: "RandomProdID which is not random", - }, - events: []*Event{ - { - Summary: "Event #1", - Description: "Lorem Ipsum", - UID: "randommduid", - Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()), - Start: time.Unix(1543626724, 0).In(config.GetTimeZone()), - End: time.Unix(1543627824, 0).In(config.GetTimeZone()), - Alarms: []Alarm{ - {Time: time.Unix(1543626524, 0).In(config.GetTimeZone())}, - {Time: time.Unix(1543626224, 0).In(config.GetTimeZone())}, - {Time: time.Unix(1543626024, 0)}, - }, - }, - { - Summary: "Event #2", - UID: "randommduidd", - Timestamp: time.Unix(1543726724, 0).In(config.GetTimeZone()), - Start: time.Unix(1543726724, 0).In(config.GetTimeZone()), - End: time.Unix(1543738724, 0).In(config.GetTimeZone()), - Alarms: []Alarm{ - {Time: time.Unix(1543626524, 0).In(config.GetTimeZone())}, - {Time: time.Unix(1543626224, 0).In(config.GetTimeZone())}, - {Time: time.Unix(1543626024, 0).In(config.GetTimeZone())}, - }, - }, - { - Summary: "Event #3 with empty uid", - Timestamp: time.Unix(1543726824, 0).In(config.GetTimeZone()), - Start: time.Unix(1543726824, 0).In(config.GetTimeZone()), - End: time.Unix(1543727000, 0).In(config.GetTimeZone()), - Alarms: []Alarm{ - {Time: time.Unix(1543626524, 0).In(config.GetTimeZone())}, - {Time: time.Unix(1543626224, 0).In(config.GetTimeZone())}, - {Time: time.Unix(1543626024, 0).In(config.GetTimeZone())}, - {Time: time.Unix(1543826824, 0).In(config.GetTimeZone())}, - }, - }, - { - Summary: "Event #4 without any", - Timestamp: time.Unix(1543726824, 0), - Start: time.Unix(1543726824, 0), - End: time.Unix(1543727000, 0), - }, - }, - }, - wantCaldavevents: `BEGIN:VCALENDAR -VERSION:2.0 -METHOD:PUBLISH -X-PUBLISHED-TTL:PT4H -X-WR-CALNAME:test2 -PRODID:-//RandomProdID which is not random//EN -BEGIN:VEVENT -UID:randommduid -SUMMARY:Event #1 -DESCRIPTION:Lorem Ipsum -DTSTAMP:20181201T011204Z -DTSTART:20181201T011204Z -DTEND:20181201T013024Z -BEGIN:VALARM -TRIGGER:-PT3M20S -ACTION:DISPLAY -DESCRIPTION:Event #1 -END:VALARM -BEGIN:VALARM -TRIGGER:-PT8M20S -ACTION:DISPLAY -DESCRIPTION:Event #1 -END:VALARM -BEGIN:VALARM -TRIGGER:-PT11M40S -ACTION:DISPLAY -DESCRIPTION:Event #1 -END:VALARM -END:VEVENT -BEGIN:VEVENT -UID:randommduidd -SUMMARY:Event #2 -DESCRIPTION: -DTSTAMP:20181202T045844Z -DTSTART:20181202T045844Z -DTEND:20181202T081844Z -BEGIN:VALARM -TRIGGER:-PT27H50M0S -ACTION:DISPLAY -DESCRIPTION:Event #2 -END:VALARM -BEGIN:VALARM -TRIGGER:-PT27H55M0S -ACTION:DISPLAY -DESCRIPTION:Event #2 -END:VALARM -BEGIN:VALARM -TRIGGER:-PT27H58M20S -ACTION:DISPLAY -DESCRIPTION:Event #2 -END:VALARM -END:VEVENT -BEGIN:VEVENT -UID:20181202T050024Z2aaef4a81d770c1e775e26bc5abebc87f1d3d7bffaa83 -SUMMARY:Event #3 with empty uid -DESCRIPTION: -DTSTAMP:20181202T050024Z -DTSTART:20181202T050024Z -DTEND:20181202T050320Z -BEGIN:VALARM -TRIGGER:-PT27H51M40S -ACTION:DISPLAY -DESCRIPTION:Event #3 with empty uid -END:VALARM -BEGIN:VALARM -TRIGGER:-PT27H56M40S -ACTION:DISPLAY -DESCRIPTION:Event #3 with empty uid -END:VALARM -BEGIN:VALARM -TRIGGER:-PT28H0M0S -ACTION:DISPLAY -DESCRIPTION:Event #3 with empty uid -END:VALARM -BEGIN:VALARM -TRIGGER:PT27H46M40S -ACTION:DISPLAY -DESCRIPTION:Event #3 with empty uid -END:VALARM -END:VEVENT -BEGIN:VEVENT -UID:20181202T050024Zae7548ce9556df85038abe90dc674d4741a61ce74d1cf -SUMMARY:Event #4 without any -DESCRIPTION: -DTSTAMP:20181202T050024Z -DTSTART:20181202T050024Z -DTEND:20181202T050320Z -END:VEVENT -END:VCALENDAR`, - }, - { - name: "Test caldavparsing with multiline description", - args: args{ - config: &Config{ - Name: "test", - ProdID: "RandomProdID which is not random", - }, - events: []*Event{ - { - Summary: "Event #1", - Description: `Lorem Ipsum -Dolor sit amet`, - UID: "randommduid", - Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()), - Start: time.Unix(1543626724, 0).In(config.GetTimeZone()), - End: time.Unix(1543627824, 0).In(config.GetTimeZone()), - }, - }, - }, - wantCaldavevents: `BEGIN:VCALENDAR -VERSION:2.0 -METHOD:PUBLISH -X-PUBLISHED-TTL:PT4H -X-WR-CALNAME:test -PRODID:-//RandomProdID which is not random//EN -BEGIN:VEVENT -UID:randommduid -SUMMARY:Event #1 -DESCRIPTION:Lorem Ipsum\nDolor sit amet -DTSTAMP:20181201T011204Z -DTSTART:20181201T011204Z -DTEND:20181201T013024Z -END:VEVENT -END:VCALENDAR`, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotCaldavevents := ParseEvents(tt.args.config, tt.args.events) - assert.Equal(t, gotCaldavevents, tt.wantCaldavevents) - }) - } -} - func TestParseTodos(t *testing.T) { type args struct { config *Config @@ -520,13 +251,88 @@ X-FUNAMBOL-COLOR:#affffeFF CATEGORIES:label1,label2 LAST-MODIFIED:00010101T000000Z END:VTODO +END:VCALENDAR`, + }, + { + name: "with alarm", + args: args{ + config: &Config{ + Name: "test", + ProdID: "RandomProdID which is not random", + }, + todos: []*Todo{ + { + Summary: "Todo #1", + UID: "randommduid", + Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Alarms: []Alarm{ + { + Time: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + { + Time: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Description: "alarm description", + }, + { + Duration: -2 * time.Hour, + RelativeTo: "due_date", + }, + { + Duration: 1 * time.Hour, + RelativeTo: "start_date", + }, + { + Duration: time.Duration(0), + RelativeTo: "end_date", + }, + }, + }, + }, + }, + wantCaldavtasks: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:test +PRODID:-//RandomProdID which is not random//EN +BEGIN:VTODO +UID:randommduid +DTSTAMP:20181201T011204Z +SUMMARY:Todo #1 +LAST-MODIFIED:00010101T000000Z +BEGIN:VALARM +TRIGGER;VALUE=DATE-TIME:20181201T011204Z +ACTION:DISPLAY +DESCRIPTION:Todo #1 +END:VALARM +BEGIN:VALARM +TRIGGER;VALUE=DATE-TIME:20181201T011204Z +ACTION:DISPLAY +DESCRIPTION:alarm description +END:VALARM +BEGIN:VALARM +TRIGGER;RELATED=END:-PT2H0M0S +ACTION:DISPLAY +DESCRIPTION:Todo #1 +END:VALARM +BEGIN:VALARM +TRIGGER;RELATED=START:PT1H0M0S +ACTION:DISPLAY +DESCRIPTION:Todo #1 +END:VALARM +BEGIN:VALARM +TRIGGER;RELATED=END:PT0S +ACTION:DISPLAY +DESCRIPTION:Todo #1 +END:VALARM +END:VTODO END:VCALENDAR`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gotCaldavtasks := ParseTodos(tt.args.config, tt.args.todos) - assert.Equal(t, gotCaldavtasks, tt.wantCaldavtasks) + assert.Equal(t, tt.wantCaldavtasks, gotCaldavtasks) }) } } diff --git a/pkg/caldav/parsing.go b/pkg/caldav/parsing.go index 4b887f86b3..56838e455f 100644 --- a/pkg/caldav/parsing.go +++ b/pkg/caldav/parsing.go @@ -17,12 +17,14 @@ package caldav import ( + "errors" "strconv" "strings" "time" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/utils" ics "github.com/arran4/golang-ical" ) @@ -38,6 +40,14 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT for _, label := range t.Labels { categories = append(categories, label.Title) } + var alarms []Alarm + for _, reminder := range t.Reminders { + alarms = append(alarms, Alarm{ + Time: reminder.Reminder, + Duration: time.Duration(reminder.RelativePeriod) * time.Second, + RelativeTo: reminder.RelativeTo, + }) + } caldavtodos = append(caldavtodos, &Todo{ Timestamp: t.Updated, @@ -56,6 +66,7 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT Duration: duration, RepeatAfter: t.RepeatAfter, RepeatMode: t.RepeatMode, + Alarms: alarms, }) } @@ -72,10 +83,13 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) { if err != nil { return nil, err } - - // We put the task details in a map to be able to handle them more easily + vTodo, ok := parsed.Components[0].(*ics.VTodo) + if !ok { + return nil, errors.New("VTODO element not found") + } + // We put the vTodo details in a map to be able to handle them more easily task := make(map[string]string) - for _, c := range parsed.Components[0].UnknownPropertiesIANAProperties() { + for _, c := range vTodo.UnknownPropertiesIANAProperties() { task[c.IANAToken] = c.Value } @@ -127,9 +141,63 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) { vTask.EndDate = vTask.StartDate.Add(duration) } + for _, vAlarm := range vTodo.SubComponents() { + if vAlarm, ok := vAlarm.(*ics.VAlarm); ok { + vTask = parseVAlarm(vAlarm, vTask) + } + } + return } +func parseVAlarm(vAlarm *ics.VAlarm, vTask *models.Task) *models.Task { + for _, property := range vAlarm.UnknownPropertiesIANAProperties() { + if property.IANAToken != "TRIGGER" { + continue + } + + if contains(property.ICalParameters["VALUE"], "DATE-TIME") { + // Example: TRIGGER;VALUE=DATE-TIME:20181201T011210Z + vTask.Reminders = append(vTask.Reminders, &models.TaskReminder{ + Reminder: caldavTimeToTimestamp(property.Value), + }) + continue + } + + duration := utils.ParseISO8601Duration(property.Value) + + if contains(property.ICalParameters["RELATED"], "END") { + // Example: TRIGGER;RELATED=END:-P2D + if vTask.EndDate.IsZero() { + vTask.Reminders = append(vTask.Reminders, &models.TaskReminder{ + RelativePeriod: int64(duration.Seconds()), + RelativeTo: models.ReminderRelationDueDate}) + } else { + vTask.Reminders = append(vTask.Reminders, &models.TaskReminder{ + RelativePeriod: int64(duration.Seconds()), + RelativeTo: models.ReminderRelationEndDate}) + } + continue + } + + // Example: TRIGGER;RELATED=START:-P2D + // Example: TRIGGER:-PT60M + vTask.Reminders = append(vTask.Reminders, &models.TaskReminder{ + RelativePeriod: int64(duration.Seconds()), + RelativeTo: models.ReminderRelationStartDate}) + } + return vTask +} + +func contains(array []string, str string) bool { + for _, value := range array { + if value == str { + return true + } + } + return false +} + // https://tools.ietf.org/html/rfc5545#section-3.3.5 func caldavTimeToTimestamp(tstring string) time.Time { if tstring == "" { diff --git a/pkg/caldav/parsing_test.go b/pkg/caldav/parsing_test.go index f5923b60c4..b513fd0b31 100644 --- a/pkg/caldav/parsing_test.go +++ b/pkg/caldav/parsing_test.go @@ -118,6 +118,107 @@ END:VCALENDAR`, Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), }, }, + { + name: "With alarm (time trigger)", + args: args{content: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:test +PRODID:-//RandomProdID which is not random//EN +BEGIN:VTODO +UID:randomuid +DTSTAMP:20181201T011204 +SUMMARY:Todo #1 +DESCRIPTION:Lorem Ipsum +BEGIN:VALARM +TRIGGER;VALUE=DATE-TIME:20181201T011210Z +ACTION:DISPLAY +END:VALARM +END:VTODO +END:VCALENDAR`, + }, + wantVTask: &models.Task{ + Title: "Todo #1", + UID: "randomuid", + Description: "Lorem Ipsum", + Reminders: []*models.TaskReminder{ + { + Reminder: time.Date(2018, 12, 1, 1, 12, 10, 0, config.GetTimeZone()), + }, + }, + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + }, + { + name: "With alarm (relative trigger)", + args: args{content: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:test +PRODID:-//RandomProdID which is not random//EN +BEGIN:VTODO +UID:randomuid +DTSTAMP:20181201T011204 +SUMMARY:Todo #1 +DESCRIPTION:Lorem Ipsum +DTSTART:20230228T170000Z +DUE:20230304T150000Z +BEGIN:VALARM +TRIGGER:PT0S +ACTION:DISPLAY +END:VALARM +BEGIN:VALARM +TRIGGER;VALUE=DURATION:-PT60M +ACTION:DISPLAY +END:VALARM +BEGIN:VALARM +TRIGGER:-PT61M +ACTION:DISPLAY +END:VALARM +BEGIN:VALARM +TRIGGER;RELATED=START:-P1D +ACTION:DISPLAY +END:VALARM +BEGIN:VALARM +TRIGGER;RELATED=END:-PT30M +ACTION:DISPLAY +END:VALARM +END:VTODO +END:VCALENDAR`, + }, + wantVTask: &models.Task{ + Title: "Todo #1", + UID: "randomuid", + Description: "Lorem Ipsum", + StartDate: time.Date(2023, 2, 28, 17, 0, 0, 0, config.GetTimeZone()), + DueDate: time.Date(2023, 3, 4, 15, 0, 0, 0, config.GetTimeZone()), + Reminders: []*models.TaskReminder{ + { + RelativeTo: models.ReminderRelationStartDate, + RelativePeriod: 0, + }, + { + RelativeTo: models.ReminderRelationStartDate, + RelativePeriod: -3600, + }, + { + RelativeTo: models.ReminderRelationStartDate, + RelativePeriod: -3660, + }, + { + RelativeTo: models.ReminderRelationStartDate, + RelativePeriod: -86400, + }, + { + RelativeTo: models.ReminderRelationDueDate, + RelativePeriod: -1800, + }, + }, + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -127,7 +228,7 @@ END:VCALENDAR`, return } if diff, equal := messagediff.PrettyDiff(got, tt.wantVTask); !equal { - t.Errorf("ParseTaskFromVTODO() gotVTask = %v, want %v, diff = %s", got, tt.wantVTask, diff) + t.Errorf("ParseTaskFromVTODO()\n gotVTask = %v\n want %v\n diff = %s", got, tt.wantVTask, diff) } }) } @@ -175,6 +276,16 @@ func TestGetCaldavTodosForTasks(t *testing.T) { Title: "label2", }, }, + Reminders: []*models.TaskReminder{ + { + Reminder: time.Unix(1543626730, 0).In(config.GetTimeZone()), + }, + { + Reminder: time.Unix(1543626731, 0).In(config.GetTimeZone()), + RelativePeriod: -3600, + RelativeTo: models.ReminderRelationDueDate, + }, + }, }, }, }, @@ -200,6 +311,16 @@ PRIORITY:3 RRULE:FREQ=SECONDLY;INTERVAL=86400 CATEGORIES:label1,label2 LAST-MODIFIED:20181201T011205Z +BEGIN:VALARM +TRIGGER;VALUE=DATE-TIME:20181201T011210Z +ACTION:DISPLAY +DESCRIPTION:Task 1 +END:VALARM +BEGIN:VALARM +TRIGGER;RELATED=END:-PT1H0M0S +ACTION:DISPLAY +DESCRIPTION:Task 1 +END:VALARM END:VTODO END:VCALENDAR`, }, diff --git a/pkg/db/fixtures/task_reminders.yml b/pkg/db/fixtures/task_reminders.yml index d3d023e9b3..a5e5ec07ad 100644 --- a/pkg/db/fixtures/task_reminders.yml +++ b/pkg/db/fixtures/task_reminders.yml @@ -12,3 +12,7 @@ task_id: 2 reminder: 2018-12-01 01:13:44 created: 2018-12-01 01:12:04 +- id: 4 + task_id: 39 + reminder: 2023-03-04 15:00:00 + created: 2018-12-01 01:12:04 diff --git a/pkg/integrations/caldav_test.go b/pkg/integrations/caldav_test.go index 5036a305ce..7ab7044462 100644 --- a/pkg/integrations/caldav_test.go +++ b/pkg/integrations/caldav_test.go @@ -37,6 +37,10 @@ SUMMARY:Caldav Task 1 CATEGORIES:tag1,tag2,tag3 CREATED:20230301T073337Z LAST-MODIFIED:20230301T073337Z +BEGIN:VALARM +TRIGGER;VALUE=DATE-TIME:20230304T150000Z +ACTION:DISPLAY +END:VALARM END:VTODO END:VCALENDAR` @@ -65,5 +69,9 @@ func TestCaldav(t *testing.T) { assert.Contains(t, rec.Body.String(), "DUE:20230301T150000Z") assert.Contains(t, rec.Body.String(), "PRIORITY:3") assert.Contains(t, rec.Body.String(), "CATEGORIES:Label #4") + assert.Contains(t, rec.Body.String(), "BEGIN:VALARM") + assert.Contains(t, rec.Body.String(), "TRIGGER;VALUE=DATE-TIME:20230304T150000Z") + assert.Contains(t, rec.Body.String(), "ACTION:DISPLAY") + assert.Contains(t, rec.Body.String(), "END:VALARM") }) } diff --git a/pkg/modules/migration/ticktick/ticktick.go b/pkg/modules/migration/ticktick/ticktick.go index c31c8817a3..861408d776 100644 --- a/pkg/modules/migration/ticktick/ticktick.go +++ b/pkg/modules/migration/ticktick/ticktick.go @@ -20,9 +20,7 @@ import ( "encoding/csv" "errors" "io" - "regexp" "sort" - "strconv" "strings" "time" @@ -30,6 +28,7 @@ import ( "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/migration" "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/utils" "github.com/gocarina/gocsv" ) @@ -75,36 +74,6 @@ func (date *tickTickTime) UnmarshalCSV(csv string) (err error) { return err } -// Copied from https://stackoverflow.com/a/57617885 -var durationRegex = regexp.MustCompile(`P([\d\.]+Y)?([\d\.]+M)?([\d\.]+D)?T?([\d\.]+H)?([\d\.]+M)?([\d\.]+?S)?`) - -// ParseDuration converts a ISO8601 duration into a time.Duration -func parseDuration(str string) time.Duration { - matches := durationRegex.FindStringSubmatch(str) - - if len(matches) == 0 { - return 0 - } - - years := parseDurationPart(matches[1], time.Hour*24*365) - months := parseDurationPart(matches[2], time.Hour*24*30) - days := parseDurationPart(matches[3], time.Hour*24) - hours := parseDurationPart(matches[4], time.Hour) - minutes := parseDurationPart(matches[5], time.Second*60) - seconds := parseDurationPart(matches[6], time.Second) - - return years + months + days + hours + minutes + seconds -} - -func parseDurationPart(value string, unit time.Duration) time.Duration { - if len(value) != 0 { - if parsed, err := strconv.ParseFloat(value[:len(value)-1], 64); err == nil { - return time.Duration(float64(unit) * parsed) - } - } - return 0 -} - func convertTickTickToVikunja(tasks []*tickTickTask) (result []*models.NamespaceWithProjectsAndTasks) { namespace := &models.NamespaceWithProjectsAndTasks{ Namespace: models.Namespace{ @@ -231,7 +200,7 @@ func (m *Migrator) Migrate(user *user.User, file io.ReaderAt, size int64) error task.IsChecklist = true } - reminder := parseDuration(task.ReminderDuration) + reminder := utils.ParseISO8601Duration(task.ReminderDuration) if reminder > 0 { task.Reminder = reminder } diff --git a/pkg/utils/duration.go b/pkg/utils/duration.go new file mode 100644 index 0000000000..de394497f2 --- /dev/null +++ b/pkg/utils/duration.go @@ -0,0 +1,57 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package utils + +import ( + "regexp" + "strconv" + "time" +) + +// ParseISO8601Duration converts a ISO8601 duration into a time.Duration +func ParseISO8601Duration(str string) time.Duration { + matches := durationRegex.FindStringSubmatch(str) + + if len(matches) == 0 { + return 0 + } + + years := parseDurationPart(matches[2], time.Hour*24*365) + months := parseDurationPart(matches[3], time.Hour*24*30) + days := parseDurationPart(matches[4], time.Hour*24) + hours := parseDurationPart(matches[5], time.Hour) + minutes := parseDurationPart(matches[6], time.Second*60) + seconds := parseDurationPart(matches[7], time.Second) + + duration := years + months + days + hours + minutes + seconds + + if matches[1] == "-" { + return -duration + } + return duration +} + +var durationRegex = regexp.MustCompile(`([-+])?P([\d\.]+Y)?([\d\.]+M)?([\d\.]+D)?T?([\d\.]+H)?([\d\.]+M)?([\d\.]+?S)?`) + +func parseDurationPart(value string, unit time.Duration) time.Duration { + if len(value) != 0 { + if parsed, err := strconv.ParseFloat(value[:len(value)-1], 64); err == nil { + return time.Duration(float64(unit) * parsed) + } + } + return 0 +} diff --git a/pkg/utils/duration_test.go b/pkg/utils/duration_test.go new file mode 100644 index 0000000000..1af2e905de --- /dev/null +++ b/pkg/utils/duration_test.go @@ -0,0 +1,39 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package utils + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestParseISO8601Duration(t *testing.T) { + t.Run("full example", func(t *testing.T) { + dur := ParseISO8601Duration("P1DT1H1M1S") + expected, _ := time.ParseDuration("25h1m1s") + + assert.Equal(t, expected, dur) + }) + t.Run("negative duration", func(t *testing.T) { + dur := ParseISO8601Duration("-P1DT1H1M1S") + expected, _ := time.ParseDuration("-25h1m1s") + + assert.Equal(t, expected, dur) + }) +}