gogcli/internal/cmd/tasks_items.go
2026-05-05 08:52:50 +01:00

731 lines
20 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/alecthomas/kong"
"google.golang.org/api/tasks/v1"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
const (
taskStatusNeedsAction = "needsAction"
taskStatusCompleted = "completed"
)
type TasksListCmd struct {
TasklistID string `arg:"" name:"tasklistId" help:"Task list ID"`
Max int64 `name:"max" aliases:"limit" help:"Max results (max allowed: 100)" default:"20"`
Page string `name:"page" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
ShowCompleted bool `name:"show-completed" help:"Include completed tasks (requires --show-hidden for some clients)" default:"true"`
ShowDeleted bool `name:"show-deleted" help:"Include deleted tasks"`
ShowHidden bool `name:"show-hidden" help:"Include hidden tasks"`
ShowAssigned bool `name:"show-assigned" help:"Include tasks assigned to current user" default:"true"`
DueMin string `name:"due-min" help:"Lower bound for due date filter (RFC3339)"`
DueMax string `name:"due-max" help:"Upper bound for due date filter (RFC3339)"`
CompletedMin string `name:"completed-min" help:"Lower bound for completion date filter (RFC3339)"`
CompletedMax string `name:"completed-max" help:"Upper bound for completion date filter (RFC3339)"`
UpdatedMin string `name:"updated-min" help:"Lower bound for updated time filter (RFC3339)"`
}
func (c *TasksListCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
tasklistID := strings.TrimSpace(c.TasklistID)
if tasklistID == "" {
return usage("empty tasklistId")
}
svc, err := newTasksService(ctx, account)
if err != nil {
return err
}
tasklistID, err = resolveTasklistID(ctx, svc, tasklistID)
if err != nil {
return err
}
fetch := func(pageToken string) ([]*tasks.Task, string, error) {
call := svc.Tasks.List(tasklistID).
MaxResults(c.Max).
ShowCompleted(c.ShowCompleted).
ShowDeleted(c.ShowDeleted).
ShowHidden(c.ShowHidden).
ShowAssigned(c.ShowAssigned)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(strings.TrimSpace(pageToken))
}
if strings.TrimSpace(c.DueMin) != "" {
call = call.DueMin(strings.TrimSpace(c.DueMin))
}
if strings.TrimSpace(c.DueMax) != "" {
call = call.DueMax(strings.TrimSpace(c.DueMax))
}
if strings.TrimSpace(c.CompletedMin) != "" {
call = call.CompletedMin(strings.TrimSpace(c.CompletedMin))
}
if strings.TrimSpace(c.CompletedMax) != "" {
call = call.CompletedMax(strings.TrimSpace(c.CompletedMax))
}
if strings.TrimSpace(c.UpdatedMin) != "" {
call = call.UpdatedMin(strings.TrimSpace(c.UpdatedMin))
}
resp, callErr := call.Context(ctx).Do()
if callErr != nil {
return nil, "", callErr
}
return resp.Items, resp.NextPageToken, nil
}
items, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"tasks": items,
"nextPageToken": nextPageToken,
}); err != nil {
return err
}
if len(items) == 0 {
return failEmptyExit(c.FailEmpty)
}
return nil
}
if len(items) == 0 {
u.Err().Println("No tasks")
return failEmptyExit(c.FailEmpty)
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ID\tTITLE\tSTATUS\tDUE\tUPDATED")
for _, t := range items {
status := strings.TrimSpace(t.Status)
if status == "" {
status = taskStatusNeedsAction
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", t.Id, t.Title, status, strings.TrimSpace(t.Due), strings.TrimSpace(t.Updated))
}
printNextPageHint(u, nextPageToken)
return nil
}
type TasksGetCmd struct {
TasklistID string `arg:"" name:"tasklistId" help:"Task list ID"`
TaskID string `arg:"" name:"taskId" help:"Task ID"`
}
func (c *TasksGetCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
tasklistID := strings.TrimSpace(c.TasklistID)
taskID := strings.TrimSpace(c.TaskID)
if tasklistID == "" {
return usage("empty tasklistId")
}
if taskID == "" {
return usage("empty taskId")
}
svc, err := newTasksService(ctx, account)
if err != nil {
return err
}
tasklistID, err = resolveTasklistID(ctx, svc, tasklistID)
if err != nil {
return err
}
task, err := svc.Tasks.Get(tasklistID, taskID).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"task": task})
}
u.Out().Printf("id\t%s", task.Id)
u.Out().Printf("title\t%s", task.Title)
if strings.TrimSpace(task.Status) != "" {
u.Out().Printf("status\t%s", task.Status)
}
if strings.TrimSpace(task.Due) != "" {
u.Out().Printf("due\t%s", task.Due)
}
if strings.TrimSpace(task.WebViewLink) != "" {
u.Out().Printf("link\t%s", task.WebViewLink)
}
return nil
}
type TasksAddCmd struct {
TasklistID string `arg:"" name:"tasklistId" help:"Task list ID"`
Title string `name:"title" help:"Task title (required)"`
Notes string `name:"notes" help:"Task notes/description"`
Due string `name:"due" help:"Due date (RFC3339 or YYYY-MM-DD; time may be ignored by Google Tasks)"`
Parent string `name:"parent" help:"Parent task ID (create as subtask)"`
Previous string `name:"previous" help:"Previous sibling task ID (controls ordering)"`
Repeat string `name:"repeat" help:"Materialize repeated tasks: daily, weekly, monthly, yearly"`
Recur string `name:"recur" help:"Alias for --repeat cadence: daily, weekly, monthly, yearly"`
RecurRRule string `name:"recur-rrule" help:"Alias for --repeat cadence via RRULE (supports FREQ + optional INTERVAL)"`
RepeatCount int `name:"repeat-count" help:"Number of occurrences to create (requires --repeat, --recur, or --recur-rrule)"`
RepeatUntil string `name:"repeat-until" help:"Repeat until date/time (RFC3339 or YYYY-MM-DD; requires --repeat, --recur, or --recur-rrule)"`
}
type tasksAddRepeatConfig struct {
Unit repeatUnit
Interval int
Repeat string
Recur string
RecurRule string
Until string
}
func resolveTasksAddRepeatConfig(c *TasksAddCmd, due string) (tasksAddRepeatConfig, error) {
config := tasksAddRepeatConfig{
Interval: 1,
Repeat: strings.TrimSpace(c.Repeat),
Recur: strings.TrimSpace(c.Recur),
RecurRule: strings.TrimSpace(c.RecurRRule),
Until: strings.TrimSpace(c.RepeatUntil),
}
if config.Repeat != "" && (config.Recur != "" || config.RecurRule != "") {
return tasksAddRepeatConfig{}, usage("--repeat cannot be combined with --recur or --recur-rrule")
}
if config.Recur != "" && config.RecurRule != "" {
return tasksAddRepeatConfig{}, usage("--recur and --recur-rrule are mutually exclusive")
}
var err error
switch {
case config.RecurRule != "":
config.Unit, config.Interval, err = parseRepeatRRule(config.RecurRule)
case config.Recur != "":
config.Unit, err = parseRepeatUnit(config.Recur)
default:
config.Unit, err = parseRepeatUnit(config.Repeat)
}
if err != nil {
return tasksAddRepeatConfig{}, err
}
if config.Unit == repeatNone && (config.Until != "" || c.RepeatCount != 0) {
return tasksAddRepeatConfig{}, usage("--repeat, --recur, or --recur-rrule is required when using --repeat-count or --repeat-until")
}
if config.Unit != repeatNone {
if due == "" {
return tasksAddRepeatConfig{}, usage("--due is required when using --repeat, --recur, or --recur-rrule")
}
if c.RepeatCount < 0 {
return tasksAddRepeatConfig{}, usage("--repeat-count must be >= 0")
}
if config.Until == "" && c.RepeatCount == 0 {
if config.Recur != "" || config.RecurRule != "" {
return tasksAddRepeatConfig{}, usage("Google Tasks API does not support server-side recurring metadata; use --repeat-count or --repeat-until with --recur/--recur-rrule to materialize occurrences")
}
return tasksAddRepeatConfig{}, usage("--repeat requires --repeat-count or --repeat-until")
}
}
return config, nil
}
func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
tasklistID := strings.TrimSpace(c.TasklistID)
if tasklistID == "" {
return usage("empty tasklistId")
}
title := strings.TrimSpace(c.Title)
if title == "" {
return usage("required: --title")
}
notes := strings.TrimSpace(c.Notes)
due := strings.TrimSpace(c.Due)
parent := strings.TrimSpace(c.Parent)
previous := strings.TrimSpace(c.Previous)
repeatConfig, err := resolveTasksAddRepeatConfig(c, due)
if err != nil {
return err
}
if dryRunErr := dryRunExit(ctx, flags, "tasks.add", map[string]any{
"tasklist_id": tasklistID,
"title": title,
"notes": notes,
"due": due,
"parent": parent,
"previous": previous,
"repeat": repeatConfig.Repeat,
"recur": repeatConfig.Recur,
"recur_rrule": repeatConfig.RecurRule,
"repeat_step": repeatConfig.Interval,
"repeat_count": c.RepeatCount,
"repeat_until": repeatConfig.Until,
}); dryRunErr != nil {
return dryRunErr
}
account, err := requireAccount(flags)
if err != nil {
return err
}
if repeatConfig.Unit == repeatNone {
svc, svcErr := newTasksService(ctx, account)
if svcErr != nil {
return svcErr
}
tasklistID, err = resolveTasklistID(ctx, svc, tasklistID)
if err != nil {
return err
}
if !outfmt.IsJSON(ctx) {
warnTasksDueTime(u, due)
}
dueValue, dueErr := normalizeTaskDue(due)
if dueErr != nil {
return dueErr
}
task := &tasks.Task{
Title: title,
Notes: notes,
Due: dueValue,
}
call := svc.Tasks.Insert(tasklistID, task)
if parent != "" {
call = call.Parent(parent)
}
if previous != "" {
call = call.Previous(previous)
}
created, createErr := call.Do()
if createErr != nil {
return createErr
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"task": created})
}
u.Out().Printf("id\t%s", created.Id)
u.Out().Printf("title\t%s", created.Title)
if strings.TrimSpace(created.Status) != "" {
u.Out().Printf("status\t%s", created.Status)
}
if strings.TrimSpace(created.Due) != "" {
u.Out().Printf("due\t%s", created.Due)
}
if strings.TrimSpace(created.WebViewLink) != "" {
u.Out().Printf("link\t%s", created.WebViewLink)
}
return nil
}
if !outfmt.IsJSON(ctx) {
warnTasksDueTime(u, due)
}
dueTime, dueHasTime, err := parseTaskDate(due)
if err != nil {
return err
}
var until *time.Time
if repeatConfig.Until != "" {
untilValue, untilHasTime, parseErr := parseTaskDate(repeatConfig.Until)
if parseErr != nil {
return parseErr
}
switch {
case dueHasTime && !untilHasTime:
untilValue = time.Date(
untilValue.Year(),
untilValue.Month(),
untilValue.Day(),
dueTime.Hour(),
dueTime.Minute(),
dueTime.Second(),
dueTime.Nanosecond(),
dueTime.Location(),
)
case !dueHasTime && untilHasTime:
untilValue = time.Date(untilValue.Year(), untilValue.Month(), untilValue.Day(), 0, 0, 0, 0, time.UTC)
}
until = &untilValue
}
schedule := expandRepeatSchedule(dueTime, repeatConfig.Unit, repeatConfig.Interval, c.RepeatCount, until)
if len(schedule) == 0 {
return usage("repeat produced no occurrences")
}
svc, svcErr := newTasksService(ctx, account)
if svcErr != nil {
return svcErr
}
tasklistID, err = resolveTasklistID(ctx, svc, tasklistID)
if err != nil {
return err
}
baseTitle := title
createdTasks := make([]*tasks.Task, 0, len(schedule))
for i, due := range schedule {
title := baseTitle
if len(schedule) > 1 {
title = fmt.Sprintf("%s (#%d/%d)", baseTitle, i+1, len(schedule))
}
task := &tasks.Task{
Title: title,
Notes: notes,
Due: formatTaskDue(due, dueHasTime),
}
call := svc.Tasks.Insert(tasklistID, task)
if parent != "" {
call = call.Parent(parent)
}
if previous != "" {
call = call.Previous(previous)
}
created, createErr := call.Do()
if createErr != nil {
return createErr
}
createdTasks = append(createdTasks, created)
if previous != "" {
previous = created.Id
}
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"tasks": createdTasks,
"count": len(createdTasks),
})
}
if len(createdTasks) == 1 {
created := createdTasks[0]
u.Out().Printf("id\t%s", created.Id)
u.Out().Printf("title\t%s", created.Title)
if strings.TrimSpace(created.Status) != "" {
u.Out().Printf("status\t%s", created.Status)
}
if strings.TrimSpace(created.Due) != "" {
u.Out().Printf("due\t%s", created.Due)
}
if strings.TrimSpace(created.WebViewLink) != "" {
u.Out().Printf("link\t%s", created.WebViewLink)
}
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "ID\tTITLE\tDUE")
for _, task := range createdTasks {
fmt.Fprintf(w, "%s\t%s\t%s\n", task.Id, task.Title, strings.TrimSpace(task.Due))
}
return nil
}
type TasksUpdateCmd struct {
TasklistID string `arg:"" name:"tasklistId" help:"Task list ID"`
TaskID string `arg:"" name:"taskId" help:"Task ID"`
Title string `name:"title" help:"New title (set empty to clear)"`
Notes string `name:"notes" help:"New notes (set empty to clear)"`
Due string `name:"due" help:"New due date (RFC3339 or YYYY-MM-DD; time may be ignored; set empty to clear)"`
Status string `name:"status" help:"New status: needsAction|completed (set empty to clear)"`
}
func (c *TasksUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
tasklistID := strings.TrimSpace(c.TasklistID)
taskID := strings.TrimSpace(c.TaskID)
if tasklistID == "" {
return usage("empty tasklistId")
}
if taskID == "" {
return usage("empty taskId")
}
patch := &tasks.Task{}
changed := false
if flagProvided(kctx, "title") {
patch.Title = strings.TrimSpace(c.Title)
changed = true
}
if flagProvided(kctx, "notes") {
patch.Notes = strings.TrimSpace(c.Notes)
changed = true
}
if flagProvided(kctx, "due") {
dueValue, dueErr := normalizeTaskDue(c.Due)
if dueErr != nil {
return dueErr
}
if dueValue == "" {
patch.NullFields = append(patch.NullFields, "Due")
} else {
if !outfmt.IsJSON(ctx) {
warnTasksDueTime(u, c.Due)
}
patch.Due = dueValue
}
changed = true
}
if flagProvided(kctx, "status") {
patch.Status = strings.TrimSpace(c.Status)
changed = true
}
if !changed {
return usage("no fields to update (set at least one of: --title, --notes, --due, --status)")
}
if flagProvided(kctx, "status") && patch.Status != "" && patch.Status != taskStatusNeedsAction && patch.Status != taskStatusCompleted {
return usage("invalid --status (expected needsAction or completed)")
}
if dryRunErr := dryRunExit(ctx, flags, "tasks.update", map[string]any{
"tasklist_id": tasklistID,
"task_id": taskID,
"patch": patch,
}); dryRunErr != nil {
return dryRunErr
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newTasksService(ctx, account)
if err != nil {
return err
}
tasklistID, err = resolveTasklistID(ctx, svc, tasklistID)
if err != nil {
return err
}
updated, err := svc.Tasks.Patch(tasklistID, taskID, patch).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"task": updated})
}
u.Out().Printf("id\t%s", updated.Id)
u.Out().Printf("title\t%s", updated.Title)
if strings.TrimSpace(updated.Status) != "" {
u.Out().Printf("status\t%s", updated.Status)
}
if strings.TrimSpace(updated.Due) != "" {
u.Out().Printf("due\t%s", updated.Due)
}
if strings.TrimSpace(updated.WebViewLink) != "" {
u.Out().Printf("link\t%s", updated.WebViewLink)
}
return nil
}
type TasksDoneCmd struct {
TasklistID string `arg:"" name:"tasklistId" help:"Task list ID"`
TaskID string `arg:"" name:"taskId" help:"Task ID"`
}
func (c *TasksDoneCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
tasklistID := strings.TrimSpace(c.TasklistID)
taskID := strings.TrimSpace(c.TaskID)
if tasklistID == "" {
return usage("empty tasklistId")
}
if taskID == "" {
return usage("empty taskId")
}
if dryRunErr := dryRunExit(ctx, flags, "tasks.done", map[string]any{
"tasklist_id": tasklistID,
"task_id": taskID,
}); dryRunErr != nil {
return dryRunErr
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newTasksService(ctx, account)
if err != nil {
return err
}
tasklistID, err = resolveTasklistID(ctx, svc, tasklistID)
if err != nil {
return err
}
updated, err := svc.Tasks.Patch(tasklistID, taskID, &tasks.Task{Status: taskStatusCompleted}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"task": updated})
}
u.Out().Printf("id\t%s", updated.Id)
u.Out().Printf("status\t%s", strings.TrimSpace(updated.Status))
return nil
}
type TasksUndoCmd struct {
TasklistID string `arg:"" name:"tasklistId" help:"Task list ID"`
TaskID string `arg:"" name:"taskId" help:"Task ID"`
}
func (c *TasksUndoCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
tasklistID := strings.TrimSpace(c.TasklistID)
taskID := strings.TrimSpace(c.TaskID)
if tasklistID == "" {
return usage("empty tasklistId")
}
if taskID == "" {
return usage("empty taskId")
}
if dryRunErr := dryRunExit(ctx, flags, "tasks.undo", map[string]any{
"tasklist_id": tasklistID,
"task_id": taskID,
}); dryRunErr != nil {
return dryRunErr
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newTasksService(ctx, account)
if err != nil {
return err
}
tasklistID, err = resolveTasklistID(ctx, svc, tasklistID)
if err != nil {
return err
}
updated, err := svc.Tasks.Patch(tasklistID, taskID, &tasks.Task{Status: "needsAction"}).Do()
if err != nil {
return err
}
if outfmt.IsJSON(ctx) {
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"task": updated})
}
u.Out().Printf("id\t%s", updated.Id)
u.Out().Printf("status\t%s", strings.TrimSpace(updated.Status))
return nil
}
type TasksDeleteCmd struct {
TasklistID string `arg:"" name:"tasklistId" help:"Task list ID"`
TaskID string `arg:"" name:"taskId" help:"Task ID"`
}
func (c *TasksDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
tasklistID := strings.TrimSpace(c.TasklistID)
taskID := strings.TrimSpace(c.TaskID)
if tasklistID == "" {
return usage("empty tasklistId")
}
if taskID == "" {
return usage("empty taskId")
}
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("delete task %s from list %s", taskID, tasklistID)); confirmErr != nil {
return confirmErr
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newTasksService(ctx, account)
if err != nil {
return err
}
tasklistID, err = resolveTasklistID(ctx, svc, tasklistID)
if err != nil {
return err
}
if err := svc.Tasks.Delete(tasklistID, taskID).Do(); err != nil {
return err
}
return writeResult(ctx, u,
kv("deleted", true),
kv("id", taskID),
)
}
type TasksClearCmd struct {
TasklistID string `arg:"" name:"tasklistId" help:"Task list ID"`
}
func (c *TasksClearCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
tasklistID := strings.TrimSpace(c.TasklistID)
if tasklistID == "" {
return usage("empty tasklistId")
}
if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("clear completed tasks from list %s", tasklistID)); confirmErr != nil {
return confirmErr
}
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newTasksService(ctx, account)
if err != nil {
return err
}
tasklistID, err = resolveTasklistID(ctx, svc, tasklistID)
if err != nil {
return err
}
if err := svc.Tasks.Clear(tasklistID).Do(); err != nil {
return err
}
return writeResult(ctx, u,
kv("cleared", true),
kv("tasklistId", tasklistID),
)
}