cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
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}