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