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

Configure Feed

Select the types of activity you want to include in your feed.

feat: notes command

+1076
+16
cmd/cli/main.go
··· 217 217 218 218 rootCmd.AddCommand(bookCmd) 219 219 220 + noteCmd := &cobra.Command{ 221 + Use: "note", 222 + Short: "Manage notes", 223 + } 224 + 225 + noteCmd.AddCommand(&cobra.Command{ 226 + Use: "create [title] [content...]", 227 + Short: "Create a new note", 228 + Aliases: []string{"new"}, 229 + RunE: func(cmd *cobra.Command, args []string) error { 230 + return handlers.Create(cmd.Context(), args) 231 + }, 232 + }) 233 + 234 + rootCmd.AddCommand(noteCmd) 235 + 220 236 rootCmd.AddCommand(&cobra.Command{ 221 237 Use: "config [key] [value]", 222 238 Short: "Manage configuration",
+363
cmd/handlers/notes.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "os" 7 + "os/exec" 8 + "path/filepath" 9 + "strings" 10 + 11 + "github.com/stormlightlabs/noteleaf/internal/models" 12 + "github.com/stormlightlabs/noteleaf/internal/repo" 13 + "github.com/stormlightlabs/noteleaf/internal/store" 14 + "github.com/stormlightlabs/noteleaf/internal/utils" 15 + ) 16 + 17 + // NoteHandler handles all note-related commands 18 + type NoteHandler struct { 19 + db *store.Database 20 + config *store.Config 21 + repos *repo.Repositories 22 + openInEditorFunc editorFunc 23 + } 24 + 25 + // NewNoteHandler creates a new note handler 26 + func NewNoteHandler() (*NoteHandler, error) { 27 + db, err := store.NewDatabase() 28 + if err != nil { 29 + return nil, fmt.Errorf("failed to initialize database: %w", err) 30 + } 31 + 32 + config, err := store.LoadConfig() 33 + if err != nil { 34 + return nil, fmt.Errorf("failed to load configuration: %w", err) 35 + } 36 + 37 + repos := repo.NewRepositories(db.DB) 38 + 39 + return &NoteHandler{ 40 + db: db, 41 + config: config, 42 + repos: repos, 43 + }, nil 44 + } 45 + 46 + // Close cleans up resources 47 + func (h *NoteHandler) Close() error { 48 + if h.db != nil { 49 + return h.db.Close() 50 + } 51 + return nil 52 + } 53 + 54 + // Create handles note creation subcommands 55 + func Create(ctx context.Context, args []string) error { 56 + handler, err := NewNoteHandler() 57 + if err != nil { 58 + return err 59 + } 60 + defer handler.Close() 61 + 62 + if len(args) == 0 { 63 + return handler.createInteractive(ctx) 64 + } 65 + 66 + if len(args) == 1 && isFile(args[0]) { 67 + return handler.createFromFile(ctx, args[0]) 68 + } 69 + 70 + title := args[0] 71 + content := "" 72 + if len(args) > 1 { 73 + content = strings.Join(args[1:], " ") 74 + } 75 + 76 + return handler.createFromArgs(ctx, title, content) 77 + } 78 + 79 + // New is an alias for Create 80 + func New(ctx context.Context, args []string) error { 81 + return Create(ctx, args) 82 + } 83 + 84 + func (h *NoteHandler) createInteractive(ctx context.Context) error { 85 + logger := utils.GetLogger() 86 + 87 + tempFile, err := os.CreateTemp("", "noteleaf-note-*.md") 88 + if err != nil { 89 + return fmt.Errorf("failed to create temporary file: %w", err) 90 + } 91 + defer os.Remove(tempFile.Name()) 92 + 93 + template := `# New Note 94 + 95 + Enter your note content here... 96 + 97 + <!-- Tags: personal, work --> 98 + ` 99 + if _, err := tempFile.WriteString(template); err != nil { 100 + return fmt.Errorf("failed to write template: %w", err) 101 + } 102 + tempFile.Close() 103 + 104 + editor := h.getEditor() 105 + if editor == "" { 106 + return fmt.Errorf("no editor configured. Set EDITOR environment variable or configure editor in settings") 107 + } 108 + 109 + logger.Info("Opening editor", "editor", editor, "file", tempFile.Name()) 110 + if err := h.openInEditor(editor, tempFile.Name()); err != nil { 111 + return fmt.Errorf("failed to open editor: %w", err) 112 + } 113 + 114 + content, err := os.ReadFile(tempFile.Name()) 115 + if err != nil { 116 + return fmt.Errorf("failed to read edited content: %w", err) 117 + } 118 + 119 + contentStr := string(content) 120 + if strings.TrimSpace(contentStr) == strings.TrimSpace(template) { 121 + fmt.Println("Note creation cancelled (no changes made)") 122 + return nil 123 + } 124 + 125 + title, noteContent, tags := h.parseNoteContent(contentStr) 126 + if title == "" { 127 + title = "Untitled Note" 128 + } 129 + 130 + note := &models.Note{ 131 + Title: title, 132 + Content: noteContent, 133 + Tags: tags, 134 + } 135 + 136 + id, err := h.repos.Notes.Create(ctx, note) 137 + if err != nil { 138 + return fmt.Errorf("failed to create note: %w", err) 139 + } 140 + 141 + fmt.Printf("Created note: %s (ID: %d)\n", title, id) 142 + if len(tags) > 0 { 143 + fmt.Printf("Tags: %s\n", strings.Join(tags, ", ")) 144 + } 145 + 146 + return nil 147 + } 148 + 149 + func (h *NoteHandler) createFromFile(ctx context.Context, filePath string) error { 150 + if _, err := os.Stat(filePath); os.IsNotExist(err) { 151 + return fmt.Errorf("file does not exist: %s", filePath) 152 + } 153 + 154 + content, err := os.ReadFile(filePath) 155 + if err != nil { 156 + return fmt.Errorf("failed to read file: %w", err) 157 + } 158 + 159 + contentStr := string(content) 160 + if strings.TrimSpace(contentStr) == "" { 161 + return fmt.Errorf("file is empty: %s", filePath) 162 + } 163 + 164 + title, noteContent, tags := h.parseNoteContent(contentStr) 165 + if title == "" { 166 + title = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) 167 + } 168 + 169 + note := &models.Note{ 170 + Title: title, 171 + Content: noteContent, 172 + Tags: tags, 173 + FilePath: filePath, 174 + } 175 + 176 + id, err := h.repos.Notes.Create(ctx, note) 177 + if err != nil { 178 + return fmt.Errorf("failed to create note: %w", err) 179 + } 180 + 181 + fmt.Printf("Created note from file: %s\n", filePath) 182 + fmt.Printf("Note: %s (ID: %d)\n", title, id) 183 + if len(tags) > 0 { 184 + fmt.Printf("Tags: %s\n", strings.Join(tags, ", ")) 185 + } 186 + 187 + return nil 188 + } 189 + 190 + func (h *NoteHandler) createFromArgs(ctx context.Context, title, content string) error { 191 + note := &models.Note{ 192 + Title: title, 193 + Content: content, 194 + } 195 + 196 + id, err := h.repos.Notes.Create(ctx, note) 197 + if err != nil { 198 + return fmt.Errorf("failed to create note: %w", err) 199 + } 200 + 201 + fmt.Printf("Created note: %s (ID: %d)\n", title, id) 202 + 203 + editor := h.getEditor() 204 + if editor != "" { 205 + fmt.Print("Open in editor? [y/N]: ") 206 + var response string 207 + fmt.Scanln(&response) 208 + if strings.ToLower(response) == "y" || strings.ToLower(response) == "yes" { 209 + return h.editNote(ctx, id) 210 + } 211 + } 212 + 213 + return nil 214 + } 215 + 216 + func (h *NoteHandler) editNote(ctx context.Context, id int64) error { 217 + note, err := h.repos.Notes.Get(ctx, id) 218 + if err != nil { 219 + return fmt.Errorf("failed to get note: %w", err) 220 + } 221 + 222 + tempFile, err := os.CreateTemp("", fmt.Sprintf("noteleaf-note-%d-*.md", id)) 223 + if err != nil { 224 + return fmt.Errorf("failed to create temporary file: %w", err) 225 + } 226 + defer os.Remove(tempFile.Name()) 227 + 228 + fullContent := h.formatNoteForEdit(note) 229 + if _, err := tempFile.WriteString(fullContent); err != nil { 230 + return fmt.Errorf("failed to write note content: %w", err) 231 + } 232 + tempFile.Close() 233 + 234 + editor := h.getEditor() 235 + if err := h.openInEditor(editor, tempFile.Name()); err != nil { 236 + return fmt.Errorf("failed to open editor: %w", err) 237 + } 238 + 239 + editedContent, err := os.ReadFile(tempFile.Name()) 240 + if err != nil { 241 + return fmt.Errorf("failed to read edited content: %w", err) 242 + } 243 + 244 + editedStr := string(editedContent) 245 + if editedStr == fullContent { 246 + fmt.Println("No changes made") 247 + return nil 248 + } 249 + 250 + title, content, tags := h.parseNoteContent(editedStr) 251 + if title == "" { 252 + title = note.Title 253 + } 254 + note.Title = title 255 + note.Content = content 256 + note.Tags = tags 257 + 258 + if err := h.repos.Notes.Update(ctx, note); err != nil { 259 + return fmt.Errorf("failed to update note: %w", err) 260 + } 261 + 262 + fmt.Printf("Updated note: %s (ID: %d)\n", title, id) 263 + return nil 264 + } 265 + 266 + func (h *NoteHandler) getEditor() string { 267 + // TODO: Add editor to config structure 268 + // For now, check environment variable 269 + if editor := os.Getenv("EDITOR"); editor != "" { 270 + return editor 271 + } 272 + 273 + editors := []string{"vim", "nano", "code", "emacs"} 274 + for _, editor := range editors { 275 + if _, err := exec.LookPath(editor); err == nil { 276 + return editor 277 + } 278 + } 279 + 280 + return "" 281 + } 282 + 283 + type editorFunc func(editor, filePath string) error 284 + 285 + func defaultOpenInEditor(editor, filePath string) error { 286 + cmd := exec.Command(editor, filePath) 287 + cmd.Stdin = os.Stdin 288 + cmd.Stdout = os.Stdout 289 + cmd.Stderr = os.Stderr 290 + return cmd.Run() 291 + } 292 + 293 + func (h *NoteHandler) openInEditor(editor, filePath string) error { 294 + if h.openInEditorFunc != nil { 295 + return h.openInEditorFunc(editor, filePath) 296 + } 297 + return defaultOpenInEditor(editor, filePath) 298 + } 299 + 300 + func (h *NoteHandler) parseNoteContent(content string) (title, noteContent string, tags []string) { 301 + lines := strings.Split(content, "\n") 302 + 303 + for _, line := range lines { 304 + line = strings.TrimSpace(line) 305 + if strings.HasPrefix(line, "# ") { 306 + title = strings.TrimPrefix(line, "# ") 307 + break 308 + } 309 + } 310 + 311 + for _, line := range lines { 312 + line = strings.TrimSpace(line) 313 + if strings.HasPrefix(line, "<!-- Tags:") && strings.HasSuffix(line, "-->") { 314 + tagStr := strings.TrimPrefix(line, "<!-- Tags:") 315 + tagStr = strings.TrimSuffix(tagStr, "-->") 316 + tagStr = strings.TrimSpace(tagStr) 317 + 318 + if tagStr != "" { 319 + for _, tag := range strings.Split(tagStr, ",") { 320 + tag = strings.TrimSpace(tag) 321 + if tag != "" { 322 + tags = append(tags, tag) 323 + } 324 + } 325 + } 326 + } 327 + } 328 + 329 + noteContent = content 330 + 331 + return title, noteContent, tags 332 + } 333 + 334 + func (h *NoteHandler) formatNoteForEdit(note *models.Note) string { 335 + var content strings.Builder 336 + 337 + if !strings.Contains(note.Content, "# "+note.Title) { 338 + content.WriteString("# " + note.Title + "\n\n") 339 + } 340 + 341 + content.WriteString(note.Content) 342 + 343 + if len(note.Tags) > 0 { 344 + if !strings.HasSuffix(note.Content, "\n") { 345 + content.WriteString("\n") 346 + } 347 + content.WriteString("\n<!-- Tags: " + strings.Join(note.Tags, ", ") + " -->\n") 348 + } 349 + 350 + return content.String() 351 + } 352 + 353 + func isFile(arg string) bool { 354 + if filepath.Ext(arg) != "" { 355 + return true 356 + } 357 + 358 + if info, err := os.Stat(arg); err == nil && !info.IsDir() { 359 + return true 360 + } 361 + 362 + return strings.Contains(arg, "/") || strings.Contains(arg, "\\") 363 + }
+697
cmd/handlers/notes_test.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "os" 6 + "path/filepath" 7 + "runtime" 8 + "strings" 9 + "testing" 10 + ) 11 + 12 + func setupNoteTest(t *testing.T) (string, func()) { 13 + tempDir, err := os.MkdirTemp("", "noteleaf-note-test-*") 14 + if err != nil { 15 + t.Fatalf("Failed to create temp dir: %v", err) 16 + } 17 + 18 + oldConfigHome := os.Getenv("XDG_CONFIG_HOME") 19 + os.Setenv("XDG_CONFIG_HOME", tempDir) 20 + 21 + cleanup := func() { 22 + os.Setenv("XDG_CONFIG_HOME", oldConfigHome) 23 + os.RemoveAll(tempDir) 24 + } 25 + 26 + ctx := context.Background() 27 + err = Setup(ctx, []string{}) 28 + if err != nil { 29 + cleanup() 30 + t.Fatalf("Failed to setup database: %v", err) 31 + } 32 + 33 + return tempDir, cleanup 34 + } 35 + 36 + func createTestMarkdownFile(t *testing.T, dir, filename, content string) string { 37 + filePath := filepath.Join(dir, filename) 38 + err := os.WriteFile(filePath, []byte(content), 0644) 39 + if err != nil { 40 + t.Fatalf("Failed to create test file: %v", err) 41 + } 42 + return filePath 43 + } 44 + 45 + func TestNoteHandler_NewNoteHandler(t *testing.T) { 46 + t.Run("creates handler successfully", func(t *testing.T) { 47 + _, cleanup := setupNoteTest(t) 48 + defer cleanup() 49 + 50 + handler, err := NewNoteHandler() 51 + if err != nil { 52 + t.Errorf("NewNoteHandler failed: %v", err) 53 + } 54 + if handler == nil { 55 + t.Error("Handler should not be nil") 56 + } 57 + defer handler.Close() 58 + 59 + if handler.db == nil { 60 + t.Error("Handler database should not be nil") 61 + } 62 + if handler.config == nil { 63 + t.Error("Handler config should not be nil") 64 + } 65 + if handler.repos == nil { 66 + t.Error("Handler repos should not be nil") 67 + } 68 + }) 69 + 70 + t.Run("handles database initialization error", func(t *testing.T) { 71 + originalXDG := os.Getenv("XDG_CONFIG_HOME") 72 + originalHome := os.Getenv("HOME") 73 + 74 + if runtime.GOOS == "windows" { 75 + originalAppData := os.Getenv("APPDATA") 76 + os.Unsetenv("APPDATA") 77 + defer os.Setenv("APPDATA", originalAppData) 78 + } else { 79 + os.Unsetenv("XDG_CONFIG_HOME") 80 + os.Unsetenv("HOME") 81 + } 82 + 83 + defer func() { 84 + os.Setenv("XDG_CONFIG_HOME", originalXDG) 85 + os.Setenv("HOME", originalHome) 86 + }() 87 + 88 + _, err := NewNoteHandler() 89 + if err == nil { 90 + t.Error("NewNoteHandler should fail when database initialization fails") 91 + } 92 + if !strings.Contains(err.Error(), "failed to initialize database") { 93 + t.Errorf("Expected database error, got: %v", err) 94 + } 95 + }) 96 + } 97 + 98 + func TestNoteHandler_parseNoteContent(t *testing.T) { 99 + handler := &NoteHandler{} 100 + 101 + testCases := []struct { 102 + name string 103 + input string 104 + expectedTitle string 105 + expectedContent string 106 + expectedTags []string 107 + }{ 108 + { 109 + name: "note with title and tags", 110 + input: `# My Test Note 111 + 112 + This is the content. 113 + 114 + <!-- Tags: personal, work, important -->`, 115 + expectedTitle: "My Test Note", 116 + expectedContent: `# My Test Note 117 + 118 + This is the content. 119 + 120 + <!-- Tags: personal, work, important -->`, 121 + expectedTags: []string{"personal", "work", "important"}, 122 + }, 123 + { 124 + name: "note without title", 125 + input: `Just some content here. 126 + 127 + No title heading. 128 + 129 + <!-- Tags: test -->`, 130 + expectedTitle: "", 131 + expectedContent: `Just some content here. 132 + 133 + No title heading. 134 + 135 + <!-- Tags: test -->`, 136 + expectedTags: []string{"test"}, 137 + }, 138 + { 139 + name: "note without tags", 140 + input: `# Title Only 141 + 142 + Content without tags.`, 143 + expectedTitle: "Title Only", 144 + expectedContent: `# Title Only 145 + 146 + Content without tags.`, 147 + expectedTags: nil, 148 + }, 149 + { 150 + name: "empty tags comment", 151 + input: `# Test Note 152 + 153 + Content here. 154 + 155 + <!-- Tags: -->`, 156 + expectedTitle: "Test Note", 157 + expectedContent: `# Test Note 158 + 159 + Content here. 160 + 161 + <!-- Tags: -->`, 162 + expectedTags: nil, 163 + }, 164 + { 165 + name: "malformed tags comment", 166 + input: `# Test Note 167 + 168 + Content here. 169 + 170 + <!-- Tags: tag1, , tag2,, tag3 -->`, 171 + expectedTitle: "Test Note", 172 + expectedContent: `# Test Note 173 + 174 + Content here. 175 + 176 + <!-- Tags: tag1, , tag2,, tag3 -->`, 177 + expectedTags: []string{"tag1", "tag2", "tag3"}, 178 + }, 179 + { 180 + name: "multiple headings", 181 + input: `## Secondary Heading 182 + 183 + # Main Title 184 + 185 + Content here.`, 186 + expectedTitle: "Main Title", 187 + expectedContent: `## Secondary Heading 188 + 189 + # Main Title 190 + 191 + Content here.`, 192 + expectedTags: nil, 193 + }, 194 + } 195 + 196 + for _, tc := range testCases { 197 + t.Run(tc.name, func(t *testing.T) { 198 + title, content, tags := handler.parseNoteContent(tc.input) 199 + 200 + if title != tc.expectedTitle { 201 + t.Errorf("Expected title %q, got %q", tc.expectedTitle, title) 202 + } 203 + 204 + if content != tc.expectedContent { 205 + t.Errorf("Expected content %q, got %q", tc.expectedContent, content) 206 + } 207 + 208 + if len(tags) != len(tc.expectedTags) { 209 + t.Errorf("Expected %d tags, got %d", len(tc.expectedTags), len(tags)) 210 + } 211 + 212 + for i, expectedTag := range tc.expectedTags { 213 + if i >= len(tags) || tags[i] != expectedTag { 214 + t.Errorf("Expected tag %q at position %d, got %q", expectedTag, i, tags[i]) 215 + } 216 + } 217 + }) 218 + } 219 + } 220 + 221 + func TestIsFile(t *testing.T) { 222 + testCases := []struct { 223 + name string 224 + input string 225 + expected bool 226 + }{ 227 + {"file with extension", "test.md", true}, 228 + {"file with multiple extensions", "test.tar.gz", true}, 229 + {"path with slash", "/path/to/file", true}, 230 + {"path with backslash", "path\\to\\file", true}, 231 + {"relative path", "./file", true}, 232 + {"just text", "hello", false}, 233 + {"empty string", "", false}, 234 + } 235 + 236 + tempDir, err := os.MkdirTemp("", "isfile-test-*") 237 + if err != nil { 238 + t.Fatalf("Failed to create temp dir: %v", err) 239 + } 240 + defer os.RemoveAll(tempDir) 241 + 242 + existingFile := filepath.Join(tempDir, "existing") 243 + err = os.WriteFile(existingFile, []byte("test"), 0644) 244 + if err != nil { 245 + t.Fatalf("Failed to create test file: %v", err) 246 + } 247 + 248 + testCases = append(testCases, struct { 249 + name string 250 + input string 251 + expected bool 252 + }{"existing file without extension", existingFile, true}) 253 + 254 + for _, tc := range testCases { 255 + t.Run(tc.name, func(t *testing.T) { 256 + result := isFile(tc.input) 257 + if result != tc.expected { 258 + t.Errorf("isFile(%q) = %v, expected %v", tc.input, result, tc.expected) 259 + } 260 + }) 261 + } 262 + } 263 + 264 + func TestNoteHandler_getEditor(t *testing.T) { 265 + handler := &NoteHandler{} 266 + 267 + t.Run("uses EDITOR environment variable", func(t *testing.T) { 268 + originalEditor := os.Getenv("EDITOR") 269 + os.Setenv("EDITOR", "test-editor") 270 + defer os.Setenv("EDITOR", originalEditor) 271 + 272 + editor := handler.getEditor() 273 + if editor != "test-editor" { 274 + t.Errorf("Expected 'test-editor', got %q", editor) 275 + } 276 + }) 277 + 278 + t.Run("finds available editor", func(t *testing.T) { 279 + originalEditor := os.Getenv("EDITOR") 280 + os.Unsetenv("EDITOR") 281 + defer os.Setenv("EDITOR", originalEditor) 282 + 283 + editor := handler.getEditor() 284 + if editor == "" { 285 + t.Skip("No common editors found on system, skipping test") 286 + } 287 + }) 288 + 289 + t.Run("returns empty when no editor available", func(t *testing.T) { 290 + originalEditor := os.Getenv("EDITOR") 291 + originalPath := os.Getenv("PATH") 292 + 293 + os.Unsetenv("EDITOR") 294 + os.Setenv("PATH", "") 295 + 296 + defer func() { 297 + os.Setenv("EDITOR", originalEditor) 298 + os.Setenv("PATH", originalPath) 299 + }() 300 + 301 + editor := handler.getEditor() 302 + if editor != "" { 303 + t.Errorf("Expected empty string when no editor available, got %q", editor) 304 + } 305 + }) 306 + } 307 + 308 + func TestNoteCreateErrorScenarios(t *testing.T) { 309 + errorTests := []struct { 310 + name string 311 + setupFunc func(t *testing.T) (cleanup func()) 312 + args []string 313 + expectError bool 314 + errorSubstr string 315 + }{ 316 + { 317 + name: "database initialization error", 318 + setupFunc: func(t *testing.T) func() { 319 + if runtime.GOOS == "windows" { 320 + original := os.Getenv("APPDATA") 321 + os.Unsetenv("APPDATA") 322 + return func() { os.Setenv("APPDATA", original) } 323 + } else { 324 + originalXDG := os.Getenv("XDG_CONFIG_HOME") 325 + originalHome := os.Getenv("HOME") 326 + os.Unsetenv("XDG_CONFIG_HOME") 327 + os.Unsetenv("HOME") 328 + return func() { 329 + os.Setenv("XDG_CONFIG_HOME", originalXDG) 330 + os.Setenv("HOME", originalHome) 331 + } 332 + } 333 + }, 334 + args: []string{"Test Note"}, 335 + expectError: true, 336 + errorSubstr: "failed to initialize database", 337 + }, 338 + { 339 + name: "note creation in database fails", 340 + setupFunc: func(t *testing.T) func() { 341 + tempDir, cleanup := setupNoteTest(t) 342 + 343 + configDir := filepath.Join(tempDir, "noteleaf") 344 + dbPath := filepath.Join(configDir, "noteleaf.db") 345 + 346 + err := os.WriteFile(dbPath, []byte("invalid sqlite content"), 0644) 347 + if err != nil { 348 + t.Fatalf("Failed to corrupt database: %v", err) 349 + } 350 + 351 + return cleanup 352 + }, 353 + args: []string{"Test Note"}, 354 + expectError: true, 355 + errorSubstr: "failed to initialize database", 356 + }, 357 + } 358 + 359 + for _, tt := range errorTests { 360 + t.Run(tt.name, func(t *testing.T) { 361 + cleanup := tt.setupFunc(t) 362 + defer cleanup() 363 + 364 + oldStdin := os.Stdin 365 + r, w, _ := os.Pipe() 366 + os.Stdin = r 367 + defer func() { os.Stdin = oldStdin }() 368 + 369 + go func() { 370 + w.WriteString("n\n") 371 + w.Close() 372 + }() 373 + 374 + ctx := context.Background() 375 + err := Create(ctx, tt.args) 376 + 377 + if tt.expectError && err == nil { 378 + t.Errorf("Expected error containing %q, got nil", tt.errorSubstr) 379 + } else if !tt.expectError && err != nil { 380 + t.Errorf("Expected no error, got: %v", err) 381 + } else if tt.expectError && err != nil && !strings.Contains(err.Error(), tt.errorSubstr) { 382 + t.Errorf("Expected error containing %q, got: %v", tt.errorSubstr, err) 383 + } 384 + }) 385 + } 386 + } 387 + 388 + func TestCreate_WithArgs(t *testing.T) { 389 + t.Run("creates note from title only", func(t *testing.T) { 390 + _, cleanup := setupNoteTest(t) 391 + defer cleanup() 392 + 393 + oldStdin := os.Stdin 394 + r, w, _ := os.Pipe() 395 + os.Stdin = r 396 + defer func() { os.Stdin = oldStdin }() 397 + 398 + go func() { 399 + w.WriteString("n\n") 400 + w.Close() 401 + }() 402 + 403 + ctx := context.Background() 404 + err := Create(ctx, []string{"Test Note"}) 405 + if err != nil { 406 + t.Errorf("Create failed: %v", err) 407 + } 408 + }) 409 + 410 + t.Run("creates note from title and content", func(t *testing.T) { 411 + _, cleanup := setupNoteTest(t) 412 + defer cleanup() 413 + 414 + oldStdin := os.Stdin 415 + r, w, _ := os.Pipe() 416 + os.Stdin = r 417 + defer func() { os.Stdin = oldStdin }() 418 + 419 + go func() { 420 + w.WriteString("n\n") 421 + w.Close() 422 + }() 423 + 424 + ctx := context.Background() 425 + err := Create(ctx, []string{"Test Note", "This", "is", "test", "content"}) 426 + if err != nil { 427 + t.Errorf("Create failed: %v", err) 428 + } 429 + }) 430 + 431 + t.Run("handles database connection error", func(t *testing.T) { 432 + tempDir, cleanup := setupNoteTest(t) 433 + defer cleanup() 434 + 435 + configDir := filepath.Join(tempDir, "noteleaf") 436 + dbPath := filepath.Join(configDir, "noteleaf.db") 437 + os.Remove(dbPath) 438 + 439 + os.MkdirAll(dbPath, 0755) 440 + defer os.RemoveAll(dbPath) 441 + 442 + ctx := context.Background() 443 + err := Create(ctx, []string{"Test Note"}) 444 + if err == nil { 445 + t.Error("Create should fail when database is inaccessible") 446 + } 447 + }) 448 + 449 + t.Run("New is alias for Create", func(t *testing.T) { 450 + _, cleanup := setupNoteTest(t) 451 + defer cleanup() 452 + 453 + oldStdin := os.Stdin 454 + r, w, _ := os.Pipe() 455 + os.Stdin = r 456 + defer func() { os.Stdin = oldStdin }() 457 + 458 + go func() { 459 + w.WriteString("n\n") 460 + w.Close() 461 + }() 462 + 463 + ctx := context.Background() 464 + err := New(ctx, []string{"Test Note via New"}) 465 + if err != nil { 466 + t.Errorf("New failed: %v", err) 467 + } 468 + }) 469 + } 470 + 471 + func TestCreate_FromFile(t *testing.T) { 472 + t.Run("creates note from markdown file", func(t *testing.T) { 473 + tempDir, cleanup := setupNoteTest(t) 474 + defer cleanup() 475 + 476 + content := `# My Test Note 477 + 478 + This is the content of my test note. 479 + 480 + ## Section 2 481 + 482 + More content here. 483 + 484 + <!-- Tags: personal, work -->` 485 + 486 + filePath := createTestMarkdownFile(t, tempDir, "test.md", content) 487 + 488 + ctx := context.Background() 489 + err := Create(ctx, []string{filePath}) 490 + if err != nil { 491 + t.Errorf("Create from file failed: %v", err) 492 + } 493 + }) 494 + 495 + t.Run("handles non-existent file", func(t *testing.T) { 496 + _, cleanup := setupNoteTest(t) 497 + defer cleanup() 498 + 499 + ctx := context.Background() 500 + err := Create(ctx, []string{"/non/existent/file.md"}) 501 + if err == nil { 502 + t.Error("Create should fail for non-existent file") 503 + } 504 + if !strings.Contains(err.Error(), "file does not exist") { 505 + t.Errorf("Expected file not found error, got: %v", err) 506 + } 507 + }) 508 + 509 + t.Run("handles empty file", func(t *testing.T) { 510 + tempDir, cleanup := setupNoteTest(t) 511 + defer cleanup() 512 + 513 + filePath := createTestMarkdownFile(t, tempDir, "empty.md", "") 514 + 515 + ctx := context.Background() 516 + err := Create(ctx, []string{filePath}) 517 + if err == nil { 518 + t.Error("Create should fail for empty file") 519 + } 520 + if !strings.Contains(err.Error(), "file is empty") { 521 + t.Errorf("Expected empty file error, got: %v", err) 522 + } 523 + }) 524 + 525 + t.Run("handles whitespace-only file", func(t *testing.T) { 526 + tempDir, cleanup := setupNoteTest(t) 527 + defer cleanup() 528 + 529 + filePath := createTestMarkdownFile(t, tempDir, "whitespace.md", " \n\t \n ") 530 + 531 + ctx := context.Background() 532 + err := Create(ctx, []string{filePath}) 533 + if err == nil { 534 + t.Error("Create should fail for whitespace-only file") 535 + } 536 + if !strings.Contains(err.Error(), "file is empty") { 537 + t.Errorf("Expected empty file error, got: %v", err) 538 + } 539 + }) 540 + 541 + t.Run("creates note without title in file", func(t *testing.T) { 542 + tempDir, cleanup := setupNoteTest(t) 543 + defer cleanup() 544 + 545 + content := `This note has no title heading. 546 + 547 + Just some content here.` 548 + 549 + filePath := createTestMarkdownFile(t, tempDir, "notitle.md", content) 550 + 551 + ctx := context.Background() 552 + err := Create(ctx, []string{filePath}) 553 + if err != nil { 554 + t.Errorf("Create from file without title failed: %v", err) 555 + } 556 + }) 557 + 558 + t.Run("handles file read error", func(t *testing.T) { 559 + tempDir, cleanup := setupNoteTest(t) 560 + defer cleanup() 561 + 562 + filePath := createTestMarkdownFile(t, tempDir, "unreadable.md", "test content") 563 + err := os.Chmod(filePath, 0000) 564 + if err != nil { 565 + t.Fatalf("Failed to make file unreadable: %v", err) 566 + } 567 + defer os.Chmod(filePath, 0644) 568 + 569 + ctx := context.Background() 570 + err = Create(ctx, []string{filePath}) 571 + if err == nil { 572 + t.Error("Create should fail for unreadable file") 573 + } 574 + if !strings.Contains(err.Error(), "failed to read file") { 575 + t.Errorf("Expected file read error, got: %v", err) 576 + } 577 + }) 578 + } 579 + 580 + func TestCreate_Interactive(t *testing.T) { 581 + t.Run("handles no editor configured", func(t *testing.T) { 582 + _, cleanup := setupNoteTest(t) 583 + defer cleanup() 584 + 585 + originalEditor := os.Getenv("EDITOR") 586 + originalPath := os.Getenv("PATH") 587 + os.Unsetenv("EDITOR") 588 + os.Setenv("PATH", "") 589 + defer func() { 590 + os.Setenv("EDITOR", originalEditor) 591 + os.Setenv("PATH", originalPath) 592 + }() 593 + 594 + ctx := context.Background() 595 + err := Create(ctx, []string{}) 596 + if err == nil { 597 + t.Error("Create should fail when no editor is configured") 598 + } 599 + if !strings.Contains(err.Error(), "no editor configured") { 600 + t.Errorf("Expected no editor error, got: %v", err) 601 + } 602 + }) 603 + 604 + t.Run("handles editor command failure", func(t *testing.T) { 605 + _, cleanup := setupNoteTest(t) 606 + defer cleanup() 607 + 608 + originalEditor := os.Getenv("EDITOR") 609 + os.Setenv("EDITOR", "nonexistent-editor-12345") 610 + defer os.Setenv("EDITOR", originalEditor) 611 + 612 + ctx := context.Background() 613 + err := Create(ctx, []string{}) 614 + if err == nil { 615 + t.Error("Create should fail when editor command fails") 616 + } 617 + if !strings.Contains(err.Error(), "failed to open editor") { 618 + t.Errorf("Expected editor failure error, got: %v", err) 619 + } 620 + }) 621 + 622 + t.Run("creates note successfully with mocked editor", func(t *testing.T) { 623 + _, cleanup := setupNoteTest(t) 624 + defer cleanup() 625 + 626 + originalEditor := os.Getenv("EDITOR") 627 + os.Setenv("EDITOR", "test-editor") 628 + defer os.Setenv("EDITOR", originalEditor) 629 + 630 + handler, err := NewNoteHandler() 631 + if err != nil { 632 + t.Fatalf("NewNoteHandler failed: %v", err) 633 + } 634 + defer handler.Close() 635 + 636 + handler.openInEditorFunc = func(editor, filePath string) error { 637 + content := `# Test Note 638 + 639 + This is edited content. 640 + 641 + <!-- Tags: test, created -->` 642 + return os.WriteFile(filePath, []byte(content), 0644) 643 + } 644 + 645 + ctx := context.Background() 646 + err = handler.createInteractive(ctx) 647 + if err != nil { 648 + t.Errorf("Interactive create failed: %v", err) 649 + } 650 + }) 651 + 652 + t.Run("handles editor cancellation", func(t *testing.T) { 653 + _, cleanup := setupNoteTest(t) 654 + defer cleanup() 655 + 656 + originalEditor := os.Getenv("EDITOR") 657 + os.Setenv("EDITOR", "test-editor") 658 + defer os.Setenv("EDITOR", originalEditor) 659 + 660 + handler, err := NewNoteHandler() 661 + if err != nil { 662 + t.Fatalf("NewNoteHandler failed: %v", err) 663 + } 664 + defer handler.Close() 665 + 666 + handler.openInEditorFunc = func(editor, filePath string) error { 667 + return nil 668 + } 669 + 670 + ctx := context.Background() 671 + err = handler.createInteractive(ctx) 672 + if err != nil { 673 + t.Errorf("Interactive create should handle cancellation gracefully: %v", err) 674 + } 675 + }) 676 + } 677 + 678 + func TestNoteHandlerClosesResources(t *testing.T) { 679 + _, cleanup := setupNoteTest(t) 680 + defer cleanup() 681 + 682 + handler, err := NewNoteHandler() 683 + if err != nil { 684 + t.Fatalf("NewNoteHandler failed: %v", err) 685 + } 686 + 687 + err = handler.Close() 688 + if err != nil { 689 + t.Errorf("Close should not return error: %v", err) 690 + } 691 + 692 + handler.db = nil 693 + err = handler.Close() 694 + if err != nil { 695 + t.Errorf("Close should handle nil database gracefully: %v", err) 696 + } 697 + }