feat(caldav): Sync Reminders / VALARM #1415

Merged
konrad merged 30 commits from ce72/api:1408_caldav_alarms into main 2023-04-01 11:09:13 +00:00
7 changed files with 244 additions and 27 deletions
Showing only changes of commit f695042465 - Show all commits

View File

@ -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

View File

@ -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
@ -247,6 +235,18 @@ CATEGORIES:` + strings.Join(t.Categories, ",")
caldavtodos += `
LAST-MODIFIED:` + makeCalDavTimeFromTimeStamp(t.Updated)
for _, a := range t.Alarms {
if a.Description == "" {
a.Description = t.Summary
}
caldavtodos += `
BEGIN:VALARM
TRIGGER;VALUE=DATE-TIME:` + makeCalDavTimeFromTimeStamp(a.Time) + `
ACTION:DISPLAY
DESCRIPTION:` + a.Description + `
END:VALARM`
}
caldavtodos += `
END:VTODO`
}

View File

@ -520,6 +520,54 @@ 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",
},
},
},
},
},
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
END:VTODO
END:VCALENDAR`,
},
}

View File

@ -17,6 +17,8 @@
package caldav
import (
"errors"
"regexp"
"strconv"
"strings"
"time"
@ -38,6 +40,12 @@ 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,
})
}
caldavtodos = append(caldavtodos, &Todo{
Timestamp: t.Updated,
@ -56,6 +64,7 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT
Duration: duration,
RepeatAfter: t.RepeatAfter,
RepeatMode: t.RepeatMode,
Alarms: alarms,
})
}
@ -72,10 +81,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 +139,55 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
vTask.EndDate = vTask.StartDate.Add(duration)
}
for _, vAlarm := range vTodo.SubComponents() {
switch vAlarm := vAlarm.(type) {
case *ics.VAlarm:
parseVAlarm(vAlarm, vTask)
}
}
return
}
func parseVAlarm(vAlarm *ics.VAlarm, vTask *models.Task) {
for _, property := range vAlarm.UnknownPropertiesIANAProperties() {
if property.IANAToken == "TRIGGER" {
if len(property.ICalParameters["VALUE"]) > 0 {
switch property.ICalParameters["VALUE"][0] {
case "DATE-TIME":
// TRIGGER;VALUE=DATE-TIME:20181201T011210Z
vTask.Reminders = append(vTask.Reminders, caldavTimeToTimestamp(property.Value))
}
} else if len(property.ICalParameters["RELATED"]) > 0 {
duration := parseDuration(property.Value)
switch property.ICalParameters["RELATED"][0] {
case "START":
// TRIGGER;RELATED=START:-P2D
if !vTask.StartDate.IsZero() {
vTask.Reminders = append(vTask.Reminders, vTask.StartDate.Add(duration))
}
case "END":

Doesn't calDAV have a concept of a due date? And if that's the case, can't we use that as a relative anchor point?

Doesn't calDAV have a concept of a due date? And if that's the case, can't we use that as a relative anchor point?
Outdated
Review

I modified the implementation to follow more closely the CalDav standard
https://icalendar.org/iCalendar-RFC-5545/3-8-6-3-trigger.html

I also took a look at tasks.org CalDav implementation https://github.com/tasks/tasks/blob/main/app/src/main/java/org/tasks/caldav/extensions/VAlarm.kt

Can you please look at it again?

I modified the implementation to follow more closely the CalDav standard https://icalendar.org/iCalendar-RFC-5545/3-8-6-3-trigger.html I also took a look at tasks.org CalDav implementation https://github.com/tasks/tasks/blob/main/app/src/main/java/org/tasks/caldav/extensions/VAlarm.kt Can you please look at it again?

I think this is fine now.

I think this is fine now.
// TRIGGER;RELATED=END:-P2D
if !vTask.EndDate.IsZero() {
vTask.Reminders = append(vTask.Reminders, vTask.EndDate.Add(duration))
} else if !vTask.DueDate.IsZero() {
vTask.Reminders = append(vTask.Reminders, vTask.DueDate.Add(duration))
}
}
} else {
duration := parseDuration(property.Value)
if duration != 0 {
// TRIGGER:-PT60M
if !vTask.DueDate.IsZero() {
vTask.Reminders = append(vTask.Reminders, vTask.DueDate.Add(duration))
}
}
}
}
}
}
// https://tools.ietf.org/html/rfc5545#section-3.3.5
func caldavTimeToTimestamp(tstring string) time.Time {
if tstring == "" {
@ -153,3 +211,38 @@ func caldavTimeToTimestamp(tstring string) time.Time {
}
return t
}
// From https://stackoverflow.com/a/57617885; with support for negative durations added
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)

We have this exact function already in the ticktick migration. Can you move this in a general helper function and use the same function in both places?

We have this exact function already in the ticktick migration. Can you move this in a general helper function and use the same function in both places?
Outdated
Review

ok

ok
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
}
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
}

View File

@ -118,6 +118,55 @@ END:VCALENDAR`,
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
},
},
{
name: "With alarm",
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;VALUE=DATE-TIME:20181201T011210Z
ACTION:DISPLAY
END:VALARM
BEGIN:VALARM
TRIGGER:-PT60M
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: []time.Time{
time.Date(2018, 12, 1, 1, 12, 10, 0, config.GetTimeZone()),
time.Date(2023, 3, 4, 14, 0, 0, 0, config.GetTimeZone()),
time.Date(2023, 2, 27, 17, 00, 0, 0, config.GetTimeZone()),
time.Date(2023, 3, 4, 14, 30, 0, 0, config.GetTimeZone()),
},
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -127,7 +176,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 +224,10 @@ func TestGetCaldavTodosForTasks(t *testing.T) {
Title: "label2",
},
},
Reminders: []time.Time{
time.Unix(1543626730, 0).In(config.GetTimeZone()),
time.Unix(1543626731, 0).In(config.GetTimeZone()),
},
},
},
},
@ -200,6 +253,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;VALUE=DATE-TIME:20181201T011211Z
ACTION:DISPLAY
DESCRIPTION:Task 1
END:VALARM
END:VTODO
END:VCALENDAR`,
},

View File

@ -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

View File

@ -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")
})
}