vikunja/pkg/caldav/caldav.go

324 lines
8.2 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present 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 License 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 License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldav
import (
"strconv"
"strings"
"time"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
)
// DateFormat is the caldav date format
const DateFormat = `20060102T150405`
// escapeICalText escapes a string for use inside an RFC 5545 §3.3.11 TEXT
// property value. Backslash MUST be replaced first so the backslashes this
// function inserts are not themselves re-escaped.
func escapeICalText(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
s = strings.ReplaceAll(s, "\r", "\n")
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "\n", `\n`)
s = strings.ReplaceAll(s, `;`, `\;`)
s = strings.ReplaceAll(s, `,`, `\,`)
return s
}
// Todo holds a single VTODO
type Todo struct {
// Required
Timestamp time.Time
UID string
// Optional
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
}
// Alarm holds infos about an alarm from a caldav event
type Alarm struct {
Time time.Time
Duration time.Duration
RelativeTo models.ReminderRelation
Description string
}
type Relation struct {
Type models.RelationKind
UID string
}
// Config is the caldav calendar config
type Config struct {
Name string
ProdID string
Color string
}
// getCaldavColor sanitizes color at the output boundary (non-hex chars
// dropped) so upstream HexColor validation gaps cannot lead to iCal
// property injection via CR/LF in a crafted color string.
func getCaldavColor(color string) (caldavcolor string) {
color = strings.TrimPrefix(color, "#")
var b strings.Builder
b.Grow(len(color))
for _, r := range color {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') {
b.WriteRune(r)
}
}
color = b.String()
if color == "" {
return ""
}
color = "#" + color + "FF"
return `
X-APPLE-CALENDAR-COLOR:` + color + `
X-OUTLOOK-COLOR:` + color + `
X-FUNAMBOL-COLOR:` + color + `
COLOR:` + color
}
func formatDuration(duration time.Duration) string {
seconds := duration.Seconds() - duration.Minutes()*60
minutes := duration.Minutes() - duration.Hours()*60
return strconv.FormatFloat(duration.Hours(), 'f', 0, 64) + `H` +
strconv.FormatFloat(minutes, 'f', 0, 64) + `M` +
strconv.FormatFloat(seconds, 'f', 0, 64) + `S`
}
func getRruleFromInterval(interval int64) (freq string, newInterval int64) {
const (
minute = 60
hour = minute * 60
day = hour * 24
week = day * 7
)
switch {
case interval%week == 0:
return "WEEKLY", interval / week
case interval%day == 0:
return "DAILY", interval / day
case interval%hour == 0:
return "HOURLY", interval / hour
case interval%minute == 0:
return "MINUTELY", interval / minute
default:
return "SECONDLY", interval
}
}
// ParseTodos returns a caldav vcalendar string with todos.
func ParseTodos(config *Config, todos []*Todo) (caldavtodos string) {
caldavtodos = `BEGIN:VCALENDAR
VERSION:2.0
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:` + escapeICalText(config.Name) + `
PRODID:-//` + config.ProdID + `//EN` + getCaldavColor(config.Color)
for _, t := range todos {
if t.UID == "" {
t.UID = makeCalDavTimeFromTimeStamp(t.Timestamp) + utils.Sha256(t.Summary)
}
caldavtodos += `
BEGIN:VTODO
UID:` + escapeICalText(t.UID) + `
DTSTAMP:` + makeCalDavTimeFromTimeStamp(t.Timestamp) + `
SUMMARY:` + escapeICalText(t.Summary) + getCaldavColor(t.Color)
if t.Start.Unix() > 0 {
caldavtodos += `
DTSTART:` + makeCalDavTimeFromTimeStamp(t.Start)
if t.Duration != 0 && t.DueDate.Unix() == 0 {
caldavtodos += `
DURATION:PT` + formatDuration(t.Duration)
}
}
if t.End.Unix() > 0 {
caldavtodos += `
DTEND:` + makeCalDavTimeFromTimeStamp(t.End)
}
if t.Description != "" {
caldavtodos += `
DESCRIPTION:` + escapeICalText(t.Description)
}
if t.Completed.Unix() > 0 {
caldavtodos += `
COMPLETED:` + makeCalDavTimeFromTimeStamp(t.Completed) + `
STATUS:COMPLETED`
}
if t.Organizer != nil {
caldavtodos += `
ORGANIZER;CN=:` + escapeICalText(t.Organizer.Username)
}
if t.DueDate.Unix() > 0 {
caldavtodos += `
DUE:` + makeCalDavTimeFromTimeStamp(t.DueDate)
}
if t.Created.Unix() > 0 {
caldavtodos += `
CREATED:` + makeCalDavTimeFromTimeStamp(t.Created)
}
if t.Priority != 0 {
caldavtodos += `
PRIORITY:` + strconv.Itoa(mapPriorityToCaldav(t.Priority))
}
if t.RepeatAfter > 0 || t.RepeatMode == models.TaskRepeatModeMonth {
if t.RepeatMode == models.TaskRepeatModeMonth {
caldavtodos += `
RRULE:FREQ=MONTHLY;BYMONTHDAY=` + t.DueDate.Format("02") // Day of the month
} else {
freq, interval := getRruleFromInterval(t.RepeatAfter)
caldavtodos += `
RRULE:FREQ=` + freq + `;INTERVAL=` + strconv.FormatInt(interval, 10)
}
}
if len(t.Categories) > 0 {
escapedCategories := make([]string, len(t.Categories))
for i, c := range t.Categories {
escapedCategories[i] = escapeICalText(c)
}
caldavtodos += `
CATEGORIES:` + strings.Join(escapedCategories, ",")
}
caldavtodos += `
LAST-MODIFIED:` + makeCalDavTimeFromTimeStamp(t.Updated)
caldavtodos += ParseAlarms(t.Alarms, t.Summary)
caldavtodos += ParseRelations(t.Relations)
caldavtodos += `
END:VTODO`
}
caldavtodos += `
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:` + escapeICalText(a.Description) + `
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 += escapeICalText(r.UID)
}
return caldavrelatedtos
}
func makeCalDavTimeFromTimeStamp(ts time.Time) (caldavtime string) {
return ts.In(time.UTC).Format(DateFormat) + "Z"
}
func makeCalDavDuration(duration time.Duration) (caldavtime string) {
if duration < 0 {
duration = duration.Abs()
caldavtime = "-"
}
caldavtime += "PT" + strings.ToUpper(duration.Truncate(time.Millisecond).String())
return
}