1
0

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:
Miguel Arroyo
2023-11-10 22:44:03 +00:00
committed by konrad
parent 6169c2e12e
commit 225d65268d
15 changed files with 1351 additions and 74 deletions

View File

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

View File

@ -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`,
},
}

View File

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

View File

@ -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`,
},
}