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	"time"
 12
 13	"git.secluded.site/go-lunatask"
 14	"git.secluded.site/lune/internal/client"
 15	"git.secluded.site/lune/internal/completion"
 16	"git.secluded.site/lune/internal/config"
 17	"git.secluded.site/lune/internal/ui"
 18	"git.secluded.site/lune/internal/validate"
 19	"github.com/charmbracelet/lipgloss"
 20	"github.com/charmbracelet/lipgloss/table"
 21	"github.com/spf13/cobra"
 22)
 23
 24// ErrUnknownArea indicates the specified area key was not found in config.
 25var ErrUnknownArea = errors.New("unknown area key")
 26
 27// ListCmd lists tasks. Exported for potential use by shortcuts.
 28var ListCmd = &cobra.Command{
 29	Use:   "list",
 30	Short: "List tasks",
 31	Long: `List tasks from Lunatask.
 32
 33By default, shows incomplete tasks and tasks completed today.
 34Use --all to show entire task history.
 35
 36Note: Due to end-to-end encryption, task names and notes
 37are not available through the API. Only metadata is shown.`,
 38	RunE: runList,
 39}
 40
 41func init() {
 42	ListCmd.Flags().StringP("area", "a", "", "Filter by area key")
 43	ListCmd.Flags().StringP("status", "s", "", "Filter by status")
 44	ListCmd.Flags().Bool("all", false, "Show all tasks including completed")
 45	ListCmd.Flags().Bool("json", false, "Output as JSON")
 46
 47	_ = ListCmd.RegisterFlagCompletionFunc("area", completion.Areas)
 48	_ = ListCmd.RegisterFlagCompletionFunc("status",
 49		completion.Static("later", "next", "started", "waiting", "completed"))
 50}
 51
 52func runList(cmd *cobra.Command, _ []string) error {
 53	apiClient, err := client.New()
 54	if err != nil {
 55		return err
 56	}
 57
 58	tasks, err := apiClient.ListTasks(cmd.Context(), nil)
 59	if err != nil {
 60		return err
 61	}
 62
 63	areaID, err := resolveAreaFilter(cmd)
 64	if err != nil {
 65		return err
 66	}
 67
 68	statusFilter, err := resolveStatusFilter(cmd)
 69	if err != nil {
 70		return err
 71	}
 72
 73	showAll := mustGetBoolFlag(cmd, "all")
 74	tasks = applyFilters(tasks, areaID, statusFilter, showAll)
 75
 76	if len(tasks) == 0 {
 77		fmt.Fprintln(cmd.OutOrStdout(), "No tasks found")
 78
 79		return nil
 80	}
 81
 82	if mustGetBoolFlag(cmd, "json") {
 83		return outputJSON(cmd, tasks)
 84	}
 85
 86	return outputTable(cmd, tasks)
 87}
 88
 89// mustGetStringFlag returns the string flag value. Panics if flag doesn't exist
 90// (indicates a programming error—flags are defined in init).
 91func mustGetStringFlag(cmd *cobra.Command, name string) string {
 92	f := cmd.Flags().Lookup(name)
 93	if f == nil {
 94		panic("flag not defined: " + name)
 95	}
 96
 97	return f.Value.String()
 98}
 99
100// mustGetBoolFlag returns the bool flag value. Panics if flag doesn't exist.
101func mustGetBoolFlag(cmd *cobra.Command, name string) bool {
102	f := cmd.Flags().Lookup(name)
103	if f == nil {
104		panic("flag not defined: " + name)
105	}
106
107	return f.Value.String() == "true"
108}
109
110func resolveAreaFilter(cmd *cobra.Command) (string, error) {
111	areaKey := mustGetStringFlag(cmd, "area")
112
113	cfg, err := config.Load()
114	if err != nil {
115		// Config not required if no area flag and we just skip default
116		if errors.Is(err, config.ErrNotFound) {
117			if areaKey != "" {
118				return "", err
119			}
120
121			return "", nil
122		}
123
124		return "", err
125	}
126
127	// Use default area if no explicit flag
128	if areaKey == "" {
129		areaKey = cfg.Defaults.Area
130	}
131
132	if areaKey == "" {
133		return "", nil
134	}
135
136	area := cfg.AreaByKey(areaKey)
137	if area == nil {
138		return "", fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
139	}
140
141	return area.ID, nil
142}
143
144func resolveStatusFilter(cmd *cobra.Command) (string, error) {
145	status := mustGetStringFlag(cmd, "status")
146	if status == "" {
147		return "", nil
148	}
149
150	s, err := validate.TaskStatus(status)
151	if err != nil {
152		return "", err
153	}
154
155	return string(s), nil
156}
157
158func applyFilters(tasks []lunatask.Task, areaID, statusFilter string, showAll bool) []lunatask.Task {
159	filtered := make([]lunatask.Task, 0, len(tasks))
160	today := time.Now().Truncate(24 * time.Hour)
161
162	for _, task := range tasks {
163		if !matchesFilters(task, areaID, statusFilter, showAll, today) {
164			continue
165		}
166
167		filtered = append(filtered, task)
168	}
169
170	return filtered
171}
172
173func matchesFilters(task lunatask.Task, areaID, statusFilter string, showAll bool, today time.Time) bool {
174	if areaID != "" && (task.AreaID == nil || *task.AreaID != areaID) {
175		return false
176	}
177
178	if statusFilter != "" && (task.Status == nil || string(*task.Status) != statusFilter) {
179		return false
180	}
181
182	// Default filter: exclude completed tasks unless completed today
183	if !showAll && statusFilter == "" && isOldCompleted(task, today) {
184		return false
185	}
186
187	return true
188}
189
190func isOldCompleted(task lunatask.Task, today time.Time) bool {
191	if task.Status == nil || *task.Status != lunatask.StatusCompleted {
192		return false
193	}
194
195	return task.CompletedAt == nil || task.CompletedAt.Before(today)
196}
197
198func outputJSON(cmd *cobra.Command, tasks []lunatask.Task) error {
199	enc := json.NewEncoder(cmd.OutOrStdout())
200	enc.SetIndent("", "  ")
201
202	if err := enc.Encode(tasks); err != nil {
203		return fmt.Errorf("encoding JSON: %w", err)
204	}
205
206	return nil
207}
208
209func outputTable(cmd *cobra.Command, tasks []lunatask.Task) error {
210	rows := make([][]string, 0, len(tasks))
211
212	for _, task := range tasks {
213		status := "-"
214		if task.Status != nil {
215			status = string(*task.Status)
216		}
217
218		scheduled := "-"
219		if task.ScheduledOn != nil {
220			scheduled = ui.FormatDate(task.ScheduledOn.Time)
221		}
222
223		created := ui.FormatDate(task.CreatedAt)
224
225		rows = append(rows, []string{task.ID, status, scheduled, created})
226	}
227
228	tbl := table.New().
229		Headers("ID", "STATUS", "SCHEDULED", "CREATED").
230		Rows(rows...).
231		StyleFunc(func(row, col int) lipgloss.Style {
232			if row == table.HeaderRow {
233				return ui.Bold
234			}
235
236			return lipgloss.NewStyle()
237		}).
238		Border(lipgloss.HiddenBorder())
239
240	fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
241
242	return nil
243}