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}