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