cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat(wip): pull & list document commands

+218 -38
+5 -3
cmd/main.go
··· 113 root.SetHelpCommand(&cobra.Command{Hidden: true}) 114 cobra.EnableCommandSorting = false 115 116 - root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) 117 - root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) 118 return root 119 } 120 ··· 216 root := rootCmd() 217 218 coreGroups := []CommandGroup{ 219 - NewTaskCommand(taskHandler), NewNoteCommand(noteHandler), NewArticleCommand(articleHandler), 220 NewPublicationCommand(publicationHandler), 221 } 222 223 for _, group := range coreGroups {
··· 113 root.SetHelpCommand(&cobra.Command{Hidden: true}) 114 cobra.EnableCommandSorting = false 115 116 + root.AddGroup(&cobra.Group{ID: "core", Title: "Core:"}) 117 + root.AddGroup(&cobra.Group{ID: "management", Title: "Manage:"}) 118 return root 119 } 120 ··· 216 root := rootCmd() 217 218 coreGroups := []CommandGroup{ 219 + NewTaskCommand(taskHandler), 220 + NewNoteCommand(noteHandler), 221 NewPublicationCommand(publicationHandler), 222 + NewArticleCommand(articleHandler), 223 } 224 225 for _, group := range coreGroups {
+36 -16
cmd/task_commands.go
··· 29 time tracking. Tasks can be filtered by status, priority, project, or context.`, 30 } 31 32 for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 33 - addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, 34 - deleteTaskCmd, taskProjectsCmd, taskTagsCmd, taskContextsCmd, 35 - taskCompleteCmd, taskStartCmd, taskStopCmd, timesheetViewCmd, 36 - taskRecurCmd, taskDependCmd, 37 } { 38 cmd := init(c.handler) 39 root.AddCommand(cmd) 40 } 41 ··· 57 Examples: 58 noteleaf todo add "Write documentation" --priority high --project docs 59 noteleaf todo add "Weekly review" --recur "FREQ=WEEKLY" --due 2024-01-15`, 60 - Args: cobra.MinimumNArgs(1), 61 RunE: func(c *cobra.Command, args []string) error { 62 description := strings.Join(args, " ") 63 priority, _ := c.Flags().GetString("priority") ··· 125 Shows all task attributes including description, status, priority, project, 126 context, tags, due date, creation time, and modification history. Use --json 127 for machine-readable output or --no-metadata to show only the description.`, 128 - Args: cobra.ExactArgs(1), 129 RunE: func(cmd *cobra.Command, args []string) error { 130 format, _ := cmd.Flags().GetString("format") 131 jsonOutput, _ := cmd.Flags().GetBool("json") ··· 153 Examples: 154 noteleaf todo update 123 --priority urgent --due tomorrow 155 noteleaf todo update 456 --add-tag urgent --project website`, 156 - Args: cobra.ExactArgs(1), 157 RunE: func(cmd *cobra.Command, args []string) error { 158 taskID := args[0] 159 description, _ := cmd.Flags().GetString("description") ··· 239 240 Records the start time for a work session. Only one task can be actively 241 tracked at a time. Use --note to add a description of what you're working on.`, 242 - Args: cobra.ExactArgs(1), 243 RunE: func(c *cobra.Command, args []string) error { 244 taskID := args[0] 245 description, _ := c.Flags().GetString("note") ··· 260 261 Records the end time and calculates duration for the current work session. 262 Duration is added to the task's total time tracked.`, 263 - Args: cobra.ExactArgs(1), 264 RunE: func(c *cobra.Command, args []string) error { 265 taskID := args[0] 266 defer h.Close() ··· 300 301 Provides a user-friendly interface with status picker and priority toggle. 302 Easier than using multiple command-line flags for complex updates.`, 303 - Args: cobra.ExactArgs(1), 304 RunE: func(c *cobra.Command, args []string) error { 305 taskID := args[0] 306 defer h.Close() ··· 317 318 This operation cannot be undone. Consider updating the task status to 319 'deleted' instead if you want to preserve the record for historical purposes.`, 320 - Args: cobra.ExactArgs(1), 321 RunE: func(c *cobra.Command, args []string) error { 322 defer h.Close() 323 return h.Delete(c.Context(), args) ··· 357 358 Sets the task status to 'completed' and records the completion time. For 359 recurring tasks, generates the next instance based on the recurrence rule.`, 360 - Args: cobra.ExactArgs(1), 361 RunE: func(c *cobra.Command, args []string) error { 362 defer h.Close() 363 return h.Done(c.Context(), args) ··· 388 Examples: 389 noteleaf todo recur set 123 --rule "FREQ=DAILY" 390 noteleaf todo recur set 456 --rule "FREQ=WEEKLY;BYDAY=MO" --until 2024-12-31`, 391 - Args: cobra.ExactArgs(1), 392 RunE: func(c *cobra.Command, args []string) error { 393 rule, _ := c.Flags().GetString("rule") 394 until, _ := c.Flags().GetString("until") ··· 406 407 Converts a recurring task to a one-time task. Existing future instances are not 408 affected.`, 409 - Args: cobra.ExactArgs(1), 410 RunE: func(c *cobra.Command, args []string) error { 411 defer h.Close() 412 return h.ClearRecur(c.Context(), args[0]) ··· 420 421 Shows the RRULE pattern, next occurrence date, and recurrence end date if 422 configured.`, 423 - Args: cobra.ExactArgs(1), 424 RunE: func(c *cobra.Command, args []string) error { 425 defer h.Close() 426 return h.ShowRecur(c.Context(), args[0]) ··· 449 450 The first task cannot be started until the second task is completed. Use task 451 UUIDs to specify dependencies.`, 452 - Args: cobra.ExactArgs(2), 453 RunE: func(c *cobra.Command, args []string) error { 454 defer h.Close() 455 return h.AddDep(c.Context(), args[0], args[1])
··· 29 time tracking. Tasks can be filtered by status, priority, project, or context.`, 30 } 31 32 + root.AddGroup( 33 + &cobra.Group{ID: "task-ops", Title: "Basic Operations"}, 34 + &cobra.Group{ID: "task-meta", Title: "Metadata"}, 35 + &cobra.Group{ID: "task-tracking", Title: "Tracking"}, 36 + ) 37 + 38 for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 39 + addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, deleteTaskCmd, 40 } { 41 cmd := init(c.handler) 42 + cmd.GroupID = "task-ops" 43 + root.AddCommand(cmd) 44 + } 45 + 46 + for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 47 + taskProjectsCmd, taskTagsCmd, taskContextsCmd, 48 + } { 49 + cmd := init(c.handler) 50 + cmd.GroupID = "task-meta" 51 + root.AddCommand(cmd) 52 + } 53 + 54 + for _, init := range []func(*handlers.TaskHandler) *cobra.Command{ 55 + timesheetViewCmd, taskStartCmd, taskStopCmd, taskCompleteCmd, taskRecurCmd, taskDependCmd, 56 + } { 57 + cmd := init(c.handler) 58 + cmd.GroupID = "task-tracking" 59 root.AddCommand(cmd) 60 } 61 ··· 77 Examples: 78 noteleaf todo add "Write documentation" --priority high --project docs 79 noteleaf todo add "Weekly review" --recur "FREQ=WEEKLY" --due 2024-01-15`, 80 + Args: cobra.MinimumNArgs(1), 81 RunE: func(c *cobra.Command, args []string) error { 82 description := strings.Join(args, " ") 83 priority, _ := c.Flags().GetString("priority") ··· 145 Shows all task attributes including description, status, priority, project, 146 context, tags, due date, creation time, and modification history. Use --json 147 for machine-readable output or --no-metadata to show only the description.`, 148 + Args: cobra.ExactArgs(1), 149 RunE: func(cmd *cobra.Command, args []string) error { 150 format, _ := cmd.Flags().GetString("format") 151 jsonOutput, _ := cmd.Flags().GetBool("json") ··· 173 Examples: 174 noteleaf todo update 123 --priority urgent --due tomorrow 175 noteleaf todo update 456 --add-tag urgent --project website`, 176 + Args: cobra.ExactArgs(1), 177 RunE: func(cmd *cobra.Command, args []string) error { 178 taskID := args[0] 179 description, _ := cmd.Flags().GetString("description") ··· 259 260 Records the start time for a work session. Only one task can be actively 261 tracked at a time. Use --note to add a description of what you're working on.`, 262 + Args: cobra.ExactArgs(1), 263 RunE: func(c *cobra.Command, args []string) error { 264 taskID := args[0] 265 description, _ := c.Flags().GetString("note") ··· 280 281 Records the end time and calculates duration for the current work session. 282 Duration is added to the task's total time tracked.`, 283 + Args: cobra.ExactArgs(1), 284 RunE: func(c *cobra.Command, args []string) error { 285 taskID := args[0] 286 defer h.Close() ··· 320 321 Provides a user-friendly interface with status picker and priority toggle. 322 Easier than using multiple command-line flags for complex updates.`, 323 + Args: cobra.ExactArgs(1), 324 RunE: func(c *cobra.Command, args []string) error { 325 taskID := args[0] 326 defer h.Close() ··· 337 338 This operation cannot be undone. Consider updating the task status to 339 'deleted' instead if you want to preserve the record for historical purposes.`, 340 + Args: cobra.ExactArgs(1), 341 RunE: func(c *cobra.Command, args []string) error { 342 defer h.Close() 343 return h.Delete(c.Context(), args) ··· 377 378 Sets the task status to 'completed' and records the completion time. For 379 recurring tasks, generates the next instance based on the recurrence rule.`, 380 + Args: cobra.ExactArgs(1), 381 RunE: func(c *cobra.Command, args []string) error { 382 defer h.Close() 383 return h.Done(c.Context(), args) ··· 408 Examples: 409 noteleaf todo recur set 123 --rule "FREQ=DAILY" 410 noteleaf todo recur set 456 --rule "FREQ=WEEKLY;BYDAY=MO" --until 2024-12-31`, 411 + Args: cobra.ExactArgs(1), 412 RunE: func(c *cobra.Command, args []string) error { 413 rule, _ := c.Flags().GetString("rule") 414 until, _ := c.Flags().GetString("until") ··· 426 427 Converts a recurring task to a one-time task. Existing future instances are not 428 affected.`, 429 + Args: cobra.ExactArgs(1), 430 RunE: func(c *cobra.Command, args []string) error { 431 defer h.Close() 432 return h.ClearRecur(c.Context(), args[0]) ··· 440 441 Shows the RRULE pattern, next occurrence date, and recurrence end date if 442 configured.`, 443 + Args: cobra.ExactArgs(1), 444 RunE: func(c *cobra.Command, args []string) error { 445 defer h.Close() 446 return h.ShowRecur(c.Context(), args[0]) ··· 469 470 The first task cannot be started until the second task is completed. Use task 471 UUIDs to specify dependencies.`, 472 + Args: cobra.ExactArgs(2), 473 RunE: func(c *cobra.Command, args []string) error { 474 defer h.Close() 475 return h.AddDep(c.Context(), args[0], args[1])
+174 -18
internal/handlers/publication.go
··· 1 - // TODO: Implement document processing 2 - // For each document: 3 - // 1. Check if note with this leaflet_rkey exists 4 - // 2. If exists: Update note content, title, metadata 5 - // 3. If new: Create new note with leaflet metadata 6 - // 4. Convert document blocks to markdown 7 - // 5. Save to database 8 - // 9 - // TODO: Implement list functionality 10 - // 1. Query notes where leaflet_rkey IS NOT NULL 11 - // 2. Apply filter (published vs draft) - "all", "published", "draft", or empty (default: all) 12 - // 3. Use prior art from package ui and other handlers to render 13 // 14 - // TODO: Implmenent pull command 15 // 1. Authenticates with AT Protocol 16 // 2. Fetches all pub.leaflet.document records 17 // 3. Creates new notes for documents not seen before 18 // 4. Updates existing notes (matched by leaflet_rkey) 19 // 5. Shows summary of pulled documents 20 package handlers 21 22 import ( ··· 24 "fmt" 25 "time" 26 27 "github.com/stormlightlabs/noteleaf/internal/repo" 28 "github.com/stormlightlabs/noteleaf/internal/services" 29 "github.com/stormlightlabs/noteleaf/internal/store" 30 ) 31 32 // PublicationHandler handles leaflet publication commands ··· 90 return fmt.Errorf("password is required") 91 } 92 93 - fmt.Printf("Authenticating as %s...\n", handle) 94 95 if err := h.atproto.Authenticate(ctx, handle, password); err != nil { 96 return fmt.Errorf("authentication failed: %w", err) ··· 112 return fmt.Errorf("authentication successful but failed to save credentials: %w", err) 113 } 114 115 - fmt.Println("โœ“ Authentication successful!") 116 - fmt.Println("โœ“ Credentials saved") 117 return nil 118 } 119 120 // Pull fetches all documents from leaflet and creates/updates local notes 121 func (h *PublicationHandler) Pull(ctx context.Context) error { 122 - fmt.Println("TODO: Implement document conversion and note creation") 123 return nil 124 } 125 126 // List displays notes with leaflet publication metadata, showing all notes that have been pulled from or pushed to leaflet 127 func (h *PublicationHandler) List(ctx context.Context, filter string) error { 128 - fmt.Println("TODO: Implement leaflet document listing") 129 return nil 130 } 131
··· 1 + // Package handlers provides command handlers for leaflet publication operations. 2 // 3 + // Pull command: 4 // 1. Authenticates with AT Protocol 5 // 2. Fetches all pub.leaflet.document records 6 // 3. Creates new notes for documents not seen before 7 // 4. Updates existing notes (matched by leaflet_rkey) 8 // 5. Shows summary of pulled documents 9 + // 10 + // List command: 11 + // 1. Query notes where leaflet_rkey IS NOT NULL 12 + // 2. Apply filter (published vs draft) - "all", "published", "draft", or empty (default: all) 13 + // 3. Static output (TUI viewing marked as TODO) 14 + // 15 + // TODO: Add TUI viewing for document details 16 package handlers 17 18 import ( ··· 20 "fmt" 21 "time" 22 23 + "github.com/stormlightlabs/noteleaf/internal/models" 24 + "github.com/stormlightlabs/noteleaf/internal/public" 25 "github.com/stormlightlabs/noteleaf/internal/repo" 26 "github.com/stormlightlabs/noteleaf/internal/services" 27 "github.com/stormlightlabs/noteleaf/internal/store" 28 + "github.com/stormlightlabs/noteleaf/internal/ui" 29 ) 30 31 // PublicationHandler handles leaflet publication commands ··· 89 return fmt.Errorf("password is required") 90 } 91 92 + ui.Infoln("Authenticating as %s...", handle) 93 94 if err := h.atproto.Authenticate(ctx, handle, password); err != nil { 95 return fmt.Errorf("authentication failed: %w", err) ··· 111 return fmt.Errorf("authentication successful but failed to save credentials: %w", err) 112 } 113 114 + ui.Successln("Authentication successful!") 115 + ui.Successln("Credentials saved") 116 return nil 117 + } 118 + 119 + // documentToMarkdown converts a leaflet Document to markdown content 120 + func documentToMarkdown(doc services.DocumentWithMeta) (string, error) { 121 + converter := public.NewMarkdownConverter() 122 + var allBlocks []public.BlockWrap 123 + 124 + for _, page := range doc.Document.Pages { 125 + allBlocks = append(allBlocks, page.Blocks...) 126 + } 127 + 128 + content, err := converter.FromLeaflet(allBlocks) 129 + if err != nil { 130 + return "", fmt.Errorf("failed to convert document to markdown: %w", err) 131 + } 132 + 133 + return content, nil 134 } 135 136 // Pull fetches all documents from leaflet and creates/updates local notes 137 func (h *PublicationHandler) Pull(ctx context.Context) error { 138 + if !h.atproto.IsAuthenticated() { 139 + return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first") 140 + } 141 + 142 + ui.Infoln("Fetching documents from leaflet...") 143 + 144 + docs, err := h.atproto.PullDocuments(ctx) 145 + if err != nil { 146 + return fmt.Errorf("failed to fetch documents: %w", err) 147 + } 148 + 149 + if len(docs) == 0 { 150 + ui.Infoln("No documents found in leaflet.") 151 + return nil 152 + } 153 + 154 + ui.Infoln("Found %d document(s). Syncing...\n", len(docs)) 155 + 156 + var created, updated int 157 + 158 + for _, doc := range docs { 159 + existing, err := h.repos.Notes.GetByLeafletRKey(ctx, doc.Meta.RKey) 160 + if err == nil && existing != nil { 161 + content, err := documentToMarkdown(doc) 162 + if err != nil { 163 + ui.Warningln("โš  Skipping document %s: %v", doc.Document.Title, err) 164 + continue 165 + } 166 + 167 + existing.Title = doc.Document.Title 168 + existing.Content = content 169 + existing.LeafletCID = &doc.Meta.CID 170 + existing.IsDraft = doc.Meta.IsDraft 171 + 172 + if doc.Document.PublishedAt != "" { 173 + publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt) 174 + if err == nil { 175 + existing.PublishedAt = &publishedAt 176 + } 177 + } 178 + 179 + if err := h.repos.Notes.Update(ctx, existing); err != nil { 180 + ui.Warningln("โš  Failed to update note for document %s: %v", doc.Document.Title, err) 181 + continue 182 + } 183 + 184 + updated++ 185 + ui.Infoln(" Updated: %s", doc.Document.Title) 186 + } else { 187 + content, err := documentToMarkdown(doc) 188 + if err != nil { 189 + ui.Warningln("โš  Skipping document %s: %v", doc.Document.Title, err) 190 + continue 191 + } 192 + 193 + note := &models.Note{ 194 + Title: doc.Document.Title, 195 + Content: content, 196 + LeafletRKey: &doc.Meta.RKey, 197 + LeafletCID: &doc.Meta.CID, 198 + IsDraft: doc.Meta.IsDraft, 199 + } 200 + 201 + if doc.Document.PublishedAt != "" { 202 + publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt) 203 + if err == nil { 204 + note.PublishedAt = &publishedAt 205 + } 206 + } 207 + 208 + _, err = h.repos.Notes.Create(ctx, note) 209 + if err != nil { 210 + ui.Warningln("โš  Failed to create note for document %s: %v", doc.Document.Title, err) 211 + continue 212 + } 213 + 214 + created++ 215 + ui.Infoln(" Created: %s", doc.Document.Title) 216 + } 217 + } 218 + 219 + ui.Successln("Sync complete: %d created, %d updated", created, updated) 220 return nil 221 } 222 223 + // printPublication prints a single publication note in static format 224 + func printPublication(note *models.Note) { 225 + status := "published" 226 + if note.IsDraft { 227 + status = "draft" 228 + } 229 + 230 + ui.Infoln("[%d] %s (%s)", note.ID, note.Title, status) 231 + 232 + if note.LeafletRKey != nil { 233 + ui.Infoln(" rkey: %s", *note.LeafletRKey) 234 + } 235 + 236 + if note.PublishedAt != nil { 237 + ui.Infoln(" published: %s", note.PublishedAt.Format("2006-01-02 15:04:05")) 238 + } 239 + 240 + ui.Infoln(" modified: %s", note.Modified.Format("2006-01-02 15:04:05")) 241 + ui.Newline() 242 + } 243 + 244 // List displays notes with leaflet publication metadata, showing all notes that have been pulled from or pushed to leaflet 245 func (h *PublicationHandler) List(ctx context.Context, filter string) error { 246 + if filter == "" { 247 + filter = "all" 248 + } 249 + 250 + var notes []*models.Note 251 + var err error 252 + 253 + switch filter { 254 + case "all": 255 + notes, err = h.repos.Notes.GetLeafletNotes(ctx) 256 + if err != nil { 257 + return fmt.Errorf("failed to fetch leaflet notes: %w", err) 258 + } 259 + case "published": 260 + notes, err = h.repos.Notes.ListPublished(ctx) 261 + if err != nil { 262 + return fmt.Errorf("failed to fetch published notes: %w", err) 263 + } 264 + case "draft": 265 + notes, err = h.repos.Notes.ListDrafts(ctx) 266 + if err != nil { 267 + return fmt.Errorf("failed to fetch draft notes: %w", err) 268 + } 269 + default: 270 + return fmt.Errorf("invalid filter: %s (must be 'all', 'published', or 'draft')", filter) 271 + } 272 + 273 + if len(notes) == 0 { 274 + ui.Infoln("No %s documents found.", filter) 275 + return nil 276 + } 277 + 278 + ui.Infoln("Found %d %s document(s):", len(notes), filter) 279 + ui.Newline() 280 + 281 + for _, note := range notes { 282 + printPublication(note) 283 + } 284 + 285 return nil 286 } 287
+1 -1
internal/handlers/tasks.go
··· 1 package handlers 2 3 import ( ··· 191 return nil 192 } 193 194 - // TODO: include context field 195 func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project, _ string) error { 196 taskTable := ui.NewTaskListFromTable(h.repos.Tasks, os.Stdout, os.Stdin, false, showAll, status, priority, project) 197 return taskTable.Browse(ctx)
··· 1 + // TODO: add context field to table in [TaskHandler.listTasksInteractive] 2 package handlers 3 4 import ( ··· 192 return nil 193 } 194 195 func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project, _ string) error { 196 taskTable := ui.NewTaskListFromTable(h.repos.Tasks, os.Stdout, os.Stdin, false, showAll, status, priority, project) 197 return taskTable.Browse(ctx)
+2
internal/ui/task_list_adapter.go
··· 1 package ui 2 3 import (
··· 1 + // TODO: Use glamour to render the markdown produced by [formatTaskForView] 2 + // TODO: remove the ID from the table 3 package ui 4 5 import (