list.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package task
  6
  7import (
  8	"encoding/json"
  9	"errors"
 10	"fmt"
 11	"text/tabwriter"
 12	"time"
 13
 14	"git.secluded.site/go-lunatask"
 15	"git.secluded.site/lune/internal/client"
 16	"git.secluded.site/lune/internal/completion"
 17	"git.secluded.site/lune/internal/config"
 18	"git.secluded.site/lune/internal/ui"
 19	"github.com/spf13/cobra"
 20)
 21
 22// ErrUnknownArea indicates the specified area key was not found in config.
 23var ErrUnknownArea = errors.New("unknown area key")
 24
 25// ListCmd lists tasks. Exported for potential use by shortcuts.
 26var ListCmd = &cobra.Command{
 27	Use:   "list",
 28	Short: "List tasks",
 29	Long: `List tasks from Lunatask.
 30
 31By default, shows incomplete tasks and tasks completed today.
 32Use --all to show entire task history.
 33
 34Note: Due to end-to-end encryption, task names and notes
 35are not available through the API. Only metadata is shown.`,
 36	RunE: runList,
 37}
 38
 39func init() {
 40	ListCmd.Flags().StringP("area", "a", "", "Filter by area key")
 41	ListCmd.Flags().StringP("status", "s", "", "Filter by status")
 42	ListCmd.Flags().Bool("all", false, "Show all tasks including completed")
 43	ListCmd.Flags().Bool("json", false, "Output as JSON")
 44
 45	_ = ListCmd.RegisterFlagCompletionFunc("area", completion.Areas)
 46	_ = ListCmd.RegisterFlagCompletionFunc("status",
 47		completion.Static("later", "next", "started", "waiting", "completed"))
 48}
 49
 50func runList(cmd *cobra.Command, _ []string) error {
 51	apiClient, err := client.New()
 52	if err != nil {
 53		return err
 54	}
 55
 56	tasks, err := apiClient.ListTasks(cmd.Context(), nil)
 57	if err != nil {
 58		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Failed to list tasks"))
 59
 60		return err
 61	}
 62
 63	areaID, err := resolveAreaFilter(cmd)
 64	if err != nil {
 65		return err
 66	}
 67
 68	statusFilter := mustGetStringFlag(cmd, "status")
 69	showAll := mustGetBoolFlag(cmd, "all")
 70	tasks = applyFilters(tasks, areaID, statusFilter, showAll)
 71
 72	if len(tasks) == 0 {
 73		fmt.Fprintln(cmd.OutOrStdout(), ui.Muted.Render("No tasks found"))
 74
 75		return nil
 76	}
 77
 78	if mustGetBoolFlag(cmd, "json") {
 79		return outputJSON(cmd, tasks)
 80	}
 81
 82	return outputTable(cmd, tasks)
 83}
 84
 85// mustGetStringFlag returns the string flag value. Panics if flag doesn't exist
 86// (indicates a programming error—flags are defined in init).
 87func mustGetStringFlag(cmd *cobra.Command, name string) string {
 88	f := cmd.Flags().Lookup(name)
 89	if f == nil {
 90		panic("flag not defined: " + name)
 91	}
 92
 93	return f.Value.String()
 94}
 95
 96// mustGetBoolFlag returns the bool flag value. Panics if flag doesn't exist.
 97func mustGetBoolFlag(cmd *cobra.Command, name string) bool {
 98	f := cmd.Flags().Lookup(name)
 99	if f == nil {
100		panic("flag not defined: " + name)
101	}
102
103	return f.Value.String() == "true"
104}
105
106func resolveAreaFilter(cmd *cobra.Command) (string, error) {
107	areaKey := mustGetStringFlag(cmd, "area")
108	if areaKey == "" {
109		return "", nil
110	}
111
112	cfg, err := config.Load()
113	if err != nil {
114		if errors.Is(err, config.ErrNotFound) {
115			fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config not found; run 'lune init' to configure areas"))
116		}
117
118		return "", err
119	}
120
121	area := cfg.AreaByKey(areaKey)
122	if area == nil {
123		fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Unknown area: "+areaKey))
124
125		return "", fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
126	}
127
128	return area.ID, nil
129}
130
131func applyFilters(tasks []lunatask.Task, areaID, statusFilter string, showAll bool) []lunatask.Task {
132	filtered := make([]lunatask.Task, 0, len(tasks))
133	today := time.Now().Truncate(24 * time.Hour)
134
135	for _, task := range tasks {
136		if !matchesFilters(task, areaID, statusFilter, showAll, today) {
137			continue
138		}
139
140		filtered = append(filtered, task)
141	}
142
143	return filtered
144}
145
146func matchesFilters(task lunatask.Task, areaID, statusFilter string, showAll bool, today time.Time) bool {
147	if areaID != "" && (task.AreaID == nil || *task.AreaID != areaID) {
148		return false
149	}
150
151	if statusFilter != "" && (task.Status == nil || string(*task.Status) != statusFilter) {
152		return false
153	}
154
155	// Default filter: exclude completed tasks unless completed today
156	if !showAll && statusFilter == "" && isOldCompleted(task, today) {
157		return false
158	}
159
160	return true
161}
162
163func isOldCompleted(task lunatask.Task, today time.Time) bool {
164	if task.Status == nil || *task.Status != lunatask.StatusCompleted {
165		return false
166	}
167
168	return task.CompletedAt == nil || task.CompletedAt.Before(today)
169}
170
171func outputJSON(cmd *cobra.Command, tasks []lunatask.Task) error {
172	enc := json.NewEncoder(cmd.OutOrStdout())
173	enc.SetIndent("", "  ")
174
175	if err := enc.Encode(tasks); err != nil {
176		return fmt.Errorf("encoding JSON: %w", err)
177	}
178
179	return nil
180}
181
182func outputTable(cmd *cobra.Command, tasks []lunatask.Task) error {
183	writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
184
185	header := ui.Bold.Render("ID") + "\t" + ui.Bold.Render("STATUS") + "\t" +
186		ui.Bold.Render("SCHEDULED") + "\t" + ui.Bold.Render("CREATED")
187	fmt.Fprintln(writer, header)
188
189	for _, task := range tasks {
190		status := "-"
191		if task.Status != nil {
192			status = string(*task.Status)
193		}
194
195		scheduled := "-"
196		if task.ScheduledOn != nil {
197			scheduled = task.ScheduledOn.String()
198		}
199
200		created := ui.FormatDate(task.CreatedAt)
201
202		fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", task.ID, status, scheduled, created)
203	}
204
205	if err := writer.Flush(); err != nil {
206		return fmt.Errorf("writing output: %w", err)
207	}
208
209	return nil
210}