diff --git a/docs/content/doc/usage/caldav.md b/docs/content/doc/usage/caldav.md
index e43ba0436..60d261299 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 0f324f1c8..0f53f5c1c 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 a8da6e240..e1de0d9ca 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 4b887f86b..56838e455 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 f5923b60c..b513fd0b3 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 d3d023e9b..a5e5ec07a 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 5036a305c..7ab704446 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 c31c8817a..861408d77 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 000000000..de394497f
--- /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 000000000..1af2e905d
--- /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)
+ })
+}