summaryrefslogtreecommitdiff
path: root/internal/api/google_tasks.go
blob: 1f9aefb5a01694071647e0e71f66be339ef4bc72 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
package api

import (
	"context"
	"fmt"
	"log"
	"strings"
	"time"

	"task-dashboard/internal/models"

	"google.golang.org/api/option"
	"google.golang.org/api/tasks/v1"
)

// GoogleTasksClient handles interactions with Google Tasks API
type GoogleTasksClient struct {
	srv        *tasks.Service
	tasklistID string
	displayTZ  *time.Location
}

// NewGoogleTasksClient creates a client for Google Tasks.
// tasklistID can be "@default" for the primary list, or a specific list ID.
// Multiple lists can be comma-separated.
func NewGoogleTasksClient(ctx context.Context, credentialsFile, tasklistID, timezone string) (*GoogleTasksClient, error) {
	srv, err := tasks.NewService(ctx, option.WithCredentialsFile(credentialsFile))
	if err != nil {
		return nil, fmt.Errorf("unable to create Tasks client: %v", err)
	}

	if tasklistID == "" {
		tasklistID = "@default"
	}

	// Load display timezone
	displayTZ, err := time.LoadLocation(timezone)
	if err != nil {
		log.Printf("Warning: invalid timezone %q, using UTC: %v", timezone, err)
		displayTZ = time.UTC
	}

	return &GoogleTasksClient{
		srv:        srv,
		tasklistID: tasklistID,
		displayTZ:  displayTZ,
	}, nil
}

// GetTasks fetches all incomplete tasks from the configured task list(s)
func (c *GoogleTasksClient) GetTasks(ctx context.Context) ([]models.GoogleTask, error) {
	var allTasks []models.GoogleTask

	// Support comma-separated list IDs
	listIDs := strings.Split(c.tasklistID, ",")
	for _, listID := range listIDs {
		listID = strings.TrimSpace(listID)
		if listID == "" {
			continue
		}

		tasks, err := c.getTasksFromList(ctx, listID)
		if err != nil {
			log.Printf("Warning: failed to fetch tasks from list %s: %v", listID, err)
			continue
		}
		allTasks = append(allTasks, tasks...)
	}

	return allTasks, nil
}

// getTasksFromList fetches tasks from a specific list
func (c *GoogleTasksClient) getTasksFromList(ctx context.Context, listID string) ([]models.GoogleTask, error) {
	call := c.srv.Tasks.List(listID).
		ShowCompleted(false).
		ShowHidden(false).
		MaxResults(100)

	taskList, err := call.Context(ctx).Do()
	if err != nil {
		return nil, fmt.Errorf("failed to list tasks: %v", err)
	}

	var result []models.GoogleTask
	for _, item := range taskList.Items {
		task := models.GoogleTask{
			ID:        item.Id,
			Title:     item.Title,
			Notes:     item.Notes,
			Status:    item.Status,
			Completed: item.Status == "completed",
			ListID:    listID,
		}

		// Parse due date if present (RFC3339 format, but date-only)
		if item.Due != "" {
			// Google Tasks due dates are date-only, but the API always
			// serializes them as an RFC3339 timestamp fixed at midnight UTC
			// regardless of the user's timezone. Converting that instant
			// into displayTZ (dueDate.In(c.displayTZ)) reinterprets it as a
			// real moment in time and shifts the calendar date backward for
			// any timezone behind UTC -- midnight UTC becomes 2pm the
			// PREVIOUS day in UTC-10, silently moving every dated Google
			// Task one day earlier than its actual due date. The Y/M/D of
			// the UTC-anchored timestamp IS the intended calendar date, so
			// re-anchor those same components to displayTZ midnight instead
			// of converting the instant.
			dueDate, err := time.Parse(time.RFC3339, item.Due)
			if err != nil {
				// Try date-only format
				dueDate, err = time.ParseInLocation("2006-01-02", item.Due[:10], c.displayTZ)
			}
			if err == nil {
				y, m, d := dueDate.Date()
				dueInTZ := time.Date(y, m, d, 0, 0, 0, 0, c.displayTZ)
				task.DueDate = &dueInTZ
			}
		}

		// Parse updated time
		if item.Updated != "" {
			if updated, err := time.Parse(time.RFC3339, item.Updated); err == nil {
				task.UpdatedAt = updated.In(c.displayTZ)
			}
		}

		// Build URL to Google Tasks
		task.URL = "https://tasks.google.com/embed/?origin=https://mail.google.com&fullWidth=1"

		result = append(result, task)
	}

	return result, nil
}

// GetTasksByDateRange fetches tasks with due dates in the specified range.
// Tasks without due dates are included and treated as "today" tasks.
func (c *GoogleTasksClient) GetTasksByDateRange(ctx context.Context, start, end time.Time) ([]models.GoogleTask, error) {
	allTasks, err := c.GetTasks(ctx)
	if err != nil {
		return nil, err
	}

	startDay := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, c.displayTZ)
	endDay := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, c.displayTZ)

	// Filter by date range, include tasks without due dates
	var filtered []models.GoogleTask
	for _, task := range allTasks {
		if task.DueDate == nil {
			// Include tasks without due dates (they'll be shown in "today" section)
			filtered = append(filtered, task)
			continue
		}
		dueDay := time.Date(task.DueDate.Year(), task.DueDate.Month(), task.DueDate.Day(), 0, 0, 0, 0, c.displayTZ)
		if !dueDay.Before(startDay) && dueDay.Before(endDay) {
			filtered = append(filtered, task)
		}
	}

	return filtered, nil
}

// CompleteTask marks a task as completed
func (c *GoogleTasksClient) CompleteTask(ctx context.Context, listID, taskID string) error {
	task := &tasks.Task{
		Status: "completed",
	}
	_, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do()
	if err != nil {
		return fmt.Errorf("failed to complete task: %v", err)
	}
	return nil
}

// UpdateTaskNotes updates a task's notes (description)
func (c *GoogleTasksClient) UpdateTaskNotes(ctx context.Context, listID, taskID, notes string) error {
	task := &tasks.Task{
		Notes: notes,
	}
	_, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do()
	if err != nil {
		return fmt.Errorf("failed to update task notes: %v", err)
	}
	return nil
}

// UncompleteTask marks a task as not completed
func (c *GoogleTasksClient) UncompleteTask(ctx context.Context, listID, taskID string) error {
	task := &tasks.Task{
		Status: "needsAction",
	}
	_, err := c.srv.Tasks.Patch(listID, taskID, task).Context(ctx).Do()
	if err != nil {
		return fmt.Errorf("failed to uncomplete task: %v", err)
	}
	return nil
}

// SetTaskListID updates the task list ID(s) used by the client
func (c *GoogleTasksClient) SetTaskListID(id string) {
	c.tasklistID = id
}

// GetTaskLists returns all task lists accessible to the user
func (c *GoogleTasksClient) GetTaskLists(ctx context.Context) ([]models.TaskListInfo, error) {
	list, err := c.srv.Tasklists.List().Context(ctx).Do()
	if err != nil {
		return nil, fmt.Errorf("failed to fetch task lists: %v", err)
	}

	var lists []models.TaskListInfo
	for _, item := range list.Items {
		lists = append(lists, models.TaskListInfo{
			ID:   item.Id,
			Name: item.Title,
		})
	}
	return lists, nil
}