cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists ๐Ÿƒ
charm leaflet readability golang

feat: note viewing commands

+819 -817
+51 -51
ROADMAP.md
··· 2 3 ## Core Task Management (TaskWarrior-inspired) 4 5 - - `list` - Display tasks with filtering and sorting options 6 - - `projects` - List all project names 7 - - `tags` - List all tag names 8 9 - - `create|new` - Add new task with description and optional metadata 10 11 - - `view` - View task by ID 12 - - `done` - Mark task as completed 13 - - `update` - Edit task properties (description, priority, project, tags) 14 - - `start/stop` - Track active time on tasks 15 - - `annotate` - Add notes/comments to existing tasks 16 17 - - `delete` - Remove task permanently 18 19 - - `calendar` - Display tasks in calendar view 20 - - `timesheet` - Show time tracking summaries 21 22 ## Todo.txt Compatibility 23 24 - - `archive` - Move completed tasks to done.txt 25 - - `[con]texts` - List all contexts (@context) 26 - - `[proj]ects` - List all projects (+project) 27 - - `[pri]ority` - Set task priority (A-Z) 28 - - `[depri]oritize` - Remove priority from task 29 - - `[re]place` - Replace task text entirely 30 - - `prepend/append` - Add text to beginning/end of task 31 32 ## Media Queue Management 33 34 - - `movie add` - Add movie to watch queue 35 - - `movie list` - Show movie queue with ratings/metadata 36 - - `movie watched|seen` - Mark movie as watched 37 - - `movie remove|rm` - Remove from queue 38 39 - - `tv add` - Add TV show/season to queue 40 - - `tv list` - Show TV queue with episode tracking 41 - - `tv watched|seen` - Mark episodes/seasons as watched 42 - - `tv remove|rm` - Remove from TV queue 43 44 ## Reading List Management 45 46 - - `book add` - Add book to reading list 47 - - `book list` - Show reading queue with progress 48 - - `book reading` - Mark book as currently reading 49 - - `book finished|read` - Mark book as completed 50 - - `book remove|rm` - Remove from reading list 51 - - `book progress` - Update reading progress percentage 52 53 ## Data Management 54 55 - - `sync` - Synchronize with remote storage 56 - - `sync setup` - Setup remote storage 57 58 - - `backup` - Create local backup 59 60 - - `import` - Import from various formats (CSV, JSON, todo.txt) 61 - - `export` - Export to various formats 62 63 - - `config` - Manage configuration settings 64 65 - - `undo` - Reverse last operation 66 67 ## Notes 68 69 - - `create|new` - Creates a new markdown note and optionally opens in configured editor 70 - Creates a note from existing markdown file content 71 - - `list` - Opens interactive TUI browser for navigating and viewing notes 72 - - `read|view` - Displays formatted note content with syntax highlighting 73 - - `edit|update` - Opens configured editor OR Replaces note content with new markdown file 74 - - `remove|rm|delete|del` - Permanently removes the note file and metadata 75 76 - - `search` - Search notes by content, title, or tags 77 - - `tag` - Add/remove tags from notes 78 - - `recent` - Show recently created/modified notes 79 - - `templates` - Create notes from predefined templates 80 - - `archive` - Archive old notes 81 - - `export` - Export notes to various formats
··· 2 3 ## Core Task Management (TaskWarrior-inspired) 4 5 + - [x] `list` - Display tasks with filtering and sorting options 6 + - [ ] `projects` - List all project names 7 + - [ ] `tags` - List all tag names 8 9 + - [x] `create|new` - Add new task with description and optional metadata 10 11 + - [x] `view` - View task by ID 12 + - [x] `done` - Mark task as completed 13 + - [x] `update` - Edit task properties (description, priority, project, tags) 14 + - [ ] `start/stop` - Track active time on tasks 15 + - [ ] `annotate` - Add notes/comments to existing tasks 16 17 + - [x] `delete` - Remove task permanently 18 19 + - [ ] `calendar` - Display tasks in calendar view 20 + - [ ] `timesheet` - Show time tracking summaries 21 22 ## Todo.txt Compatibility 23 24 + - [ ] `archive` - Move completed tasks to done.txt 25 + - [ ] `[con]texts` - List all contexts (@context) 26 + - [ ] `[proj]ects` - List all projects (+project) 27 + - [ ] `[pri]ority` - Set task priority (A-Z) 28 + - [ ] `[depri]oritize` - Remove priority from task 29 + - [ ] `[re]place` - Replace task text entirely 30 + - [ ] `prepend/append` - Add text to beginning/end of task 31 32 ## Media Queue Management 33 34 + - [ ] `movie add` - Add movie to watch queue 35 + - [ ] `movie list` - Show movie queue with ratings/metadata 36 + - [ ] `movie watched|seen` - Mark movie as watched 37 + - [ ] `movie remove|rm` - Remove from queue 38 39 + - [ ] `tv add` - Add TV show/season to queue 40 + - [ ] `tv list` - Show TV queue with episode tracking 41 + - [ ] `tv watched|seen` - Mark episodes/seasons as watched 42 + - [ ] `tv remove|rm` - Remove from TV queue 43 44 ## Reading List Management 45 46 + - [x] `book add` - Add book to reading list 47 + - [x] `book list` - Show reading queue with progress 48 + - [x] `book reading` - Mark book as currently reading 49 + - [x] `book finished|read` - Mark book as completed 50 + - [x] `book remove|rm` - Remove from reading list 51 + - [x] `book progress` - Update reading progress percentage 52 53 ## Data Management 54 55 + - [ ] `sync` - Synchronize with remote storage 56 + - [ ] `sync setup` - Setup remote storage 57 58 + - [ ] `backup` - Create local backup 59 60 + - [ ] `import` - Import from various formats (CSV, JSON, todo.txt) 61 + - [ ] `export` - Export to various formats 62 63 + - [ ] `config` - Manage configuration settings 64 65 + - [ ] `undo` - Reverse last operation 66 67 ## Notes 68 69 + - [x] `create|new` - Creates a new markdown note and optionally opens in configured editor 70 - Creates a note from existing markdown file content 71 + - [x] `list` - Opens interactive TUI browser for navigating and viewing notes 72 + - [x] `read|view` - Displays formatted note content with syntax highlighting 73 + - [x] `edit|update` - Opens configured editor OR Replaces note content with new markdown file 74 + - [x] `remove|rm|delete|del` - Permanently removes the note file and metadata 75 76 + - [ ] `search` - Search notes by content, title, or tags 77 + - [ ] `tag` - Add/remove tags from notes 78 + - [ ] `recent` - Show recently created/modified notes 79 + - [ ] `templates` - Create notes from predefined templates 80 + - [ ] `archive` - Archive old notes 81 + - [ ] `export` - Export notes to various formats
+119 -8
cmd/commands.go
··· 2 3 import ( 4 "fmt" 5 "strings" 6 7 "github.com/spf13/cobra" 8 "github.com/stormlightlabs/noteleaf/internal/handlers" ··· 12 func rootCmd() *cobra.Command { 13 return &cobra.Command{ 14 Use: "noteleaf", 15 - Long: ui.Colossal.ColoredInViewport(), 16 Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists", 17 - Run: func(cmd *cobra.Command, args []string) { 18 if len(args) == 0 { 19 - cmd.Help() 20 - } else { 21 - output := strings.Join(args, " ") 22 - fmt.Println(output) 23 } 24 }, 25 } 26 } ··· 274 Short: "Manage notes", 275 } 276 277 - root.AddCommand(&cobra.Command{ 278 Use: "create [title] [content...]", 279 Short: "Create a new note", 280 Aliases: []string{"new"}, 281 RunE: func(cmd *cobra.Command, args []string) error { 282 - return handlers.Create(cmd.Context(), args) 283 }, 284 }) 285
··· 2 3 import ( 4 "fmt" 5 + "strconv" 6 "strings" 7 + 8 + "github.com/charmbracelet/log" 9 10 "github.com/spf13/cobra" 11 "github.com/stormlightlabs/noteleaf/internal/handlers" ··· 15 func rootCmd() *cobra.Command { 16 return &cobra.Command{ 17 Use: "noteleaf", 18 + Long: ui.Georgia.ColoredInViewport(), 19 Short: "A TaskWarrior-inspired CLI with notes, media queues and reading lists", 20 + RunE: func(cmd *cobra.Command, args []string) error { 21 if len(args) == 0 { 22 + return cmd.Help() 23 } 24 + 25 + output := strings.Join(args, " ") 26 + fmt.Println(output) 27 + return nil 28 }, 29 } 30 } ··· 278 Short: "Manage notes", 279 } 280 281 + handler, err := handlers.NewNoteHandler() 282 + if err != nil { 283 + log.Fatalf("failed to instantiate note handler: %v", err) 284 + } 285 + 286 + createCmd := &cobra.Command{ 287 Use: "create [title] [content...]", 288 Short: "Create a new note", 289 Aliases: []string{"new"}, 290 RunE: func(cmd *cobra.Command, args []string) error { 291 + interactive, _ := cmd.Flags().GetBool("interactive") 292 + filePath, _ := cmd.Flags().GetString("file") 293 + 294 + var title, content string 295 + if len(args) > 0 { 296 + title = args[0] 297 + } 298 + if len(args) > 1 { 299 + content = strings.Join(args[1:], " ") 300 + } 301 + 302 + if err != nil { 303 + return err 304 + } 305 + defer handler.Close() 306 + return handler.Create(cmd.Context(), title, content, filePath, interactive) 307 + }, 308 + } 309 + createCmd.Flags().BoolP("interactive", "i", false, "Open interactive editor") 310 + createCmd.Flags().StringP("file", "f", "", "Create note from markdown file") 311 + root.AddCommand(createCmd) 312 + 313 + listCmd := &cobra.Command{ 314 + Use: "list [--archived] [--tags=tag1,tag2]", 315 + Short: "Opens interactive TUI browser for navigating and viewing notes", 316 + Aliases: []string{"ls"}, 317 + RunE: func(cmd *cobra.Command, args []string) error { 318 + archived, _ := cmd.Flags().GetBool("archived") 319 + tagsStr, _ := cmd.Flags().GetString("tags") 320 + 321 + var tags []string 322 + if tagsStr != "" { 323 + tags = strings.Split(tagsStr, ",") 324 + for i := range tags { 325 + tags[i] = strings.TrimSpace(tags[i]) 326 + } 327 + } 328 + 329 + handler, err := handlers.NewNoteHandler() 330 + if err != nil { 331 + return err 332 + } 333 + defer handler.Close() 334 + return handler.List(cmd.Context(), false, archived, tags) 335 + }, 336 + } 337 + listCmd.Flags().BoolP("archived", "a", false, "Show archived notes") 338 + listCmd.Flags().String("tags", "", "Filter by tags (comma-separated)") 339 + root.AddCommand(listCmd) 340 + 341 + root.AddCommand(&cobra.Command{ 342 + Use: "read [note-id]", 343 + Short: "Display formatted note content with syntax highlighting", 344 + Aliases: []string{"view"}, 345 + Args: cobra.ExactArgs(1), 346 + RunE: func(cmd *cobra.Command, args []string) error { 347 + noteID, err := strconv.ParseInt(args[0], 10, 64) 348 + if err != nil { 349 + return fmt.Errorf("invalid note ID: %s", args[0]) 350 + } 351 + handler, err := handlers.NewNoteHandler() 352 + if err != nil { 353 + return err 354 + } 355 + defer handler.Close() 356 + return handler.View(cmd.Context(), noteID) 357 + }, 358 + }) 359 + 360 + root.AddCommand(&cobra.Command{ 361 + Use: "edit [note-id]", 362 + Short: "Edit note in configured editor", 363 + Args: cobra.ExactArgs(1), 364 + RunE: func(cmd *cobra.Command, args []string) error { 365 + noteID, err := strconv.ParseInt(args[0], 10, 64) 366 + if err != nil { 367 + return fmt.Errorf("invalid note ID: %s", args[0]) 368 + } 369 + handler, err := handlers.NewNoteHandler() 370 + if err != nil { 371 + return err 372 + } 373 + defer handler.Close() 374 + return handler.Edit(cmd.Context(), noteID) 375 + }, 376 + }) 377 + 378 + root.AddCommand(&cobra.Command{ 379 + Use: "remove [note-id]", 380 + Short: "Permanently removes the note file and metadata", 381 + Aliases: []string{"rm", "delete", "del"}, 382 + Args: cobra.ExactArgs(1), 383 + RunE: func(cmd *cobra.Command, args []string) error { 384 + noteID, err := strconv.ParseInt(args[0], 10, 64) 385 + if err != nil { 386 + return fmt.Errorf("invalid note ID: %s", args[0]) 387 + } 388 + handler, err := handlers.NewNoteHandler() 389 + if err != nil { 390 + return err 391 + } 392 + defer handler.Close() 393 + return handler.Delete(cmd.Context(), noteID) 394 }, 395 }) 396
+13 -1
go.mod
··· 17 ) 18 19 require ( 20 github.com/atotto/clipboard v0.1.4 // indirect 21 github.com/catppuccin/go v0.3.0 // indirect 22 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 23 github.com/dustin/go-humanize v1.0.1 // indirect 24 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 25 github.com/mattn/go-localereader v0.0.1 // indirect 26 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 27 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 28 golang.org/x/sync v0.13.0 // indirect 29 ) 30 31 require ( 32 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 33 github.com/charmbracelet/bubbles v0.21.0 34 github.com/charmbracelet/colorprofile v0.3.1 // indirect 35 - github.com/charmbracelet/lipgloss v1.1.0 36 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 37 github.com/charmbracelet/log v0.4.2 38 github.com/charmbracelet/x/ansi v0.9.3 // indirect
··· 17 ) 18 19 require ( 20 + github.com/alecthomas/chroma/v2 v2.14.0 // indirect 21 github.com/atotto/clipboard v0.1.4 // indirect 22 + github.com/aymerick/douceur v0.2.0 // indirect 23 github.com/catppuccin/go v0.3.0 // indirect 24 + github.com/charmbracelet/glamour v0.10.0 25 + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect 26 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 27 + github.com/dlclark/regexp2 v1.11.0 // indirect 28 github.com/dustin/go-humanize v1.0.1 // indirect 29 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 30 + github.com/gorilla/css v1.0.1 // indirect 31 github.com/mattn/go-localereader v0.0.1 // indirect 32 + github.com/microcosm-cc/bluemonday v1.0.27 // indirect 33 github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 34 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 35 + github.com/muesli/reflow v0.3.0 // indirect 36 + github.com/yuin/goldmark v1.7.8 // indirect 37 + github.com/yuin/goldmark-emoji v1.0.5 // indirect 38 + golang.org/x/net v0.33.0 // indirect 39 golang.org/x/sync v0.13.0 // indirect 40 + golang.org/x/term v0.31.0 // indirect 41 ) 42 43 require ( 44 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 45 github.com/charmbracelet/bubbles v0.21.0 46 github.com/charmbracelet/colorprofile v0.3.1 // indirect 47 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 48 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 49 github.com/charmbracelet/log v0.4.2 50 github.com/charmbracelet/x/ansi v0.9.3 // indirect
+29
go.sum
··· 2 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 4 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 5 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 6 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 7 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 8 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 9 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 10 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 11 github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 12 github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 13 github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= ··· 18 github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 19 github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA= 20 github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M= 21 github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= 22 github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= 23 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 24 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 25 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= 26 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= 27 github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= ··· 38 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= 39 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 40 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 41 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 42 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 43 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= ··· 51 github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 52 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 53 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 54 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 55 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 56 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= ··· 59 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 60 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 61 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 62 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 63 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 64 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= ··· 67 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 68 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 69 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 70 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 71 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 72 github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 73 github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 74 github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 75 github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 76 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= ··· 83 github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= 84 github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 85 github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 86 github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 87 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 88 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 89 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 90 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 91 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 92 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 93 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 94 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= ··· 101 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 102 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 103 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 104 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 105 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 106 golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 107 golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 108 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 109 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 110 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 111 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 112 golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 113 golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 114 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
··· 2 github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= 4 github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 5 + github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 6 + github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 7 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 8 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 9 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 10 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 11 github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 12 github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 13 + github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 14 + github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 15 github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= 16 github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= 17 github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= ··· 22 github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0= 23 github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA= 24 github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M= 25 + github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY= 26 + github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk= 27 github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc= 28 github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk= 29 github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 30 github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 31 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= 32 + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= 33 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU= 34 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc= 35 github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= ··· 46 github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= 47 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= 48 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 49 + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= 50 + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 51 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= 52 github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= 53 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= ··· 61 github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 62 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 63 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 64 + github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 65 + github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 66 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 67 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 68 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= ··· 71 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 72 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 73 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 74 + github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 75 + github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 76 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 77 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 78 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= ··· 81 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 82 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 83 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 84 + github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 85 github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 86 github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 87 github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= 88 github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 89 + github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 90 + github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 91 github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= 92 github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= 93 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= ··· 100 github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= 101 github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= 102 github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= 103 + github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 104 + github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 105 github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 106 github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= 107 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 108 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 109 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 110 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 111 + github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 112 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 113 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 114 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= ··· 121 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 122 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 123 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 124 + github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 125 + github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= 126 + github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= 127 + github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= 128 + github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 129 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 130 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 131 + golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 132 + golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 133 golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 134 golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 135 golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 136 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 137 golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 138 golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 139 + golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 140 + golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 141 golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 142 golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 143 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
+110 -45
internal/handlers/notes.go
··· 6 "os" 7 "os/exec" 8 "path/filepath" 9 - "strconv" 10 "strings" 11 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 "github.com/stormlightlabs/noteleaf/internal/repo" 14 "github.com/stormlightlabs/noteleaf/internal/store" 15 "github.com/stormlightlabs/noteleaf/internal/utils" 16 ) 17 ··· 54 return nil 55 } 56 57 - // Create handles note creation subcommands 58 - func Create(ctx context.Context, args []string) error { 59 - handler, err := NewNoteHandler() 60 - if err != nil { 61 - return err 62 - } 63 - defer handler.Close() 64 - 65 - if len(args) == 0 { 66 - return handler.createInteractive(ctx) 67 - } 68 - 69 - if len(args) == 1 && isFile(args[0]) { 70 - return handler.createFromFile(ctx, args[0]) 71 } 72 73 - title := args[0] 74 - content := "" 75 - if len(args) > 1 { 76 - content = strings.Join(args[1:], " ") 77 } 78 79 - return handler.createFromArgs(ctx, title, content) 80 - } 81 - 82 - // New is an alias for Create 83 - func New(ctx context.Context, args []string) error { 84 - return Create(ctx, args) 85 } 86 87 // Edit handles note editing by ID 88 - func Edit(ctx context.Context, args []string) error { 89 - if len(args) != 1 { 90 - return fmt.Errorf("edit requires exactly one argument: note ID") 91 - } 92 93 - id, err := strconv.ParseInt(args[0], 10, 64) 94 - if err != nil { 95 - return fmt.Errorf("invalid note ID: %s", args[0]) 96 - } 97 98 - handler, err := NewNoteHandler() 99 - if err != nil { 100 - return err 101 - } 102 - defer handler.Close() 103 104 - return handler.editNote(ctx, id) 105 } 106 107 func (h *NoteHandler) createInteractive(ctx context.Context) error { ··· 371 return content.String() 372 } 373 374 - func isFile(arg string) bool { 375 - if filepath.Ext(arg) != "" { 376 - return true 377 } 378 379 - if info, err := os.Stat(arg); err == nil && !info.IsDir() { 380 - return true 381 } 382 383 - return strings.Contains(arg, "/") || strings.Contains(arg, "\\") 384 }
··· 6 "os" 7 "os/exec" 8 "path/filepath" 9 "strings" 10 11 + "github.com/charmbracelet/glamour" 12 "github.com/stormlightlabs/noteleaf/internal/models" 13 "github.com/stormlightlabs/noteleaf/internal/repo" 14 "github.com/stormlightlabs/noteleaf/internal/store" 15 + "github.com/stormlightlabs/noteleaf/internal/ui" 16 "github.com/stormlightlabs/noteleaf/internal/utils" 17 ) 18 ··· 55 return nil 56 } 57 58 + // Create handles note creation with optional title, content, and file path 59 + func (h *NoteHandler) Create(ctx context.Context, title string, content string, filePath string, interactive bool) error { 60 + if interactive || (title == "" && content == "" && filePath == "") { 61 + return h.createInteractive(ctx) 62 } 63 64 + if filePath != "" { 65 + return h.createFromFile(ctx, filePath) 66 } 67 68 + return h.createFromArgs(ctx, title, content) 69 } 70 71 // Edit handles note editing by ID 72 + func (h *NoteHandler) Edit(ctx context.Context, noteID int64) error { 73 + return h.editNote(ctx, noteID) 74 + } 75 76 + // View displays a note with formatted markdown content 77 + func (h *NoteHandler) View(ctx context.Context, noteID int64) error { 78 + return h.viewNote(ctx, noteID) 79 + } 80 81 + // List opens either an interactive TUI browser for navigating and viewing notes or a static list 82 + func (h *NoteHandler) List(ctx context.Context, static, showArchived bool, tags []string) error { 83 + return h.listNotes(ctx, showArchived, tags, static) 84 + } 85 86 + // Delete permanently removes a note and its metadata 87 + func (h *NoteHandler) Delete(ctx context.Context, noteID int64) error { 88 + return h.deleteNote(ctx, noteID) 89 } 90 91 func (h *NoteHandler) createInteractive(ctx context.Context) error { ··· 355 return content.String() 356 } 357 358 + func (h *NoteHandler) viewNote(ctx context.Context, id int64) error { 359 + note, err := h.repos.Notes.Get(ctx, id) 360 + if err != nil { 361 + return fmt.Errorf("failed to get note: %w", err) 362 + } 363 + 364 + renderer, err := glamour.NewTermRenderer( 365 + glamour.WithAutoStyle(), 366 + glamour.WithWordWrap(80), 367 + ) 368 + if err != nil { 369 + return fmt.Errorf("failed to create markdown renderer: %w", err) 370 + } 371 + 372 + content := h.formatNoteForView(note) 373 + rendered, err := renderer.Render(content) 374 + if err != nil { 375 + return fmt.Errorf("failed to render markdown: %w", err) 376 + } 377 + 378 + fmt.Print(rendered) 379 + return nil 380 + } 381 + 382 + func (h *NoteHandler) formatNoteForView(note *models.Note) string { 383 + var content strings.Builder 384 + 385 + content.WriteString("# " + note.Title + "\n\n") 386 + 387 + if len(note.Tags) > 0 { 388 + content.WriteString("**Tags:** ") 389 + for i, tag := range note.Tags { 390 + if i > 0 { 391 + content.WriteString(", ") 392 + } 393 + content.WriteString("`" + tag + "`") 394 + } 395 + content.WriteString("\n\n") 396 + } 397 + 398 + content.WriteString("**Created:** " + note.Created.Format("2006-01-02 15:04") + "\n") 399 + content.WriteString("**Modified:** " + note.Modified.Format("2006-01-02 15:04") + "\n\n") 400 + content.WriteString("---\n\n") 401 + 402 + noteContent := strings.TrimSpace(note.Content) 403 + if !strings.HasPrefix(noteContent, "# ") { 404 + content.WriteString(noteContent) 405 + } else { 406 + lines := strings.Split(noteContent, "\n") 407 + if len(lines) > 1 { 408 + content.WriteString(strings.Join(lines[1:], "\n")) 409 + } 410 } 411 412 + return content.String() 413 + } 414 + 415 + func (h *NoteHandler) listNotes(ctx context.Context, showArchived bool, tags []string, static bool) error { 416 + opts := ui.NoteListOptions{ 417 + Output: os.Stdout, 418 + Input: os.Stdin, 419 + Static: static, 420 + ShowArchived: showArchived, 421 + Tags: tags, 422 } 423 424 + noteList := ui.NewNoteList(h.repos.Notes, opts) 425 + return noteList.Browse(ctx) 426 + } 427 + 428 + func (h *NoteHandler) deleteNote(ctx context.Context, id int64) error { 429 + note, err := h.repos.Notes.Get(ctx, id) 430 + if err != nil { 431 + return fmt.Errorf("failed to find note: %w", err) 432 + } 433 + 434 + if note.FilePath != "" { 435 + if err := os.Remove(note.FilePath); err != nil && !os.IsNotExist(err) { 436 + return fmt.Errorf("failed to remove note file %s: %w", note.FilePath, err) 437 + } 438 + } 439 + 440 + if err := h.repos.Notes.Delete(ctx, id); err != nil { 441 + return fmt.Errorf("failed to delete note from database: %w", err) 442 + } 443 + 444 + fmt.Printf("Note deleted (ID: %d): %s\n", note.ID, note.Title) 445 + if note.FilePath != "" { 446 + fmt.Printf("File removed: %s\n", note.FilePath) 447 + } 448 + return nil 449 }
+177 -711
internal/handlers/notes_test.go
··· 43 } 44 45 func TestNoteHandler(t *testing.T) { 46 t.Run("New", func(t *testing.T) { 47 t.Run("creates handler successfully", func(t *testing.T) { 48 - _, cleanup := setupNoteTest(t) 49 - defer cleanup() 50 - 51 - handler, err := NewNoteHandler() 52 if err != nil { 53 t.Fatalf("NewNoteHandler failed: %v", err) 54 } 55 - if handler == nil { 56 t.Fatal("Handler should not be nil") 57 } 58 - defer handler.Close() 59 60 - if handler.db == nil { 61 t.Error("Handler database should not be nil") 62 } 63 - if handler.config == nil { 64 t.Error("Handler config should not be nil") 65 } 66 - if handler.repos == nil { 67 t.Error("Handler repos should not be nil") 68 } 69 }) ··· 96 }) 97 }) 98 99 - t.Run("parse content", func(t *testing.T) { 100 - handler := &NoteHandler{} 101 - 102 - testCases := []struct { 103 - name string 104 - input string 105 - expectedTitle string 106 - expectedContent string 107 - expectedTags []string 108 - }{ 109 - { 110 - name: "note with title and tags", 111 - input: `# My Test Note 112 - 113 - This is the content. 114 - 115 - <!-- Tags: personal, work, important -->`, 116 - expectedTitle: "My Test Note", 117 - expectedContent: `# My Test Note 118 - 119 - This is the content. 120 - 121 - <!-- Tags: personal, work, important -->`, 122 - expectedTags: []string{"personal", "work", "important"}, 123 - }, 124 - { 125 - name: "note without title", 126 - input: `Just some content here. 127 - 128 - No title heading. 129 - 130 - <!-- Tags: test -->`, 131 - expectedTitle: "", 132 - expectedContent: `Just some content here. 133 - 134 - No title heading. 135 - 136 - <!-- Tags: test -->`, 137 - expectedTags: []string{"test"}, 138 - }, 139 - { 140 - name: "note without tags", 141 - input: `# Title Only 142 - 143 - Content without tags.`, 144 - expectedTitle: "Title Only", 145 - expectedContent: `# Title Only 146 - 147 - Content without tags.`, 148 - expectedTags: nil, 149 - }, 150 - { 151 - name: "empty tags comment", 152 - input: `# Test Note 153 - 154 - Content here. 155 - 156 - <!-- Tags: -->`, 157 - expectedTitle: "Test Note", 158 - expectedContent: `# Test Note 159 - 160 - Content here. 161 - 162 - <!-- Tags: -->`, 163 - expectedTags: nil, 164 - }, 165 - { 166 - name: "malformed tags comment", 167 - input: `# Test Note 168 - 169 - Content here. 170 - 171 - <!-- Tags: tag1, , tag2,, tag3 -->`, 172 - expectedTitle: "Test Note", 173 - expectedContent: `# Test Note 174 - 175 - Content here. 176 - 177 - <!-- Tags: tag1, , tag2,, tag3 -->`, 178 - expectedTags: []string{"tag1", "tag2", "tag3"}, 179 - }, 180 - { 181 - name: "multiple headings", 182 - input: `## Secondary Heading 183 - 184 - # Main Title 185 - 186 - Content here.`, 187 - expectedTitle: "Main Title", 188 - expectedContent: `## Secondary Heading 189 - 190 - # Main Title 191 - 192 - Content here.`, 193 - expectedTags: nil, 194 - }, 195 - } 196 - 197 - for _, tc := range testCases { 198 - t.Run(tc.name, func(t *testing.T) { 199 - title, content, tags := handler.parseNoteContent(tc.input) 200 - 201 - if title != tc.expectedTitle { 202 - t.Errorf("Expected title %q, got %q", tc.expectedTitle, title) 203 - } 204 - 205 - if content != tc.expectedContent { 206 - t.Errorf("Expected content %q, got %q", tc.expectedContent, content) 207 - } 208 - 209 - if len(tags) != len(tc.expectedTags) { 210 - t.Errorf("Expected %d tags, got %d", len(tc.expectedTags), len(tags)) 211 - } 212 - 213 - for i, expectedTag := range tc.expectedTags { 214 - if i >= len(tags) || tags[i] != expectedTag { 215 - t.Errorf("Expected tag %q at position %d, got %q", expectedTag, i, tags[i]) 216 - } 217 - } 218 - }) 219 - } 220 - }) 221 - 222 - t.Run("IsFile helper", func(t *testing.T) { 223 - testCases := []struct { 224 - name string 225 - input string 226 - expected bool 227 - }{ 228 - {"file with extension", "test.md", true}, 229 - {"file with multiple extensions", "test.tar.gz", true}, 230 - {"path with slash", "/path/to/file", true}, 231 - {"path with backslash", "path\\to\\file", true}, 232 - {"relative path", "./file", true}, 233 - {"just text", "hello", false}, 234 - {"empty string", "", false}, 235 - } 236 - 237 - tempDir, err := os.MkdirTemp("", "isfile-test-*") 238 - if err != nil { 239 - t.Fatalf("Failed to create temp dir: %v", err) 240 - } 241 - defer os.RemoveAll(tempDir) 242 - 243 - existingFile := filepath.Join(tempDir, "existing") 244 - err = os.WriteFile(existingFile, []byte("test"), 0644) 245 - if err != nil { 246 - t.Fatalf("Failed to create test file: %v", err) 247 - } 248 - 249 - testCases = append(testCases, struct { 250 - name string 251 - input string 252 - expected bool 253 - }{"existing file without extension", existingFile, true}) 254 - 255 - for _, tc := range testCases { 256 - t.Run(tc.name, func(t *testing.T) { 257 - result := isFile(tc.input) 258 - if result != tc.expected { 259 - t.Errorf("isFile(%q) = %v, expected %v", tc.input, result, tc.expected) 260 - } 261 - }) 262 - } 263 - }) 264 - 265 - t.Run("getEditor", func(t *testing.T) { 266 - handler := &NoteHandler{} 267 - 268 - t.Run("uses EDITOR environment variable", func(t *testing.T) { 269 - originalEditor := os.Getenv("EDITOR") 270 - os.Setenv("EDITOR", "test-editor") 271 - defer os.Setenv("EDITOR", originalEditor) 272 - 273 - editor := handler.getEditor() 274 - if editor != "test-editor" { 275 - t.Errorf("Expected 'test-editor', got %q", editor) 276 - } 277 - }) 278 - 279 - t.Run("finds available editor", func(t *testing.T) { 280 - originalEditor := os.Getenv("EDITOR") 281 - os.Unsetenv("EDITOR") 282 - defer os.Setenv("EDITOR", originalEditor) 283 - 284 - editor := handler.getEditor() 285 - if editor == "" { 286 - t.Skip("No common editors found on system, skipping test") 287 - } 288 - }) 289 - 290 - t.Run("returns empty when no editor available", func(t *testing.T) { 291 - originalEditor := os.Getenv("EDITOR") 292 - originalPath := os.Getenv("PATH") 293 - 294 - os.Unsetenv("EDITOR") 295 - os.Setenv("PATH", "") 296 - 297 - defer func() { 298 - os.Setenv("EDITOR", originalEditor) 299 - os.Setenv("PATH", originalPath) 300 - }() 301 - 302 - editor := handler.getEditor() 303 - if editor != "" { 304 - t.Errorf("Expected empty string when no editor available, got %q", editor) 305 - } 306 - }) 307 - }) 308 - 309 - t.Run("Create Errors", func(t *testing.T) { 310 - errorTests := []struct { 311 - name string 312 - setupFunc func(t *testing.T) (cleanup func()) 313 - args []string 314 - expectError bool 315 - errorSubstr string 316 - }{ 317 - { 318 - name: "database initialization error", 319 - setupFunc: func(t *testing.T) func() { 320 - if runtime.GOOS == "windows" { 321 - original := os.Getenv("APPDATA") 322 - os.Unsetenv("APPDATA") 323 - return func() { os.Setenv("APPDATA", original) } 324 - } else { 325 - originalXDG := os.Getenv("XDG_CONFIG_HOME") 326 - originalHome := os.Getenv("HOME") 327 - os.Unsetenv("XDG_CONFIG_HOME") 328 - os.Unsetenv("HOME") 329 - return func() { 330 - os.Setenv("XDG_CONFIG_HOME", originalXDG) 331 - os.Setenv("HOME", originalHome) 332 - } 333 - } 334 - }, 335 - args: []string{"Test Note"}, 336 - expectError: true, 337 - errorSubstr: "failed to initialize database", 338 - }, 339 - { 340 - name: "note creation in database fails", 341 - setupFunc: func(t *testing.T) func() { 342 - tempDir, cleanup := setupNoteTest(t) 343 - 344 - configDir := filepath.Join(tempDir, "noteleaf") 345 - dbPath := filepath.Join(configDir, "noteleaf.db") 346 - 347 - err := os.WriteFile(dbPath, []byte("invalid sqlite content"), 0644) 348 - if err != nil { 349 - t.Fatalf("Failed to corrupt database: %v", err) 350 - } 351 352 - return cleanup 353 - }, 354 - args: []string{"Test Note"}, 355 - expectError: true, 356 - errorSubstr: "failed to initialize database", 357 - }, 358 - } 359 - 360 - for _, tt := range errorTests { 361 - t.Run(tt.name, func(t *testing.T) { 362 - cleanup := tt.setupFunc(t) 363 - defer cleanup() 364 - 365 - oldStdin := os.Stdin 366 - r, w, _ := os.Pipe() 367 - os.Stdin = r 368 - defer func() { os.Stdin = oldStdin }() 369 - 370 - go func() { 371 - w.WriteString("n\n") 372 - w.Close() 373 - }() 374 - 375 - ctx := context.Background() 376 - err := Create(ctx, tt.args) 377 - 378 - if tt.expectError && err == nil { 379 - t.Errorf("Expected error containing %q, got nil", tt.errorSubstr) 380 - } else if !tt.expectError && err != nil { 381 - t.Errorf("Expected no error, got: %v", err) 382 - } else if tt.expectError && err != nil && !strings.Contains(err.Error(), tt.errorSubstr) { 383 - t.Errorf("Expected error containing %q, got: %v", tt.errorSubstr, err) 384 - } 385 - }) 386 - } 387 - }) 388 - 389 - t.Run("Create (args)", func(t *testing.T) { 390 t.Run("creates note from title only", func(t *testing.T) { 391 - _, cleanup := setupNoteTest(t) 392 - defer cleanup() 393 - 394 - oldStdin := os.Stdin 395 - r, w, _ := os.Pipe() 396 - os.Stdin = r 397 - defer func() { os.Stdin = oldStdin }() 398 - 399 - go func() { 400 - w.WriteString("n\n") 401 - w.Close() 402 - }() 403 - 404 - ctx := context.Background() 405 - err := Create(ctx, []string{"Test Note"}) 406 if err != nil { 407 t.Errorf("Create failed: %v", err) 408 } 409 }) 410 411 t.Run("creates note from title and content", func(t *testing.T) { 412 - _, cleanup := setupNoteTest(t) 413 - defer cleanup() 414 - 415 - oldStdin := os.Stdin 416 - r, w, _ := os.Pipe() 417 - os.Stdin = r 418 - defer func() { os.Stdin = oldStdin }() 419 - 420 - go func() { 421 - w.WriteString("n\n") 422 - w.Close() 423 - }() 424 - 425 - ctx := context.Background() 426 - err := Create(ctx, []string{"Test Note", "This", "is", "test", "content"}) 427 if err != nil { 428 t.Errorf("Create failed: %v", err) 429 } 430 }) 431 432 - t.Run("handles database connection error", func(t *testing.T) { 433 - tempDir, cleanup := setupNoteTest(t) 434 - defer cleanup() 435 - 436 - configDir := filepath.Join(tempDir, "noteleaf") 437 - dbPath := filepath.Join(configDir, "noteleaf.db") 438 - os.Remove(dbPath) 439 - 440 - os.MkdirAll(dbPath, 0755) 441 - defer os.RemoveAll(dbPath) 442 - 443 - ctx := context.Background() 444 - err := Create(ctx, []string{"Test Note"}) 445 - if err == nil { 446 - t.Error("Create should fail when database is inaccessible") 447 - } 448 - }) 449 - 450 - t.Run("New is alias for Create", func(t *testing.T) { 451 - _, cleanup := setupNoteTest(t) 452 - defer cleanup() 453 - 454 - oldStdin := os.Stdin 455 - r, w, _ := os.Pipe() 456 - os.Stdin = r 457 - defer func() { os.Stdin = oldStdin }() 458 - 459 - go func() { 460 - w.WriteString("n\n") 461 - w.Close() 462 - }() 463 - 464 - ctx := context.Background() 465 - err := New(ctx, []string{"Test Note via New"}) 466 - if err != nil { 467 - t.Errorf("New failed: %v", err) 468 - } 469 - }) 470 - }) 471 - 472 - t.Run("Create from file", func(t *testing.T) { 473 t.Run("creates note from markdown file", func(t *testing.T) { 474 - tempDir, cleanup := setupNoteTest(t) 475 - defer cleanup() 476 - 477 content := `# My Test Note 478 - 479 - This is the content of my test note. 480 - 481 - ## Section 2 482 - 483 - More content here. 484 485 - <!-- Tags: personal, work -->` 486 - 487 filePath := createTestMarkdownFile(t, tempDir, "test.md", content) 488 489 - ctx := context.Background() 490 - err := Create(ctx, []string{filePath}) 491 if err != nil { 492 t.Errorf("Create from file failed: %v", err) 493 } 494 }) 495 496 t.Run("handles non-existent file", func(t *testing.T) { 497 - _, cleanup := setupNoteTest(t) 498 - defer cleanup() 499 - 500 - ctx := context.Background() 501 - err := Create(ctx, []string{"/non/existent/file.md"}) 502 if err == nil { 503 - t.Error("Create should fail for non-existent file") 504 - } 505 - if !strings.Contains(err.Error(), "file does not exist") { 506 - t.Errorf("Expected file not found error, got: %v", err) 507 - } 508 - }) 509 - 510 - t.Run("handles empty file", func(t *testing.T) { 511 - tempDir, cleanup := setupNoteTest(t) 512 - defer cleanup() 513 - 514 - filePath := createTestMarkdownFile(t, tempDir, "empty.md", "") 515 - 516 - ctx := context.Background() 517 - err := Create(ctx, []string{filePath}) 518 - if err == nil { 519 - t.Error("Create should fail for empty file") 520 - } 521 - if !strings.Contains(err.Error(), "file is empty") { 522 - t.Errorf("Expected empty file error, got: %v", err) 523 - } 524 - }) 525 - 526 - t.Run("handles whitespace-only file", func(t *testing.T) { 527 - tempDir, cleanup := setupNoteTest(t) 528 - defer cleanup() 529 - 530 - filePath := createTestMarkdownFile(t, tempDir, "whitespace.md", " \n\t \n ") 531 - 532 - ctx := context.Background() 533 - err := Create(ctx, []string{filePath}) 534 - if err == nil { 535 - t.Error("Create should fail for whitespace-only file") 536 - } 537 - if !strings.Contains(err.Error(), "file is empty") { 538 - t.Errorf("Expected empty file error, got: %v", err) 539 - } 540 - }) 541 - 542 - t.Run("creates note without title in file", func(t *testing.T) { 543 - tempDir, cleanup := setupNoteTest(t) 544 - defer cleanup() 545 - 546 - content := `This note has no title heading. 547 - 548 - Just some content here.` 549 - 550 - filePath := createTestMarkdownFile(t, tempDir, "notitle.md", content) 551 - 552 - ctx := context.Background() 553 - err := Create(ctx, []string{filePath}) 554 - if err != nil { 555 - t.Errorf("Create from file without title failed: %v", err) 556 } 557 }) 558 559 - t.Run("handles file read error", func(t *testing.T) { 560 - tempDir, cleanup := setupNoteTest(t) 561 - defer cleanup() 562 - 563 - filePath := createTestMarkdownFile(t, tempDir, "unreadable.md", "test content") 564 - err := os.Chmod(filePath, 0000) 565 - if err != nil { 566 - t.Fatalf("Failed to make file unreadable: %v", err) 567 - } 568 - defer os.Chmod(filePath, 0644) 569 570 - ctx := context.Background() 571 - err = Create(ctx, []string{filePath}) 572 if err == nil { 573 - t.Error("Create should fail for unreadable file") 574 } 575 - if !strings.Contains(err.Error(), "failed to read file") { 576 - t.Errorf("Expected file read error, got: %v", err) 577 } 578 }) 579 - }) 580 581 - t.Run("Interactive Create", func(t *testing.T) { 582 t.Run("handles no editor configured", func(t *testing.T) { 583 - _, cleanup := setupNoteTest(t) 584 - defer cleanup() 585 - 586 originalEditor := os.Getenv("EDITOR") 587 originalPath := os.Getenv("PATH") 588 - os.Unsetenv("EDITOR") 589 os.Setenv("PATH", "") 590 defer func() { 591 os.Setenv("EDITOR", originalEditor) 592 os.Setenv("PATH", originalPath) 593 }() 594 595 - ctx := context.Background() 596 - err := Create(ctx, []string{}) 597 if err == nil { 598 - t.Error("Create should fail when no editor is configured") 599 } 600 - if !strings.Contains(err.Error(), "no editor configured") { 601 t.Errorf("Expected no editor error, got: %v", err) 602 } 603 }) 604 605 - t.Run("handles editor command failure", func(t *testing.T) { 606 - _, cleanup := setupNoteTest(t) 607 - defer cleanup() 608 609 - originalEditor := os.Getenv("EDITOR") 610 - os.Setenv("EDITOR", "nonexistent-editor-12345") 611 - defer os.Setenv("EDITOR", originalEditor) 612 613 - ctx := context.Background() 614 - err := Create(ctx, []string{}) 615 if err == nil { 616 - t.Error("Create should fail when editor command fails") 617 } 618 - if !strings.Contains(err.Error(), "failed to open editor") { 619 - t.Errorf("Expected editor failure error, got: %v", err) 620 } 621 }) 622 623 - t.Run("creates note successfully with mocked editor", func(t *testing.T) { 624 - _, cleanup := setupNoteTest(t) 625 - defer cleanup() 626 627 - originalEditor := os.Getenv("EDITOR") 628 - os.Setenv("EDITOR", "test-editor") 629 - defer os.Setenv("EDITOR", originalEditor) 630 631 - handler, err := NewNoteHandler() 632 if err != nil { 633 - t.Fatalf("NewNoteHandler failed: %v", err) 634 } 635 - defer handler.Close() 636 637 - handler.openInEditorFunc = func(editor, filePath string) error { 638 - content := `# Test Note 639 - 640 - This is edited content. 641 - 642 - <!-- Tags: test, created -->` 643 - return os.WriteFile(filePath, []byte(content), 0644) 644 - } 645 - 646 - ctx := context.Background() 647 - err = handler.createInteractive(ctx) 648 if err != nil { 649 - t.Errorf("Interactive create failed: %v", err) 650 } 651 }) 652 653 - t.Run("handles editor cancellation", func(t *testing.T) { 654 - _, cleanup := setupNoteTest(t) 655 - defer cleanup() 656 - 657 - originalEditor := os.Getenv("EDITOR") 658 - os.Setenv("EDITOR", "test-editor") 659 - defer os.Setenv("EDITOR", originalEditor) 660 661 - handler, err := NewNoteHandler() 662 if err != nil { 663 - t.Fatalf("NewNoteHandler failed: %v", err) 664 - } 665 - defer handler.Close() 666 - 667 - handler.openInEditorFunc = func(editor, filePath string) error { 668 - return nil 669 } 670 671 - ctx := context.Background() 672 - err = handler.createInteractive(ctx) 673 if err != nil { 674 - t.Errorf("Interactive create should handle cancellation gracefully: %v", err) 675 } 676 }) 677 }) 678 679 - t.Run("Close", func(t *testing.T) { 680 - _, cleanup := setupNoteTest(t) 681 - defer cleanup() 682 - 683 - handler, err := NewNoteHandler() 684 - if err != nil { 685 - t.Fatalf("NewNoteHandler failed: %v", err) 686 - } 687 - 688 - err = handler.Close() 689 - if err != nil { 690 - t.Errorf("Close should not return error: %v", err) 691 - } 692 - 693 - handler.db = nil 694 - err = handler.Close() 695 - if err != nil { 696 - t.Errorf("Close should handle nil database gracefully: %v", err) 697 - } 698 - }) 699 - 700 - t.Run("Edit", func(t *testing.T) { 701 - t.Run("validates argument count", func(t *testing.T) { 702 - _, cleanup := setupNoteTest(t) 703 - defer cleanup() 704 - 705 - ctx := context.Background() 706 - 707 - err := Edit(ctx, []string{}) 708 - if err == nil { 709 - t.Error("Edit should fail with no arguments") 710 - } 711 - if !strings.Contains(err.Error(), "edit requires exactly one argument") { 712 - t.Errorf("Expected argument count error, got: %v", err) 713 - } 714 - 715 - err = Edit(ctx, []string{"1", "2"}) 716 - if err == nil { 717 - t.Error("Edit should fail with too many arguments") 718 - } 719 - if !strings.Contains(err.Error(), "edit requires exactly one argument") { 720 - t.Errorf("Expected argument count error, got: %v", err) 721 - } 722 - }) 723 - 724 - t.Run("validates note ID format", func(t *testing.T) { 725 - _, cleanup := setupNoteTest(t) 726 - defer cleanup() 727 - 728 - ctx := context.Background() 729 - 730 - err := Edit(ctx, []string{"invalid"}) 731 - if err == nil { 732 - t.Error("Edit should fail with invalid note ID") 733 - } 734 - if !strings.Contains(err.Error(), "invalid note ID") { 735 - t.Errorf("Expected invalid ID error, got: %v", err) 736 - } 737 - 738 - err = Edit(ctx, []string{"-1"}) 739 - if err == nil { 740 - t.Error("Edit should fail with negative note ID") 741 - } 742 - 743 - if !strings.Contains(err.Error(), "failed to get note") { 744 - t.Errorf("Expected note not found error for negative ID, got: %v", err) 745 - } 746 - }) 747 748 t.Run("handles non-existent note", func(t *testing.T) { 749 - _, cleanup := setupNoteTest(t) 750 - defer cleanup() 751 - 752 - ctx := context.Background() 753 - 754 - err := Edit(ctx, []string{"999"}) 755 if err == nil { 756 - t.Error("Edit should fail with non-existent note ID") 757 } 758 - if !strings.Contains(err.Error(), "failed to get note") { 759 t.Errorf("Expected note not found error, got: %v", err) 760 } 761 }) 762 763 - t.Run("handles no editor configured", func(t *testing.T) { 764 - _, cleanup := setupNoteTest(t) 765 - defer cleanup() 766 - 767 - originalEditor := os.Getenv("EDITOR") 768 - originalPath := os.Getenv("PATH") 769 - os.Setenv("EDITOR", "") 770 - os.Setenv("PATH", "") 771 - defer func() { 772 - os.Setenv("EDITOR", originalEditor) 773 - os.Setenv("PATH", originalPath) 774 - }() 775 - 776 - ctx := context.Background() 777 - 778 - err := Create(ctx, []string{"Test Note", "Test content"}) 779 if err != nil { 780 t.Fatalf("Failed to create test note: %v", err) 781 } 782 783 - err = Edit(ctx, []string{"1"}) 784 - if err == nil { 785 - t.Error("Edit should fail when no editor is configured") 786 - } 787 - 788 - if !strings.Contains(err.Error(), "no editor configured") && !strings.Contains(err.Error(), "failed to open editor") { 789 - t.Errorf("Expected no editor or editor failure error, got: %v", err) 790 - } 791 - }) 792 - 793 - t.Run("handles editor command failure", func(t *testing.T) { 794 - _, cleanup := setupNoteTest(t) 795 - defer cleanup() 796 - 797 - originalEditor := os.Getenv("EDITOR") 798 - os.Setenv("EDITOR", "nonexistent-editor-12345") 799 - defer os.Setenv("EDITOR", originalEditor) 800 - 801 - ctx := context.Background() 802 - 803 - err := Create(ctx, []string{"Test Note", "Test content"}) 804 if err != nil { 805 - t.Fatalf("Failed to create test note: %v", err) 806 } 807 808 - err = Edit(ctx, []string{"1"}) 809 if err == nil { 810 - t.Error("Edit should fail when editor command fails") 811 - } 812 - if !strings.Contains(err.Error(), "failed to open editor") { 813 - t.Errorf("Expected editor failure error, got: %v", err) 814 } 815 }) 816 817 - t.Run("edits note successfully with mocked editor", func(t *testing.T) { 818 - _, cleanup := setupNoteTest(t) 819 - defer cleanup() 820 821 - originalEditor := os.Getenv("EDITOR") 822 - os.Setenv("EDITOR", "test-editor") 823 - defer os.Setenv("EDITOR", originalEditor) 824 - 825 - ctx := context.Background() 826 - 827 - err := Create(ctx, []string{"Original Title", "Original content"}) 828 if err != nil { 829 - t.Fatalf("Failed to create test note: %v", err) 830 } 831 832 - handler, err := NewNoteHandler() 833 if err != nil { 834 - t.Fatalf("NewNoteHandler failed: %v", err) 835 - } 836 - defer handler.Close() 837 - 838 - handler.openInEditorFunc = func(editor, filePath string) error { 839 - newContent := `# Updated Title 840 - 841 - This is updated content. 842 - 843 - <!-- Tags: updated, test -->` 844 - return os.WriteFile(filePath, []byte(newContent), 0644) 845 } 846 847 - err = handler.editNote(ctx, 1) 848 if err != nil { 849 - t.Errorf("Edit should succeed with mocked editor: %v", err) 850 } 851 852 - note, err := handler.repos.Notes.Get(ctx, 1) 853 - if err != nil { 854 - t.Fatalf("Failed to get updated note: %v", err) 855 } 856 857 - if note.Title != "Updated Title" { 858 - t.Errorf("Expected title 'Updated Title', got %q", note.Title) 859 - } 860 861 - if !strings.Contains(note.Content, "This is updated content") { 862 - t.Errorf("Expected content to contain 'This is updated content', got %q", note.Content) 863 - } 864 865 - expectedTags := []string{"updated", "test"} 866 - if len(note.Tags) != len(expectedTags) { 867 - t.Errorf("Expected %d tags, got %d", len(expectedTags), len(note.Tags)) 868 } 869 - for i, tag := range expectedTags { 870 - if i >= len(note.Tags) || note.Tags[i] != tag { 871 - t.Errorf("Expected tag %q at index %d, got %q", tag, i, note.Tags[i]) 872 - } 873 } 874 }) 875 876 - t.Run("handles editor cancellation (no changes)", func(t *testing.T) { 877 - _, cleanup := setupNoteTest(t) 878 - defer cleanup() 879 - 880 originalEditor := os.Getenv("EDITOR") 881 - os.Setenv("EDITOR", "test-editor") 882 defer os.Setenv("EDITOR", originalEditor) 883 884 - ctx := context.Background() 885 886 - err := Create(ctx, []string{"Test Note", "Test content"}) 887 - if err != nil { 888 - t.Fatalf("Failed to create test note: %v", err) 889 - } 890 891 - handler, err := NewNoteHandler() 892 - if err != nil { 893 - t.Fatalf("NewNoteHandler failed: %v", err) 894 - } 895 - defer handler.Close() 896 897 - handler.openInEditorFunc = func(editor, filePath string) error { 898 - return nil 899 - } 900 - 901 - err = handler.editNote(ctx, 1) 902 - if err != nil { 903 - t.Errorf("Edit should handle cancellation gracefully: %v", err) 904 - } 905 - 906 - note, err := handler.repos.Notes.Get(ctx, 1) 907 - if err != nil { 908 - t.Fatalf("Failed to get note: %v", err) 909 - } 910 - 911 - if note.Title != "Test Note" { 912 - t.Errorf("Expected title 'Test Note', got %q", note.Title) 913 - } 914 - 915 - if note.Content != "Test content" { 916 - t.Errorf("Expected content 'Test content', got %q", note.Content) 917 - } 918 }) 919 }) 920 }
··· 43 } 44 45 func TestNoteHandler(t *testing.T) { 46 + tempDir, cleanup := setupNoteTest(t) 47 + defer cleanup() 48 + 49 + handler, err := NewNoteHandler() 50 + if err != nil { 51 + t.Fatalf("Failed to create note handler: %v", err) 52 + } 53 + defer handler.Close() 54 + 55 t.Run("New", func(t *testing.T) { 56 t.Run("creates handler successfully", func(t *testing.T) { 57 + testHandler, err := NewNoteHandler() 58 if err != nil { 59 t.Fatalf("NewNoteHandler failed: %v", err) 60 } 61 + if testHandler == nil { 62 t.Fatal("Handler should not be nil") 63 } 64 + defer testHandler.Close() 65 66 + if testHandler.db == nil { 67 t.Error("Handler database should not be nil") 68 } 69 + if testHandler.config == nil { 70 t.Error("Handler config should not be nil") 71 } 72 + if testHandler.repos == nil { 73 t.Error("Handler repos should not be nil") 74 } 75 }) ··· 102 }) 103 }) 104 105 + t.Run("Create", func(t *testing.T) { 106 + ctx := context.Background() 107 108 t.Run("creates note from title only", func(t *testing.T) { 109 + err := handler.Create(ctx, "Test Note 1", "", "", false) 110 if err != nil { 111 t.Errorf("Create failed: %v", err) 112 } 113 }) 114 115 t.Run("creates note from title and content", func(t *testing.T) { 116 + err := handler.Create(ctx, "Test Note 2", "This is test content", "", false) 117 if err != nil { 118 t.Errorf("Create failed: %v", err) 119 } 120 }) 121 122 t.Run("creates note from markdown file", func(t *testing.T) { 123 content := `# My Test Note 124 + <!-- tags: personal, work --> 125 126 + This is the content of my note.` 127 filePath := createTestMarkdownFile(t, tempDir, "test.md", content) 128 129 + err := handler.Create(ctx, "", "", filePath, false) 130 if err != nil { 131 t.Errorf("Create from file failed: %v", err) 132 } 133 }) 134 135 t.Run("handles non-existent file", func(t *testing.T) { 136 + err := handler.Create(ctx, "", "", "/non/existent/file.md", false) 137 if err == nil { 138 + t.Error("Create should fail with non-existent file") 139 } 140 }) 141 + }) 142 143 + t.Run("Edit", func(t *testing.T) { 144 + ctx := context.Background() 145 146 + t.Run("handles non-existent note", func(t *testing.T) { 147 + err := handler.Edit(ctx, 999) 148 if err == nil { 149 + t.Error("Edit should fail with non-existent note ID") 150 } 151 + if !strings.Contains(err.Error(), "failed to get note") && !strings.Contains(err.Error(), "failed to find note") { 152 + t.Errorf("Expected note not found error, got: %v", err) 153 } 154 }) 155 156 t.Run("handles no editor configured", func(t *testing.T) { 157 originalEditor := os.Getenv("EDITOR") 158 originalPath := os.Getenv("PATH") 159 + os.Setenv("EDITOR", "") 160 os.Setenv("PATH", "") 161 defer func() { 162 os.Setenv("EDITOR", originalEditor) 163 os.Setenv("PATH", originalPath) 164 }() 165 166 + err := handler.Edit(ctx, 1) 167 if err == nil { 168 + t.Error("Edit should fail when no editor is configured") 169 } 170 + if !strings.Contains(err.Error(), "no editor configured") && !strings.Contains(err.Error(), "failed to open editor") { 171 t.Errorf("Expected no editor error, got: %v", err) 172 } 173 }) 174 + }) 175 176 + t.Run("Read/View", func(t *testing.T) { 177 + ctx := context.Background() 178 179 + t.Run("views note successfully", func(t *testing.T) { 180 + err := handler.View(ctx, 1) 181 + if err != nil { 182 + t.Errorf("View should succeed: %v", err) 183 + } 184 + }) 185 186 + t.Run("handles non-existent note", func(t *testing.T) { 187 + err := handler.View(ctx, 999) 188 if err == nil { 189 + t.Error("View should fail with non-existent note ID") 190 } 191 + if !strings.Contains(err.Error(), "failed to get note") && !strings.Contains(err.Error(), "failed to find note") { 192 + t.Errorf("Expected note not found error, got: %v", err) 193 } 194 }) 195 196 + }) 197 198 + t.Run("List", func(t *testing.T) { 199 + ctx := context.Background() 200 201 + t.Run("lists with archived filter", func(t *testing.T) { 202 + err := handler.List(ctx, true, true, nil) 203 if err != nil { 204 + t.Errorf("List with archived filter should succeed: %v", err) 205 } 206 + }) 207 208 + t.Run("lists with tag filter", func(t *testing.T) { 209 + err := handler.List(ctx, true, false, []string{"work", "personal"}) 210 if err != nil { 211 + t.Errorf("List with tag filter should succeed: %v", err) 212 } 213 }) 214 215 + t.Run("handles empty note list", func(t *testing.T) { 216 + _, emptyCleanup := setupNoteTest(t) 217 + defer emptyCleanup() 218 219 + emptyHandler, err := NewNoteHandler() 220 if err != nil { 221 + t.Fatalf("Failed to create empty handler: %v", err) 222 } 223 + defer emptyHandler.Close() 224 225 + err = emptyHandler.List(ctx, true, false, nil) 226 if err != nil { 227 + t.Errorf("ListStatic should succeed with empty list: %v", err) 228 } 229 }) 230 }) 231 232 + t.Run("Delete", func(t *testing.T) { 233 + ctx := context.Background() 234 235 t.Run("handles non-existent note", func(t *testing.T) { 236 + err := handler.Delete(ctx, 999) 237 if err == nil { 238 + t.Error("Delete should fail with non-existent note ID") 239 } 240 + if !strings.Contains(err.Error(), "failed to get note") && !strings.Contains(err.Error(), "failed to find note") { 241 t.Errorf("Expected note not found error, got: %v", err) 242 } 243 }) 244 245 + t.Run("deletes note successfully", func(t *testing.T) { 246 + err := handler.Create(ctx, "Note to Delete", "This will be deleted", "", false) 247 if err != nil { 248 t.Fatalf("Failed to create test note: %v", err) 249 } 250 251 + // Delete the note (should be a high ID number since we've created many notes) 252 + err = handler.Delete(ctx, 1) 253 if err != nil { 254 + t.Errorf("Delete should succeed: %v", err) 255 } 256 257 + err = handler.View(ctx, 1) 258 if err == nil { 259 + t.Error("Note should be gone after deletion") 260 } 261 }) 262 263 + t.Run("deletes note with file path", func(t *testing.T) { 264 + filePath := createTestMarkdownFile(t, tempDir, "delete-test.md", "# Test Note\n\nTest content") 265 266 + err := handler.Create(ctx, "", "", filePath, false) 267 if err != nil { 268 + t.Fatalf("Failed to create test note from file: %v", err) 269 } 270 271 + err = handler.Create(ctx, "File Note to Delete", "", "", false) 272 if err != nil { 273 + t.Fatalf("Failed to create file note: %v", err) 274 } 275 276 + err = handler.Delete(ctx, 2) 277 if err != nil { 278 + t.Errorf("Delete should succeed: %v", err) 279 } 280 281 + err = handler.View(ctx, 2) 282 + if err == nil { 283 + t.Error("Note should be gone after deletion") 284 } 285 + }) 286 + }) 287 288 + t.Run("Close", func(t *testing.T) { 289 + testHandler, err := NewNoteHandler() 290 + if err != nil { 291 + t.Fatalf("Failed to create test handler: %v", err) 292 + } 293 294 + err = testHandler.Close() 295 + if err != nil { 296 + t.Errorf("Close should succeed: %v", err) 297 + } 298 + }) 299 300 + t.Run("Helper Methods", func(t *testing.T) { 301 + t.Run("parseNoteContent", func(t *testing.T) { 302 + tests := []struct { 303 + name string 304 + content string 305 + expectedTitle string 306 + expectedContent string 307 + expectedTags []string 308 + }{ 309 + { 310 + name: "note with title and tags", 311 + content: "# My Note\n<!-- tags: work, personal -->\n\nContent here", 312 + expectedTitle: "My Note", 313 + expectedContent: "# My Note\n<!-- tags: work, personal -->\n\nContent here", 314 + expectedTags: nil, 315 + }, 316 + { 317 + name: "note without title", 318 + content: "Just some content without title", 319 + expectedTitle: "", 320 + expectedContent: "Just some content without title", 321 + expectedTags: nil, 322 + }, 323 + { 324 + name: "note without tags", 325 + content: "# Title Only\n\nContent here", 326 + expectedTitle: "Title Only", 327 + expectedContent: "# Title Only\n\nContent here", 328 + expectedTags: nil, 329 + }, 330 } 331 + 332 + for _, tt := range tests { 333 + t.Run(tt.name, func(t *testing.T) { 334 + title, content, tags := handler.parseNoteContent(tt.content) 335 + if title != tt.expectedTitle { 336 + t.Errorf("Expected title %q, got %q", tt.expectedTitle, title) 337 + } 338 + if content != tt.expectedContent { 339 + t.Errorf("Expected content %q, got %q", tt.expectedContent, content) 340 + } 341 + if len(tags) != len(tt.expectedTags) { 342 + t.Errorf("Expected %d tags, got %d", len(tt.expectedTags), len(tags)) 343 + } 344 + for i, tag := range tt.expectedTags { 345 + if i < len(tags) && tags[i] != tag { 346 + t.Errorf("Expected tag %q, got %q", tag, tags[i]) 347 + } 348 + } 349 + }) 350 } 351 }) 352 353 + t.Run("getEditor", func(t *testing.T) { 354 originalEditor := os.Getenv("EDITOR") 355 defer os.Setenv("EDITOR", originalEditor) 356 357 + t.Run("uses EDITOR environment variable", func(t *testing.T) { 358 + os.Setenv("EDITOR", "test-editor") 359 + editor := handler.getEditor() 360 + if editor != "test-editor" { 361 + t.Errorf("Expected 'test-editor', got %q", editor) 362 + } 363 + }) 364 365 + t.Run("finds available editor", func(t *testing.T) { 366 + os.Unsetenv("EDITOR") 367 + editor := handler.getEditor() 368 + if editor == "" { 369 + t.Skip("No editors available in PATH") 370 + } 371 + }) 372 373 + t.Run("returns empty when no editor available", func(t *testing.T) { 374 + os.Unsetenv("EDITOR") 375 + originalPath := os.Getenv("PATH") 376 + os.Setenv("PATH", "") 377 + defer os.Setenv("PATH", originalPath) 378 379 + editor := handler.getEditor() 380 + if editor != "" { 381 + t.Errorf("Expected empty editor, got %q", editor) 382 + } 383 + }) 384 }) 385 }) 386 }
+319
internal/ui/note_list.go
···
··· 1 + // FIXME: this module is missing test coverage 2 + package ui 3 + 4 + import ( 5 + "context" 6 + "fmt" 7 + "io" 8 + "os" 9 + "strings" 10 + 11 + tea "github.com/charmbracelet/bubbletea" 12 + "github.com/charmbracelet/glamour" 13 + "github.com/charmbracelet/lipgloss" 14 + "github.com/stormlightlabs/noteleaf/internal/models" 15 + "github.com/stormlightlabs/noteleaf/internal/repo" 16 + ) 17 + 18 + // NoteListOptions configures the note list UI behavior 19 + type NoteListOptions struct { 20 + // Output destination (stdout for interactive, buffer for testing) 21 + Output io.Writer 22 + // Input source (stdin for interactive, strings reader for testing) 23 + Input io.Reader 24 + // Enable static mode (no interactive components) 25 + Static bool 26 + // Show archived notes 27 + ShowArchived bool 28 + // Filter by tags 29 + Tags []string 30 + } 31 + 32 + // NoteList handles note browsing and viewing UI 33 + type NoteList struct { 34 + repo *repo.NoteRepository 35 + opts NoteListOptions 36 + } 37 + 38 + // NewNoteList creates a new note list UI component 39 + func NewNoteList(repo *repo.NoteRepository, opts NoteListOptions) *NoteList { 40 + if opts.Output == nil { 41 + opts.Output = os.Stdout 42 + } 43 + if opts.Input == nil { 44 + opts.Input = os.Stdin 45 + } 46 + return &NoteList{repo: repo, opts: opts} 47 + } 48 + 49 + type noteListModel struct { 50 + notes []*models.Note 51 + selected int 52 + viewing bool 53 + viewContent string 54 + err error 55 + repo *repo.NoteRepository 56 + opts NoteListOptions 57 + currentPage int 58 + } 59 + 60 + type notesLoadedMsg []*models.Note 61 + type noteViewMsg string 62 + type errorNoteMsg error 63 + 64 + func (m noteListModel) Init() tea.Cmd { 65 + return m.loadNotes() 66 + } 67 + 68 + func (m noteListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 69 + switch msg := msg.(type) { 70 + case tea.KeyMsg: 71 + if m.viewing { 72 + switch msg.String() { 73 + case "q", "esc", "backspace": 74 + m.viewing = false 75 + m.viewContent = "" 76 + return m, nil 77 + } 78 + return m, nil 79 + } 80 + 81 + switch msg.String() { 82 + case "ctrl+c", "q": 83 + return m, tea.Quit 84 + case "up", "k": 85 + if m.selected > 0 { 86 + m.selected-- 87 + } 88 + case "down", "j": 89 + if m.selected < len(m.notes)-1 { 90 + m.selected++ 91 + } 92 + case "enter", "v": 93 + if len(m.notes) > 0 && m.selected < len(m.notes) { 94 + return m, m.viewNote(m.notes[m.selected]) 95 + } 96 + case "r": 97 + return m, m.loadNotes() 98 + } 99 + case notesLoadedMsg: 100 + m.notes = []*models.Note(msg) 101 + case noteViewMsg: 102 + m.viewContent = string(msg) 103 + m.viewing = true 104 + case errorNoteMsg: 105 + m.err = error(msg) 106 + } 107 + return m, nil 108 + } 109 + 110 + func (m noteListModel) View() string { 111 + var s strings.Builder 112 + 113 + style := lipgloss.NewStyle().Foreground(lipgloss.Color("86")) 114 + titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Bold(true) 115 + selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true) 116 + headerStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true) 117 + 118 + if m.viewing { 119 + s.WriteString(m.viewContent) 120 + s.WriteString("\n\n") 121 + s.WriteString(style.Render("Press q/esc/backspace to return to list")) 122 + return s.String() 123 + } 124 + 125 + s.WriteString(titleStyle.Render("Notes")) 126 + s.WriteString("\n\n") 127 + 128 + if m.err != nil { 129 + s.WriteString(fmt.Sprintf("Error: %s", m.err)) 130 + return s.String() 131 + } 132 + 133 + if len(m.notes) == 0 { 134 + s.WriteString("No notes found") 135 + s.WriteString("\n\n") 136 + s.WriteString(style.Render("Press r to refresh, q to quit")) 137 + return s.String() 138 + } 139 + 140 + headerLine := fmt.Sprintf("%-4s %-30s %-20s %-15s", "ID", "Title", "Tags", "Modified") 141 + s.WriteString(headerStyle.Render(headerLine)) 142 + s.WriteString("\n") 143 + s.WriteString(headerStyle.Render(strings.Repeat("โ”€", 70))) 144 + s.WriteString("\n") 145 + 146 + for i, note := range m.notes { 147 + prefix := " " 148 + if i == m.selected { 149 + prefix = "> " 150 + } 151 + 152 + title := note.Title 153 + if len(title) > 28 { 154 + title = title[:25] + "..." 155 + } 156 + 157 + tags := "" 158 + if len(note.Tags) > 0 { 159 + tags = strings.Join(note.Tags, ", ") 160 + if len(tags) > 18 { 161 + tags = tags[:15] + "..." 162 + } 163 + } 164 + 165 + modified := note.Modified.Format("2006-01-02 15:04") 166 + 167 + line := fmt.Sprintf("%s%-4d %-30s %-20s %-15s", 168 + prefix, note.ID, title, tags, modified) 169 + 170 + if i == m.selected { 171 + s.WriteString(selectedStyle.Render(line)) 172 + } else { 173 + s.WriteString(style.Render(line)) 174 + } 175 + s.WriteString("\n") 176 + } 177 + 178 + s.WriteString("\n") 179 + s.WriteString(style.Render("Use โ†‘/โ†“ to navigate, Enter/v to view, r to refresh, q to quit")) 180 + 181 + return s.String() 182 + } 183 + 184 + func (m noteListModel) loadNotes() tea.Cmd { 185 + return func() tea.Msg { 186 + opts := repo.NoteListOptions{ 187 + Tags: m.opts.Tags, 188 + } 189 + if !m.opts.ShowArchived { 190 + archived := false 191 + opts.Archived = &archived 192 + } 193 + 194 + notes, err := m.repo.List(context.Background(), opts) 195 + if err != nil { 196 + return errorNoteMsg(err) 197 + } 198 + 199 + return notesLoadedMsg(notes) 200 + } 201 + } 202 + 203 + func (m noteListModel) viewNote(note *models.Note) tea.Cmd { 204 + return func() tea.Msg { 205 + renderer, err := glamour.NewTermRenderer( 206 + glamour.WithAutoStyle(), 207 + glamour.WithWordWrap(80), 208 + ) 209 + if err != nil { 210 + return errorNoteMsg(fmt.Errorf("failed to create markdown renderer: %w", err)) 211 + } 212 + 213 + content := m.formatNoteForView(note) 214 + rendered, err := renderer.Render(content) 215 + if err != nil { 216 + return errorNoteMsg(fmt.Errorf("failed to render markdown: %w", err)) 217 + } 218 + 219 + return noteViewMsg(rendered) 220 + } 221 + } 222 + 223 + func (m noteListModel) formatNoteForView(note *models.Note) string { 224 + var content strings.Builder 225 + 226 + content.WriteString("# " + note.Title + "\n\n") 227 + 228 + if len(note.Tags) > 0 { 229 + content.WriteString("**Tags:** ") 230 + for i, tag := range note.Tags { 231 + if i > 0 { 232 + content.WriteString(", ") 233 + } 234 + content.WriteString("`" + tag + "`") 235 + } 236 + content.WriteString("\n\n") 237 + } 238 + 239 + content.WriteString("**Created:** " + note.Created.Format("2006-01-02 15:04") + "\n") 240 + content.WriteString("**Modified:** " + note.Modified.Format("2006-01-02 15:04") + "\n\n") 241 + content.WriteString("---\n\n") 242 + 243 + noteContent := strings.TrimSpace(note.Content) 244 + if !strings.HasPrefix(noteContent, "# ") { 245 + content.WriteString(noteContent) 246 + } else { 247 + lines := strings.Split(noteContent, "\n") 248 + if len(lines) > 1 { 249 + content.WriteString(strings.Join(lines[1:], "\n")) 250 + } 251 + } 252 + 253 + return content.String() 254 + } 255 + 256 + // Browse opens an interactive TUI for navigating and viewing notes 257 + func (nl *NoteList) Browse(ctx context.Context) error { 258 + if nl.opts.Static { 259 + return nl.staticList(ctx) 260 + } 261 + 262 + model := noteListModel{ 263 + repo: nl.repo, 264 + opts: nl.opts, 265 + currentPage: 1, 266 + } 267 + 268 + program := tea.NewProgram(model, tea.WithInput(nl.opts.Input), tea.WithOutput(nl.opts.Output)) 269 + 270 + _, err := program.Run() 271 + return err 272 + } 273 + 274 + func (nl *NoteList) staticList(ctx context.Context) error { 275 + opts := repo.NoteListOptions{ 276 + Tags: nl.opts.Tags, 277 + } 278 + if !nl.opts.ShowArchived { 279 + archived := false 280 + opts.Archived = &archived 281 + } 282 + 283 + notes, err := nl.repo.List(ctx, opts) 284 + if err != nil { 285 + fmt.Fprintf(nl.opts.Output, "Error: %s\n", err) 286 + return err 287 + } 288 + 289 + fmt.Fprintf(nl.opts.Output, "Notes\n\n") 290 + 291 + if len(notes) == 0 { 292 + fmt.Fprintf(nl.opts.Output, "No notes found\n") 293 + return nil 294 + } 295 + 296 + fmt.Fprintf(nl.opts.Output, "%-4s %-30s %-20s %-15s\n", "ID", "Title", "Tags", "Modified") 297 + fmt.Fprintf(nl.opts.Output, "%s\n", strings.Repeat("โ”€", 70)) 298 + 299 + for _, note := range notes { 300 + title := note.Title 301 + if len(title) > 28 { 302 + title = title[:25] + "..." 303 + } 304 + 305 + tags := "" 306 + if len(note.Tags) > 0 { 307 + tags = strings.Join(note.Tags, ", ") 308 + if len(tags) > 18 { 309 + tags = tags[:15] + "..." 310 + } 311 + } 312 + 313 + modified := note.Modified.Format("2006-01-02 15:04") 314 + 315 + fmt.Fprintf(nl.opts.Output, "%-4d %-30s %-20s %-15s\n", note.ID, title, tags, modified) 316 + } 317 + 318 + return nil 319 + }
+1 -1
justfile
··· 22 # Build the binary to /tmp/ 23 build: 24 mkdir -p ./tmp/ 25 - go build -o ./tmp/noteleaf ./cmd/cli/ 26 @echo "Binary built: ./tmp/noteleaf" 27 28 # Clean build artifacts
··· 22 # Build the binary to /tmp/ 23 build: 24 mkdir -p ./tmp/ 25 + go build -o ./tmp/noteleaf ./cmd/ 26 @echo "Binary built: ./tmp/noteleaf" 27 28 # Clean build artifacts