package api import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" "task-dashboard/internal/models" ) const ( todoistBaseURL = "https://api.todoist.com/rest/v2" todoistSyncBaseURL = "https://api.todoist.com/sync/v9" ) // TodoistClient handles interactions with the Todoist API type TodoistClient struct { apiKey string baseURL string httpClient *http.Client } // NewTodoistClient creates a new Todoist API client func NewTodoistClient(apiKey string) *TodoistClient { return &TodoistClient{ apiKey: apiKey, baseURL: todoistBaseURL, httpClient: &http.Client{ Timeout: 15 * time.Second, }, } } // 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 *struct { Date string `json:"date"` Datetime string `json:"datetime"` } `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 *struct { Date string `json:"date"` Datetime string `json:"datetime"` } `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) { req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/tasks", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch tasks: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) } var apiTasks []todoistTaskResponse if err := json.NewDecoder(resp.Body).Decode(&apiTasks); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } // Fetch projects to get project names projects, err := c.GetProjects(ctx) projectMap := make(map[string]string) if err == nil { // Build map of project ID to name 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, } // Parse created_at if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } // Parse due date if apiTask.Due != nil { var dueDate time.Time if apiTask.Due.Datetime != "" { dueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime) } else if apiTask.Due.Date != "" { dueDate, err = time.Parse("2006-01-02", apiTask.Due.Date) } if err == nil { task.DueDate = &dueDate } } tasks = append(tasks, task) } return tasks, nil } // GetProjects fetches all projects func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, error) { req, err := http.NewRequestWithContext(ctx, "GET", c.baseURL+"/projects", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch projects: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) } var apiProjects []todoistProjectResponse if err := json.NewDecoder(resp.Body).Decode(&apiProjects); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } // Convert to model 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 // If syncToken is empty or "*", a full sync is performed // Returns the new sync token and the sync response func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyncResponse, error) { if syncToken == "" { syncToken = "*" // Full sync } // Prepare sync request payload := map[string]interface{}{ "sync_token": syncToken, "resource_types": []string{"items", "projects"}, } jsonData, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("failed to marshal sync request: %w", err) } req, err := http.NewRequestWithContext(ctx, "POST", todoistSyncBaseURL+"/sync", bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create sync request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to perform sync: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("todoist sync API error (status %d): %s", resp.StatusCode, string(body)) } var syncResp TodoistSyncResponse if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil { return nil, fmt.Errorf("failed to decode sync response: %w", err) } return &syncResp, nil } // 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 { // Skip completed or deleted items if item.IsCompleted || item.IsDeleted { continue } 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), } // Parse added_at if item.AddedAt != "" { if createdAt, err := time.Parse(time.RFC3339, item.AddedAt); err == nil { task.CreatedAt = createdAt } } // Parse due date if item.Due != nil { var dueDate time.Time var err error if item.Due.Datetime != "" { dueDate, err = time.Parse(time.RFC3339, item.Due.Datetime) } else if item.Due.Date != "" { dueDate, err = time.Parse("2006-01-02", item.Due.Date) } if err == nil { task.DueDate = &dueDate } } 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) { // Prepare request payload 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 } jsonData, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } // Create POST request req, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/tasks", bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") // Execute request resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to create task: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) } // Decode response var apiTask todoistTaskResponse if err := json.NewDecoder(resp.Body).Decode(&apiTask); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } // Convert to our model 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, } // Parse created_at if createdAt, err := time.Parse(time.RFC3339, apiTask.CreatedAt); err == nil { task.CreatedAt = createdAt } // Parse due date if apiTask.Due != nil { var taskDueDate time.Time if apiTask.Due.Datetime != "" { taskDueDate, err = time.Parse(time.RFC3339, apiTask.Due.Datetime) } else if apiTask.Due.Date != "" { taskDueDate, err = time.Parse("2006-01-02", apiTask.Due.Date) } if err == nil { task.DueDate = &taskDueDate } } 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 { jsonData, err := json.Marshal(updates) if err != nil { return fmt.Errorf("failed to marshal updates: %w", err) } url := fmt.Sprintf("%s/tasks/%s", c.baseURL, taskID) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to update task: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) } return nil } // CompleteTask marks a task as complete in Todoist func (c *TodoistClient) CompleteTask(ctx context.Context, taskID string) error { // Create POST request to close endpoint url := fmt.Sprintf("%s/tasks/%s/close", c.baseURL, taskID) req, err := http.NewRequestWithContext(ctx, "POST", url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) // Execute request resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to complete task: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) } return nil } // ReopenTask marks a completed task as active in Todoist func (c *TodoistClient) ReopenTask(ctx context.Context, taskID string) error { url := fmt.Sprintf("%s/tasks/%s/reopen", c.baseURL, taskID) req, err := http.NewRequestWithContext(ctx, "POST", url, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiKey) resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to reopen task: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("todoist API error (status %d): %s", resp.StatusCode, string(body)) } return nil }