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