feat(caldav): Sync Reminders / VALARM #1415
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
konrad
commented
Won't this add a reminder relative to the end date when there's a due date but no end date? Won't this add a reminder relative to the end date when there's a due date but no end date?
ce72
commented
Yes, that's intended. CalDav standard does not know RELATED=DUE or something like that. The same is true for the implementation at tasks.org. After looking more closely at it, there is maybe an issue in the existing code. The VTODO element does not know DTEND at all. I did not change that, but this implies that we don't need ReminderRelationEndDate (we won't be able to sync it anyway, because clients will ignore it.) Yes, that's intended. CalDav standard does not know RELATED=DUE or something like that. The same is true for the implementation at tasks.org.
According to the standard https://icalendar.org/iCalendar-RFC-5545/3-8-6-3-trigger.html due dates are backed by triggers set relative to "END".
After looking more closely at it, there is maybe an issue in the existing code. The VTODO element does not know DTEND at all. I did not change that, but this implies that we don't need ReminderRelationEndDate (we won't be able to sync it anyway, because clients will ignore it.)
There seems to be room for more cleanup PRs... But I think the statement you commented is not incorrect by itself.
konrad
commented
Okay, that makes sense. Thanks for the explanation.
So if a users sets a reminder to be relative to the end date in the frontend, it will be ignored by the CalDAV clients?
There almost certainly is! Okay, that makes sense. Thanks for the explanation.
> The VTODO element does not know DTEND at all. I did not change that, but this implies that we don't need ReminderRelationEndDate (we won't be able to sync it anyway, because clients will ignore it.)
So if a users sets a reminder to be relative to the end date in the frontend, it will be ignored by the CalDAV clients?
> There seems to be room for more cleanup PRs
There almost certainly is!
ce72
commented
We would serialize this like
The client will most certainly ignore DTEND and the alarm might get lost. If the task additionally has a due_date given in the frontend, the client will relate the trigger to this date, transferred as DUE. The other direction probably will not happen: clients will never send DTEND (and the parsing code ignores it anyway..) I am looking forward to testing this with tasks.org once it's in the unstable version. Then maybe we need another iteration. And maybe even some edge cases will never work at all, because Vikunjas model and CalDav do not overlap by 100%. I think in part it is tolerable as long as reminders relative to due_date and then to start_date work as expected, and we understand (and document) what's going on. > So if a users sets a reminder to be relative to the end date in the frontend, it will be ignored by the CalDAV clients?
>
We would serialize this like
```
BEGIN:VTODO
DTEND=<timestamp>
...
BEGIN:VALARM
TRIGGER;RELATED=END:<duration>
...
```
The client will most certainly ignore DTEND and the alarm might get lost. If the task additionally has a due_date given in the frontend, the client will relate the trigger to this date, transferred as DUE.
The other direction probably will not happen: clients will never send DTEND (and the parsing code ignores it anyway..)
If they send `DUE=<timestamp>` and `TRIGGER;RELATED=END:<duration>` then we will create a reminder relative to the due date (which might be the 95% scenario).
I am looking forward to testing this with tasks.org once it's in the unstable version. Then maybe we need another iteration. And maybe even some edge cases will never work at all, because Vikunjas model and CalDav do not overlap by 100%. I think in part it is tolerable as long as reminders relative to due_date and then to start_date work as expected, and we understand (and document) what's going on.
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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") {
|
||||
konrad
commented
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?
ce72
commented
I modified the implementation to follow more closely the CalDav standard 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?
konrad
commented
I think this is fine now. I think this is fine now.
|
||||
// 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 == "" {
|
||||
|
|
|
@ -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`,
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
}
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
I think this should be a string enum (yes, Go does not have real enums but the const type thing)
ok