feat(caldav): Add support for subtasks (i.e. RELATED-TO
property) in CalDAV (#1634)
As I mentioned [here](https://kolaente.dev/vikunja/api/pulls/1442#issuecomment-55215), this is mainly a cleanup of @zewaren 's original [PR](https://kolaente.dev/vikunja/api/pulls/1442). It adds support for the `RELATED-TO` property in CalDAV's `VTODO` and the `RELTYPE=PARENT` and `RELTYPE=CHILD` relationships. In other words, it allows for `ParentTask->SubTask` relations to be handled supported through CalDAV. In addition to the included tests, this has been tested by both @zewaren & myself with DAVx5 & Tasks (Android) and it's been working great. Resolves https://kolaente.dev/vikunja/api/issues/1345 Co-authored-by: Miguel A. Arroyo <miguel@codeheads.dev> Co-authored-by: Erwan Martin <public@fzwte.net> Reviewed-on: https://kolaente.dev/vikunja/api/pulls/1634 Reviewed-by: konrad <k@knt.li> Co-authored-by: Miguel Arroyo <mayanez@noreply.kolaente.de> Co-committed-by: Miguel Arroyo <mayanez@noreply.kolaente.de>
This commit is contained in:
@ -38,21 +38,21 @@ type Todo struct {
|
||||
UID string
|
||||
|
||||
// Optional
|
||||
Summary string
|
||||
Description string
|
||||
Completed time.Time
|
||||
Organizer *user.User
|
||||
Priority int64 // 0-9, 1 is highest
|
||||
RelatedToUID string
|
||||
Color string
|
||||
Categories []string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
DueDate time.Time
|
||||
Duration time.Duration
|
||||
RepeatAfter int64
|
||||
RepeatMode models.TaskRepeatMode
|
||||
Alarms []Alarm
|
||||
Summary string
|
||||
Description string
|
||||
Completed time.Time
|
||||
Organizer *user.User
|
||||
Priority int64 // 0-9, 1 is highest
|
||||
Relations []Relation
|
||||
Color string
|
||||
Categories []string
|
||||
Start time.Time
|
||||
End time.Time
|
||||
DueDate time.Time
|
||||
Duration time.Duration
|
||||
RepeatAfter int64
|
||||
RepeatMode models.TaskRepeatMode
|
||||
Alarms []Alarm
|
||||
|
||||
Created time.Time
|
||||
Updated time.Time // last-mod
|
||||
@ -66,6 +66,11 @@ type Alarm struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
type Relation struct {
|
||||
Type models.RelationKind
|
||||
UID string
|
||||
}
|
||||
|
||||
// Config is the caldav calendar config
|
||||
type Config struct {
|
||||
Name string
|
||||
@ -147,11 +152,6 @@ STATUS:COMPLETED`
|
||||
ORGANIZER;CN=:` + t.Organizer.Username
|
||||
}
|
||||
|
||||
if t.RelatedToUID != "" {
|
||||
caldavtodos += `
|
||||
RELATED-TO:` + t.RelatedToUID
|
||||
}
|
||||
|
||||
if t.DueDate.Unix() > 0 {
|
||||
caldavtodos += `
|
||||
DUE:` + makeCalDavTimeFromTimeStamp(t.DueDate)
|
||||
@ -185,6 +185,7 @@ CATEGORIES:` + strings.Join(t.Categories, ",")
|
||||
caldavtodos += `
|
||||
LAST-MODIFIED:` + makeCalDavTimeFromTimeStamp(t.Updated)
|
||||
caldavtodos += ParseAlarms(t.Alarms, t.Summary)
|
||||
caldavtodos += ParseRelations(t.Relations)
|
||||
caldavtodos += `
|
||||
END:VTODO`
|
||||
}
|
||||
@ -222,6 +223,47 @@ END:VALARM`
|
||||
return caldavalarms
|
||||
}
|
||||
|
||||
func ParseRelations(relations []Relation) (caldavrelatedtos string) {
|
||||
|
||||
for _, r := range relations {
|
||||
switch r.Type {
|
||||
case models.RelationKindParenttask:
|
||||
caldavrelatedtos += `
|
||||
RELATED-TO;RELTYPE=PARENT:`
|
||||
case models.RelationKindSubtask:
|
||||
caldavrelatedtos += `
|
||||
RELATED-TO;RELTYPE=CHILD:`
|
||||
case models.RelationKindUnknown:
|
||||
continue
|
||||
case models.RelationKindRelated:
|
||||
continue
|
||||
case models.RelationKindDuplicateOf:
|
||||
continue
|
||||
case models.RelationKindDuplicates:
|
||||
continue
|
||||
case models.RelationKindBlocking:
|
||||
continue
|
||||
case models.RelationKindBlocked:
|
||||
continue
|
||||
case models.RelationKindPreceeds:
|
||||
continue
|
||||
case models.RelationKindFollows:
|
||||
continue
|
||||
case models.RelationKindCopiedFrom:
|
||||
continue
|
||||
case models.RelationKindCopiedTo:
|
||||
continue
|
||||
default:
|
||||
caldavrelatedtos += `
|
||||
RELATED-TO:`
|
||||
}
|
||||
|
||||
caldavrelatedtos += r.UID
|
||||
}
|
||||
|
||||
return caldavrelatedtos
|
||||
}
|
||||
|
||||
func makeCalDavTimeFromTimeStamp(ts time.Time) (caldavtime string) {
|
||||
return ts.In(time.UTC).Format(DateFormat) + "Z"
|
||||
}
|
||||
|
@ -326,6 +326,49 @@ ACTION:DISPLAY
|
||||
DESCRIPTION:Todo #1
|
||||
END:VALARM
|
||||
END:VTODO
|
||||
END:VCALENDAR`,
|
||||
},
|
||||
{
|
||||
name: "with related-to",
|
||||
args: args{
|
||||
config: &Config{
|
||||
Name: "test",
|
||||
ProdID: "RandomProdID which is not random",
|
||||
},
|
||||
todos: []*Todo{
|
||||
{
|
||||
Summary: "Todo #1",
|
||||
Description: "Lorem Ipsum",
|
||||
UID: "randommduid",
|
||||
Relations: []Relation{
|
||||
{
|
||||
Type: models.RelationKindParenttask,
|
||||
UID: "parentuid",
|
||||
},
|
||||
{
|
||||
Type: models.RelationKindSubtask,
|
||||
UID: "subtaskuid",
|
||||
},
|
||||
},
|
||||
Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
},
|
||||
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
|
||||
DESCRIPTION:Lorem Ipsum
|
||||
LAST-MODIFIED:00010101T000000Z
|
||||
RELATED-TO;RELTYPE=PARENT:parentuid
|
||||
RELATED-TO;RELTYPE=CHILD:subtaskuid
|
||||
END:VTODO
|
||||
END:VCALENDAR`,
|
||||
},
|
||||
}
|
||||
|
@ -23,7 +23,6 @@ import (
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
@ -51,6 +50,16 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT
|
||||
})
|
||||
}
|
||||
|
||||
var relations []Relation
|
||||
for reltype, tasks := range t.RelatedTasks {
|
||||
for _, r := range tasks {
|
||||
relations = append(relations, Relation{
|
||||
Type: reltype,
|
||||
UID: r.UID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
caldavtodos = append(caldavtodos, &Todo{
|
||||
Timestamp: t.Updated,
|
||||
UID: t.UID,
|
||||
@ -69,6 +78,7 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT
|
||||
RepeatAfter: t.RepeatAfter,
|
||||
RepeatMode: t.RepeatMode,
|
||||
Alarms: alarms,
|
||||
Relations: relations,
|
||||
})
|
||||
}
|
||||
|
||||
@ -91,11 +101,11 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
|
||||
}
|
||||
// We put the vTodo details in a map to be able to handle them more easily
|
||||
task := make(map[string]ics.IANAProperty)
|
||||
var relation ics.IANAProperty
|
||||
var relations []ics.IANAProperty
|
||||
for _, c := range vTodo.UnknownPropertiesIANAProperties() {
|
||||
task[c.IANAToken] = c
|
||||
if strings.HasPrefix(c.IANAToken, "RELATED-TO") {
|
||||
relation = c
|
||||
relations = append(relations, c)
|
||||
}
|
||||
}
|
||||
|
||||
@ -139,17 +149,33 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
|
||||
DoneAt: caldavTimeToTimestamp(task["COMPLETED"]),
|
||||
}
|
||||
|
||||
if relation.Value != "" {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
for _, c := range relations {
|
||||
var relTypeStr string
|
||||
if _, ok := c.ICalParameters["RELTYPE"]; ok {
|
||||
if len(c.ICalParameters["RELTYPE"]) != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
subtask, err := models.GetTaskSimpleByUUID(s, relation.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
relTypeStr = c.ICalParameters["RELTYPE"][0]
|
||||
}
|
||||
|
||||
vTask.RelatedTasks = make(map[models.RelationKind][]*models.Task)
|
||||
vTask.RelatedTasks[models.RelationKindSubtask] = []*models.Task{subtask}
|
||||
var relationKind models.RelationKind
|
||||
switch relTypeStr {
|
||||
case "PARENT":
|
||||
relationKind = models.RelationKindParenttask
|
||||
case "CHILD":
|
||||
relationKind = models.RelationKindSubtask
|
||||
default:
|
||||
relationKind = models.RelationKindParenttask
|
||||
}
|
||||
|
||||
if vTask.RelatedTasks == nil {
|
||||
vTask.RelatedTasks = make(map[models.RelationKind][]*models.Task)
|
||||
}
|
||||
|
||||
vTask.RelatedTasks[relationKind] = append(vTask.RelatedTasks[relationKind], &models.Task{
|
||||
UID: c.Value,
|
||||
})
|
||||
}
|
||||
|
||||
if task["STATUS"].Value == "COMPLETED" {
|
||||
|
@ -219,6 +219,70 @@ END:VCALENDAR`,
|
||||
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With parent",
|
||||
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:SubTask #1
|
||||
DESCRIPTION:Lorem Ipsum
|
||||
LAST-MODIFIED:00010101T000000
|
||||
RELATED-TO;RELTYPE=PARENT:randomuid_parent
|
||||
END:VTODO
|
||||
END:VCALENDAR`,
|
||||
},
|
||||
wantVTask: &models.Task{
|
||||
Title: "SubTask #1",
|
||||
UID: "randomuid",
|
||||
Description: "Lorem Ipsum",
|
||||
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindParenttask: {
|
||||
{
|
||||
UID: "randomuid_parent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "With subtask",
|
||||
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:Parent
|
||||
DESCRIPTION:Lorem Ipsum
|
||||
LAST-MODIFIED:00010101T000000
|
||||
RELATED-TO;RELTYPE=CHILD:randomuid_child
|
||||
END:VTODO
|
||||
END:VCALENDAR`,
|
||||
},
|
||||
wantVTask: &models.Task{
|
||||
Title: "Parent",
|
||||
UID: "randomuid",
|
||||
Description: "Lorem Ipsum",
|
||||
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
{
|
||||
UID: "randomuid_child",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "example task from tasks.org app",
|
||||
args: args{content: `BEGIN:VCALENDAR
|
||||
@ -392,6 +456,124 @@ ACTION:DISPLAY
|
||||
DESCRIPTION:Task 1
|
||||
END:VALARM
|
||||
END:VTODO
|
||||
END:VCALENDAR`,
|
||||
},
|
||||
{
|
||||
name: "Format Task with Related Tasks as CalDAV",
|
||||
args: args{
|
||||
list: &models.ProjectWithTasksAndBuckets{
|
||||
Project: models.Project{
|
||||
Title: "List title",
|
||||
},
|
||||
},
|
||||
tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Parent Task",
|
||||
UID: "randomuid_parent",
|
||||
Description: "A parent task",
|
||||
Priority: 3,
|
||||
Created: time.Unix(1543626721, 0).In(config.GetTimeZone()),
|
||||
Updated: time.Unix(1543626725, 0).In(config.GetTimeZone()),
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
{
|
||||
Title: "Subtask 1",
|
||||
UID: "randomuid_child_1",
|
||||
Description: "The first child task",
|
||||
Created: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
},
|
||||
{
|
||||
Title: "Subtask 2",
|
||||
UID: "randomuid_child_2",
|
||||
Description: "The second child task",
|
||||
Created: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Subtask 1",
|
||||
UID: "randomuid_child_1",
|
||||
Description: "The first child task",
|
||||
Created: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindParenttask: {
|
||||
{
|
||||
Title: "Parent task",
|
||||
UID: "randomuid_parent",
|
||||
Description: "A parent task",
|
||||
Priority: 3,
|
||||
Created: time.Unix(1543626721, 0).In(config.GetTimeZone()),
|
||||
Updated: time.Unix(1543626725, 0).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Subtask 2",
|
||||
UID: "randomuid_child_2",
|
||||
Description: "The second child task",
|
||||
Created: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindParenttask: {
|
||||
{
|
||||
Title: "Parent task",
|
||||
UID: "randomuid_parent",
|
||||
Description: "A parent task",
|
||||
Priority: 3,
|
||||
Created: time.Unix(1543626721, 0).In(config.GetTimeZone()),
|
||||
Updated: time.Unix(1543626725, 0).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCaldav: `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT4H
|
||||
X-WR-CALNAME:List title
|
||||
PRODID:-//Vikunja Todo App//EN
|
||||
BEGIN:VTODO
|
||||
UID:randomuid_parent
|
||||
DTSTAMP:20181201T011205Z
|
||||
SUMMARY:Parent Task
|
||||
DESCRIPTION:A parent task
|
||||
CREATED:20181201T011201Z
|
||||
PRIORITY:3
|
||||
LAST-MODIFIED:20181201T011205Z
|
||||
RELATED-TO;RELTYPE=CHILD:randomuid_child_1
|
||||
RELATED-TO;RELTYPE=CHILD:randomuid_child_2
|
||||
END:VTODO
|
||||
BEGIN:VTODO
|
||||
UID:randomuid_child_1
|
||||
DTSTAMP:20181201T011204Z
|
||||
SUMMARY:Subtask 1
|
||||
DESCRIPTION:The first child task
|
||||
CREATED:20181201T011204Z
|
||||
LAST-MODIFIED:20181201T011204Z
|
||||
RELATED-TO;RELTYPE=PARENT:randomuid_parent
|
||||
END:VTODO
|
||||
BEGIN:VTODO
|
||||
UID:randomuid_child_2
|
||||
DTSTAMP:20181201T011204Z
|
||||
SUMMARY:Subtask 2
|
||||
DESCRIPTION:The second child task
|
||||
CREATED:20181201T011204Z
|
||||
LAST-MODIFIED:20181201T011204Z
|
||||
RELATED-TO;RELTYPE=PARENT:randomuid_parent
|
||||
END:VTODO
|
||||
END:VCALENDAR`,
|
||||
},
|
||||
}
|
||||
|
Reference in New Issue
Block a user