250 lines
7.1 KiB
Go
250 lines
7.1 KiB
Go
// 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 caldav
|
|
|
|
import (
|
|
"errors"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.vikunja.io/api/pkg/log"
|
|
"code.vikunja.io/api/pkg/models"
|
|
|
|
ics "github.com/arran4/golang-ical"
|
|
)
|
|
|
|
func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectTasks []*models.TaskWithComments) string {
|
|
|
|
// Make caldav todos from Vikunja todos
|
|
var caldavtodos []*Todo
|
|
for _, t := range projectTasks {
|
|
|
|
duration := t.EndDate.Sub(t.StartDate)
|
|
var categories []string
|
|
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: string(reminder.RelativeTo),
|
|
})
|
|
}
|
|
|
|
caldavtodos = append(caldavtodos, &Todo{
|
|
Timestamp: t.Updated,
|
|
UID: t.UID,
|
|
Summary: t.Title,
|
|
Description: t.Description,
|
|
Completed: t.DoneAt,
|
|
// Organizer: &t.CreatedBy, // Disabled until we figure out how this works
|
|
Categories: categories,
|
|
Priority: t.Priority,
|
|
Start: t.StartDate,
|
|
End: t.EndDate,
|
|
Created: t.Created,
|
|
Updated: t.Updated,
|
|
DueDate: t.DueDate,
|
|
Duration: duration,
|
|
RepeatAfter: t.RepeatAfter,
|
|
RepeatMode: t.RepeatMode,
|
|
Alarms: alarms,
|
|
})
|
|
}
|
|
|
|
caldavConfig := &Config{
|
|
Name: project.Title,
|
|
ProdID: "Vikunja Todo App",
|
|
}
|
|
|
|
return ParseTodos(caldavConfig, caldavtodos)
|
|
}
|
|
|
|
func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
|
|
parsed, err := ics.ParseCalendar(strings.NewReader(content))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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 vTodo.UnknownPropertiesIANAProperties() {
|
|
task[c.IANAToken] = c.Value
|
|
}
|
|
|
|
// Parse the priority
|
|
var priority int64
|
|
if _, ok := task["PRIORITY"]; ok {
|
|
priorityParsed, err := strconv.ParseInt(task["PRIORITY"], 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
priority = parseVTODOPriority(priorityParsed)
|
|
}
|
|
|
|
// Parse the enddate
|
|
duration, _ := time.ParseDuration(task["DURATION"])
|
|
|
|
description := strings.ReplaceAll(task["DESCRIPTION"], "\\,", ",")
|
|
description = strings.ReplaceAll(description, "\\n", "\n")
|
|
|
|
var labels []*models.Label
|
|
if val, ok := task["CATEGORIES"]; ok {
|
|
categories := strings.Split(val, ",")
|
|
labels = make([]*models.Label, 0, len(categories))
|
|
for _, category := range categories {
|
|
labels = append(labels, &models.Label{
|
|
Title: category,
|
|
})
|
|
}
|
|
}
|
|
|
|
vTask = &models.Task{
|
|
UID: task["UID"],
|
|
Title: task["SUMMARY"],
|
|
Description: description,
|
|
Priority: priority,
|
|
Labels: labels,
|
|
DueDate: caldavTimeToTimestamp(task["DUE"]),
|
|
Updated: caldavTimeToTimestamp(task["DTSTAMP"]),
|
|
StartDate: caldavTimeToTimestamp(task["DTSTART"]),
|
|
DoneAt: caldavTimeToTimestamp(task["COMPLETED"]),
|
|
}
|
|
|
|
if task["STATUS"] == "COMPLETED" {
|
|
vTask.Done = true
|
|
}
|
|
|
|
if duration > 0 && !vTask.StartDate.IsZero() {
|
|
vTask.EndDate = vTask.StartDate.Add(duration)
|
|
}
|
|
|
|
reminders := make([]*models.TaskReminder, 0)
|
|
for _, vAlarm := range vTodo.SubComponents() {
|
|
if vAlarm, ok := vAlarm.(*ics.VAlarm); ok {
|
|
reminders = parseVAlarm(vAlarm, reminders)
|
|
}
|
|
}
|
|
if len(reminders) > 0 {
|
|
vTask.Reminders = reminders
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func parseVAlarm(vAlarm *ics.VAlarm, reminders []*models.TaskReminder) []*models.TaskReminder {
|
|
for _, property := range vAlarm.UnknownPropertiesIANAProperties() {
|
|
if property.IANAToken == "TRIGGER" {
|
|
switch {
|
|
case len(property.ICalParameters["VALUE"]) > 0:
|
|
if property.ICalParameters["VALUE"][0] == "DATE-TIME" {
|
|
// Example: TRIGGER;VALUE=DATE-TIME:20181201T011210Z
|
|
reminders = append(reminders, &models.TaskReminder{
|
|
Reminder: caldavTimeToTimestamp(property.Value)})
|
|
}
|
|
case len(property.ICalParameters["RELATED"]) > 0:
|
|
duration := parseDuration(property.Value)
|
|
switch property.ICalParameters["RELATED"][0] {
|
|
case "START":
|
|
// Example: TRIGGER;RELATED=START:-P2D
|
|
reminders = append(reminders, &models.TaskReminder{
|
|
RelativePeriod: int64(duration.Seconds()),
|
|
RelativeTo: models.ReminderRelationStartDate})
|
|
case "END":
|
|
// Example: TRIGGER;RELATED=END:-P2D
|
|
reminders = append(reminders, &models.TaskReminder{
|
|
RelativePeriod: int64(duration.Seconds()),
|
|
RelativeTo: models.ReminderRelationEndDate})
|
|
}
|
|
default:
|
|
duration := parseDuration(property.Value)
|
|
// Example: TRIGGER:-PT60M
|
|
reminders = append(reminders, &models.TaskReminder{
|
|
RelativePeriod: int64(duration.Seconds()),
|
|
RelativeTo: models.ReminderRelationDueDate})
|
|
}
|
|
}
|
|
}
|
|
return reminders
|
|
}
|
|
|
|
// https://tools.ietf.org/html/rfc5545#section-3.3.5
|
|
func caldavTimeToTimestamp(tstring string) time.Time {
|
|
if tstring == "" {
|
|
return time.Time{}
|
|
}
|
|
|
|
format := DateFormat
|
|
|
|
if strings.HasSuffix(tstring, "Z") {
|
|
format = `20060102T150405Z`
|
|
}
|
|
|
|
if len(tstring) == 8 {
|
|
format = `20060102`
|
|
}
|
|
|
|
t, err := time.Parse(format, tstring)
|
|
if err != nil {
|
|
log.Warningf("Error while parsing caldav time %s to TimeStamp: %s", tstring, err)
|
|
return time.Time{}
|
|
}
|
|
return t
|
|
}
|
|
|
|
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[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
|
|
}
|
|
|
|
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
|
|
}
|