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(), ui.Muted.Render("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 if areaKey == "" {
110 return "", nil
111 }
112
113 cfg, err := config.Load()
114 if err != nil {
115 if errors.Is(err, config.ErrNotFound) {
116 fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Config not found; run 'lune init' to configure areas"))
117 }
118
119 return "", err
120 }
121
122 area := cfg.AreaByKey(areaKey)
123 if area == nil {
124 fmt.Fprintln(cmd.ErrOrStderr(), ui.Error.Render("Unknown area: "+areaKey))
125
126 return "", fmt.Errorf("%w: %s", ErrUnknownArea, areaKey)
127 }
128
129 return area.ID, nil
130}
131
132func applyFilters(tasks []lunatask.Task, areaID, statusFilter string, showAll bool) []lunatask.Task {
133 filtered := make([]lunatask.Task, 0, len(tasks))
134 today := time.Now().Truncate(24 * time.Hour)
135
136 for _, task := range tasks {
137 if !matchesFilters(task, areaID, statusFilter, showAll, today) {
138 continue
139 }
140
141 filtered = append(filtered, task)
142 }
143
144 return filtered
145}
146
147func matchesFilters(task lunatask.Task, areaID, statusFilter string, showAll bool, today time.Time) bool {
148 if areaID != "" && (task.AreaID == nil || *task.AreaID != areaID) {
149 return false
150 }
151
152 if statusFilter != "" && (task.Status == nil || string(*task.Status) != statusFilter) {
153 return false
154 }
155
156 // Default filter: exclude completed tasks unless completed today
157 if !showAll && statusFilter == "" && isOldCompleted(task, today) {
158 return false
159 }
160
161 return true
162}
163
164func isOldCompleted(task lunatask.Task, today time.Time) bool {
165 if task.Status == nil || *task.Status != lunatask.StatusCompleted {
166 return false
167 }
168
169 return task.CompletedAt == nil || task.CompletedAt.Before(today)
170}
171
172func outputJSON(cmd *cobra.Command, tasks []lunatask.Task) error {
173 enc := json.NewEncoder(cmd.OutOrStdout())
174 enc.SetIndent("", " ")
175
176 if err := enc.Encode(tasks); err != nil {
177 return fmt.Errorf("encoding JSON: %w", err)
178 }
179
180 return nil
181}
182
183func outputTable(cmd *cobra.Command, tasks []lunatask.Task) error {
184 rows := make([][]string, 0, len(tasks))
185
186 for _, task := range tasks {
187 status := "-"
188 if task.Status != nil {
189 status = string(*task.Status)
190 }
191
192 scheduled := "-"
193 if task.ScheduledOn != nil {
194 scheduled = ui.FormatDate(task.ScheduledOn.Time)
195 }
196
197 created := ui.FormatDate(task.CreatedAt)
198
199 rows = append(rows, []string{task.ID, status, scheduled, created})
200 }
201
202 tbl := table.New().
203 Headers("ID", "STATUS", "SCHEDULED", "CREATED").
204 Rows(rows...).
205 StyleFunc(func(row, col int) lipgloss.Style {
206 if row == table.HeaderRow {
207 return ui.Bold
208 }
209
210 return lipgloss.NewStyle()
211 }).
212 Border(lipgloss.HiddenBorder())
213
214 fmt.Fprintln(cmd.OutOrStdout(), tbl.Render())
215
216 return nil
217}