Better caldav support (#73)
This commit is contained in:
@ -17,11 +17,16 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DateFormat ist the caldav date format
|
||||
const DateFormat = `20060102T150405`
|
||||
|
||||
// Event holds a single caldav event
|
||||
type Event struct {
|
||||
Summary string
|
||||
@ -34,6 +39,29 @@ type Event struct {
|
||||
EndUnix int64
|
||||
}
|
||||
|
||||
// Todo holds a single VTODO
|
||||
type Todo struct {
|
||||
// Required
|
||||
TimestampUnix int64
|
||||
UID string
|
||||
|
||||
// Optional
|
||||
Summary string
|
||||
Description string
|
||||
CompletedUnix int64
|
||||
Organizer *models.User
|
||||
Priority int64 // 0-9, 1 is highest
|
||||
RelatedToUID string
|
||||
|
||||
StartUnix int64
|
||||
EndUnix int64
|
||||
DueDateUnix int64
|
||||
Duration time.Duration
|
||||
|
||||
CreatedUnix int64
|
||||
UpdatedUnix int64 // last-mod
|
||||
}
|
||||
|
||||
// Alarm holds infos about an alarm from a caldav event
|
||||
type Alarm struct {
|
||||
TimeUnix int64
|
||||
@ -92,10 +120,89 @@ END:VCALENDAR` // Need a line break
|
||||
return
|
||||
}
|
||||
|
||||
// ParseTodos returns a caldav vcalendar string with todos
|
||||
func ParseTodos(config *Config, todos []*Todo) (caldavtodos string) {
|
||||
caldavtodos = `BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
METHOD:PUBLISH
|
||||
X-PUBLISHED-TTL:PT4H
|
||||
X-WR-CALNAME:` + config.Name + `
|
||||
PRODID:-//` + config.ProdID + `//EN`
|
||||
|
||||
for _, t := range todos {
|
||||
if t.UID == "" {
|
||||
t.UID = makeCalDavTimeFromUnixTime(t.TimestampUnix) + utils.Sha256(t.Summary)
|
||||
}
|
||||
|
||||
caldavtodos += `
|
||||
BEGIN:VTODO
|
||||
UID:` + t.UID + `
|
||||
DTSTAMP:` + makeCalDavTimeFromUnixTime(t.TimestampUnix) + `
|
||||
SUMMARY:` + t.Summary
|
||||
|
||||
if t.StartUnix != 0 {
|
||||
caldavtodos += `
|
||||
DTSTART: ` + makeCalDavTimeFromUnixTime(t.StartUnix)
|
||||
}
|
||||
if t.EndUnix != 0 {
|
||||
caldavtodos += `
|
||||
DTEND: ` + makeCalDavTimeFromUnixTime(t.EndUnix)
|
||||
}
|
||||
if t.Description != "" {
|
||||
caldavtodos += `
|
||||
DESCRIPTION:` + t.Description
|
||||
}
|
||||
if t.CompletedUnix != 0 {
|
||||
caldavtodos += `
|
||||
COMPLETED: ` + makeCalDavTimeFromUnixTime(t.CompletedUnix)
|
||||
}
|
||||
if t.Organizer != nil {
|
||||
caldavtodos += `
|
||||
ORGANIZER;CN=:` + t.Organizer.Username
|
||||
}
|
||||
|
||||
if t.RelatedToUID != "" {
|
||||
caldavtodos += `
|
||||
RELATED-TO:` + t.RelatedToUID
|
||||
}
|
||||
|
||||
if t.DueDateUnix != 0 {
|
||||
caldavtodos += `
|
||||
DUE:` + makeCalDavTimeFromUnixTime(t.DueDateUnix)
|
||||
}
|
||||
|
||||
if t.CreatedUnix != 0 {
|
||||
caldavtodos += `
|
||||
CREATED:` + makeCalDavTimeFromUnixTime(t.CreatedUnix)
|
||||
}
|
||||
|
||||
if t.Duration != 0 {
|
||||
caldavtodos += `
|
||||
DURATION:PT` + fmt.Sprintf("%.6f", t.Duration.Hours()) + `H` + fmt.Sprintf("%.6f", t.Duration.Minutes()) + `M` + fmt.Sprintf("%.6f", t.Duration.Seconds()) + `S`
|
||||
}
|
||||
|
||||
if t.Priority != 0 {
|
||||
caldavtodos += `
|
||||
PRIORITY:` + strconv.Itoa(int(t.Priority))
|
||||
}
|
||||
|
||||
caldavtodos += `
|
||||
LAST-MODIFIED:` + makeCalDavTimeFromUnixTime(t.UpdatedUnix)
|
||||
|
||||
caldavtodos += `
|
||||
END:VTODO`
|
||||
}
|
||||
|
||||
caldavtodos += `
|
||||
END:VCALENDAR` // Need a line break
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func makeCalDavTimeFromUnixTime(unixtime int64) (caldavtime string) {
|
||||
tz, _ := time.LoadLocation("UTC")
|
||||
tm := time.Unix(unixtime, 0).In(tz)
|
||||
return tm.Format("20060102T150405")
|
||||
return tm.Format(DateFormat)
|
||||
}
|
||||
|
||||
func calcAlarmDateFromReminder(eventStartUnix, reminderUnix int64) (alarmTime string) {
|
||||
|
@ -40,6 +40,7 @@ func InitConfig() {
|
||||
viper.SetDefault("service.JWTSecret", random)
|
||||
viper.SetDefault("service.interface", ":3456")
|
||||
viper.SetDefault("service.frontendurl", "")
|
||||
viper.SetDefault("service.enablecaldav", true)
|
||||
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
|
@ -80,33 +80,33 @@ func TestListTask(t *testing.T) {
|
||||
t.Run("by priority", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"priority"}}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`)
|
||||
})
|
||||
t.Run("by priority desc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"prioritydesc"}}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1`)
|
||||
})
|
||||
t.Run("by priority asc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"priorityasc"}}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":4,"text":"task #4 low prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}}]`)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":4,"text":"task #4 low prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":1,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":3,"text":"task #3 high prio","description":"","done":false,"doneAt":0,"dueDate":0,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":100,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}}]`)
|
||||
})
|
||||
// should equal duedate desc
|
||||
t.Run("by duedate", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"dueadate"}}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
|
||||
})
|
||||
t.Run("by duedate desc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"dueadatedesc"}}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
|
||||
assert.Contains(t, rec.Body.String(), `[{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":6,"text":"task #6 lower due date"`)
|
||||
})
|
||||
t.Run("by duedate asc", func(t *testing.T) {
|
||||
rec, err := testHandler.testReadAll(url.Values{"sort": []string{"duedateasc"}}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"dueDate":1543616724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}}]`)
|
||||
assert.Contains(t, rec.Body.String(), `{"id":6,"text":"task #6 lower due date","description":"","done":false,"doneAt":0,"dueDate":1543616724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}},{"id":5,"text":"task #5 higher due date","description":"","done":false,"doneAt":0,"dueDate":1543636724,"reminderDates":null,"listID":1,"repeatAfter":0,"parentTaskID":0,"priority":0,"startDate":0,"endDate":0,"assignees":null,"labels":null,"hexColor":"","subtasks":null,"created":1543626724,"updated":1543626724,"createdBy":{"id":0,"username":"","created":0,"updated":0}}]`)
|
||||
})
|
||||
t.Run("invalid parameter", func(t *testing.T) {
|
||||
// Invalid parameter should not sort at all
|
||||
|
84
pkg/migration/20190511202210.go
Normal file
84
pkg/migration/20190511202210.go
Normal file
@ -0,0 +1,84 @@
|
||||
// Vikunja is a todo-list application to facilitate your life.
|
||||
// Copyright 2019 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 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
"github.com/go-xorm/xorm"
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
)
|
||||
|
||||
type listTask20190511202210 struct {
|
||||
ID int64 `xorm:"int(11) autoincr not null unique pk" json:"id" param:"listtask"`
|
||||
Text string `xorm:"varchar(250) not null" json:"text" valid:"runelength(3|250)" minLength:"3" maxLength:"250"`
|
||||
Description string `xorm:"varchar(250)" json:"description" valid:"runelength(0|250)" maxLength:"250"`
|
||||
Done bool `xorm:"INDEX null" json:"done"`
|
||||
DoneAtUnix int64 `xorm:"INDEX null" json:"doneAt"`
|
||||
DueDateUnix int64 `xorm:"int(11) INDEX null" json:"dueDate"`
|
||||
RemindersUnix []int64 `xorm:"JSON TEXT null" json:"reminderDates"`
|
||||
CreatedByID int64 `xorm:"int(11) not null" json:"-"` // ID of the user who put that task on the list
|
||||
ListID int64 `xorm:"int(11) INDEX not null" json:"listID" param:"list"`
|
||||
RepeatAfter int64 `xorm:"int(11) INDEX null" json:"repeatAfter"`
|
||||
ParentTaskID int64 `xorm:"int(11) INDEX null" json:"parentTaskID"`
|
||||
Priority int64 `xorm:"int(11) null" json:"priority"`
|
||||
StartDateUnix int64 `xorm:"int(11) INDEX null" json:"startDate" query:"-"`
|
||||
EndDateUnix int64 `xorm:"int(11) INDEX null" json:"endDate" query:"-"`
|
||||
HexColor string `xorm:"varchar(6) null" json:"hexColor" valid:"runelength(0|6)" maxLength:"6"`
|
||||
UID string `xorm:"varchar(250) null" json:"-"`
|
||||
Sorting string `xorm:"-" json:"-" query:"sort"` // Parameter to sort by
|
||||
StartDateSortUnix int64 `xorm:"-" json:"-" query:"startdate"`
|
||||
EndDateSortUnix int64 `xorm:"-" json:"-" query:"enddate"`
|
||||
Created int64 `xorm:"created not null" json:"created"`
|
||||
Updated int64 `xorm:"updated not null" json:"updated"`
|
||||
}
|
||||
|
||||
func (listTask20190511202210) TableName() string {
|
||||
return "tasks"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20190511202210",
|
||||
Description: "Add task uid",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
err := tx.Sync2(listTask20190511202210{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get all tasks and generate a random uid for them
|
||||
var allTasks []*listTask20190511202210
|
||||
err = tx.Find(&allTasks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, t := range allTasks {
|
||||
t.UID = utils.MakeRandomString(40)
|
||||
_, err = tx.Where("id = ?", t.ID).Cols("uid").Update(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return dropTableColum(tx, "tasks", "uid")
|
||||
},
|
||||
})
|
||||
}
|
43
pkg/migration/20190514192749.go
Normal file
43
pkg/migration/20190514192749.go
Normal file
@ -0,0 +1,43 @@
|
||||
// Vikunja is a todo-list application to facilitate your life.
|
||||
// Copyright 2019 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 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"github.com/go-xorm/xorm"
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
)
|
||||
|
||||
type listTask20190514192749 struct {
|
||||
DoneAtUnix int64 `xorm:"INDEX null" json:"doneAt"`
|
||||
}
|
||||
|
||||
func (listTask20190514192749) TableName() string {
|
||||
return "tasks"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20190514192749",
|
||||
Description: "Add task done at",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
return tx.Sync2(listTask20190514192749{})
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return dropTableColum(tx, "tasks", "done_at_unix")
|
||||
},
|
||||
})
|
||||
}
|
@ -86,6 +86,11 @@ func (lt *LabelTask) Create(a web.Auth) (err error) {
|
||||
|
||||
// Insert it
|
||||
_, err = x.Insert(lt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListByTaskID(lt.TaskID)
|
||||
return
|
||||
}
|
||||
|
||||
@ -272,6 +277,8 @@ func (t *ListTask) updateTaskLabels(creator web.Auth, labels []*Label) (err erro
|
||||
}
|
||||
t.Labels = append(t.Labels, label)
|
||||
}
|
||||
|
||||
err = updateListLastUpdated(&List{ID: t.ListID})
|
||||
return
|
||||
}
|
||||
|
||||
@ -299,7 +306,7 @@ type LabelTaskBulk struct {
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /tasks/{taskID}/labels/bulk [post]
|
||||
func (ltb *LabelTaskBulk) Create(a web.Auth) (err error) {
|
||||
task, err := GetListTaskByID(ltb.TaskID)
|
||||
task, err := GetTaskByID(ltb.TaskID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ func (ltb *LabelTaskBulk) CanCreate(a web.Auth) (bool, error) {
|
||||
// always the same check for either deleting or adding a label to a task
|
||||
func canDoLabelTask(taskID int64, a web.Auth) (bool, error) {
|
||||
// A user can add a label to a task if he can write to the task
|
||||
task, err := getTaskByIDSimple(taskID)
|
||||
task, err := GetTaskByIDSimple(taskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
@ -166,6 +166,8 @@ func TestLabelTask_Create(t *testing.T) {
|
||||
a: &User{ID: 1},
|
||||
},
|
||||
wantForbidden: true,
|
||||
wantErr: true,
|
||||
errType: IsErrListTaskDoesNotExist,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
|
@ -71,6 +71,21 @@ func (l *List) Update() (err error) {
|
||||
return CreateOrUpdateList(l)
|
||||
}
|
||||
|
||||
func updateListLastUpdated(list *List) error {
|
||||
_, err := x.ID(list.ID).Cols("updated").Update(list)
|
||||
return err
|
||||
}
|
||||
|
||||
func updateListByTaskID(taskID int64) (err error) {
|
||||
// need to get the task to update the list last updated timestamp
|
||||
task, err := GetTaskByIDSimple(taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return updateListLastUpdated(&List{ID: task.ListID})
|
||||
}
|
||||
|
||||
// Create implements the create method of CRUDable
|
||||
// @Summary Creates a new list
|
||||
// @Description Creates a new list in a given namespace. The user needs write-access to the namespace.
|
||||
|
@ -125,6 +125,8 @@ func (t *ListTask) updateTaskAssignees(assignees []*User) (err error) {
|
||||
}
|
||||
|
||||
t.setTaskAssignees(assignees)
|
||||
|
||||
err = updateListLastUpdated(&List{ID: t.ListID})
|
||||
return
|
||||
}
|
||||
|
||||
@ -152,6 +154,11 @@ func (t *ListTask) setTaskAssignees(assignees []*User) {
|
||||
// @Router /tasks/{taskID}/assignees/{userID} [delete]
|
||||
func (la *ListTaskAssginee) Delete() (err error) {
|
||||
_, err = x.Delete(&ListTaskAssginee{TaskID: la.TaskID, UserID: la.UserID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListByTaskID(la.TaskID)
|
||||
return
|
||||
}
|
||||
|
||||
@ -198,7 +205,11 @@ func (t *ListTask) addNewAssigneeByID(newAssigneeID int64, list *List) (err erro
|
||||
TaskID: t.ID,
|
||||
UserID: newAssigneeID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListLastUpdated(&List{ID: t.ListID})
|
||||
return
|
||||
}
|
||||
|
||||
@ -249,7 +260,7 @@ type BulkAssignees struct {
|
||||
// @Failure 500 {object} models.Message "Internal error"
|
||||
// @Router /tasks/{taskID}/assignees/bulk [post]
|
||||
func (ba *BulkAssignees) Create(a web.Auth) (err error) {
|
||||
task, err := GetListTaskByID(ba.TaskID) // We need to use the full method here because we need all current assignees.
|
||||
task, err := GetTaskByID(ba.TaskID) // We need to use the full method here because we need all current assignees.
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
@ -31,6 +31,8 @@ type ListTask struct {
|
||||
Description string `xorm:"varchar(250)" json:"description" valid:"runelength(0|250)" maxLength:"250"`
|
||||
// Whether a task is done or not.
|
||||
Done bool `xorm:"INDEX null" json:"done"`
|
||||
// The unix timestamp when a task was marked as done.
|
||||
DoneAtUnix int64 `xorm:"INDEX null" json:"doneAt"`
|
||||
// A unix timestamp when the task is due.
|
||||
DueDateUnix int64 `xorm:"int(11) INDEX null" json:"dueDate"`
|
||||
// An array of unix timestamps when the user wants to be reminded of the task.
|
||||
@ -55,6 +57,9 @@ type ListTask struct {
|
||||
// The task color in hex
|
||||
HexColor string `xorm:"varchar(6) null" json:"hexColor" valid:"runelength(0|6)" maxLength:"6"`
|
||||
|
||||
// The UID is currently not used for anything other than caldav, which is why we don't expose it over json
|
||||
UID string `xorm:"varchar(250) null" json:"-"`
|
||||
|
||||
Sorting string `xorm:"-" json:"-" query:"sort"` // Parameter to sort by
|
||||
StartDateSortUnix int64 `xorm:"-" json:"-" query:"startdate"`
|
||||
EndDateSortUnix int64 `xorm:"-" json:"-" query:"enddate"`
|
||||
@ -88,96 +93,36 @@ func GetTasksByListID(listID int64) (tasks []*ListTask, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
// No need to iterate over users and stuff if the list doesn't has tasks
|
||||
if len(taskMap) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all users & task ids and put them into the array
|
||||
var userIDs []int64
|
||||
var taskIDs []int64
|
||||
for _, i := range taskMap {
|
||||
taskIDs = append(taskIDs, i.ID)
|
||||
userIDs = append(userIDs, i.CreatedByID)
|
||||
}
|
||||
|
||||
// Get all assignees
|
||||
taskAssignees, err := getRawTaskAssigneesForTasks(taskIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Put the assignees in the task map
|
||||
for _, a := range taskAssignees {
|
||||
if a != nil {
|
||||
taskMap[a.TaskID].Assignees = append(taskMap[a.TaskID].Assignees, &a.User)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all labels for the tasks
|
||||
labels, err := getLabelsByTaskIDs(&LabelByTaskIDsOptions{TaskIDs: taskIDs})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, l := range labels {
|
||||
if l != nil {
|
||||
taskMap[l.TaskID].Labels = append(taskMap[l.TaskID].Labels, &l.Label)
|
||||
}
|
||||
}
|
||||
|
||||
users := make(map[int64]*User)
|
||||
err = x.In("id", userIDs).Find(&users)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Add all user objects to the appropriate tasks
|
||||
for _, task := range taskMap {
|
||||
|
||||
// Make created by user objects
|
||||
taskMap[task.ID].CreatedBy = *users[task.CreatedByID]
|
||||
|
||||
// Reorder all subtasks
|
||||
if task.ParentTaskID != 0 {
|
||||
taskMap[task.ParentTaskID].Subtasks = append(taskMap[task.ParentTaskID].Subtasks, task)
|
||||
delete(taskMap, task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// make a complete slice from the map
|
||||
tasks = []*ListTask{}
|
||||
for _, t := range taskMap {
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
|
||||
// Sort the output. In Go, contents on a map are put on that map in no particular order (saved on heap).
|
||||
// Because of this, tasks are not sorted anymore in the output, this leads to confiusion.
|
||||
// To avoid all this, we need to sort the slice afterwards
|
||||
sort.Slice(tasks, func(i, j int) bool {
|
||||
return tasks[i].ID < tasks[j].ID
|
||||
})
|
||||
|
||||
tasks, err = addMoreInfoToTasks(taskMap)
|
||||
return
|
||||
}
|
||||
|
||||
func getTaskByIDSimple(taskID int64) (task ListTask, err error) {
|
||||
// GetTaskByIDSimple returns a raw task without extra data by the task ID
|
||||
func GetTaskByIDSimple(taskID int64) (task ListTask, err error) {
|
||||
if taskID < 1 {
|
||||
return ListTask{}, ErrListTaskDoesNotExist{taskID}
|
||||
}
|
||||
|
||||
exists, err := x.ID(taskID).Get(&task)
|
||||
return GetTaskSimple(&ListTask{ID: taskID})
|
||||
}
|
||||
|
||||
// GetTaskSimple returns a raw task without extra data
|
||||
func GetTaskSimple(t *ListTask) (task ListTask, err error) {
|
||||
task = *t
|
||||
exists, err := x.Get(&task)
|
||||
if err != nil {
|
||||
return ListTask{}, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return ListTask{}, ErrListTaskDoesNotExist{taskID}
|
||||
return ListTask{}, ErrListTaskDoesNotExist{t.ID}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetListTaskByID returns all tasks a list has
|
||||
func GetListTaskByID(listTaskID int64) (listTask ListTask, err error) {
|
||||
listTask, err = getTaskByIDSimple(listTaskID)
|
||||
// GetTaskByID returns all tasks a list has
|
||||
func GetTaskByID(listTaskID int64) (listTask ListTask, err error) {
|
||||
listTask, err = GetTaskByIDSimple(listTaskID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -221,37 +166,101 @@ func (bt *BulkTask) GetTasksByIDs() (err error) {
|
||||
}
|
||||
}
|
||||
|
||||
err = x.In("id", bt.IDs).Find(&bt.Tasks)
|
||||
taskMap := make(map[int64]*ListTask, len(bt.Tasks))
|
||||
err = x.In("id", bt.IDs).Find(&taskMap)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
// We use a map, to avoid looping over two slices at once
|
||||
var usermapids = make(map[int64]bool) // Bool ist just something, doesn't acutually matter
|
||||
for _, list := range bt.Tasks {
|
||||
usermapids[list.CreatedByID] = true
|
||||
}
|
||||
bt.Tasks, err = addMoreInfoToTasks(taskMap)
|
||||
return
|
||||
}
|
||||
|
||||
// Make a slice from the map
|
||||
var userids []int64
|
||||
for uid := range usermapids {
|
||||
userids = append(userids, uid)
|
||||
}
|
||||
|
||||
// Get all users for the tasks
|
||||
var users []*User
|
||||
err = x.In("id", userids).Find(&users)
|
||||
// GetTasksByUIDs gets all tasks from a bunch of uids
|
||||
func GetTasksByUIDs(uids []string) (tasks []*ListTask, err error) {
|
||||
taskMap := make(map[int64]*ListTask)
|
||||
err = x.In("uid", uids).Find(&taskMap)
|
||||
if err != nil {
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
for in, task := range bt.Tasks {
|
||||
for _, u := range users {
|
||||
if task.CreatedByID == u.ID {
|
||||
bt.Tasks[in].CreatedBy = *u
|
||||
}
|
||||
tasks, err = addMoreInfoToTasks(taskMap)
|
||||
return
|
||||
}
|
||||
|
||||
// This function takes a map with pointers and returns a slice with pointers to tasks
|
||||
// It adds more stuff like assignees/labels/etc to a bunch of tasks
|
||||
func addMoreInfoToTasks(taskMap map[int64]*ListTask) (tasks []*ListTask, err error) {
|
||||
|
||||
// No need to iterate over users and stuff if the list doesn't has tasks
|
||||
if len(taskMap) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Get all users & task ids and put them into the array
|
||||
var userIDs []int64
|
||||
var taskIDs []int64
|
||||
for _, i := range taskMap {
|
||||
taskIDs = append(taskIDs, i.ID)
|
||||
userIDs = append(userIDs, i.CreatedByID)
|
||||
}
|
||||
|
||||
// Get all assignees
|
||||
taskAssignees, err := getRawTaskAssigneesForTasks(taskIDs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Put the assignees in the task map
|
||||
for _, a := range taskAssignees {
|
||||
if a != nil {
|
||||
taskMap[a.TaskID].Assignees = append(taskMap[a.TaskID].Assignees, &a.User)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all labels for all the tasks
|
||||
labels, err := getLabelsByTaskIDs(&LabelByTaskIDsOptions{TaskIDs: taskIDs})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, l := range labels {
|
||||
if l != nil {
|
||||
taskMap[l.TaskID].Labels = append(taskMap[l.TaskID].Labels, &l.Label)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all users of a task
|
||||
// aka the ones who created a task
|
||||
users := make(map[int64]*User)
|
||||
err = x.In("id", userIDs).Find(&users)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Add all user objects to the appropriate tasks
|
||||
for _, task := range taskMap {
|
||||
|
||||
// Make created by user objects
|
||||
taskMap[task.ID].CreatedBy = *users[task.CreatedByID]
|
||||
|
||||
// Reorder all subtasks
|
||||
if task.ParentTaskID != 0 {
|
||||
taskMap[task.ParentTaskID].Subtasks = append(taskMap[task.ParentTaskID].Subtasks, task)
|
||||
delete(taskMap, task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// make a complete slice from the map
|
||||
tasks = []*ListTask{}
|
||||
for _, t := range taskMap {
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
|
||||
// Sort the output. In Go, contents on a map are put on that map in no particular order.
|
||||
// Because of this, tasks are not sorted anymore in the output, this leads to confiusion.
|
||||
// To avoid all this, we need to sort the slice afterwards
|
||||
sort.Slice(tasks, func(i, j int) bool {
|
||||
return tasks[i].ID < tasks[j].ID
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
@ -18,8 +18,10 @@ package models
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/metrics"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
"code.vikunja.io/web"
|
||||
"github.com/imdario/mergo"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Create is the implementation to create a list task
|
||||
@ -60,6 +62,11 @@ func (t *ListTask) Create(a web.Auth) (err error) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Generate a uuid if we don't already have one
|
||||
if t.UID == "" {
|
||||
t.UID = utils.MakeRandomString(40)
|
||||
}
|
||||
|
||||
t.CreatedByID = u.ID
|
||||
t.CreatedBy = u
|
||||
if _, err = x.Insert(t); err != nil {
|
||||
@ -72,6 +79,8 @@ func (t *ListTask) Create(a web.Auth) (err error) {
|
||||
}
|
||||
|
||||
metrics.UpdateCount(1, metrics.TaskCountKey)
|
||||
|
||||
err = updateListLastUpdated(&List{ID: t.ListID})
|
||||
return
|
||||
}
|
||||
|
||||
@ -91,7 +100,7 @@ func (t *ListTask) Create(a web.Auth) (err error) {
|
||||
// @Router /tasks/{id} [post]
|
||||
func (t *ListTask) Update() (err error) {
|
||||
// Check if the task exists
|
||||
ot, err := GetListTaskByID(t.ID)
|
||||
ot, err := GetTaskByID(t.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -189,12 +198,20 @@ func (t *ListTask) Update() (err error) {
|
||||
"priority",
|
||||
"start_date_unix",
|
||||
"end_date_unix",
|
||||
"hex_color").
|
||||
"hex_color",
|
||||
"done_at_unix").
|
||||
Update(ot)
|
||||
*t = ot
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListLastUpdated(&List{ID: t.ListID})
|
||||
return
|
||||
}
|
||||
|
||||
// This helper function updates the reminders and doneAtUnix of the *old* task (since that's the one we're inserting
|
||||
// with updated values into the db)
|
||||
func updateDone(oldTask *ListTask, newTask *ListTask) {
|
||||
if !oldTask.Done && newTask.Done && oldTask.RepeatAfter > 0 {
|
||||
oldTask.DueDateUnix = oldTask.DueDateUnix + oldTask.RepeatAfter // assuming we'll save the old task (merged)
|
||||
@ -205,4 +222,13 @@ func updateDone(oldTask *ListTask, newTask *ListTask) {
|
||||
|
||||
newTask.Done = false
|
||||
}
|
||||
|
||||
// Update the "done at" timestamp
|
||||
if !oldTask.Done && newTask.Done {
|
||||
oldTask.DoneAtUnix = time.Now().Unix()
|
||||
}
|
||||
// When unmarking a task as done, reset the timestamp
|
||||
if oldTask.Done && !newTask.Done {
|
||||
oldTask.DoneAtUnix = 0
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ import (
|
||||
func (t *ListTask) Delete() (err error) {
|
||||
|
||||
// Check if it exists
|
||||
_, err = GetListTaskByID(t.ID)
|
||||
_, err = GetTaskByID(t.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -51,5 +51,7 @@ func (t *ListTask) Delete() (err error) {
|
||||
}
|
||||
|
||||
metrics.UpdateCount(-1, metrics.TaskCountKey)
|
||||
|
||||
err = updateListLastUpdated(&List{ID: t.ListID})
|
||||
return
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ func (t *ListTask) CanCreate(a web.Auth) (bool, error) {
|
||||
func (t *ListTask) CanRead(a web.Auth) (canRead bool, err error) {
|
||||
//return t.canDoListTask(a)
|
||||
// Get the task, error out if it doesn't exist
|
||||
*t, err = getTaskByIDSimple(t.ID)
|
||||
*t, err = GetTaskByIDSimple(t.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -58,7 +58,7 @@ func (t *ListTask) canDoListTask(a web.Auth) (bool, error) {
|
||||
doer := getUserForRights(a)
|
||||
|
||||
// Get the task
|
||||
lI, err := getTaskByIDSimple(t.ID)
|
||||
lI, err := GetTaskByIDSimple(t.ID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ func TestListTask_Create(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check if it was updated
|
||||
li, err := GetListTaskByID(listtask.ID)
|
||||
li, err := GetTaskByID(listtask.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, li.Text, "Test34")
|
||||
|
||||
@ -91,3 +91,18 @@ func TestListTask_Create(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.True(t, IsErrUserDoesNotExist(err))
|
||||
}
|
||||
|
||||
func TestUpdateDone(t *testing.T) {
|
||||
t.Run("marking a task as done", func(t *testing.T) {
|
||||
oldTask := &ListTask{Done: false}
|
||||
newTask := &ListTask{Done: true}
|
||||
updateDone(oldTask, newTask)
|
||||
assert.NotEqual(t, int64(0), oldTask.DoneAtUnix)
|
||||
})
|
||||
t.Run("unmarking a task as done", func(t *testing.T) {
|
||||
oldTask := &ListTask{Done: true}
|
||||
newTask := &ListTask{Done: false}
|
||||
updateDone(oldTask, newTask)
|
||||
assert.Equal(t, int64(0), oldTask.DoneAtUnix)
|
||||
})
|
||||
}
|
||||
|
@ -67,6 +67,10 @@ func (lu *ListUser) Create(a web.Auth) (err error) {
|
||||
|
||||
// Insert user <-> list relation
|
||||
_, err = x.Insert(lu)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListLastUpdated(l)
|
||||
return
|
||||
}
|
||||
|
@ -51,5 +51,10 @@ func (lu *ListUser) Delete() (err error) {
|
||||
|
||||
_, err = x.Where("user_id = ? AND list_id = ?", lu.UserID, lu.ListID).
|
||||
Delete(&ListUser{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListLastUpdated(&List{ID: lu.ListID})
|
||||
return
|
||||
}
|
||||
|
@ -44,5 +44,10 @@ func (lu *ListUser) Update() (err error) {
|
||||
Where("list_id = ? AND user_id = ?", lu.ListID, lu.UserID).
|
||||
Cols("right").
|
||||
Update(lu)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListLastUpdated(&List{ID: lu.ListID})
|
||||
return
|
||||
}
|
||||
|
@ -65,5 +65,10 @@ func (tl *TeamList) Create(a web.Auth) (err error) {
|
||||
|
||||
// Insert the new team
|
||||
_, err = x.Insert(tl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListLastUpdated(l)
|
||||
return
|
||||
}
|
||||
|
@ -53,6 +53,10 @@ func (tl *TeamList) Delete() (err error) {
|
||||
_, err = x.Where("team_id = ?", tl.TeamID).
|
||||
And("list_id = ?", tl.ListID).
|
||||
Delete(TeamList{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListLastUpdated(&List{ID: tl.ListID})
|
||||
return
|
||||
}
|
||||
|
@ -44,5 +44,10 @@ func (tl *TeamList) Update() (err error) {
|
||||
Where("list_id = ? AND team_id = ?", tl.ListID, tl.TeamID).
|
||||
Cols("right").
|
||||
Update(tl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = updateListLastUpdated(&List{ID: tl.ListID})
|
||||
return
|
||||
}
|
||||
|
@ -115,7 +115,7 @@ func CreateUser(user User) (newUser User, err error) {
|
||||
|
||||
// HashPassword hashes a password
|
||||
func hashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 11)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
|
@ -1,89 +0,0 @@
|
||||
// Vikunja is a todo-list application to facilitate your life.
|
||||
// Copyright 2018 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 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package v1
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/caldav"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/labstack/echo/v4"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Caldav returns a caldav-readable format with all tasks
|
||||
// @Summary CalDAV-readable format with all tasks as calendar events.
|
||||
// @Description Returns a calDAV-parsable format with all tasks as calendar events. Only returns tasks with a due date. Also creates reminders when the task has one.
|
||||
// @tags task
|
||||
// @Produce text/plain
|
||||
// @Security BasicAuth
|
||||
// @Success 200 {string} string "The caldav events."
|
||||
// @Failure 403 {string} string "Unauthorized."
|
||||
// @Router /tasks/caldav [get]
|
||||
func Caldav(c echo.Context) error {
|
||||
|
||||
// Request basic auth
|
||||
user, pass, ok := c.Request().BasicAuth()
|
||||
|
||||
// Check credentials
|
||||
creds := &models.UserLogin{
|
||||
Username: user,
|
||||
Password: pass,
|
||||
}
|
||||
u, err := models.CheckUserCredentials(creds)
|
||||
|
||||
if !ok || err != nil {
|
||||
c.Response().Header().Set("WWW-Authenticate", `Basic realm="Vikunja cal"`)
|
||||
return c.String(http.StatusUnauthorized, "Unauthorized.")
|
||||
}
|
||||
|
||||
// Get all tasks for that user
|
||||
tasks, err := models.GetTasksByUser("", &u, -1, models.SortTasksByUnsorted, time.Now(), time.Now().Add(24*356*time.Hour))
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
hour := int64(time.Hour.Seconds())
|
||||
var caldavTasks []*caldav.Event
|
||||
for _, t := range tasks {
|
||||
if t.DueDateUnix != 0 {
|
||||
event := &caldav.Event{
|
||||
Summary: t.Text,
|
||||
Description: t.Description,
|
||||
UID: "",
|
||||
TimestampUnix: t.Updated,
|
||||
StartUnix: t.DueDateUnix,
|
||||
EndUnix: t.DueDateUnix + hour,
|
||||
}
|
||||
|
||||
if len(t.RemindersUnix) > 0 {
|
||||
for _, rem := range t.RemindersUnix {
|
||||
event.Alarms = append(event.Alarms, caldav.Alarm{TimeUnix: rem})
|
||||
}
|
||||
}
|
||||
|
||||
caldavTasks = append(caldavTasks, event)
|
||||
}
|
||||
}
|
||||
|
||||
caldavConfig := &caldav.Config{
|
||||
Name: "Vikunja Calendar for " + u.Username,
|
||||
ProdID: "Vikunja Todo App",
|
||||
}
|
||||
|
||||
return c.String(http.StatusOK, caldav.ParseEvents(caldavConfig, caldavTasks))
|
||||
}
|
184
pkg/routes/caldav/handler.go
Normal file
184
pkg/routes/caldav/handler.go
Normal file
@ -0,0 +1,184 @@
|
||||
// Vikunja is a todo-list application to facilitate your life.
|
||||
// Copyright 2019 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 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/web/handler"
|
||||
"fmt"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/samedi/caldav-go"
|
||||
"github.com/samedi/caldav-go/lib"
|
||||
"io/ioutil"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func getBasicAuthUserFromContext(c echo.Context) (user models.User, err error) {
|
||||
u, is := c.Get("userBasicAuth").(models.User)
|
||||
if !is {
|
||||
return models.User{}, fmt.Errorf("user is not user element, is %s", reflect.TypeOf(c.Get("userBasicAuth")))
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListHandler returns all tasks from a list
|
||||
func ListHandler(c echo.Context) error {
|
||||
listID, err := getIntParam(c, "list")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u, err := getBasicAuthUserFromContext(c)
|
||||
if err != nil {
|
||||
log.Log.Error(err)
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
storage := &VikunjaCaldavListStorage{
|
||||
list: &models.List{ID: listID},
|
||||
user: &u,
|
||||
}
|
||||
|
||||
// Try to parse a task from the request payload
|
||||
body, _ := ioutil.ReadAll(c.Request().Body)
|
||||
// Restore the io.ReadCloser to its original state
|
||||
c.Request().Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
// Parse it
|
||||
vtodo := string(body)
|
||||
if vtodo != "" && strings.HasPrefix(vtodo, `BEGIN:VCALENDAR`) {
|
||||
storage.task, err = parseTaskFromVTODO(vtodo)
|
||||
if err != nil {
|
||||
log.Log.Error(err)
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
log.Log.Debugf("[CALDAV] Request Body: %v\n", string(body))
|
||||
log.Log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header)
|
||||
|
||||
caldav.SetupStorage(storage)
|
||||
caldav.SetupUser("dav/lists")
|
||||
caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO})
|
||||
response := caldav.HandleRequest(c.Request())
|
||||
response.Write(c.Response())
|
||||
return nil
|
||||
}
|
||||
|
||||
// TaskHandler is the handler which manages updating/deleting a single task
|
||||
func TaskHandler(c echo.Context) error {
|
||||
listID, err := getIntParam(c, "list")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
u, err := getBasicAuthUserFromContext(c)
|
||||
if err != nil {
|
||||
log.Log.Error(err)
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
// Get the task uid
|
||||
taskUID := strings.TrimSuffix(c.Param("task"), ".ics")
|
||||
|
||||
storage := &VikunjaCaldavListStorage{
|
||||
list: &models.List{ID: listID},
|
||||
task: &models.ListTask{UID: taskUID},
|
||||
user: &u,
|
||||
}
|
||||
|
||||
caldav.SetupStorage(storage)
|
||||
response := caldav.HandleRequest(c.Request())
|
||||
response.Write(c.Response())
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrincipalHandler handles all request to principal resources
|
||||
func PrincipalHandler(c echo.Context) error {
|
||||
u, err := getBasicAuthUserFromContext(c)
|
||||
if err != nil {
|
||||
log.Log.Error(err)
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
storage := &VikunjaCaldavListStorage{
|
||||
user: &u,
|
||||
isPrincipal: true,
|
||||
}
|
||||
|
||||
// Try to parse a task from the request payload
|
||||
body, _ := ioutil.ReadAll(c.Request().Body)
|
||||
// Restore the io.ReadCloser to its original state
|
||||
c.Request().Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
log.Log.Debugf("[CALDAV] Request Body: %v\n", string(body))
|
||||
log.Log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header)
|
||||
|
||||
caldav.SetupStorage(storage)
|
||||
caldav.SetupUser("dav/principals/" + u.Username)
|
||||
caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO})
|
||||
|
||||
response := caldav.HandleRequest(c.Request())
|
||||
response.Write(c.Response())
|
||||
return nil
|
||||
}
|
||||
|
||||
// EntryHandler handles all request to principal resources
|
||||
func EntryHandler(c echo.Context) error {
|
||||
u, err := getBasicAuthUserFromContext(c)
|
||||
if err != nil {
|
||||
log.Log.Error(err)
|
||||
return echo.ErrInternalServerError
|
||||
}
|
||||
|
||||
storage := &VikunjaCaldavListStorage{
|
||||
user: &u,
|
||||
isEntry: true,
|
||||
}
|
||||
|
||||
// Try to parse a task from the request payload
|
||||
body, _ := ioutil.ReadAll(c.Request().Body)
|
||||
// Restore the io.ReadCloser to its original state
|
||||
c.Request().Body = ioutil.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
log.Log.Debugf("[CALDAV] Request Body: %v\n", string(body))
|
||||
log.Log.Debugf("[CALDAV] Request Headers: %v\n", c.Request().Header)
|
||||
|
||||
caldav.SetupStorage(storage)
|
||||
caldav.SetupUser("dav/principals/" + u.Username)
|
||||
caldav.SetupSupportedComponents([]string{lib.VCALENDAR, lib.VTODO})
|
||||
|
||||
response := caldav.HandleRequest(c.Request())
|
||||
response.Write(c.Response())
|
||||
return nil
|
||||
}
|
||||
|
||||
func getIntParam(c echo.Context, paramName string) (intParam int64, err error) {
|
||||
param := c.Param(paramName)
|
||||
if param == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
intParam, err = strconv.ParseInt(param, 10, 64)
|
||||
if err != nil {
|
||||
return 0, handler.HandleHTTPError(err, c)
|
||||
}
|
||||
return
|
||||
}
|
404
pkg/routes/caldav/listStorageProvider.go
Normal file
404
pkg/routes/caldav/listStorageProvider.go
Normal file
@ -0,0 +1,404 @@
|
||||
// Vikunja is a todo-list application to facilitate your life.
|
||||
// Copyright 2019 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 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"github.com/samedi/caldav-go/data"
|
||||
"github.com/samedi/caldav-go/errs"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DavBasePath is the base url path
|
||||
const DavBasePath = `/dav/`
|
||||
|
||||
// ListBasePath is the base path for all lists resources
|
||||
const ListBasePath = DavBasePath + `lists`
|
||||
|
||||
// VikunjaCaldavListStorage represents a list storage
|
||||
type VikunjaCaldavListStorage struct {
|
||||
// Used when handling a list
|
||||
list *models.List
|
||||
// Used when handling a single task, like updating
|
||||
task *models.ListTask
|
||||
// The current user
|
||||
user *models.User
|
||||
isPrincipal bool
|
||||
isEntry bool // Entry level handling should only return a link to the principal url
|
||||
}
|
||||
|
||||
// GetResources returns either all lists, links to the principal, or only one list, depending on the request
|
||||
func (vcls *VikunjaCaldavListStorage) GetResources(rpath string, withChildren bool) ([]data.Resource, error) {
|
||||
|
||||
// It looks like we need to have the same handler for returning both the calendar home set and the user principal
|
||||
// Since the client seems to ignore the whatever is being returned in the first request and just makes a second one
|
||||
// to the same url but requesting the calendar home instead
|
||||
// The problem with this is caldav-go just return whatever ressource it gets and making that the requested path
|
||||
// And for us here, there is no easy (I can think of at least one hacky way) to figure out if the client is requesting
|
||||
// the home or principal url. Ough.
|
||||
|
||||
// Ok, maybe the problem is more the client making a request to /dav/ and getting a response which says
|
||||
// something like "hey, for /dav/lists, the calendar home is /dav/lists", but the client expects a
|
||||
// response to go something like "hey, for /dav/, the calendar home is /dav/lists" since it requested /dav/
|
||||
// and not /dav/lists. I'm not sure if thats a bug in the client or in caldav-go.
|
||||
|
||||
if vcls.isEntry {
|
||||
r := data.NewResource(rpath, &VikunjaListResourceAdapter{
|
||||
isPrincipal: true,
|
||||
isCollection: true,
|
||||
})
|
||||
return []data.Resource{r}, nil
|
||||
}
|
||||
|
||||
// If the request wants the principal url, we'll return that and nothing else
|
||||
if vcls.isPrincipal {
|
||||
r := data.NewResource(DavBasePath+`/lists/`, &VikunjaListResourceAdapter{
|
||||
isPrincipal: true,
|
||||
isCollection: true,
|
||||
})
|
||||
return []data.Resource{r}, nil
|
||||
}
|
||||
|
||||
// If vcls.list.ID is != 0, this means the user is doing a PROPFIND request to /lists/:list
|
||||
// Which means we need to get only one list
|
||||
if vcls.list != nil && vcls.list.ID != 0 {
|
||||
rr, err := vcls.getListRessource(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := data.NewResource(rpath, &rr)
|
||||
r.Name = vcls.list.Title
|
||||
return []data.Resource{r}, nil
|
||||
}
|
||||
|
||||
// Otherwise get all lists
|
||||
thelists, err := vcls.list.ReadAll("", vcls.user, -1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lists := thelists.([]*models.List)
|
||||
|
||||
var resources []data.Resource
|
||||
for _, l := range lists {
|
||||
rr := VikunjaListResourceAdapter{
|
||||
list: l,
|
||||
isCollection: true,
|
||||
}
|
||||
r := data.NewResource(ListBasePath+"/"+strconv.FormatInt(l.ID, 10), &rr)
|
||||
r.Name = l.Title
|
||||
resources = append(resources, r)
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// GetResourcesByList fetches a list of resources from a slice of paths
|
||||
func (vcls *VikunjaCaldavListStorage) GetResourcesByList(rpaths []string) ([]data.Resource, error) {
|
||||
|
||||
// Parse the set of resourcepaths into usable uids
|
||||
// A path looks like this: /dav/lists/10/a6eb526d5748a5c499da202fe74f36ed1aea2aef.ics
|
||||
// So we split the url in parts, take the last one and strip the ".ics" at the end
|
||||
var uids []string
|
||||
for _, path := range rpaths {
|
||||
parts := strings.Split(path, "/")
|
||||
uid := []rune(parts[4]) // The 4th part is the id with ".ics" suffix
|
||||
endlen := len(uid) - 4 // ".ics" are 4 bytes
|
||||
uids = append(uids, string(uid[:endlen]))
|
||||
}
|
||||
|
||||
// GetTasksByUIDs...
|
||||
// Parse these into ressources...
|
||||
tasks, err := models.GetTasksByUIDs(uids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var resources []data.Resource
|
||||
for _, t := range tasks {
|
||||
rr := VikunjaListResourceAdapter{
|
||||
task: t,
|
||||
}
|
||||
r := data.NewResource(getTaskURL(t), &rr)
|
||||
r.Name = t.Text
|
||||
resources = append(resources, r)
|
||||
}
|
||||
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// GetResourcesByFilters fetches a list of resources with a filter
|
||||
func (vcls *VikunjaCaldavListStorage) GetResourcesByFilters(rpath string, filters *data.ResourceFilter) ([]data.Resource, error) {
|
||||
|
||||
// If we already have a list saved, that means the user is making a REPORT request to find out if
|
||||
// anything changed, in that case we need to return all tasks.
|
||||
// That list is coming from a previous "getListRessource" in L177
|
||||
if vcls.list.Tasks != nil {
|
||||
var resources []data.Resource
|
||||
for _, t := range vcls.list.Tasks {
|
||||
rr := VikunjaListResourceAdapter{
|
||||
list: vcls.list,
|
||||
task: t,
|
||||
isCollection: false,
|
||||
}
|
||||
r := data.NewResource(getTaskURL(t), &rr)
|
||||
r.Name = t.Text
|
||||
resources = append(resources, r)
|
||||
}
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// This is used to get all
|
||||
rr, err := vcls.getListRessource(false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := data.NewResource(rpath, &rr)
|
||||
r.Name = vcls.list.Title
|
||||
return []data.Resource{r}, nil
|
||||
// For now, filtering is disabled.
|
||||
//return vcls.GetResources(rpath, false)
|
||||
}
|
||||
|
||||
func getTaskURL(task *models.ListTask) string {
|
||||
return ListBasePath + "/" + strconv.FormatInt(task.ListID, 10) + `/` + task.UID + `.ics`
|
||||
}
|
||||
|
||||
// GetResource fetches a single resource
|
||||
func (vcls *VikunjaCaldavListStorage) GetResource(rpath string) (*data.Resource, bool, error) {
|
||||
|
||||
// If the task is not nil, we need to get the task and not the list
|
||||
if vcls.task != nil {
|
||||
// save and override the updated unix date to not break any later etag checks
|
||||
updated := vcls.task.Updated
|
||||
task, err := models.GetTaskSimple(&models.ListTask{ID: vcls.task.ID, UID: vcls.task.UID})
|
||||
if err != nil {
|
||||
if models.IsErrListTaskDoesNotExist(err) {
|
||||
return nil, false, errs.ResourceNotFoundError
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
vcls.task = &task
|
||||
if updated > 0 {
|
||||
vcls.task.Updated = updated
|
||||
}
|
||||
|
||||
rr := VikunjaListResourceAdapter{
|
||||
list: vcls.list,
|
||||
task: &task,
|
||||
}
|
||||
r := data.NewResource(rpath, &rr)
|
||||
return &r, true, nil
|
||||
}
|
||||
|
||||
// Otherwise get the list with all tasks
|
||||
rr, err := vcls.getListRessource(true)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
r := data.NewResource(rpath, &rr)
|
||||
return &r, true, nil
|
||||
}
|
||||
|
||||
// GetShallowResource gets a ressource without childs
|
||||
// Since Vikunja has no children, this is the same as GetResource
|
||||
func (vcls *VikunjaCaldavListStorage) GetShallowResource(rpath string) (*data.Resource, bool, error) {
|
||||
// Since Vikunja has no childs, this just returns the same as GetResource()
|
||||
// FIXME: This should just get the list with no tasks whatsoever, nothing else
|
||||
return vcls.GetResource(rpath)
|
||||
}
|
||||
|
||||
// CreateResource creates a new resource
|
||||
func (vcls *VikunjaCaldavListStorage) CreateResource(rpath, content string) (*data.Resource, error) {
|
||||
|
||||
vTask, err := parseTaskFromVTODO(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vTask.ListID = vcls.list.ID
|
||||
|
||||
// Check the rights
|
||||
canCreate, err := vTask.CanCreate(vcls.user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canCreate {
|
||||
return nil, errs.ForbiddenError
|
||||
}
|
||||
|
||||
// Create the task
|
||||
err = vTask.Create(vcls.user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build up the proper response
|
||||
rr := VikunjaListResourceAdapter{
|
||||
list: vcls.list,
|
||||
task: vTask,
|
||||
}
|
||||
r := data.NewResource(rpath, &rr)
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// UpdateResource updates a resource
|
||||
func (vcls *VikunjaCaldavListStorage) UpdateResource(rpath, content string) (*data.Resource, error) {
|
||||
|
||||
vTask, err := parseTaskFromVTODO(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// At this point, we already have the right task in vcls.task, so we can use that ID directly
|
||||
vTask.ID = vcls.task.ID
|
||||
|
||||
// Check the rights
|
||||
canUpdate, err := vTask.CanUpdate(vcls.user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canUpdate {
|
||||
return nil, errs.ForbiddenError
|
||||
}
|
||||
|
||||
// Update the task
|
||||
err = vTask.Update()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build up the proper response
|
||||
rr := VikunjaListResourceAdapter{
|
||||
list: vcls.list,
|
||||
task: vTask,
|
||||
}
|
||||
r := data.NewResource(rpath, &rr)
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// DeleteResource deletes a resource
|
||||
func (vcls *VikunjaCaldavListStorage) DeleteResource(rpath string) error {
|
||||
if vcls.task != nil {
|
||||
// Check the rights
|
||||
canDelete, err := vcls.task.CanDelete(vcls.user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !canDelete {
|
||||
return errs.ForbiddenError
|
||||
}
|
||||
|
||||
// Delete it
|
||||
return vcls.task.Delete()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VikunjaListResourceAdapter holds the actual resource
|
||||
type VikunjaListResourceAdapter struct {
|
||||
list *models.List
|
||||
task *models.ListTask
|
||||
|
||||
isPrincipal bool
|
||||
isCollection bool
|
||||
}
|
||||
|
||||
// IsCollection checks if the resoure in the adapter is a collection
|
||||
func (vlra *VikunjaListResourceAdapter) IsCollection() bool {
|
||||
// If the discovery does not work, setting this to true makes it work again.
|
||||
return vlra.isCollection
|
||||
}
|
||||
|
||||
// CalculateEtag returns the etag of a resource
|
||||
func (vlra *VikunjaListResourceAdapter) CalculateEtag() string {
|
||||
|
||||
// If we're updating a task, the client sends the etag of the list instead of the one from the task.
|
||||
// And therefore, updating the task fails since these etags don't match.
|
||||
// To fix that, we use this extra field to determine if we're currently updating a task and return the
|
||||
// etag of the list instead.
|
||||
//if vlra.list != nil {
|
||||
// return `"` + strconv.FormatInt(vlra.list.ID, 10) + `-` + strconv.FormatInt(vlra.list.Updated, 10) + `"`
|
||||
//}
|
||||
|
||||
// Return the etag of a task if we have one
|
||||
if vlra.task != nil {
|
||||
return `"` + strconv.FormatInt(vlra.task.ID, 10) + `-` + strconv.FormatInt(vlra.task.Updated, 10) + `"`
|
||||
}
|
||||
// This also returns the etag of the list, and not of the task,
|
||||
// which becomes problematic because the client uses this etag (= the one from the list) to make
|
||||
// Requests to update a task. These do not match and thus updating a task fails.
|
||||
return `"` + strconv.FormatInt(vlra.list.ID, 10) + `-` + strconv.FormatInt(vlra.list.Updated, 10) + `"`
|
||||
}
|
||||
|
||||
// GetContent returns the content string of a resource (a task in our case)
|
||||
func (vlra *VikunjaListResourceAdapter) GetContent() string {
|
||||
if vlra.list != nil && vlra.list.Tasks != nil {
|
||||
return getCaldavTodosForTasks(vlra.list)
|
||||
}
|
||||
|
||||
if vlra.task != nil {
|
||||
list := models.List{Tasks: []*models.ListTask{vlra.task}}
|
||||
return getCaldavTodosForTasks(&list)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetContentSize is the size of a caldav content
|
||||
func (vlra *VikunjaListResourceAdapter) GetContentSize() int64 {
|
||||
return int64(len(vlra.GetContent()))
|
||||
}
|
||||
|
||||
// GetModTime returns when the resource was last modified
|
||||
func (vlra *VikunjaListResourceAdapter) GetModTime() time.Time {
|
||||
if vlra.task != nil {
|
||||
return time.Unix(vlra.task.Updated, 0)
|
||||
}
|
||||
|
||||
if vlra.list != nil {
|
||||
return time.Unix(vlra.list.Updated, 0)
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func (vcls *VikunjaCaldavListStorage) getListRessource(isCollection bool) (rr VikunjaListResourceAdapter, err error) {
|
||||
can, err := vcls.list.CanRead(vcls.user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !can {
|
||||
log.Log.Errorf("User %v tried to access a caldav resource (List %v) which they are not allowed to access", vcls.user.Username, vcls.list.ID)
|
||||
return rr, models.ErrUserDoesNotHaveAccessToList{ListID: vcls.list.ID}
|
||||
}
|
||||
err = vcls.list.ReadOne()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
rr = VikunjaListResourceAdapter{
|
||||
list: vcls.list,
|
||||
isCollection: isCollection,
|
||||
}
|
||||
|
||||
return
|
||||
}
|
125
pkg/routes/caldav/parsing.go
Normal file
125
pkg/routes/caldav/parsing.go
Normal file
@ -0,0 +1,125 @@
|
||||
// Vikunja is a todo-list application to facilitate your life.
|
||||
// Copyright 2019 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 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 General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"code.vikunja.io/api/pkg/caldav"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"github.com/laurent22/ical-go/ical"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func getCaldavTodosForTasks(list *models.List) string {
|
||||
|
||||
// Make caldav todos from Vikunja todos
|
||||
var caldavtodos []*caldav.Todo
|
||||
for _, t := range list.Tasks {
|
||||
|
||||
durationString := t.EndDateUnix - t.StartDateUnix
|
||||
duration, _ := time.ParseDuration(strconv.FormatInt(durationString, 10) + `s`)
|
||||
|
||||
caldavtodos = append(caldavtodos, &caldav.Todo{
|
||||
TimestampUnix: t.Updated,
|
||||
UID: t.UID,
|
||||
Summary: t.Text,
|
||||
Description: t.Description,
|
||||
CompletedUnix: t.DoneAtUnix,
|
||||
// Organizer: &t.CreatedBy, // Disabled until we figure out how this works
|
||||
Priority: t.Priority,
|
||||
StartUnix: t.StartDateUnix,
|
||||
EndUnix: t.EndDateUnix,
|
||||
CreatedUnix: t.Created,
|
||||
UpdatedUnix: t.Updated,
|
||||
DueDateUnix: t.DueDateUnix,
|
||||
Duration: duration,
|
||||
})
|
||||
}
|
||||
|
||||
caldavConfig := &caldav.Config{
|
||||
Name: list.Title,
|
||||
ProdID: "Vikunja Todo App",
|
||||
}
|
||||
|
||||
return caldav.ParseTodos(caldavConfig, caldavtodos)
|
||||
}
|
||||
|
||||
func parseTaskFromVTODO(content string) (vTask *models.ListTask, err error) {
|
||||
parsed, err := ical.ParseCalendar(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// We put the task details in a map to be able to handle them more easily
|
||||
task := make(map[string]string)
|
||||
for _, c := range parsed.Children {
|
||||
if c.Name == "VTODO" {
|
||||
for _, entry := range c.Children {
|
||||
task[entry.Name] = entry.Value
|
||||
}
|
||||
// Breaking, to only process the first task
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the UID
|
||||
var priority int64
|
||||
if _, ok := task["PRIORITY"]; ok {
|
||||
priority, err = strconv.ParseInt(task["PRIORITY"], 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the enddate
|
||||
duration, _ := time.ParseDuration(task["DURATION"])
|
||||
|
||||
vTask = &models.ListTask{
|
||||
UID: task["UID"],
|
||||
Text: task["SUMMARY"],
|
||||
Description: task["DESCRIPTION"],
|
||||
Priority: priority,
|
||||
DueDateUnix: caldavTimeToUnixTimestamp(task["DUE"]),
|
||||
Updated: caldavTimeToUnixTimestamp(task["DTSTAMP"]),
|
||||
StartDateUnix: caldavTimeToUnixTimestamp(task["DTSTART"]),
|
||||
DoneAtUnix: caldavTimeToUnixTimestamp(task["COMPLETED"]),
|
||||
}
|
||||
|
||||
if task["STATUS"] == "COMPLETED" {
|
||||
vTask.Done = true
|
||||
}
|
||||
|
||||
if duration > 0 && vTask.StartDateUnix > 0 {
|
||||
vTask.EndDateUnix = vTask.StartDateUnix + int64(duration.Seconds())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func caldavTimeToUnixTimestamp(tstring string) int64 {
|
||||
if tstring == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
t, err := time.Parse(caldav.DateFormat, tstring)
|
||||
if err != nil {
|
||||
log.Log.Warningf("Error while parsing caldav time %s to unix time: %s", tstring, err)
|
||||
return 0
|
||||
}
|
||||
return t.Unix()
|
||||
}
|
@ -43,6 +43,7 @@ import (
|
||||
"code.vikunja.io/api/pkg/metrics"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
apiv1 "code.vikunja.io/api/pkg/routes/api/v1"
|
||||
"code.vikunja.io/api/pkg/routes/caldav"
|
||||
_ "code.vikunja.io/api/pkg/swagger" // To generate swagger docs
|
||||
"code.vikunja.io/web"
|
||||
"code.vikunja.io/web/handler"
|
||||
@ -52,6 +53,7 @@ import (
|
||||
elog "github.com/labstack/gommon/log"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"github.com/spf13/viper"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CustomValidator is a dummy struct to use govalidator with echo
|
||||
@ -119,13 +121,34 @@ func NewEcho() *echo.Echo {
|
||||
// RegisterRoutes registers all routes for the application
|
||||
func RegisterRoutes(e *echo.Echo) {
|
||||
|
||||
if viper.GetBool("service.enablecaldav") {
|
||||
// Caldav routes
|
||||
wkg := e.Group("/.well-known")
|
||||
wkg.Use(middleware.BasicAuth(caldavBasicAuth))
|
||||
wkg.Any("/caldav", caldav.PrincipalHandler)
|
||||
wkg.Any("/caldav/", caldav.PrincipalHandler)
|
||||
c := e.Group("/dav")
|
||||
registerCalDavRoutes(c)
|
||||
}
|
||||
|
||||
// CORS_SHIT
|
||||
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
|
||||
AllowOrigins: []string{"*"},
|
||||
Skipper: func(context echo.Context) bool {
|
||||
// Since it is not possible to register this middleware just for the api group,
|
||||
// we just disable it when for caldav requests.
|
||||
// Caldav requires OPTIONS requests to be answered in a specific manner,
|
||||
// not doing this would break the caldav implementation
|
||||
return strings.HasPrefix(context.Path(), "/dav")
|
||||
},
|
||||
}))
|
||||
|
||||
// API Routes
|
||||
a := e.Group("/api/v1")
|
||||
registerAPIRoutes(a)
|
||||
}
|
||||
|
||||
func registerAPIRoutes(a *echo.Group) {
|
||||
|
||||
// Docs
|
||||
a.GET("/docs.json", apiv1.DocsJSON)
|
||||
@ -192,9 +215,6 @@ func RegisterRoutes(e *echo.Echo) {
|
||||
a.POST("/user/password/reset", apiv1.UserResetPassword)
|
||||
a.POST("/user/confirm", apiv1.UserConfirmEmail)
|
||||
|
||||
// Caldav, with auth
|
||||
a.GET("/tasks/caldav", apiv1.Caldav)
|
||||
|
||||
// ===== Routes with Authetification =====
|
||||
// Authetification
|
||||
a.Use(middleware.JWT([]byte(viper.GetString("service.JWTSecret"))))
|
||||
@ -363,3 +383,34 @@ func RegisterRoutes(e *echo.Echo) {
|
||||
a.PUT("/teams/:team/members", teamMemberHandler.CreateWeb)
|
||||
a.DELETE("/teams/:team/members/:user", teamMemberHandler.DeleteWeb)
|
||||
}
|
||||
|
||||
func registerCalDavRoutes(c *echo.Group) {
|
||||
|
||||
// Basic auth middleware
|
||||
c.Use(middleware.BasicAuth(caldavBasicAuth))
|
||||
|
||||
// THIS is the entry point for caldav clients, otherwise lists will show up double
|
||||
c.Any("", caldav.EntryHandler)
|
||||
c.Any("/", caldav.EntryHandler)
|
||||
c.Any("/principals/*/", caldav.PrincipalHandler)
|
||||
c.Any("/lists", caldav.ListHandler)
|
||||
c.Any("/lists/", caldav.ListHandler)
|
||||
c.Any("/lists/:list", caldav.ListHandler)
|
||||
c.Any("/lists/:list/", caldav.ListHandler)
|
||||
c.Any("/lists/:list/:task", caldav.TaskHandler) // Mostly used for editing
|
||||
}
|
||||
|
||||
func caldavBasicAuth(username, password string, c echo.Context) (bool, error) {
|
||||
creds := &models.UserLogin{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
u, err := models.CheckUserCredentials(creds)
|
||||
if err != nil {
|
||||
log.Log.Errorf("Error during basic auth for caldav: %v", err)
|
||||
return false, nil
|
||||
}
|
||||
// Save the user in echo context for later use
|
||||
c.Set("userBasicAuth", u)
|
||||
return true, nil
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
// GENERATED BY THE COMMAND ABOVE; DO NOT EDIT
|
||||
// This file was generated by swaggo/swag at
|
||||
// 2019-04-30 11:26:19.179895431 +0200 CEST m=+0.133585470
|
||||
// 2019-05-22 19:24:37.734465408 +0200 CEST m=+0.660846954
|
||||
|
||||
package swagger
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
var doc = `{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "This is the documentation for the [Vikunja](http://vikunja.io) API. Vikunja is a cross-plattform Todo-application with a lot of features, such as sharing lists with users or teams. \u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs ` + "`" + `Authorization: Bearer \u003cjwt-token\u003e` + "`" + `-header to authenticate successfully.\n\n**BasicAuth:** Only used when requesting tasks via caldav.\n\u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e",
|
||||
"description": "\u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e",
|
||||
"title": "Vikunja API",
|
||||
"contact": {
|
||||
"name": "General Vikunja contact",
|
||||
@ -391,7 +391,7 @@ var doc = `{
|
||||
"JWTKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a team by its ID.",
|
||||
"description": "Returns a list by its ID.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@ -399,13 +399,13 @@ var doc = `{
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"team"
|
||||
"list"
|
||||
],
|
||||
"summary": "Gets one team",
|
||||
"summary": "Gets one list",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Team ID",
|
||||
"description": "List ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -413,14 +413,14 @@ var doc = `{
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The team",
|
||||
"description": "The list",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/models.Team"
|
||||
"$ref": "#/definitions/models.List"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The user does not have access to the team",
|
||||
"description": "The user does not have access to the list",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/code.vikunja.io.web.HTTPError"
|
||||
@ -2381,37 +2381,6 @@ var doc = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/caldav": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a calDAV-parsable format with all tasks as calendar events. Only returns tasks with a due date. Also creates reminders when the task has one.",
|
||||
"produces": [
|
||||
"text/plain"
|
||||
],
|
||||
"tags": [
|
||||
"task"
|
||||
],
|
||||
"summary": "CalDAV-readable format with all tasks as calendar events.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The caldav events.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Unauthorized.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{id}": {
|
||||
"post": {
|
||||
"security": [
|
||||
@ -3688,6 +3657,10 @@ var doc = `{
|
||||
"description": "Whether a task is done or not.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"doneAt": {
|
||||
"description": "The unix timestamp when a task was marked as done.",
|
||||
"type": "integer"
|
||||
},
|
||||
"dueDate": {
|
||||
"description": "A unix timestamp when the task is due.",
|
||||
"type": "integer"
|
||||
@ -3905,6 +3878,10 @@ var doc = `{
|
||||
"description": "Whether a task is done or not.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"doneAt": {
|
||||
"description": "The unix timestamp when a task was marked as done.",
|
||||
"type": "integer"
|
||||
},
|
||||
"dueDate": {
|
||||
"description": "A unix timestamp when the task is due.",
|
||||
"type": "integer"
|
||||
|
@ -1,7 +1,7 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"description": "This is the documentation for the [Vikunja](http://vikunja.io) API. Vikunja is a cross-plattform Todo-application with a lot of features, such as sharing lists with users or teams. \u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e\n# Authorization\n**JWT-Auth:** Main authorization method, used for most of the requests. Needs ` + \"`\" + `Authorization: Bearer \u003cjwt-token\u003e` + \"`\" + `-header to authenticate successfully.\n\n**BasicAuth:** Only used when requesting tasks via caldav.\n\u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e",
|
||||
"description": "\u003c!-- ReDoc-Inject: \u003csecurity-definitions\u003e --\u003e",
|
||||
"title": "Vikunja API",
|
||||
"contact": {
|
||||
"name": "General Vikunja contact",
|
||||
@ -378,7 +378,7 @@
|
||||
"JWTKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a team by its ID.",
|
||||
"description": "Returns a list by its ID.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@ -386,13 +386,13 @@
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"team"
|
||||
"list"
|
||||
],
|
||||
"summary": "Gets one team",
|
||||
"summary": "Gets one list",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Team ID",
|
||||
"description": "List ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
@ -400,14 +400,14 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The team",
|
||||
"description": "The list",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/models.Team"
|
||||
"$ref": "#/definitions/models.List"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "The user does not have access to the team",
|
||||
"description": "The user does not have access to the list",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/code.vikunja.io/web.HTTPError"
|
||||
@ -2368,37 +2368,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/caldav": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BasicAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a calDAV-parsable format with all tasks as calendar events. Only returns tasks with a due date. Also creates reminders when the task has one.",
|
||||
"produces": [
|
||||
"text/plain"
|
||||
],
|
||||
"tags": [
|
||||
"task"
|
||||
],
|
||||
"summary": "CalDAV-readable format with all tasks as calendar events.",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The caldav events.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Unauthorized.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tasks/{id}": {
|
||||
"post": {
|
||||
"security": [
|
||||
@ -3674,6 +3643,10 @@
|
||||
"description": "Whether a task is done or not.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"doneAt": {
|
||||
"description": "The unix timestamp when a task was marked as done.",
|
||||
"type": "integer"
|
||||
},
|
||||
"dueDate": {
|
||||
"description": "A unix timestamp when the task is due.",
|
||||
"type": "integer"
|
||||
@ -3891,6 +3864,10 @@
|
||||
"description": "Whether a task is done or not.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"doneAt": {
|
||||
"description": "The unix timestamp when a task was marked as done.",
|
||||
"type": "integer"
|
||||
},
|
||||
"dueDate": {
|
||||
"description": "A unix timestamp when the task is due.",
|
||||
"type": "integer"
|
||||
|
@ -51,6 +51,9 @@ definitions:
|
||||
done:
|
||||
description: Whether a task is done or not.
|
||||
type: boolean
|
||||
doneAt:
|
||||
description: The unix timestamp when a task was marked as done.
|
||||
type: integer
|
||||
dueDate:
|
||||
description: A unix timestamp when the task is due.
|
||||
type: integer
|
||||
@ -223,6 +226,9 @@ definitions:
|
||||
done:
|
||||
description: Whether a task is done or not.
|
||||
type: boolean
|
||||
doneAt:
|
||||
description: The unix timestamp when a task was marked as done.
|
||||
type: integer
|
||||
dueDate:
|
||||
description: A unix timestamp when the task is due.
|
||||
type: integer
|
||||
@ -645,13 +651,7 @@ info:
|
||||
email: hello@vikunja.io
|
||||
name: General Vikunja contact
|
||||
url: http://vikunja.io/en/contact/
|
||||
description: |-
|
||||
This is the documentation for the [Vikunja](http://vikunja.io) API. Vikunja is a cross-plattform Todo-application with a lot of features, such as sharing lists with users or teams. <!-- ReDoc-Inject: <security-definitions> -->
|
||||
# Authorization
|
||||
**JWT-Auth:** Main authorization method, used for most of the requests. Needs ` + "`" + `Authorization: Bearer <jwt-token>` + "`" + `-header to authenticate successfully.
|
||||
|
||||
**BasicAuth:** Only used when requesting tasks via caldav.
|
||||
<!-- ReDoc-Inject: <security-definitions> -->
|
||||
description: '<!-- ReDoc-Inject: <security-definitions> -->'
|
||||
license:
|
||||
name: GPLv3
|
||||
url: http://code.vikunja.io/api/src/branch/master/LICENSE
|
||||
@ -935,9 +935,9 @@ paths:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Returns a team by its ID.
|
||||
description: Returns a list by its ID.
|
||||
parameters:
|
||||
- description: Team ID
|
||||
- description: List ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
@ -946,12 +946,12 @@ paths:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: The team
|
||||
description: The list
|
||||
schema:
|
||||
$ref: '#/definitions/models.Team'
|
||||
$ref: '#/definitions/models.List'
|
||||
type: object
|
||||
"403":
|
||||
description: The user does not have access to the team
|
||||
description: The user does not have access to the list
|
||||
schema:
|
||||
$ref: '#/definitions/code.vikunja.io/web.HTTPError'
|
||||
type: object
|
||||
@ -962,9 +962,9 @@ paths:
|
||||
type: object
|
||||
security:
|
||||
- JWTKeyAuth: []
|
||||
summary: Gets one team
|
||||
summary: Gets one list
|
||||
tags:
|
||||
- team
|
||||
- list
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
@ -2638,27 +2638,6 @@ paths:
|
||||
summary: Update a bunch of tasks at once
|
||||
tags:
|
||||
- task
|
||||
/tasks/caldav:
|
||||
get:
|
||||
description: Returns a calDAV-parsable format with all tasks as calendar events.
|
||||
Only returns tasks with a due date. Also creates reminders when the task has
|
||||
one.
|
||||
produces:
|
||||
- text/plain
|
||||
responses:
|
||||
"200":
|
||||
description: The caldav events.
|
||||
schema:
|
||||
type: string
|
||||
"403":
|
||||
description: Unauthorized.
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- BasicAuth: []
|
||||
summary: CalDAV-readable format with all tasks as calendar events.
|
||||
tags:
|
||||
- task
|
||||
/teams:
|
||||
get:
|
||||
consumes:
|
||||
|
Reference in New Issue
Block a user