summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/http.go143
-rw-r--r--internal/api/plantoeat.go67
-rw-r--r--internal/api/todoist.go264
-rw-r--r--internal/api/todoist_test.go38
-rw-r--r--internal/api/trello.go211
-rw-r--r--internal/api/trello_test.go35
6 files changed, 277 insertions, 481 deletions
diff --git a/internal/api/http.go b/internal/api/http.go
new file mode 100644
index 0000000..8854625
--- /dev/null
+++ b/internal/api/http.go
@@ -0,0 +1,143 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+)
+
+// BaseClient provides common HTTP functionality for API clients
+type BaseClient struct {
+ HTTPClient *http.Client
+ BaseURL string
+}
+
+// NewBaseClient creates a new BaseClient with default settings
+func NewBaseClient(baseURL string) BaseClient {
+ return BaseClient{
+ HTTPClient: &http.Client{Timeout: 15 * time.Second},
+ BaseURL: baseURL,
+ }
+}
+
+// Get performs a GET request and decodes the JSON response
+func (c *BaseClient) Get(ctx context.Context, path string, headers map[string]string, result interface{}) error {
+ req, err := http.NewRequestWithContext(ctx, "GET", c.BaseURL+path, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+
+ return c.doJSON(req, result)
+}
+
+// Post performs a POST request with JSON body and decodes the response
+func (c *BaseClient) Post(ctx context.Context, path string, headers map[string]string, body interface{}, result interface{}) error {
+ var bodyReader io.Reader
+ if body != nil {
+ jsonData, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("failed to marshal request body: %w", err)
+ }
+ bodyReader = bytes.NewBuffer(jsonData)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, bodyReader)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+
+ return c.doJSON(req, result)
+}
+
+// PostForm performs a POST request with form-encoded body
+func (c *BaseClient) PostForm(ctx context.Context, path string, headers map[string]string, formData string, result interface{}) error {
+ req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, bytes.NewBufferString(formData))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+
+ return c.doJSON(req, result)
+}
+
+// PostEmpty performs a POST request with no body and expects no response body
+func (c *BaseClient) PostEmpty(ctx context.Context, path string, headers map[string]string) error {
+ req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL+path, nil)
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ return nil
+}
+
+// Put performs a PUT request with form-encoded body
+func (c *BaseClient) Put(ctx context.Context, path string, headers map[string]string, formData string, result interface{}) error {
+ req, err := http.NewRequestWithContext(ctx, "PUT", c.BaseURL+path, bytes.NewBufferString(formData))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+
+ return c.doJSON(req, result)
+}
+
+// doJSON executes the request and decodes JSON response
+func (c *BaseClient) doJSON(req *http.Request, result interface{}) error {
+ resp, err := c.HTTPClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ if result != nil {
+ if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
+ return fmt.Errorf("failed to decode response: %w", err)
+ }
+ }
+
+ return nil
+}
diff --git a/internal/api/plantoeat.go b/internal/api/plantoeat.go
index 1dae246..eb29c63 100644
--- a/internal/api/plantoeat.go
+++ b/internal/api/plantoeat.go
@@ -2,35 +2,32 @@ package api
import (
"context"
- "encoding/json"
"fmt"
- "io"
- "net/http"
"time"
"task-dashboard/internal/models"
)
-const (
- planToEatBaseURL = "https://www.plantoeat.com/api/v2"
-)
+const planToEatBaseURL = "https://www.plantoeat.com/api/v2"
// PlanToEatClient handles interactions with the PlanToEat API
type PlanToEatClient struct {
- apiKey string
- httpClient *http.Client
+ BaseClient
+ apiKey string
}
// NewPlanToEatClient creates a new PlanToEat API client
func NewPlanToEatClient(apiKey string) *PlanToEatClient {
return &PlanToEatClient{
- apiKey: apiKey,
- httpClient: &http.Client{
- Timeout: 15 * time.Second,
- },
+ BaseClient: NewBaseClient(planToEatBaseURL),
+ apiKey: apiKey,
}
}
+func (c *PlanToEatClient) authHeaders() map[string]string {
+ return map[string]string{"Authorization": "Bearer " + c.apiKey}
+}
+
// planToEatPlannerItem represents a planner item from the API
type planToEatPlannerItem struct {
ID int `json:"id"`
@@ -51,59 +48,35 @@ type planToEatResponse struct {
// GetUpcomingMeals fetches meals for the next N days
func (c *PlanToEatClient) GetUpcomingMeals(ctx context.Context, days int) ([]models.Meal, error) {
if days <= 0 {
- days = 7 // Default to 7 days
+ days = 7
}
startDate := time.Now()
endDate := startDate.AddDate(0, 0, days)
- req, err := http.NewRequestWithContext(ctx, "GET", planToEatBaseURL+"/planner_items", nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- // Add query parameters
- q := req.URL.Query()
- q.Add("start_date", startDate.Format("2006-01-02"))
- q.Add("end_date", endDate.Format("2006-01-02"))
- req.URL.RawQuery = q.Encode()
-
- // Add API key (check docs for correct header name)
- req.Header.Set("Authorization", "Bearer "+c.apiKey)
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch meals: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("plantoeat API error (status %d): %s", resp.StatusCode, string(body))
- }
+ path := fmt.Sprintf("/planner_items?start_date=%s&end_date=%s",
+ startDate.Format("2006-01-02"),
+ endDate.Format("2006-01-02"))
var apiResponse planToEatResponse
- if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ if err := c.Get(ctx, path, c.authHeaders(), &apiResponse); err != nil {
+ return nil, fmt.Errorf("failed to fetch meals: %w", err)
}
- // Convert to our model
meals := make([]models.Meal, 0, len(apiResponse.Items))
for _, item := range apiResponse.Items {
mealDate, err := time.Parse("2006-01-02", item.Date)
if err != nil {
- continue // Skip invalid dates
+ continue
}
- meal := models.Meal{
+ meals = append(meals, models.Meal{
ID: fmt.Sprintf("%d", item.ID),
RecipeName: item.Recipe.Title,
Date: mealDate,
MealType: normalizeMealType(item.MealType),
RecipeURL: item.Recipe.URL,
- }
-
- meals = append(meals, meal)
+ })
}
return meals, nil
@@ -121,18 +94,16 @@ func normalizeMealType(mealType string) string {
case "snack", "Snack":
return "snack"
default:
- return "dinner" // Default to dinner
+ return "dinner"
}
}
// GetRecipes fetches recipes (for Phase 2)
func (c *PlanToEatClient) GetRecipes(ctx context.Context) error {
- // This will be implemented in Phase 2
return fmt.Errorf("not implemented yet")
}
// AddMealToPlanner adds a meal to the planner (for Phase 2)
func (c *PlanToEatClient) AddMealToPlanner(ctx context.Context, recipeID string, date time.Time, mealType string) error {
- // This will be implemented in Phase 2
return fmt.Errorf("not implemented yet")
}
diff --git a/internal/api/todoist.go b/internal/api/todoist.go
index b3d4579..6c998cf 100644
--- a/internal/api/todoist.go
+++ b/internal/api/todoist.go
@@ -1,12 +1,8 @@
package api
import (
- "bytes"
"context"
- "encoding/json"
"fmt"
- "io"
- "net/http"
"time"
"task-dashboard/internal/models"
@@ -19,22 +15,24 @@ const (
// TodoistClient handles interactions with the Todoist API
type TodoistClient struct {
+ BaseClient
+ syncClient BaseClient
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,
- },
+ 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"`
@@ -93,34 +91,15 @@ type SyncProjectResponse struct {
// 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)
+ 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 {
- // Build map of project ID to name
for _, proj := range projects {
projectMap[proj.ID] = proj.Name
}
@@ -141,24 +120,11 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) {
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
- }
- }
-
+ task.DueDate = parseDueDate(apiTask.Due)
tasks = append(tasks, task)
}
@@ -167,30 +133,11 @@ func (c *TodoistClient) GetTasks(ctx context.Context) ([]models.Task, error) {
// 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)
+ if err := c.Get(ctx, "/projects", c.authHeaders(), &apiProjects); err != nil {
+ return nil, fmt.Errorf("failed to fetch projects: %w", err)
}
- // Convert to model
projects := make([]models.Project, 0, len(apiProjects))
for _, apiProj := range apiProjects {
projects = append(projects, models.Project{
@@ -203,46 +150,19 @@ func (c *TodoistClient) GetProjects(ctx context.Context) ([]models.Project, erro
}
// 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)
+ 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
@@ -252,7 +172,6 @@ func (c *TodoistClient) Sync(ctx context.Context, syncToken string) (*TodoistSyn
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
}
@@ -269,27 +188,13 @@ func ConvertSyncItemsToTasks(items []SyncItemResponse, projectMap map[string]str
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
- }
- }
-
+ task.DueDate = parseDueDate(item.Due)
tasks = append(tasks, task)
}
return tasks
@@ -308,55 +213,22 @@ func BuildProjectMapFromSync(projects []SyncProjectResponse) map[string]string {
// 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,
- }
-
+ 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)
+ if err := c.Post(ctx, "/tasks", c.authHeaders(), payload, &apiTask); err != nil {
+ return nil, fmt.Errorf("failed to create task: %w", err)
}
- // Convert to our model
task := &models.Task{
ID: apiTask.ID,
Content: apiTask.Content,
@@ -368,100 +240,58 @@ func (c *TodoistClient) CreateTask(ctx context.Context, content, projectID strin
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
- }
- }
-
+ 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 {
- 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 {
+ 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)
}
- 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 {
+ 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)
}
- 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 {
+ 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)
}
- defer resp.Body.Close()
+ return nil
+}
- 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))
+// parseDueDate parses due date from API response
+func parseDueDate(due *struct {
+ Date string `json:"date"`
+ Datetime string `json:"datetime"`
+}) *time.Time {
+ if due == nil {
+ return nil
+ }
+ var dueDate time.Time
+ var err error
+ if due.Datetime != "" {
+ dueDate, err = time.Parse(time.RFC3339, due.Datetime)
+ } else if due.Date != "" {
+ dueDate, err = time.Parse("2006-01-02", due.Date)
}
-
- return nil
+ if err != nil {
+ return nil
+ }
+ return &dueDate
}
diff --git a/internal/api/todoist_test.go b/internal/api/todoist_test.go
index 56b1484..88f94f8 100644
--- a/internal/api/todoist_test.go
+++ b/internal/api/todoist_test.go
@@ -10,6 +10,14 @@ import (
"time"
)
+// newTestTodoistClient creates a TodoistClient for testing with custom base URL
+func newTestTodoistClient(baseURL, apiKey string) *TodoistClient {
+ client := NewTodoistClient(apiKey)
+ client.BaseClient.BaseURL = baseURL
+ client.syncClient.BaseURL = baseURL
+ return client
+}
+
func TestTodoistClient_CreateTask(t *testing.T) {
// Mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -59,11 +67,7 @@ func TestTodoistClient_CreateTask(t *testing.T) {
defer server.Close()
// Create client with mock server URL
- client := &TodoistClient{
- apiKey: "test-key",
- baseURL: server.URL,
- httpClient: &http.Client{},
- }
+ client := newTestTodoistClient(server.URL, "test-key")
// Test CreateTask
ctx := context.Background()
@@ -122,11 +126,7 @@ func TestTodoistClient_CreateTask_WithDueDate(t *testing.T) {
defer server.Close()
// Create client
- client := &TodoistClient{
- apiKey: "test-key",
- baseURL: server.URL,
- httpClient: &http.Client{},
- }
+ client := newTestTodoistClient(server.URL, "test-key")
// Test CreateTask with due date
ctx := context.Background()
@@ -170,11 +170,7 @@ func TestTodoistClient_CompleteTask(t *testing.T) {
defer server.Close()
// Create client
- client := &TodoistClient{
- apiKey: "test-key",
- baseURL: server.URL,
- httpClient: &http.Client{},
- }
+ client := newTestTodoistClient(server.URL, "test-key")
// Test CompleteTask
ctx := context.Background()
@@ -194,11 +190,7 @@ func TestTodoistClient_CompleteTask_Error(t *testing.T) {
defer server.Close()
// Create client
- client := &TodoistClient{
- apiKey: "test-key",
- baseURL: server.URL,
- httpClient: &http.Client{},
- }
+ client := newTestTodoistClient(server.URL, "test-key")
// Test CompleteTask with error
ctx := context.Background()
@@ -235,11 +227,7 @@ func TestTodoistClient_GetProjects(t *testing.T) {
defer server.Close()
// Create client
- client := &TodoistClient{
- apiKey: "test-key",
- baseURL: server.URL,
- httpClient: &http.Client{},
- }
+ client := newTestTodoistClient(server.URL, "test-key")
// Test GetProjects
ctx := context.Background()
diff --git a/internal/api/trello.go b/internal/api/trello.go
index 4c4dc95..4cf7e9e 100644
--- a/internal/api/trello.go
+++ b/internal/api/trello.go
@@ -2,44 +2,41 @@ package api
import (
"context"
- "encoding/json"
"fmt"
- "io"
"log"
- "net/http"
"net/url"
"sort"
- "strings"
"sync"
"time"
"task-dashboard/internal/models"
)
-const (
- trelloBaseURL = "https://api.trello.com/1"
-)
+const trelloBaseURL = "https://api.trello.com/1"
// TrelloClient handles interactions with the Trello API
type TrelloClient struct {
- apiKey string
- token string
- baseURL string
- httpClient *http.Client
+ BaseClient
+ apiKey string
+ token string
}
// NewTrelloClient creates a new Trello API client
func NewTrelloClient(apiKey, token string) *TrelloClient {
return &TrelloClient{
- apiKey: apiKey,
- token: token,
- baseURL: trelloBaseURL,
- httpClient: &http.Client{
- Timeout: 15 * time.Second,
- },
+ BaseClient: NewBaseClient(trelloBaseURL),
+ apiKey: apiKey,
+ token: token,
}
}
+func (c *TrelloClient) authParams() url.Values {
+ params := url.Values{}
+ params.Set("key", c.apiKey)
+ params.Set("token", c.token)
+ return params
+}
+
// trelloBoardResponse represents a board from the API
type trelloBoardResponse struct {
ID string `json:"id"`
@@ -65,43 +62,22 @@ type trelloListResponse struct {
// GetBoards fetches all boards for the authenticated user
func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) {
- params := url.Values{}
- params.Set("key", c.apiKey)
- params.Set("token", c.token)
+ params := c.authParams()
params.Set("filter", "open")
- params.Set("fields", "id,name") // Only fetch required fields
-
- reqURL := fmt.Sprintf("%s/members/me/boards?%s", c.baseURL, params.Encode())
- req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch boards: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
- }
+ params.Set("fields", "id,name")
var apiBoards []trelloBoardResponse
- if err := json.NewDecoder(resp.Body).Decode(&apiBoards); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ if err := c.Get(ctx, "/members/me/boards?"+params.Encode(), nil, &apiBoards); err != nil {
+ return nil, fmt.Errorf("failed to fetch boards: %w", err)
}
- // Convert to our model
boards := make([]models.Board, 0, len(apiBoards))
for _, apiBoard := range apiBoards {
- board := models.Board{
+ boards = append(boards, models.Board{
ID: apiBoard.ID,
Name: apiBoard.Name,
- Cards: []models.Card{}, // Will be populated by GetCards
- }
- boards = append(boards, board)
+ Cards: []models.Card{},
+ })
}
return boards, nil
@@ -109,32 +85,14 @@ func (c *TrelloClient) GetBoards(ctx context.Context) ([]models.Board, error) {
// GetCards fetches all cards for a specific board
func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.Card, error) {
- params := url.Values{}
- params.Set("key", c.apiKey)
- params.Set("token", c.token)
+ params := c.authParams()
params.Set("filter", "visible")
- params.Set("fields", "id,name,idList,due,url,idBoard") // Only fetch required fields
-
- reqURL := fmt.Sprintf("%s/boards/%s/cards?%s", c.baseURL, boardID, params.Encode())
- req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch cards: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
- }
+ params.Set("fields", "id,name,idList,due,url,idBoard")
var apiCards []trelloCardResponse
- if err := json.NewDecoder(resp.Body).Decode(&apiCards); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ path := fmt.Sprintf("/boards/%s/cards?%s", boardID, params.Encode())
+ if err := c.Get(ctx, path, nil, &apiCards); err != nil {
+ return nil, fmt.Errorf("failed to fetch cards: %w", err)
}
log.Printf("Trello GetCards: board %s returned %d cards from API", boardID, len(apiCards))
@@ -143,15 +101,13 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C
lists, err := c.getLists(ctx, boardID)
listMap := make(map[string]string)
if err != nil {
- log.Printf("Warning: failed to fetch lists for board %s, cards will have empty list names: %v", boardID, err)
+ log.Printf("Warning: failed to fetch lists for board %s: %v", boardID, err)
} else {
- // Build map of list ID to name
for _, list := range lists {
listMap[list.ID] = list.Name
}
}
- // Convert to our model
cards := make([]models.Card, 0, len(apiCards))
for _, apiCard := range apiCards {
card := models.Card{
@@ -162,10 +118,8 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C
URL: apiCard.URL,
}
- // Parse due date if present
if apiCard.Due != nil && *apiCard.Due != "" {
- dueDate, err := time.Parse(time.RFC3339, *apiCard.Due)
- if err == nil {
+ if dueDate, err := time.Parse(time.RFC3339, *apiCard.Due); err == nil {
card.DueDate = &dueDate
}
}
@@ -178,34 +132,15 @@ func (c *TrelloClient) GetCards(ctx context.Context, boardID string) ([]models.C
// getLists fetches lists for a board
func (c *TrelloClient) getLists(ctx context.Context, boardID string) ([]models.List, error) {
- params := url.Values{}
- params.Set("key", c.apiKey)
- params.Set("token", c.token)
- params.Set("fields", "id,name") // Only fetch required fields
-
- reqURL := fmt.Sprintf("%s/boards/%s/lists?%s", c.baseURL, boardID, params.Encode())
- req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to fetch lists: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
- }
+ params := c.authParams()
+ params.Set("fields", "id,name")
var apiLists []trelloListResponse
- if err := json.NewDecoder(resp.Body).Decode(&apiLists); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ path := fmt.Sprintf("/boards/%s/lists?%s", boardID, params.Encode())
+ if err := c.Get(ctx, path, nil, &apiLists); err != nil {
+ return nil, fmt.Errorf("failed to fetch lists: %w", err)
}
- // Convert to model
lists := make([]models.List, 0, len(apiLists))
for _, apiList := range apiLists {
lists = append(lists, models.List{
@@ -222,7 +157,7 @@ func (c *TrelloClient) GetLists(ctx context.Context, boardID string) ([]models.L
return c.getLists(ctx, boardID)
}
-// GetBoardsWithCards fetches all boards and their cards in one call
+// GetBoardsWithCards fetches all boards and their cards concurrently
func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board, error) {
boards, err := c.GetBoards(ctx)
if err != nil {
@@ -237,24 +172,19 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board,
go func(i int) {
defer wg.Done()
- // Acquire semaphore
sem <- struct{}{}
defer func() { <-sem }()
- // Fetch cards
cards, err := c.GetCards(ctx, boards[i].ID)
if err != nil {
log.Printf("Error fetching cards for board %s (%s): %v", boards[i].Name, boards[i].ID, err)
} else {
- // Set BoardName for each card
for j := range cards {
cards[j].BoardName = boards[i].Name
}
- // It is safe to write to specific indices of the slice concurrently
boards[i].Cards = cards
}
- // Fetch lists
lists, err := c.getLists(ctx, boards[i].ID)
if err != nil {
log.Printf("Error fetching lists for board %s: %v", boards[i].Name, err)
@@ -266,18 +196,15 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board,
wg.Wait()
- // Sort boards: Non-empty boards first, newest card activity, then alphabetical by name
- // Trello card IDs are chronologically sortable (newer IDs > older IDs)
+ // Sort boards: Non-empty boards first, newest card activity, then alphabetical
sort.Slice(boards, func(i, j int) bool {
hasCardsI := len(boards[i].Cards) > 0
hasCardsJ := len(boards[j].Cards) > 0
- // 1. Prioritize boards with cards
if hasCardsI != hasCardsJ {
- return hasCardsI // true (non-empty) comes before false
+ return hasCardsI
}
- // 2. If both have cards, compare by newest card (max ID)
if hasCardsI && hasCardsJ {
maxIDI := ""
for _, card := range boards[i].Cards {
@@ -294,11 +221,10 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board,
}
if maxIDI != maxIDJ {
- return maxIDI > maxIDJ // Newer (larger) ID comes first
+ return maxIDI > maxIDJ
}
}
- // 3. Fallback to alphabetical by name
return boards[i].Name < boards[j].Name
})
@@ -307,48 +233,21 @@ func (c *TrelloClient) GetBoardsWithCards(ctx context.Context) ([]models.Board,
// CreateCard creates a new card in the specified list
func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description string, dueDate *time.Time) (*models.Card, error) {
- // Prepare request payload
- data := url.Values{}
- data.Set("key", c.apiKey)
- data.Set("token", c.token)
+ data := c.authParams()
data.Set("idList", listID)
data.Set("name", name)
-
if description != "" {
data.Set("desc", description)
}
-
if dueDate != nil {
data.Set("due", dueDate.Format(time.RFC3339))
}
- // Create POST request
- reqURL := c.baseURL + "/cards"
- req, err := http.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(data.Encode()))
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
- // Execute request
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to create card: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
- }
-
- // Decode response
var apiCard trelloCardResponse
- if err := json.NewDecoder(resp.Body).Decode(&apiCard); err != nil {
- return nil, fmt.Errorf("failed to decode response: %w", err)
+ if err := c.PostForm(ctx, "/cards", nil, data.Encode(), &apiCard); err != nil {
+ return nil, fmt.Errorf("failed to create card: %w", err)
}
- // Convert to our model
card := &models.Card{
ID: apiCard.ID,
Name: apiCard.Name,
@@ -356,10 +255,8 @@ func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description
URL: apiCard.URL,
}
- // Parse due date if present
if apiCard.Due != nil && *apiCard.Due != "" {
- parsedDate, err := time.Parse(time.RFC3339, *apiCard.Due)
- if err == nil {
+ if parsedDate, err := time.Parse(time.RFC3339, *apiCard.Due); err == nil {
card.DueDate = &parsedDate
}
}
@@ -369,35 +266,15 @@ func (c *TrelloClient) CreateCard(ctx context.Context, listID, name, description
// UpdateCard updates a card with the specified changes
func (c *TrelloClient) UpdateCard(ctx context.Context, cardID string, updates map[string]interface{}) error {
- // Prepare request payload
- data := url.Values{}
- data.Set("key", c.apiKey)
- data.Set("token", c.token)
-
- // Add updates to payload
+ data := c.authParams()
for key, value := range updates {
data.Set(key, fmt.Sprintf("%v", value))
}
- // Create PUT request
- reqURL := fmt.Sprintf("%s/cards/%s", c.baseURL, cardID)
- req, err := http.NewRequestWithContext(ctx, "PUT", reqURL, strings.NewReader(data.Encode()))
- if err != nil {
- return fmt.Errorf("failed to create request: %w", err)
- }
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
-
- // Execute request
- resp, err := c.httpClient.Do(req)
- if err != nil {
+ path := fmt.Sprintf("/cards/%s", cardID)
+ if err := c.Put(ctx, path, nil, data.Encode(), nil); err != nil {
return fmt.Errorf("failed to update card: %w", err)
}
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("trello API error (status %d): %s", resp.StatusCode, string(body))
- }
return nil
}
diff --git a/internal/api/trello_test.go b/internal/api/trello_test.go
index b43b55e..7433ff0 100644
--- a/internal/api/trello_test.go
+++ b/internal/api/trello_test.go
@@ -12,6 +12,13 @@ import (
"time"
)
+// newTestTrelloClient creates a TrelloClient for testing with custom base URL
+func newTestTrelloClient(baseURL, apiKey, token string) *TrelloClient {
+ client := NewTrelloClient(apiKey, token)
+ client.BaseClient.BaseURL = baseURL
+ return client
+}
+
func TestTrelloClient_CreateCard(t *testing.T) {
// Mock server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -65,12 +72,7 @@ func TestTrelloClient_CreateCard(t *testing.T) {
defer server.Close()
// Create client with mock server URL
- client := &TrelloClient{
- apiKey: "test-key",
- token: "test-token",
- baseURL: server.URL,
- httpClient: &http.Client{},
- }
+ client := newTestTrelloClient(server.URL, "test-key", "test-token")
// Test CreateCard
ctx := context.Background()
@@ -125,12 +127,7 @@ func TestTrelloClient_CreateCard_WithDueDate(t *testing.T) {
defer server.Close()
// Create client
- client := &TrelloClient{
- apiKey: "test-key",
- token: "test-token",
- baseURL: server.URL,
- httpClient: &http.Client{},
- }
+ client := newTestTrelloClient(server.URL, "test-key", "test-token")
// Test CreateCard with due date
ctx := context.Background()
@@ -184,12 +181,7 @@ func TestTrelloClient_UpdateCard(t *testing.T) {
defer server.Close()
// Create client
- client := &TrelloClient{
- apiKey: "test-key",
- token: "test-token",
- baseURL: server.URL,
- httpClient: &http.Client{},
- }
+ client := newTestTrelloClient(server.URL, "test-key", "test-token")
// Test UpdateCard
ctx := context.Background()
@@ -214,12 +206,7 @@ func TestTrelloClient_UpdateCard_Error(t *testing.T) {
defer server.Close()
// Create client
- client := &TrelloClient{
- apiKey: "test-key",
- token: "test-token",
- baseURL: server.URL,
- httpClient: &http.Client{},
- }
+ client := newTestTrelloClient(server.URL, "test-key", "test-token")
// Test UpdateCard with error
ctx := context.Background()