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