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