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", completion.TaskStatuses)
 49}
 50
 51func runList(cmd *cobra.Command, _ []string) error {
 52	apiClient, err := client.New()
 53	if err != nil {
 54		return err
 55	}
 56
 57	tasks, err := ui.Spin("Fetching tasks…", func() ([]lunatask.Task, error) {
 58		return apiClient.ListTasks(cmd.Context(), nil)
 59	})
 60	if err != nil {
 61		return err
 62	}
 63
 64	areaID, err := resolveAreaFilter(cmd)
 65	if err != nil {
 66		return err
 67	}
 68
 69	statusFilter, err := resolveStatusFilter(cmd)
 70	if err != nil {
 71		return err
 72	}
 73
 74	showAll := mustGetBoolFlag(cmd, "all")
 75	tasks = applyFilters(tasks, areaID, statusFilter, showAll)
 76
 77	if len(tasks) == 0 {
 78		fmt.Fprintln(cmd.OutOrStdout(), "No tasks found")
 79
 80		return nil
 81	}
 82
 83	if mustGetBoolFlag(cmd, "json") {
 84		return outputJSON(cmd, tasks)
 85	}
 86
 87	return outputTable(cmd, tasks)
 88}
 89
 90// mustGetStringFlag returns the string flag value. Panics if flag doesn't exist
 91// (indicates a programming error—flags are defined in init).
 92func mustGetStringFlag(cmd *cobra.Command, name string) string {
 93	f := cmd.Flags().Lookup(name)
 94	if f == nil {
 95		panic("flag not defined: " + name)
 96	}
 97
 98	return f.Value.String()
 99}
100
101// mustGetBoolFlag returns the bool flag value. Panics if flag doesn't exist.
102func mustGetBoolFlag(cmd *cobra.Command, name string) bool {
103	f := cmd.Flags().Lookup(name)
104	if f == nil {
105		panic("flag not defined: " + name)
106	}
107
108	return f.Value.String() == "true"
109}
110
111func resolveAreaFilter(cmd *cobra.Command) (string, error) {
112	areaKey := mustGetStringFlag(cmd, "area")
113
114	cfg, err := config.Load()
115	if err != nil {
116		// Config not required if no area flag and we just skip default
117		if errors.Is(err, config.ErrNotFound) {
118			if areaKey != "" {
119				return "", err
120			}
121
122			return "", nil
123		}
124
125		return "", err
126	}
127
128	// Use default area if no explicit flag
129	if areaKey == "" {
130		areaKey = cfg.Defaults.Area
131	}
132
133	if areaKey == "" {
134		return "", nil
135	}
136
137	area := cfg.AreaByKey(areaKey)
138	if area == nil {
139		return "", fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
140	}
141
142	return area.ID, nil
143}
144
145func resolveStatusFilter(cmd *cobra.Command) (string, error) {
146	status := mustGetStringFlag(cmd, "status")
147	if status == "" {
148		return "", nil
149	}
150
151	s, err := validate.TaskStatus(status)
152	if err != nil {
153		return "", err
154	}
155
156	return string(s), nil
157}
158
159func applyFilters(tasks []lunatask.Task, areaID, statusFilter string, showAll bool) []lunatask.Task {
160	opts := &lunatask.TaskFilterOptions{
161		IncludeCompleted: showAll,
162		Today:            time.Now(),
163	}
164
165	if areaID != "" {
166		opts.AreaID = &areaID
167	}
168
169	if statusFilter != "" {
170		s := lunatask.TaskStatus(statusFilter)
171		opts.Status = &s
172	}
173
174	return lunatask.FilterTasks(tasks, opts)
175}
176
177func outputJSON(cmd *cobra.Command, tasks []lunatask.Task) error {
178	enc := json.NewEncoder(cmd.OutOrStdout())
179	enc.SetIndent("", "  ")
180
181	if err := enc.Encode(tasks); err != nil {
182		return fmt.Errorf("encoding JSON: %w", err)
183	}
184
185	return nil
186}
187
188func outputTable(cmd *cobra.Command, tasks []lunatask.Task) error {
189	rows := make([][]string, 0, len(tasks))
190
191	for _, task := range tasks {
192		status := "-"
193		if task.Status != nil {
194			status = string(*task.Status)
195		}
196
197		scheduled := "-"
198		if task.ScheduledOn != nil {
199			scheduled = ui.FormatDate(task.ScheduledOn.Time)
200		}
201
202		created := ui.FormatDate(task.CreatedAt)
203
204		rows = append(rows, []string{task.ID, status, scheduled, created})
205	}
206
207	tbl := table.New().
208		Headers("ID", "STATUS", "SCHEDULED", "CREATED").
209		Rows(rows...).
210		StyleFunc(func(row, col int) lipgloss.Style {
211			if row == table.HeaderRow {
212				return ui.TableHeaderStyle()
213			}
214
215			return lipgloss.NewStyle()
216		}).
217		Border(ui.TableBorder())
218
219	fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
220
221	return nil
222}