cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
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
12// CommandGroup represents a group of related CLI commands
13type CommandGroup interface {
14 Create() *cobra.Command
15}
16
17// MovieCommand implements [CommandGroup] for movie-related commands
18type MovieCommand struct {
19 handler *handlers.MovieHandler
20}
21
22// NewMovieCommand creates a new MovieCommands with the given handler
23func NewMovieCommand(handler *handlers.MovieHandler) *MovieCommand {
24 return &MovieCommand{handler: handler}
25}
26
27func (c *MovieCommand) Create() *cobra.Command {
28 root := &cobra.Command{
29 Use: "movie",
30 Short: "Manage movie watch queue",
31 Long: `Track movies you want to watch.
32
33Search for movies and add them to your queue. Mark movies as watched
34when completed. Maintains a history of your movie watching activity.`,
35 }
36
37 // TODO: add colors
38 // TODO: fix critic score parsing
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 // TODO: add interactive list view
60 root.AddCommand(&cobra.Command{
61 Use: "list [--all|--watched|--queued]",
62 Short: "List movies in queue with status filtering",
63 Long: `Display movies in your queue with optional status filters.
64
65Shows movie titles, release years, and current status. Filter by --all to show
66everything, --watched for completed movies, or --queued for unwatched items.
67Default shows queued movies only.`,
68 RunE: func(cmd *cobra.Command, args []string) error {
69 var status string
70 if len(args) > 0 {
71 switch args[0] {
72 case "--all":
73 status = ""
74 case "--watched":
75 status = "watched"
76 case "--queued":
77 status = "queued"
78 default:
79 return fmt.Errorf("invalid status filter: %s (use: --all, --watched, --queued)", args[0])
80 }
81 }
82
83 return c.handler.List(cmd.Context(), status)
84 },
85 })
86
87 root.AddCommand(&cobra.Command{
88 Use: "watched [id]",
89 Short: "Mark movie as watched",
90 Aliases: []string{"seen"},
91 Long: "Mark a movie as watched with current timestamp. Moves the movie from queued to watched status.",
92 Args: cobra.ExactArgs(1),
93 RunE: func(cmd *cobra.Command, args []string) error {
94 return c.handler.MarkWatched(cmd.Context(), args[0])
95 },
96 })
97
98 root.AddCommand(&cobra.Command{
99 Use: "remove [id]",
100 Short: "Remove movie from queue",
101 Aliases: []string{"rm"},
102 Long: "Remove a movie from your watch queue. Use this for movies you no longer want to track.",
103 Args: cobra.ExactArgs(1),
104 RunE: func(cmd *cobra.Command, args []string) error {
105 return c.handler.Remove(cmd.Context(), args[0])
106 },
107 })
108 return root
109}
110
111// TVCommand implements [CommandGroup] for TV show-related commands
112type TVCommand struct {
113 handler *handlers.TVHandler
114}
115
116// NewTVCommand creates a new [TVCommand] with the given handler
117func NewTVCommand(handler *handlers.TVHandler) *TVCommand {
118 return &TVCommand{handler: handler}
119}
120
121func (c *TVCommand) Create() *cobra.Command {
122 root := &cobra.Command{
123 Use: "tv",
124 Short: "Manage TV show watch queue",
125 Long: `Track TV shows and episodes.
126
127Search for TV shows and add them to your queue. Track which shows you're currently
128watching, mark episodes as watched, and maintain a complete history of your viewing
129activity.`,
130 }
131
132 addCmd := &cobra.Command{
133 Use: "add [search query...]",
134 Short: "Search and add TV show to watch queue",
135 Long: `Search for TV shows and add them to your watch queue.
136
137By default, shows search results in a simple list format where you can select by number.
138Use the -i flag for an interactive interface with navigation keys.`,
139 RunE: func(cmd *cobra.Command, args []string) error {
140 if len(args) == 0 {
141 return fmt.Errorf("search query cannot be empty")
142 }
143 interactive, _ := cmd.Flags().GetBool("interactive")
144 query := strings.Join(args, " ")
145
146 return c.handler.SearchAndAdd(cmd.Context(), query, interactive)
147 },
148 }
149 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for TV show selection")
150 root.AddCommand(addCmd)
151
152 // TODO: Add interactive list view
153 root.AddCommand(&cobra.Command{
154 Use: "list [--all|--queued|--watching|--watched]",
155 Short: "List TV shows in queue with status filtering",
156 Long: `Display TV shows in your queue with optional status filters.
157
158Shows show titles, air dates, and current status. Filter by --all, --queued,
159--watching for shows in progress, or --watched for completed series. Default
160shows queued shows only.`,
161 RunE: func(cmd *cobra.Command, args []string) error {
162 var status string
163 if len(args) > 0 {
164 switch args[0] {
165 case "--all":
166 status = ""
167 case "--queued":
168 status = "queued"
169 case "--watching":
170 status = "watching"
171 case "--watched":
172 status = "watched"
173 default:
174 return fmt.Errorf("invalid status filter: %s (use: --all, --queued, --watching, --watched)", args[0])
175 }
176 }
177
178 return c.handler.List(cmd.Context(), status)
179 },
180 })
181
182 root.AddCommand(&cobra.Command{
183 Use: "watching [id]",
184 Short: "Mark TV show as currently watching",
185 Long: "Mark a TV show as currently watching. Use this when you start watching a series.",
186 Args: cobra.ExactArgs(1),
187 RunE: func(cmd *cobra.Command, args []string) error {
188 return c.handler.MarkTVShowWatching(cmd.Context(), args[0])
189 },
190 })
191
192 root.AddCommand(&cobra.Command{
193 Use: "watched [id]",
194 Short: "Mark TV show/episodes as watched",
195 Aliases: []string{"seen"},
196 Long: `Mark TV show episodes or entire series as watched.
197
198Updates episode tracking and completion status. Can mark individual episodes
199or complete seasons/series depending on ID format.`,
200 Args: cobra.ExactArgs(1),
201 RunE: func(cmd *cobra.Command, args []string) error {
202 return c.handler.MarkWatched(cmd.Context(), args[0])
203 },
204 })
205
206 root.AddCommand(&cobra.Command{
207 Use: "remove [id]",
208 Short: "Remove TV show from queue",
209 Aliases: []string{"rm"},
210 Long: "Remove a TV show from your watch queue. Use this for shows you no longer want to track.",
211 Args: cobra.ExactArgs(1),
212 RunE: func(cmd *cobra.Command, args []string) error {
213 return c.handler.Remove(cmd.Context(), args[0])
214 },
215 })
216
217 return root
218}
219
220// BookCommand implements [CommandGroup] for book-related commands
221type BookCommand struct {
222 handler *handlers.BookHandler
223}
224
225// NewBookCommand creates a new [BookCommand] with the given handler
226func NewBookCommand(handler *handlers.BookHandler) *BookCommand {
227 return &BookCommand{handler: handler}
228}
229
230func (c *BookCommand) Create() *cobra.Command {
231 root := &cobra.Command{
232 Use: "book",
233 Short: "Manage reading list",
234 Long: `Track books and reading progress.
235
236Search Google Books API to add books to your reading list. Track which books
237you're reading, update progress percentages, and maintain a history of finished
238books.`,
239 }
240
241 addCmd := &cobra.Command{
242 Use: "add [search query...]",
243 Short: "Search and add book to reading list",
244 Long: `Search for books and add them to your reading list.
245
246By default, shows search results in a simple list format where you can select by number.
247Use the -i flag for an interactive interface with navigation keys.`,
248 RunE: func(cmd *cobra.Command, args []string) error {
249 interactive, _ := cmd.Flags().GetBool("interactive")
250 query := strings.Join(args, " ")
251 return c.handler.SearchAndAdd(cmd.Context(), query, interactive)
252 },
253 }
254 addCmd.Flags().BoolP("interactive", "i", false, "Use interactive interface for book selection")
255 root.AddCommand(addCmd)
256
257 root.AddCommand(&cobra.Command{
258 Use: "list [--all|--reading|--finished|--queued]",
259 Short: "Show reading queue with progress",
260 Long: `Display books in your reading list with progress indicators.
261
262Shows book titles, authors, and reading progress percentages. Filter by --all,
263--reading for books in progress, --finished for completed books, or --queued
264for books not yet started. Default shows queued books only.`,
265 RunE: func(cmd *cobra.Command, args []string) error {
266 var status string
267 if len(args) > 0 {
268 switch args[0] {
269 case "--all":
270 status = ""
271 case "--reading":
272 status = "reading"
273 case "--finished":
274 status = "finished"
275 case "--queued":
276 status = "queued"
277 default:
278 return fmt.Errorf("invalid status filter: %s (use: --all, --reading, --finished, --queued)", args[0])
279 }
280 }
281 return c.handler.List(cmd.Context(), status)
282 },
283 })
284
285 root.AddCommand(&cobra.Command{
286 Use: "reading <id>",
287 Short: "Mark book as currently reading",
288 Long: "Mark a book as currently reading. Use this when you start a book from your queue.",
289 Args: cobra.ExactArgs(1),
290 RunE: func(cmd *cobra.Command, args []string) error {
291 return c.handler.UpdateStatus(cmd.Context(), args[0], "reading")
292 },
293 })
294
295 root.AddCommand(&cobra.Command{
296 Use: "finished <id>",
297 Short: "Mark book as completed",
298 Aliases: []string{"read"},
299 Long: "Mark a book as finished with current timestamp. Sets reading progress to 100%.",
300 Args: cobra.ExactArgs(1),
301 RunE: func(cmd *cobra.Command, args []string) error {
302 return c.handler.UpdateStatus(cmd.Context(), args[0], "finished")
303 },
304 })
305
306 root.AddCommand(&cobra.Command{
307 Use: "remove <id>",
308 Short: "Remove from reading list",
309 Aliases: []string{"rm"},
310 Long: "Remove a book from your reading list. Use this for books you no longer want to track.",
311 Args: cobra.ExactArgs(1),
312 RunE: func(cmd *cobra.Command, args []string) error {
313 return c.handler.UpdateStatus(cmd.Context(), args[0], "removed")
314 },
315 })
316
317 root.AddCommand(&cobra.Command{
318 Use: "progress <id> <percentage>",
319 Short: "Update reading progress percentage (0-100)",
320 Long: `Set reading progress for a book.
321
322Specify a percentage value between 0 and 100 to indicate how far you've
323progressed through the book. Automatically updates status to 'reading' if not
324already set.`,
325 Args: cobra.ExactArgs(2),
326 RunE: func(cmd *cobra.Command, args []string) error {
327 progress, err := strconv.Atoi(args[1])
328 if err != nil {
329 return fmt.Errorf("invalid progress percentage: %s", args[1])
330 }
331 return c.handler.UpdateProgress(cmd.Context(), args[0], progress)
332 },
333 })
334
335 root.AddCommand(&cobra.Command{
336 Use: "update <id> <status>",
337 Short: "Update book status (queued|reading|finished|removed)",
338 Long: `Change a book's status directly.
339
340Valid statuses are: queued (not started), reading (in progress), finished
341(completed), or removed (no longer tracking).`,
342 Args: cobra.ExactArgs(2),
343 RunE: func(cmd *cobra.Command, args []string) error {
344 return c.handler.UpdateStatus(cmd.Context(), args[0], args[1])
345 },
346 })
347
348 return root
349}
350
351// NoteCommand implements [CommandGroup] for note-related commands
352type NoteCommand struct {
353 handler *handlers.NoteHandler
354}
355
356// NewNoteCommand creates a new [NoteCommand] with the given handler
357func NewNoteCommand(handler *handlers.NoteHandler) *NoteCommand {
358 return &NoteCommand{handler: handler}
359}
360
361func (c *NoteCommand) Create() *cobra.Command {
362 root := &cobra.Command{
363 Use: "note",
364 Short: "Manage notes",
365 Long: `Create and organize markdown notes with tags.
366
367Write notes in markdown format, organize them with tags, browse them in an
368interactive TUI, and edit them in your preferred editor. Notes are stored as
369files on disk with metadata tracked in the database.`,
370 }
371
372 createCmd := &cobra.Command{
373 Use: "create [title] [content...]",
374 Short: "Create a new note",
375 Aliases: []string{"new"},
376 Long: `Create a new markdown note.
377
378Provide a title and optional content inline, or use --interactive to open an
379editor. Use --file to import content from an existing markdown file. Notes
380support tags for organization and full-text search.
381
382Examples:
383 noteleaf note create "Meeting notes" "Discussed project timeline"
384 noteleaf note create -i
385 noteleaf note create --file ~/documents/draft.md`,
386 RunE: func(cmd *cobra.Command, args []string) error {
387 interactive, _ := cmd.Flags().GetBool("interactive")
388 editor, _ := cmd.Flags().GetBool("editor")
389 filePath, _ := cmd.Flags().GetString("file")
390
391 var title, content string
392 if len(args) > 0 {
393 title = args[0]
394 }
395 if len(args) > 1 {
396 content = strings.Join(args[1:], " ")
397 }
398
399 defer c.handler.Close()
400 return c.handler.CreateWithOptions(cmd.Context(), title, content, filePath, interactive, editor)
401 },
402 }
403 createCmd.Flags().BoolP("interactive", "i", false, "Open interactive editor")
404 createCmd.Flags().BoolP("editor", "e", false, "Prompt to open note in editor after creation")
405 createCmd.Flags().StringP("file", "f", "", "Create note from markdown file")
406 root.AddCommand(createCmd)
407
408 listCmd := &cobra.Command{
409 Use: "list [--archived] [--static] [--tags=tag1,tag2]",
410 Short: "Opens interactive TUI browser for navigating and viewing notes",
411 Aliases: []string{"ls"},
412 RunE: func(cmd *cobra.Command, args []string) error {
413 archived, _ := cmd.Flags().GetBool("archived")
414 static, _ := cmd.Flags().GetBool("static")
415 tagsStr, _ := cmd.Flags().GetString("tags")
416
417 var tags []string
418 if tagsStr != "" {
419 tags = strings.Split(tagsStr, ",")
420 for i := range tags {
421 tags[i] = strings.TrimSpace(tags[i])
422 }
423 }
424
425 defer c.handler.Close()
426 return c.handler.List(cmd.Context(), static, archived, tags)
427 },
428 }
429 listCmd.Flags().BoolP("archived", "a", false, "Show archived notes")
430 listCmd.Flags().BoolP("static", "s", false, "Show static list instead of interactive TUI")
431 listCmd.Flags().String("tags", "", "Filter by tags (comma-separated)")
432 root.AddCommand(listCmd)
433
434 root.AddCommand(&cobra.Command{
435 Use: "read [note-id]",
436 Short: "Display formatted note content with syntax highlighting",
437 Aliases: []string{"view"},
438 Long: `Display note content with formatted markdown rendering.
439
440Shows the note with syntax highlighting, proper formatting, and metadata.
441Useful for quick viewing without opening an editor.`,
442 Args: cobra.ExactArgs(1),
443 RunE: func(cmd *cobra.Command, args []string) error {
444 if noteID, err := handlers.ParseID(args[0], "note"); err != nil {
445 return err
446 } else {
447 defer c.handler.Close()
448 return c.handler.View(cmd.Context(), noteID)
449 }
450 },
451 })
452
453 root.AddCommand(&cobra.Command{
454 Use: "edit [note-id]",
455 Short: "Edit note in configured editor",
456 Long: `Open note in your configured text editor.
457
458Uses the editor specified in your noteleaf configuration or the EDITOR
459environment variable. Changes are automatically saved when you close the
460editor.`,
461 Args: cobra.ExactArgs(1),
462 RunE: func(cmd *cobra.Command, args []string) error {
463 if noteID, err := handlers.ParseID(args[0], "note"); err != nil {
464 return err
465 } else {
466 defer c.handler.Close()
467 return c.handler.Edit(cmd.Context(), noteID)
468 }
469 },
470 })
471
472 root.AddCommand(&cobra.Command{
473 Use: "remove [note-id]",
474 Short: "Permanently removes the note file and metadata",
475 Aliases: []string{"rm", "delete", "del"},
476 Long: `Delete a note permanently.
477
478Removes both the markdown file and database metadata. This operation cannot be
479undone. You will be prompted for confirmation before deletion.`,
480 Args: cobra.ExactArgs(1),
481 RunE: func(cmd *cobra.Command, args []string) error {
482 if noteID, err := handlers.ParseID(args[0], "note"); err != nil {
483 return err
484 } else {
485 defer c.handler.Close()
486 return c.handler.Delete(cmd.Context(), noteID)
487 }
488 },
489 })
490
491 return root
492}
493
494// ArticleCommand implements [CommandGroup] for article-related commands
495type ArticleCommand struct {
496 handler *handlers.ArticleHandler
497}
498
499// NewArticleCommand creates a new ArticleCommand with the given handler
500func NewArticleCommand(handler *handlers.ArticleHandler) *ArticleCommand {
501 return &ArticleCommand{handler: handler}
502}
503
504func (c *ArticleCommand) Create() *cobra.Command {
505 root := &cobra.Command{
506 Use: "article",
507 Short: "Manage saved articles",
508 Long: `Save and archive web articles locally.
509
510Parse articles from supported websites, extract clean content, and save as
511both markdown and HTML. Maintains a searchable archive of articles with
512metadata including author, title, and publication date.`,
513 }
514
515 addCmd := &cobra.Command{
516 Use: "add <url>",
517 Short: "Parse and save article from URL",
518 Long: `Parse and save article content from a supported website.
519
520The article will be parsed using domain-specific XPath rules and saved
521as both Markdown and HTML files. Article metadata is stored in the database.`,
522 Args: cobra.ExactArgs(1),
523 RunE: func(cmd *cobra.Command, args []string) error {
524
525 defer c.handler.Close()
526 return c.handler.Add(cmd.Context(), args[0])
527 },
528 }
529 root.AddCommand(addCmd)
530
531 listCmd := &cobra.Command{
532 Use: "list [query]",
533 Short: "List saved articles",
534 Aliases: []string{"ls"},
535 Long: `List saved articles with optional filtering.
536
537Use query to filter by title, or use flags for more specific filtering.`,
538 RunE: func(cmd *cobra.Command, args []string) error {
539 author, _ := cmd.Flags().GetString("author")
540 limit, _ := cmd.Flags().GetInt("limit")
541
542 var query string
543 if len(args) > 0 {
544 query = strings.Join(args, " ")
545 }
546
547 defer c.handler.Close()
548 return c.handler.List(cmd.Context(), query, author, limit)
549 },
550 }
551 listCmd.Flags().String("author", "", "Filter by author")
552 listCmd.Flags().IntP("limit", "l", 0, "Limit number of results (0 = no limit)")
553 root.AddCommand(listCmd)
554
555 viewCmd := &cobra.Command{
556 Use: "view <id>",
557 Short: "View article details and content preview",
558 Aliases: []string{"show"},
559 Long: `Display article metadata and summary.
560
561Shows article title, author, publication date, URL, and a brief content
562preview. Use 'read' command to view the full article content.`,
563 Args: cobra.ExactArgs(1),
564 RunE: func(cmd *cobra.Command, args []string) error {
565 if articleID, err := handlers.ParseID(args[0], "article"); err != nil {
566 return err
567 } else {
568 defer c.handler.Close()
569 return c.handler.View(cmd.Context(), articleID)
570 }
571 },
572 }
573 root.AddCommand(viewCmd)
574
575 readCmd := &cobra.Command{
576 Use: "read <id>",
577 Short: "Read article content with formatted markdown",
578 Long: `Read the full markdown content of an article with beautiful formatting.
579
580This displays the complete article content using syntax highlighting and proper formatting.`,
581 Args: cobra.ExactArgs(1),
582 RunE: func(cmd *cobra.Command, args []string) error {
583 if articleID, err := handlers.ParseID(args[0], "article"); err != nil {
584 return err
585 } else {
586 defer c.handler.Close()
587 return c.handler.Read(cmd.Context(), articleID)
588 }
589 },
590 }
591 root.AddCommand(readCmd)
592
593 removeCmd := &cobra.Command{
594 Use: "remove <id>",
595 Short: "Remove article and associated files",
596 Aliases: []string{"rm", "delete"},
597 Long: `Delete an article and its files permanently.
598
599Removes the article metadata from the database and deletes associated markdown
600and HTML files. This operation cannot be undone.`,
601 Args: cobra.ExactArgs(1),
602 RunE: func(cmd *cobra.Command, args []string) error {
603 if articleID, err := handlers.ParseID(args[0], "article"); err != nil {
604 return err
605 } else {
606 defer c.handler.Close()
607 return c.handler.Remove(cmd.Context(), articleID)
608 }
609 },
610 }
611 root.AddCommand(removeCmd)
612
613 originalHelpFunc := root.HelpFunc()
614 root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
615 originalHelpFunc(cmd, args)
616
617 fmt.Println()
618 defer c.handler.Close()
619 c.handler.Help()
620 })
621
622 return root
623}
624
625// ConfigCommand implements [CommandGroup] for configuration management commands
626type ConfigCommand struct {
627 handler *handlers.ConfigHandler
628}
629
630// NewConfigCommand creates a new [ConfigCommand] with the given handler
631func NewConfigCommand(handler *handlers.ConfigHandler) *ConfigCommand {
632 return &ConfigCommand{handler: handler}
633}
634
635func (c *ConfigCommand) Create() *cobra.Command {
636 root := &cobra.Command{
637 Use: "config",
638 Short: "Manage noteleaf configuration",
639 }
640
641 root.AddCommand(&cobra.Command{
642 Use: "get [key]",
643 Short: "Get configuration value(s)",
644 Long: `Display configuration values.
645
646If no key is provided, displays all configuration values.
647Otherwise, displays the value for the specified key.`,
648 Args: cobra.MaximumNArgs(1),
649 RunE: func(cmd *cobra.Command, args []string) error {
650 var key string
651 if len(args) > 0 {
652 key = args[0]
653 }
654 return c.handler.Get(key)
655 },
656 })
657
658 root.AddCommand(&cobra.Command{
659 Use: "set <key> <value>",
660 Short: "Set configuration value",
661 Long: `Update a configuration value.
662
663Available keys:
664 database_path - Custom database file path
665 data_dir - Custom data directory
666 date_format - Date format string (default: 2006-01-02)
667 color_scheme - Color scheme (default: default)
668 default_view - Default view mode (default: list)
669 default_priority - Default task priority
670 editor - Preferred text editor
671 articles_dir - Articles storage directory
672 notes_dir - Notes storage directory
673 auto_archive - Auto-archive completed items (true/false)
674 sync_enabled - Enable synchronization (true/false)
675 sync_endpoint - Synchronization endpoint URL
676 sync_token - Synchronization token
677 export_format - Default export format (default: json)
678 movie_api_key - API key for movie database
679 book_api_key - API key for book database`,
680 Args: cobra.ExactArgs(2),
681 RunE: func(cmd *cobra.Command, args []string) error {
682 return c.handler.Set(args[0], args[1])
683 },
684 })
685
686 root.AddCommand(&cobra.Command{
687 Use: "path",
688 Short: "Show configuration file path",
689 Long: "Display the path to the configuration file being used.",
690 RunE: func(cmd *cobra.Command, args []string) error {
691 return c.handler.Path()
692 },
693 })
694
695 root.AddCommand(&cobra.Command{
696 Use: "reset",
697 Short: "Reset configuration to defaults",
698 Long: "Reset all configuration values to their defaults.",
699 RunE: func(cmd *cobra.Command, args []string) error {
700 return c.handler.Reset()
701 },
702 })
703
704 return root
705}