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}