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 113 root.SetHelpCommand(&cobra.Command{Hidden: true}) 114 114 cobra.EnableCommandSorting = false 115 115 116 - root.AddGroup(&cobra.Group{ID: "core", Title: "Core Commands:"}) 117 - root.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"}) 116 + root.AddGroup(&cobra.Group{ID: "core", Title: "Core:"}) 117 + root.AddGroup(&cobra.Group{ID: "management", Title: "Manage:"}) 118 118 return root 119 119 } 120 120 ··· 216 216 root := rootCmd() 217 217 218 218 coreGroups := []CommandGroup{ 219 - NewTaskCommand(taskHandler), NewNoteCommand(noteHandler), NewArticleCommand(articleHandler), 219 + NewTaskCommand(taskHandler), 220 + NewNoteCommand(noteHandler), 220 221 NewPublicationCommand(publicationHandler), 222 + NewArticleCommand(articleHandler), 221 223 } 222 224 223 225 for _, group := range coreGroups {
+36 -16
cmd/task_commands.go
··· 29 29 time tracking. Tasks can be filtered by status, priority, project, or context.`, 30 30 } 31 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 + 32 38 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, 39 + addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, deleteTaskCmd, 37 40 } { 38 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" 39 59 root.AddCommand(cmd) 40 60 } 41 61 ··· 57 77 Examples: 58 78 noteleaf todo add "Write documentation" --priority high --project docs 59 79 noteleaf todo add "Weekly review" --recur "FREQ=WEEKLY" --due 2024-01-15`, 60 - Args: cobra.MinimumNArgs(1), 80 + Args: cobra.MinimumNArgs(1), 61 81 RunE: func(c *cobra.Command, args []string) error { 62 82 description := strings.Join(args, " ") 63 83 priority, _ := c.Flags().GetString("priority") ··· 125 145 Shows all task attributes including description, status, priority, project, 126 146 context, tags, due date, creation time, and modification history. Use --json 127 147 for machine-readable output or --no-metadata to show only the description.`, 128 - Args: cobra.ExactArgs(1), 148 + Args: cobra.ExactArgs(1), 129 149 RunE: func(cmd *cobra.Command, args []string) error { 130 150 format, _ := cmd.Flags().GetString("format") 131 151 jsonOutput, _ := cmd.Flags().GetBool("json") ··· 153 173 Examples: 154 174 noteleaf todo update 123 --priority urgent --due tomorrow 155 175 noteleaf todo update 456 --add-tag urgent --project website`, 156 - Args: cobra.ExactArgs(1), 176 + Args: cobra.ExactArgs(1), 157 177 RunE: func(cmd *cobra.Command, args []string) error { 158 178 taskID := args[0] 159 179 description, _ := cmd.Flags().GetString("description") ··· 239 259 240 260 Records the start time for a work session. Only one task can be actively 241 261 tracked at a time. Use --note to add a description of what you're working on.`, 242 - Args: cobra.ExactArgs(1), 262 + Args: cobra.ExactArgs(1), 243 263 RunE: func(c *cobra.Command, args []string) error { 244 264 taskID := args[0] 245 265 description, _ := c.Flags().GetString("note") ··· 260 280 261 281 Records the end time and calculates duration for the current work session. 262 282 Duration is added to the task's total time tracked.`, 263 - Args: cobra.ExactArgs(1), 283 + Args: cobra.ExactArgs(1), 264 284 RunE: func(c *cobra.Command, args []string) error { 265 285 taskID := args[0] 266 286 defer h.Close() ··· 300 320 301 321 Provides a user-friendly interface with status picker and priority toggle. 302 322 Easier than using multiple command-line flags for complex updates.`, 303 - Args: cobra.ExactArgs(1), 323 + Args: cobra.ExactArgs(1), 304 324 RunE: func(c *cobra.Command, args []string) error { 305 325 taskID := args[0] 306 326 defer h.Close() ··· 317 337 318 338 This operation cannot be undone. Consider updating the task status to 319 339 'deleted' instead if you want to preserve the record for historical purposes.`, 320 - Args: cobra.ExactArgs(1), 340 + Args: cobra.ExactArgs(1), 321 341 RunE: func(c *cobra.Command, args []string) error { 322 342 defer h.Close() 323 343 return h.Delete(c.Context(), args) ··· 357 377 358 378 Sets the task status to 'completed' and records the completion time. For 359 379 recurring tasks, generates the next instance based on the recurrence rule.`, 360 - Args: cobra.ExactArgs(1), 380 + Args: cobra.ExactArgs(1), 361 381 RunE: func(c *cobra.Command, args []string) error { 362 382 defer h.Close() 363 383 return h.Done(c.Context(), args) ··· 388 408 Examples: 389 409 noteleaf todo recur set 123 --rule "FREQ=DAILY" 390 410 noteleaf todo recur set 456 --rule "FREQ=WEEKLY;BYDAY=MO" --until 2024-12-31`, 391 - Args: cobra.ExactArgs(1), 411 + Args: cobra.ExactArgs(1), 392 412 RunE: func(c *cobra.Command, args []string) error { 393 413 rule, _ := c.Flags().GetString("rule") 394 414 until, _ := c.Flags().GetString("until") ··· 406 426 407 427 Converts a recurring task to a one-time task. Existing future instances are not 408 428 affected.`, 409 - Args: cobra.ExactArgs(1), 429 + Args: cobra.ExactArgs(1), 410 430 RunE: func(c *cobra.Command, args []string) error { 411 431 defer h.Close() 412 432 return h.ClearRecur(c.Context(), args[0]) ··· 420 440 421 441 Shows the RRULE pattern, next occurrence date, and recurrence end date if 422 442 configured.`, 423 - Args: cobra.ExactArgs(1), 443 + Args: cobra.ExactArgs(1), 424 444 RunE: func(c *cobra.Command, args []string) error { 425 445 defer h.Close() 426 446 return h.ShowRecur(c.Context(), args[0]) ··· 449 469 450 470 The first task cannot be started until the second task is completed. Use task 451 471 UUIDs to specify dependencies.`, 452 - Args: cobra.ExactArgs(2), 472 + Args: cobra.ExactArgs(2), 453 473 RunE: func(c *cobra.Command, args []string) error { 454 474 defer h.Close() 455 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 1 + // Package handlers provides command handlers for leaflet publication operations. 13 2 // 14 - // TODO: Implmenent pull command 3 + // Pull command: 15 4 // 1. Authenticates with AT Protocol 16 5 // 2. Fetches all pub.leaflet.document records 17 6 // 3. Creates new notes for documents not seen before 18 7 // 4. Updates existing notes (matched by leaflet_rkey) 19 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 20 16 package handlers 21 17 22 18 import ( ··· 24 20 "fmt" 25 21 "time" 26 22 23 + "github.com/stormlightlabs/noteleaf/internal/models" 24 + "github.com/stormlightlabs/noteleaf/internal/public" 27 25 "github.com/stormlightlabs/noteleaf/internal/repo" 28 26 "github.com/stormlightlabs/noteleaf/internal/services" 29 27 "github.com/stormlightlabs/noteleaf/internal/store" 28 + "github.com/stormlightlabs/noteleaf/internal/ui" 30 29 ) 31 30 32 31 // PublicationHandler handles leaflet publication commands ··· 90 89 return fmt.Errorf("password is required") 91 90 } 92 91 93 - fmt.Printf("Authenticating as %s...\n", handle) 92 + ui.Infoln("Authenticating as %s...", handle) 94 93 95 94 if err := h.atproto.Authenticate(ctx, handle, password); err != nil { 96 95 return fmt.Errorf("authentication failed: %w", err) ··· 112 111 return fmt.Errorf("authentication successful but failed to save credentials: %w", err) 113 112 } 114 113 115 - fmt.Println("โœ“ Authentication successful!") 116 - fmt.Println("โœ“ Credentials saved") 114 + ui.Successln("Authentication successful!") 115 + ui.Successln("Credentials saved") 117 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 118 134 } 119 135 120 136 // Pull fetches all documents from leaflet and creates/updates local notes 121 137 func (h *PublicationHandler) Pull(ctx context.Context) error { 122 - fmt.Println("TODO: Implement document conversion and note creation") 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) 123 220 return nil 124 221 } 125 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 + 126 244 // List displays notes with leaflet publication metadata, showing all notes that have been pulled from or pushed to leaflet 127 245 func (h *PublicationHandler) List(ctx context.Context, filter string) error { 128 - fmt.Println("TODO: Implement leaflet document listing") 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 + 129 285 return nil 130 286 } 131 287
+1 -1
internal/handlers/tasks.go
··· 1 + // TODO: add context field to table in [TaskHandler.listTasksInteractive] 1 2 package handlers 2 3 3 4 import ( ··· 191 192 return nil 192 193 } 193 194 194 - // TODO: include context field 195 195 func (h *TaskHandler) listTasksInteractive(ctx context.Context, showAll bool, status, priority, project, _ string) error { 196 196 taskTable := ui.NewTaskListFromTable(h.repos.Tasks, os.Stdout, os.Stdin, false, showAll, status, priority, project) 197 197 return taskTable.Browse(ctx)
+2
internal/ui/task_list_adapter.go
··· 1 + // TODO: Use glamour to render the markdown produced by [formatTaskForView] 2 + // TODO: remove the ID from the table 1 3 package ui 2 4 3 5 import (