cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
29
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 7452aaa4e1d32eaa8a95e2413dcd589b4c807007 605 lines 18 kB view raw
1package main 2 3import ( 4 "fmt" 5 "strconv" 6 "strings" 7 8 "github.com/spf13/cobra" 9 "github.com/stormlightlabs/noteleaf/internal/handlers" 10) 11 12func parseID(k string, args []string) (int64, error) { 13 id, err := strconv.ParseInt(args[0], 10, 64) 14 if err != nil { 15 return id, fmt.Errorf("invalid %v ID: %s", k, args[0]) 16 } 17 18 return id, err 19} 20 21// CommandGroup represents a group of related CLI commands 22type CommandGroup interface { 23 Create() *cobra.Command 24} 25 26// MovieCommand implements [CommandGroup] for movie-related commands 27type MovieCommand struct { 28 handler *handlers.MovieHandler 29} 30 31// NewMovieCommand creates a new MovieCommands with the given handler 32func NewMovieCommand(handler *handlers.MovieHandler) *MovieCommand { 33 return &MovieCommand{handler: handler} 34} 35 36func (c *MovieCommand) Create() *cobra.Command { 37 root := &cobra.Command{Use: "movie", Short: "Manage movie watch queue"} 38 39 addCmd := &cobra.Command{ 40 Use: "add [search query...]", 41 Short: "Search and add movie to watch queue", 42 Long: `Search for movies and add them to your watch queue. 43 44By default, shows search results in a simple list format where you can select by number. 45Use the -i flag for an interactive interface with navigation keys.`, 46 RunE: func(cmd *cobra.Command, args []string) error { 47 if len(args) == 0 { 48 return fmt.Errorf("search query cannot be empty") 49 } 50 interactive, _ := cmd.Flags().GetBool("interactive") 51 query := strings.Join(args, " ") 52 53 return c.handler.SearchAndAdd(cmd.Context(), query, interactive) 54 }, 55 } 56 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for movie selection") 57 root.AddCommand(addCmd) 58 59 root.AddCommand(&cobra.Command{ 60 Use: "list [--all|--watched|--queued]", 61 Short: "List movies in queue with status filtering", 62 RunE: func(cmd *cobra.Command, args []string) error { 63 var status string 64 if len(args) > 0 { 65 switch args[0] { 66 case "--all": 67 status = "" 68 case "--watched": 69 status = "watched" 70 case "--queued": 71 status = "queued" 72 default: 73 return fmt.Errorf("invalid status filter: %s (use: --all, --watched, --queued)", args[0]) 74 } 75 } 76 77 return c.handler.List(cmd.Context(), status) 78 }, 79 }) 80 81 root.AddCommand(&cobra.Command{ 82 Use: "watched [id]", 83 Short: "Mark movie as watched", 84 Aliases: []string{"seen"}, 85 Args: cobra.ExactArgs(1), 86 RunE: func(cmd *cobra.Command, args []string) error { 87 return c.handler.MarkWatched(cmd.Context(), args[0]) 88 }, 89 }) 90 91 root.AddCommand(&cobra.Command{ 92 Use: "remove [id]", 93 Short: "Remove movie from queue", 94 Aliases: []string{"rm"}, 95 Args: cobra.ExactArgs(1), 96 RunE: func(cmd *cobra.Command, args []string) error { 97 return c.handler.Remove(cmd.Context(), args[0]) 98 }, 99 }) 100 101 return root 102} 103 104// TVCommand implements [CommandGroup] for TV show-related commands 105type TVCommand struct { 106 handler *handlers.TVHandler 107} 108 109// NewTVCommand creates a new [TVCommand] with the given handler 110func NewTVCommand(handler *handlers.TVHandler) *TVCommand { 111 return &TVCommand{handler: handler} 112} 113 114func (c *TVCommand) Create() *cobra.Command { 115 root := &cobra.Command{Use: "tv", Short: "Manage TV show watch queue"} 116 117 addCmd := &cobra.Command{ 118 Use: "add [search query...]", 119 Short: "Search and add TV show to watch queue", 120 Long: `Search for TV shows and add them to your watch queue. 121 122By default, shows search results in a simple list format where you can select by number. 123Use the -i flag for an interactive interface with navigation keys.`, 124 RunE: func(cmd *cobra.Command, args []string) error { 125 if len(args) == 0 { 126 return fmt.Errorf("search query cannot be empty") 127 } 128 interactive, _ := cmd.Flags().GetBool("interactive") 129 query := strings.Join(args, " ") 130 131 return c.handler.SearchAndAdd(cmd.Context(), query, interactive) 132 }, 133 } 134 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for TV show selection") 135 root.AddCommand(addCmd) 136 137 root.AddCommand(&cobra.Command{ 138 Use: "list [--all|--queued|--watching|--watched]", 139 Short: "List TV shows in queue with status filtering", 140 RunE: func(cmd *cobra.Command, args []string) error { 141 var status string 142 if len(args) > 0 { 143 switch args[0] { 144 case "--all": 145 status = "" 146 case "--queued": 147 status = "queued" 148 case "--watching": 149 status = "watching" 150 case "--watched": 151 status = "watched" 152 default: 153 return fmt.Errorf("invalid status filter: %s (use: --all, --queued, --watching, --watched)", args[0]) 154 } 155 } 156 157 return c.handler.List(cmd.Context(), status) 158 }, 159 }) 160 161 root.AddCommand(&cobra.Command{ 162 Use: "watching [id]", 163 Short: "Mark TV show as currently watching", 164 Args: cobra.ExactArgs(1), 165 RunE: func(cmd *cobra.Command, args []string) error { 166 return c.handler.MarkTVShowWatching(cmd.Context(), args[0]) 167 }, 168 }) 169 170 root.AddCommand(&cobra.Command{ 171 Use: "watched [id]", 172 Short: "Mark TV show/episodes as watched", 173 Aliases: []string{"seen"}, 174 Args: cobra.ExactArgs(1), 175 RunE: func(cmd *cobra.Command, args []string) error { 176 return c.handler.MarkWatched(cmd.Context(), args[0]) 177 }, 178 }) 179 180 root.AddCommand(&cobra.Command{ 181 Use: "remove [id]", 182 Short: "Remove TV show from queue", 183 Aliases: []string{"rm"}, 184 Args: cobra.ExactArgs(1), 185 RunE: func(cmd *cobra.Command, args []string) error { 186 return c.handler.Remove(cmd.Context(), args[0]) 187 }, 188 }) 189 190 return root 191} 192 193// BookCommand implements [CommandGroup] for book-related commands 194type BookCommand struct { 195 handler *handlers.BookHandler 196} 197 198// NewBookCommand creates a new [BookCommand] with the given handler 199func NewBookCommand(handler *handlers.BookHandler) *BookCommand { 200 return &BookCommand{handler: handler} 201} 202 203func (c *BookCommand) Create() *cobra.Command { 204 root := &cobra.Command{Use: "book", Short: "Manage reading list"} 205 206 addCmd := &cobra.Command{ 207 Use: "add [search query...]", 208 Short: "Search and add book to reading list", 209 Long: `Search for books and add them to your reading list. 210 211By default, shows search results in a simple list format where you can select by number. 212Use the -i flag for an interactive interface with navigation keys.`, 213 RunE: func(cmd *cobra.Command, args []string) error { 214 interactive, _ := cmd.Flags().GetBool("interactive") 215 return c.handler.SearchAndAdd(cmd.Context(), args, interactive) 216 }, 217 } 218 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection") 219 root.AddCommand(addCmd) 220 221 root.AddCommand(&cobra.Command{ 222 Use: "list [--all|--reading|--finished|--queued]", 223 Short: "Show reading queue with progress", 224 RunE: func(cmd *cobra.Command, args []string) error { 225 var status string 226 if len(args) > 0 { 227 switch args[0] { 228 case "--all": 229 status = "" 230 case "--reading": 231 status = "reading" 232 case "--finished": 233 status = "finished" 234 case "--queued": 235 status = "queued" 236 default: 237 return fmt.Errorf("invalid status filter: %s (use: --all, --reading, --finished, --queued)", args[0]) 238 } 239 } 240 return c.handler.List(cmd.Context(), status) 241 }, 242 }) 243 244 root.AddCommand(&cobra.Command{ 245 Use: "reading <id>", 246 Short: "Mark book as currently reading", 247 Args: cobra.ExactArgs(1), 248 RunE: func(cmd *cobra.Command, args []string) error { 249 return c.handler.UpdateStatus(cmd.Context(), args[0], "reading") 250 }, 251 }) 252 253 root.AddCommand(&cobra.Command{ 254 Use: "finished <id>", 255 Short: "Mark book as completed", 256 Aliases: []string{"read"}, 257 Args: cobra.ExactArgs(1), 258 RunE: func(cmd *cobra.Command, args []string) error { 259 return c.handler.UpdateStatus(cmd.Context(), args[0], "finished") 260 }, 261 }) 262 263 root.AddCommand(&cobra.Command{ 264 Use: "remove <id>", 265 Short: "Remove from reading list", 266 Aliases: []string{"rm"}, 267 Args: cobra.ExactArgs(1), 268 RunE: func(cmd *cobra.Command, args []string) error { 269 return c.handler.UpdateStatus(cmd.Context(), args[0], "removed") 270 }, 271 }) 272 273 root.AddCommand(&cobra.Command{ 274 Use: "progress <id> <percentage>", 275 Short: "Update reading progress percentage (0-100)", 276 Args: cobra.ExactArgs(2), 277 RunE: func(cmd *cobra.Command, args []string) error { 278 progress, err := strconv.Atoi(args[1]) 279 if err != nil { 280 return fmt.Errorf("invalid progress percentage: %s", args[1]) 281 } 282 return c.handler.UpdateProgress(cmd.Context(), args[0], progress) 283 }, 284 }) 285 286 root.AddCommand(&cobra.Command{ 287 Use: "update <id> <status>", 288 Short: "Update book status (queued|reading|finished|removed)", 289 Args: cobra.ExactArgs(2), 290 RunE: func(cmd *cobra.Command, args []string) error { 291 return c.handler.UpdateStatus(cmd.Context(), args[0], args[1]) 292 }, 293 }) 294 295 return root 296} 297 298// NoteCommand implements [CommandGroup] for note-related commands 299type NoteCommand struct { 300 handler *handlers.NoteHandler 301} 302 303// NewNoteCommand creates a new NoteCommand with the given handler 304func NewNoteCommand(handler *handlers.NoteHandler) *NoteCommand { 305 return &NoteCommand{handler: handler} 306} 307 308func (c *NoteCommand) Create() *cobra.Command { 309 root := &cobra.Command{Use: "note", Short: "Manage notes"} 310 311 createCmd := &cobra.Command{ 312 Use: "create [title] [content...]", 313 Short: "Create a new note", 314 Aliases: []string{"new"}, 315 RunE: func(cmd *cobra.Command, args []string) error { 316 interactive, _ := cmd.Flags().GetBool("interactive") 317 editor, _ := cmd.Flags().GetBool("editor") 318 filePath, _ := cmd.Flags().GetString("file") 319 320 var title, content string 321 if len(args) > 0 { 322 title = args[0] 323 } 324 if len(args) > 1 { 325 content = strings.Join(args[1:], " ") 326 } 327 328 defer c.handler.Close() 329 return c.handler.CreateWithOptions(cmd.Context(), title, content, filePath, interactive, editor) 330 }, 331 } 332 createCmd.Flags().BoolP("interactive", "i", false, "Open interactive editor") 333 createCmd.Flags().BoolP("editor", "e", false, "Prompt to open note in editor after creation") 334 createCmd.Flags().StringP("file", "f", "", "Create note from markdown file") 335 root.AddCommand(createCmd) 336 337 listCmd := &cobra.Command{ 338 Use: "list [--archived] [--static] [--tags=tag1,tag2]", 339 Short: "Opens interactive TUI browser for navigating and viewing notes", 340 Aliases: []string{"ls"}, 341 RunE: func(cmd *cobra.Command, args []string) error { 342 archived, _ := cmd.Flags().GetBool("archived") 343 static, _ := cmd.Flags().GetBool("static") 344 tagsStr, _ := cmd.Flags().GetString("tags") 345 346 var tags []string 347 if tagsStr != "" { 348 tags = strings.Split(tagsStr, ",") 349 for i := range tags { 350 tags[i] = strings.TrimSpace(tags[i]) 351 } 352 } 353 354 defer c.handler.Close() 355 return c.handler.List(cmd.Context(), static, archived, tags) 356 }, 357 } 358 listCmd.Flags().BoolP("archived", "a", false, "Show archived notes") 359 listCmd.Flags().BoolP("static", "s", false, "Show static list instead of interactive TUI") 360 listCmd.Flags().String("tags", "", "Filter by tags (comma-separated)") 361 root.AddCommand(listCmd) 362 363 root.AddCommand(&cobra.Command{ 364 Use: "read [note-id]", 365 Short: "Display formatted note content with syntax highlighting", 366 Aliases: []string{"view"}, 367 Args: cobra.ExactArgs(1), 368 RunE: func(cmd *cobra.Command, args []string) error { 369 if noteID, err := parseID("note", args); err != nil { 370 return err 371 } else { 372 defer c.handler.Close() 373 return c.handler.View(cmd.Context(), noteID) 374 } 375 }, 376 }) 377 378 root.AddCommand(&cobra.Command{ 379 Use: "edit [note-id]", 380 Short: "Edit note in configured editor", 381 Args: cobra.ExactArgs(1), 382 RunE: func(cmd *cobra.Command, args []string) error { 383 if noteID, err := parseID("note", args); err != nil { 384 return err 385 } else { 386 defer c.handler.Close() 387 return c.handler.Edit(cmd.Context(), noteID) 388 } 389 }, 390 }) 391 392 root.AddCommand(&cobra.Command{ 393 Use: "remove [note-id]", 394 Short: "Permanently removes the note file and metadata", 395 Aliases: []string{"rm", "delete", "del"}, 396 Args: cobra.ExactArgs(1), 397 RunE: func(cmd *cobra.Command, args []string) error { 398 if noteID, err := parseID("note", args); err != nil { 399 return err 400 } else { 401 defer c.handler.Close() 402 return c.handler.Delete(cmd.Context(), noteID) 403 } 404 }, 405 }) 406 407 return root 408} 409 410// ArticleCommand implements [CommandGroup] for article-related commands 411type ArticleCommand struct { 412 handler *handlers.ArticleHandler 413} 414 415// NewArticleCommand creates a new ArticleCommand with the given handler 416func NewArticleCommand(handler *handlers.ArticleHandler) *ArticleCommand { 417 return &ArticleCommand{handler: handler} 418} 419 420func (c *ArticleCommand) Create() *cobra.Command { 421 root := &cobra.Command{Use: "article", Short: "Manage saved articles"} 422 423 addCmd := &cobra.Command{ 424 Use: "add <url>", 425 Short: "Parse and save article from URL", 426 Long: `Parse and save article content from a supported website. 427 428The article will be parsed using domain-specific XPath rules and saved 429as both Markdown and HTML files. Article metadata is stored in the database.`, 430 Args: cobra.ExactArgs(1), 431 RunE: func(cmd *cobra.Command, args []string) error { 432 433 defer c.handler.Close() 434 return c.handler.Add(cmd.Context(), args[0]) 435 }, 436 } 437 root.AddCommand(addCmd) 438 439 listCmd := &cobra.Command{ 440 Use: "list [query]", 441 Short: "List saved articles", 442 Aliases: []string{"ls"}, 443 Long: `List saved articles with optional filtering. 444 445Use query to filter by title, or use flags for more specific filtering.`, 446 RunE: func(cmd *cobra.Command, args []string) error { 447 author, _ := cmd.Flags().GetString("author") 448 limit, _ := cmd.Flags().GetInt("limit") 449 450 var query string 451 if len(args) > 0 { 452 query = strings.Join(args, " ") 453 } 454 455 defer c.handler.Close() 456 return c.handler.List(cmd.Context(), query, author, limit) 457 }, 458 } 459 listCmd.Flags().String("author", "", "Filter by author") 460 listCmd.Flags().IntP("limit", "l", 0, "Limit number of results (0 = no limit)") 461 root.AddCommand(listCmd) 462 463 viewCmd := &cobra.Command{ 464 Use: "view <id>", 465 Short: "View article details and content preview", 466 Aliases: []string{"show"}, 467 Args: cobra.ExactArgs(1), 468 RunE: func(cmd *cobra.Command, args []string) error { 469 if articleID, err := parseID("article", args); err != nil { 470 return err 471 } else { 472 defer c.handler.Close() 473 return c.handler.View(cmd.Context(), articleID) 474 } 475 }, 476 } 477 root.AddCommand(viewCmd) 478 479 readCmd := &cobra.Command{ 480 Use: "read <id>", 481 Short: "Read article content with formatted markdown", 482 Long: `Read the full markdown content of an article with beautiful formatting. 483 484This displays the complete article content using syntax highlighting and proper formatting.`, 485 Args: cobra.ExactArgs(1), 486 RunE: func(cmd *cobra.Command, args []string) error { 487 if articleID, err := parseID("article", args); err != nil { 488 return err 489 } else { 490 defer c.handler.Close() 491 return c.handler.Read(cmd.Context(), articleID) 492 } 493 }, 494 } 495 root.AddCommand(readCmd) 496 497 removeCmd := &cobra.Command{ 498 Use: "remove <id>", 499 Short: "Remove article and associated files", 500 Aliases: []string{"rm", "delete"}, 501 Args: cobra.ExactArgs(1), 502 RunE: func(cmd *cobra.Command, args []string) error { 503 if articleID, err := parseID("article", args); err != nil { 504 return err 505 } else { 506 defer c.handler.Close() 507 return c.handler.Remove(cmd.Context(), articleID) 508 } 509 }, 510 } 511 root.AddCommand(removeCmd) 512 513 originalHelpFunc := root.HelpFunc() 514 root.SetHelpFunc(func(cmd *cobra.Command, args []string) { 515 originalHelpFunc(cmd, args) 516 517 fmt.Println() 518 defer c.handler.Close() 519 c.handler.Help() 520 }) 521 522 return root 523} 524 525// ConfigCommand implements [CommandGroup] for configuration management commands 526type ConfigCommand struct { 527 handler *handlers.ConfigHandler 528} 529 530// NewConfigCommand creates a new [ConfigCommand] with the given handler 531func NewConfigCommand(handler *handlers.ConfigHandler) *ConfigCommand { 532 return &ConfigCommand{handler: handler} 533} 534 535func (c *ConfigCommand) Create() *cobra.Command { 536 root := &cobra.Command{ 537 Use: "config", 538 Short: "Manage noteleaf configuration", 539 } 540 541 root.AddCommand(&cobra.Command{ 542 Use: "get [key]", 543 Short: "Get configuration value(s)", 544 Long: `Display configuration values. 545 546If no key is provided, displays all configuration values. 547Otherwise, displays the value for the specified key.`, 548 Args: cobra.MaximumNArgs(1), 549 RunE: func(cmd *cobra.Command, args []string) error { 550 var key string 551 if len(args) > 0 { 552 key = args[0] 553 } 554 return c.handler.Get(key) 555 }, 556 }) 557 558 root.AddCommand(&cobra.Command{ 559 Use: "set <key> <value>", 560 Short: "Set configuration value", 561 Long: `Update a configuration value. 562 563Available keys: 564 database_path - Custom database file path 565 data_dir - Custom data directory 566 date_format - Date format string (default: 2006-01-02) 567 color_scheme - Color scheme (default: default) 568 default_view - Default view mode (default: list) 569 default_priority - Default task priority 570 editor - Preferred text editor 571 articles_dir - Articles storage directory 572 notes_dir - Notes storage directory 573 auto_archive - Auto-archive completed items (true/false) 574 sync_enabled - Enable synchronization (true/false) 575 sync_endpoint - Synchronization endpoint URL 576 sync_token - Synchronization token 577 export_format - Default export format (default: json) 578 movie_api_key - API key for movie database 579 book_api_key - API key for book database`, 580 Args: cobra.ExactArgs(2), 581 RunE: func(cmd *cobra.Command, args []string) error { 582 return c.handler.Set(args[0], args[1]) 583 }, 584 }) 585 586 root.AddCommand(&cobra.Command{ 587 Use: "path", 588 Short: "Show configuration file path", 589 Long: "Display the path to the configuration file being used.", 590 RunE: func(cmd *cobra.Command, args []string) error { 591 return c.handler.Path() 592 }, 593 }) 594 595 root.AddCommand(&cobra.Command{ 596 Use: "reset", 597 Short: "Reset configuration to defaults", 598 Long: "Reset all configuration values to their defaults.", 599 RunE: func(cmd *cobra.Command, args []string) error { 600 return c.handler.Reset() 601 }, 602 }) 603 604 return root 605}