package api import ( "context" "fmt" "time" "task-dashboard/internal/config" "task-dashboard/internal/models" ) const ( todoistBaseURL = "https://api.todoist.com/api/v1" todoistSyncBaseURL = "https://api.todoist.com/sync/v9" ) // TodoistClient handles interactions with the Todoist API type TodoistClient struct { BaseClient syncClient BaseClient apiKey string } // NewTodoistClient creates a new Todoist API client func NewTodoistClient(apiKey string) *TodoistClient { return &TodoistClient{ BaseClient: NewBaseClient(todoistBaseURL), syncClient: NewBaseClient(todoistSyncBaseURL), apiKey: apiKey, } } func (c *TodoistClient) authHeaders() map[string]string { return map[string]string{"Authorization": "Bearer " + c.apiKey} } // todoistTaskResponse represents the API response structure type todoistTaskResponse struct { ID string `json:"id"` Content string `json:"content"` Description string `json:"description"` ProjectID string `json:"project_id"` Priority int `json:"priority"` Labels []string `json:"labels"` Due *dueInfo `json:"due"` URL string `json:"url"` CreatedAt string `json:"created_at"` } // todoistProjectResponse represents the project API response type todoistProjectResponse struct { ID string `json:"id"` Name string `json:"name"` } // Sync API v9 response types // TodoistSyncResponse represents the Sync API response type TodoistSyncResponse struct { SyncToken string `json:"sync_token"` FullSync bool `json:"full_sync"` Items []SyncItemResponse `json:"items"` Projects []SyncProjectResponse `json:"projects"` } // SyncItemResponse represents a task item from Sync API type SyncItemResponse struct { ID string `json:"id"` Content string `json:"content"` Description string `json:"description"` ProjectID string `json:"project_id"` Priority int `json:"priority"` Labels []string `json:"labels"` Due *dueInfo `json:"due"` IsCompleted bool `json:"is_completed"` IsDeleted bool `json:"is_deleted"` AddedAt string `json:"added_at"` } // SyncProjectResponse represents a project from Sync API type SyncProjectResponse struct { ID string `json:"id"` Name string `json:"name"` IsDeleted bool `json:"is_deleted"` } // GetTasks fetches all active tasks from Todoist func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) { var apiTasks []todoistTaskResponse if err := c.Get(ctx, "/tasks", c.authHeaders(), &apiTasks); err != nil { return nil, fmt.Errorf("failed to fetch tasks: %w", err) } // Fetch projects to get project names projects, err := c.GetProjects(ctx) projectMap := make(map[string]string) if err == nil { for _, proj := range projects { projectMap[proj.ID] = proj.Name } } // Convert to our model tasks := make([]models.Task, 0, len(apiTasks)) for _, apiTask := range apiTasks { task := models.Task{ ID: apiTask.ID, Content: apiTask.Content, Description: apiTask.Description, ProjectID: apiTask.ProjectID, ProjectName: projectMap[apiTask.ProjectID], Priority: apiTask.Priority, Completed: false, Labels: apiTask.Labels, URL: apiTask.URL, } if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } task.DueDate = parseDueDate(apiTask.Due) if apiTask.Due != nil { task.IsRecurring = apiTask.Due.IsRecurring } tasks = append(tasks, task) } return tasks, nil } // GetProjects fetches all projects func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { var apiProjects []todoistProjectResponse if err := c.Get(ctx, "/projects", c.authHeaders(), &apiProjects); err != nil { return nil, fmt.Errorf("failed to fetch projects: %w", err) } projects := make([]models.Project, 0, len(apiProjects)) for _, apiProj := range apiProjects { projects = append(projects, models.Project{ ID: apiProj.ID, Name: apiProj.Name, }) } return projects, nil } // Sync performs an incremental sync using the Sync API v9 func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) { if syncToken == "" { syncToken = "*" // Full sync } payload := map[string]interface{}{ "sync_token": syncToken, "resource_types": []string{"items", "projects"}, } var syncResp TodoistSyncResponse if err := c.syncClient.Post(ctx, "/sync", c.authHeaders(), payload, &syncResp); err != nil { return nil, fmt.Errorf("failed to perform sync: %w", err) } return &syncResp, nil } // ConvertSyncItemToTask converts a single sync item to a Task model. // Returns the task and true if the item is active, or a zero Task and false if it should be skipped. func ConvertSyncItemToTask(item SyncItemResponse, projectMap map[string]string) (models.Task, bool) { if item.IsCompleted || item.IsDeleted { return models.Task{}, false } task := models.Task{ ID: item.ID, Content: item.Content, Description: item.Description, ProjectID: item.ProjectID, ProjectName: projectMap[item.ProjectID], Priority: item.Priority, Completed: false, Labels: item.Labels, URL: fmt.Sprintf("https://todoist.com/app/task/%s", item.ID), } if item.AddedAt != "" { if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { task.CreatedAt = createdAt } } task.DueDate = parseDueDate(item.Due) return task, true } // ConvertSyncItemsToTasks converts sync API items to Task models func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]string) []models.Task { tasks := make([]models.Task, 0, len(items)) for _, item := range items { if task, ok := ConvertSyncItemToTask(item, projectMap); ok { tasks = append(tasks, task) } } return tasks } // BuildProjectMapFromSync builds a project ID to name map from sync response func BuildProjectMapFromSync(projects []SyncProjectResponse) map[string]string { projectMap := make(map[string]string) for _, proj := range projects { if !proj.IsDeleted { projectMap[proj.ID] = proj.Name } } return projectMap } // CreateTask creates a new task in Todoist func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID string, dueDate *time.Time, priority int) (*models.Task, error) { payload := map[string]interface{}{"content": content} if projectID != "" { payload["project_id"] = projectID } if dueDate != nil { payload["due_date"] = dueDate.Format("2006-01-02") } if priority > 0 { payload["priority"] = priority } var apiTask todoistTaskResponse if err := c.Post(ctx, "/tasks", c.authHeaders(), payload, &apiTask); err != nil { return nil, fmt.Errorf("failed to create task: %w", err) } task := &models.Task{ ID: apiTask.ID, Content: apiTask.Content, Description: apiTask.Description, ProjectID: apiTask.ProjectID, Priority: apiTask.Priority, Completed: false, Labels: apiTask.Labels, URL: apiTask.URL, } if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } task.DueDate = parseDueDate(apiTask.Due) return task, nil } // UpdateTask updates a task with the specified changes func (c *TodoistClient) UpdateTask(ctx context.Context, taskID string, updates map[string]interface{}) error { path := fmt.Sprintf("/tasks/%s", taskID) if err := c.Post(ctx, path, c.authHeaders(), updates, nil); err != nil { return fmt.Errorf("failed to update task: %w", err) } return nil } // CompleteTask marks a task as complete in Todoist func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { path := fmt.Sprintf("/tasks/%s/close", taskID) if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil { return fmt.Errorf("failed to complete task: %w", err) } return nil } // ReopenTask marks a completed task as active in Todoist func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error { path := fmt.Sprintf("/tasks/%s/reopen", taskID) if err := c.PostEmpty(ctx, path, c.authHeaders()); err != nil { return fmt.Errorf("failed to reopen task: %w", err) } return nil } // parseDueDate parses due date from API response // dueInfo represents the due date structure from Todoist API type dueInfo struct { Date string `json:"date"` Datetime string `json:"datetime"` IsRecurring bool `json:"is_recurring"` } func parseDueDate(due *dueInfo) *time.Time { if due == nil { return nil } var dueDate time.Time var err error if due.Datetime != "" { // RFC3339 includes timezone, then convert to display timezone dueDate, err = time.Parse(time.RFC3339, due.Datetime) if err == nil { dueDate = config.ToDisplayTZ(dueDate) } } else if due.Date != "" { // Date-only, parse in display timezone dueDate, err = config.ParseDateInDisplayTZ(due.Date) } if err != nil { return nil } return &dueDate }