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
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}