cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1// TODO: Use glamour to render the markdown produced by [formatTaskForView]
2// TODO: remove the ID from the table
3package ui
4
5import (
6 "context"
7 "fmt"
8 "io"
9 "strings"
10 "time"
11
12 tea "github.com/charmbracelet/bubbletea"
13 "github.com/stormlightlabs/noteleaf/internal/models"
14 "github.com/stormlightlabs/noteleaf/internal/repo"
15 "github.com/stormlightlabs/noteleaf/internal/utils"
16)
17
18// TaskRecord adapts models.Task to work with DataTable
19type TaskRecord struct {
20 *models.Task
21}
22
23func (t *TaskRecord) GetField(name string) any {
24 switch name {
25 case "id":
26 return t.ID
27 case "uuid":
28 return t.UUID
29 case "description":
30 return t.Description
31 case "status":
32 return t.Status
33 case "priority":
34 return t.Priority
35 case "project":
36 return t.Project
37 case "tags":
38 return t.Tags
39 case "due":
40 return t.Due
41 case "entry":
42 return t.Entry
43 case "start":
44 return t.Start
45 case "end":
46 return t.End
47 case "modified":
48 return t.Modified
49 case "annotations":
50 return t.Annotations
51 default:
52 return ""
53 }
54}
55
56// TaskDataSource adapts TaskRepository to work with DataTable
57type TaskDataSource struct {
58 repo utils.TestTaskRepository
59 showAll bool
60 status string
61 priority string
62 project string
63}
64
65func (t *TaskDataSource) Load(ctx context.Context, opts DataOptions) ([]DataRecord, error) {
66 repoOpts := repo.TaskListOptions{
67 SortBy: "modified",
68 SortOrder: "DESC",
69 Limit: 50,
70 }
71
72 if !t.showAll && t.status == "" {
73 repoOpts.Status = "pending"
74 }
75 if t.status != "" {
76 repoOpts.Status = t.status
77 }
78 if t.priority != "" {
79 repoOpts.Priority = t.priority
80 }
81 if t.project != "" {
82 repoOpts.Project = t.project
83 }
84
85 tasks, err := t.repo.List(ctx, repoOpts)
86 if err != nil {
87 return nil, err
88 }
89
90 records := make([]DataRecord, len(tasks))
91 for i, task := range tasks {
92 records[i] = &TaskRecord{Task: task}
93 }
94
95 return records, nil
96}
97
98func (t *TaskDataSource) Count(ctx context.Context, opts DataOptions) (int, error) {
99 records, err := t.Load(ctx, opts)
100 if err != nil {
101 return 0, err
102 }
103 return len(records), nil
104}
105
106func formatTaskForView(task *models.Task) string {
107 var content strings.Builder
108 content.WriteString(fmt.Sprintf("# Task %d\n\n", task.ID))
109 content.WriteString(fmt.Sprintf("**UUID:** %s\n", task.UUID))
110 content.WriteString(fmt.Sprintf("**Description:** %s\n", task.Description))
111 content.WriteString(fmt.Sprintf("**Status:** %s\n", task.Status))
112
113 if task.Priority != "" {
114 content.WriteString(fmt.Sprintf("**Priority:** %s\n", task.Priority))
115 }
116
117 if task.Project != "" {
118 content.WriteString(fmt.Sprintf("**Project:** %s\n", task.Project))
119 }
120
121 if len(task.Tags) > 0 {
122 content.WriteString(fmt.Sprintf("**Tags:** %s\n", strings.Join(task.Tags, ", ")))
123 }
124
125 if task.Due != nil {
126 content.WriteString(fmt.Sprintf("**Due:** %s\n", task.Due.Format("2006-01-02 15:04")))
127 }
128
129 content.WriteString(fmt.Sprintf("**Created:** %s\n", task.Entry.Format("2006-01-02 15:04")))
130 content.WriteString(fmt.Sprintf("**Modified:** %s\n", task.Modified.Format("2006-01-02 15:04")))
131
132 if task.Start != nil {
133 content.WriteString(fmt.Sprintf("**Started:** %s\n", task.Start.Format("2006-01-02 15:04")))
134 }
135
136 if task.End != nil {
137 content.WriteString(fmt.Sprintf("**Completed:** %s\n", task.End.Format("2006-01-02 15:04")))
138 }
139
140 if len(task.Annotations) > 0 {
141 content.WriteString("\n**Annotations:**\n")
142 for _, annotation := range task.Annotations {
143 content.WriteString(fmt.Sprintf("- %s\n", annotation))
144 }
145 }
146
147 return content.String()
148}
149
150func formatPriorityField(priority string) string {
151 if priority == "" {
152 return "-"
153 }
154
155 titlecase := utils.Titlecase(priority)
156 padded := fmt.Sprintf("%-10s", titlecase)
157
158 switch strings.ToLower(priority) {
159 case "high", "urgent":
160 return PriorityHigh.Render(padded)
161 case "medium":
162 return PriorityMedium.Render(padded)
163 case "low":
164 return PriorityLow.Render(padded)
165 default:
166 return padded
167 }
168}
169
170// NewTaskDataTable creates a new DataTable for browsing tasks
171func NewTaskDataTable(repo utils.TestTaskRepository, opts DataTableOptions, showAll bool, status, priority, project string) *DataTable {
172 if opts.Title == "" {
173 title := "Tasks"
174 if showAll {
175 title += " (showing all)"
176 } else {
177 title += " (pending only)"
178 }
179 opts.Title = title
180 }
181
182 opts.Fields = []Field{
183 {Name: "id", Title: "ID", Width: 4},
184 {Name: "description", Title: "Description", Width: 40,
185 Formatter: func(v any) string {
186 desc := fmt.Sprintf("%v", v)
187 if len(desc) > 38 {
188 return desc[:35] + "..."
189 }
190 return desc
191 }},
192 {Name: "status", Title: "Status", Width: 10,
193 Formatter: func(v any) string {
194 status := fmt.Sprintf("%v", v)
195 if len(status) > 8 {
196 return status[:8]
197 }
198 return status
199 }},
200 {Name: "priority", Title: "Priority", Width: 10,
201 Formatter: func(v any) string {
202 priority := fmt.Sprintf("%v", v)
203 return formatPriorityField(priority)
204 }},
205 {Name: "project", Title: "Project", Width: 15,
206 Formatter: func(v any) string {
207 project := fmt.Sprintf("%v", v)
208 if project == "" {
209 return "-"
210 }
211 if len(project) > 13 {
212 return project[:10] + "..."
213 }
214 return project
215 }},
216 }
217
218 if opts.ViewHandler == nil {
219 opts.ViewHandler = func(record DataRecord) string {
220 if taskRecord, ok := record.(*TaskRecord); ok {
221 return formatTaskForView(taskRecord.Task)
222 }
223 return "Unable to display task"
224 }
225 }
226
227 if len(opts.Actions) == 0 {
228 opts.Actions = []Action{
229 {
230 Key: "d",
231 Description: "mark done",
232 Handler: func(record DataRecord) tea.Cmd {
233 return func() tea.Msg {
234 if taskRecord, ok := record.(*TaskRecord); ok {
235 if taskRecord.Status == "completed" {
236 return dataErrorMsg(fmt.Errorf("task already completed"))
237 }
238 taskRecord.Status = "completed"
239 taskRecord.End = &time.Time{}
240 *taskRecord.End = time.Now()
241 err := repo.Update(context.Background(), taskRecord.Task)
242 if err != nil {
243 return dataErrorMsg(err)
244 }
245 return dataLoadedMsg([]DataRecord{})
246 }
247 return dataErrorMsg(fmt.Errorf("invalid task record"))
248 }
249 },
250 },
251 }
252 }
253
254 source := &TaskDataSource{
255 repo: repo,
256 showAll: showAll,
257 status: status,
258 priority: priority,
259 project: project,
260 }
261
262 return NewDataTable(source, opts)
263}
264
265// NewTaskListFromTable creates a TaskList-compatible interface using DataTable
266func NewTaskListFromTable(repo utils.TestTaskRepository, output io.Writer, input io.Reader, static bool, showAll bool, status, priority, project string) *DataTable {
267 opts := DataTableOptions{
268 Output: output,
269 Input: input,
270 Static: static,
271 }
272 return NewTaskDataTable(repo, opts, showAll, status, priority, project)
273}