cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 273 lines 6.7 kB view raw
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}