336 lines
8.7 KiB
Go
336 lines
8.7 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/log"
|
|
"code.vikunja.io/api/pkg/models"
|
|
"code.vikunja.io/api/pkg/richtext"
|
|
"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 != "" {
|
|
// CalDAV clients show plain text, so emit markdown. On the near-impossible
|
|
// conversion error, log it and keep the stored value (GetContent can't
|
|
// return an error) rather than drop the description.
|
|
description, err := richtext.HTMLToMarkdown(t.Description)
|
|
if err != nil {
|
|
log.Errorf("[CALDAV] Failed to convert description to markdown for task %q: %v", t.UID, err)
|
|
description = t.Description
|
|
}
|
|
if description != "" {
|
|
caldavtodos += `
|
|
DESCRIPTION:` + escapeICalText(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
|
|
}
|