Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ Task Wizard's primary goal is to allow users to own and protect their data and t
🗝️ Fine-grained access tokens for endless integration possibilities

🌐 Authenticated CalDAV endpoint at `/dav/tasks` with app token as the password
- Full support for task synchronization including title, due dates, and labels
- Real-time WebSocket notifications when tasks are updated via CalDAV

## ⌨️ Keyboard Shortcuts

Expand Down
18 changes: 17 additions & 1 deletion apiserver/internal/apis/caldav.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,22 @@ import (
"dkhalife.com/tasks/core/internal/utils/auth"
"dkhalife.com/tasks/core/internal/utils/caldav"
middleware "dkhalife.com/tasks/core/internal/utils/middleware"
"dkhalife.com/tasks/core/internal/ws"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"
)

type CalDAVAPIHandler struct {
tRepo *tRepo.TaskRepository
cRepo *cRepo.CalDavRepository
ws *ws.WSServer
}

func CalDAVAPI(tRepo *tRepo.TaskRepository, cRepo *cRepo.CalDavRepository) *CalDAVAPIHandler {
func CalDAVAPI(tRepo *tRepo.TaskRepository, cRepo *cRepo.CalDavRepository, wsServer *ws.WSServer) *CalDAVAPIHandler {
return &CalDAVAPIHandler{
tRepo: tRepo,
cRepo: cRepo,
ws: wsServer,
}
}

Expand Down Expand Up @@ -263,6 +266,19 @@ func (h *CalDAVAPIHandler) handlePut(c *gin.Context) {
return
}

// Get the updated task to broadcast via WebSocket
updatedTask, err := h.tRepo.GetTask(c, taskID)
if err != nil {
log.Errorf("Error getting updated task: %s", err.Error())
// Don't fail the request if we can't broadcast
} else {
// Broadcast the update to all connected clients for this user
h.ws.BroadcastToUser(currentIdentity.UserID, ws.WSResponse{
Action: "task_updated",
Data: updatedTask,
})
}

c.Status(http.StatusNoContent)
}

Expand Down
49 changes: 49 additions & 0 deletions apiserver/internal/repos/caldav/caldav_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,52 @@ func TestGenerateVTODO_TitleBackslash(t *testing.T) {

require.Contains(t, vtodo, "SUMMARY:Back\\\\slash")
}

func TestGenerateVTODO_WithDueDate(t *testing.T) {
dueDate := time.Date(2023, 3, 15, 14, 30, 0, 0, time.UTC)
task := &models.Task{
ID: 4,
Title: "Task with due date",
CreatedAt: time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC),
NextDueDate: &dueDate,
}

vtodo := generateVTODO(task)

require.Contains(t, vtodo, "SUMMARY:Task with due date")
require.Contains(t, vtodo, "DUE:20230315T143000Z")
}

func TestGenerateVTODO_WithoutDueDate(t *testing.T) {
task := &models.Task{
ID: 5,
Title: "Task without due date",
CreatedAt: time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC),
NextDueDate: nil,
}

vtodo := generateVTODO(task)

require.Contains(t, vtodo, "SUMMARY:Task without due date")
require.NotContains(t, vtodo, "DUE:")
}

func TestGenerateVTODO_DueDateUpdated(t *testing.T) {
// Test that updating a due date generates correct VTODO
dueDate := time.Date(2025, 10, 10, 10, 0, 0, 0, time.UTC)
updatedAt := time.Date(2023, 2, 1, 12, 0, 0, 0, time.UTC)

task := &models.Task{
ID: 6,
Title: "Task with updated due date",
CreatedAt: time.Date(2023, 1, 2, 3, 4, 5, 0, time.UTC),
UpdatedAt: &updatedAt,
NextDueDate: &dueDate,
}

vtodo := generateVTODO(task)

require.Contains(t, vtodo, "SUMMARY:Task with updated due date")
require.Contains(t, vtodo, "DUE:20251010T100000Z")
require.Contains(t, vtodo, "LAST-MODIFIED:20230201T120000Z")
}
151 changes: 151 additions & 0 deletions apiserver/internal/utils/caldav/parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package caldav

import (
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestParseVTODO_WithDueDateTimestamp(t *testing.T) {
vtodo := `BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTODO
CREATED:20230102T030405Z
LAST-MODIFIED:20230102T030405Z
DTSTAMP:20230102T030405Z
UID:1
SUMMARY:Test Task
DUE:20230115T120000Z
CATEGORIES:
PERCENT-COMPLETE:0
X-MOZ-GENERATION:1
END:VTODO
END:VCALENDAR`

title, due, err := ParseVTODO(vtodo)

require.NoError(t, err)
require.Equal(t, "Test Task", title)
require.NotNil(t, due)

expectedDue := time.Date(2023, 1, 15, 12, 0, 0, 0, time.UTC)
require.Equal(t, expectedDue, *due)
}

func TestParseVTODO_WithDueDateOnly(t *testing.T) {
vtodo := `BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTODO
CREATED:20230102T030405Z
LAST-MODIFIED:20230102T030405Z
DTSTAMP:20230102T030405Z
UID:2
SUMMARY:Test Task Date Only
DUE:20230116
CATEGORIES:
PERCENT-COMPLETE:0
X-MOZ-GENERATION:1
END:VTODO
END:VCALENDAR`

title, due, err := ParseVTODO(vtodo)

require.NoError(t, err)
require.Equal(t, "Test Task Date Only", title)
require.NotNil(t, due)

expectedDue := time.Date(2023, 1, 16, 0, 0, 0, 0, time.UTC)
require.Equal(t, expectedDue, *due)
}

func TestParseVTODO_WithoutDueDate(t *testing.T) {
vtodo := `BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTODO
CREATED:20230102T030405Z
LAST-MODIFIED:20230102T030405Z
DTSTAMP:20230102T030405Z
UID:3
SUMMARY:Test Task No Due Date
CATEGORIES:
PERCENT-COMPLETE:0
X-MOZ-GENERATION:1
END:VTODO
END:VCALENDAR`

title, due, err := ParseVTODO(vtodo)

require.NoError(t, err)
require.Equal(t, "Test Task No Due Date", title)
require.Nil(t, due)
}

func TestParseVTODO_UpdateDueDate(t *testing.T) {
// Simulate updating a task with a new due date
vtodo := `BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTODO
CREATED:20230102T030405Z
LAST-MODIFIED:20230102T030405Z
DTSTAMP:20230102T030405Z
UID:1
SUMMARY:Updated Task Title
DUE:20250315T140000Z
CATEGORIES:
PERCENT-COMPLETE:0
X-MOZ-GENERATION:1
END:VTODO
END:VCALENDAR`

title, due, err := ParseVTODO(vtodo)

require.NoError(t, err)
require.Equal(t, "Updated Task Title", title)
require.NotNil(t, due)

expectedDue := time.Date(2025, 3, 15, 14, 0, 0, 0, time.UTC)
require.Equal(t, expectedDue, *due)
}

func TestParseVTODO_RemoveDueDate(t *testing.T) {
// Simulate updating a task to remove the due date
vtodo := `BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTODO
CREATED:20230102T030405Z
LAST-MODIFIED:20230102T030405Z
DTSTAMP:20230102T030405Z
UID:1
SUMMARY:Task Without Due Date
CATEGORIES:
PERCENT-COMPLETE:0
X-MOZ-GENERATION:1
END:VTODO
END:VCALENDAR`

title, due, err := ParseVTODO(vtodo)

require.NoError(t, err)
require.Equal(t, "Task Without Due Date", title)
require.Nil(t, due)
}

func TestParseVTODO_EmptyVTODO(t *testing.T) {
vtodo := `BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VTODO
END:VTODO
END:VCALENDAR`

title, due, err := ParseVTODO(vtodo)

require.NoError(t, err)
require.Equal(t, "", title)
require.Nil(t, due)
}
Loading