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