cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm leaflet readability golang
at main 427 lines 11 kB view raw
1package handlers 2 3import ( 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/ui" 15 "github.com/stormlightlabs/noteleaf/internal/utils" 16) 17 18type editorFunc func(editor, filePath string) error 19 20// NoteHandler handles all note-related commands 21type NoteHandler struct { 22 db *store.Database 23 config *store.Config 24 repos *repo.Repositories 25 openInEditorFunc editorFunc 26} 27 28// NewNoteHandler creates a new note handler 29func NewNoteHandler() (*NoteHandler, error) { 30 db, err := store.NewDatabase() 31 if err != nil { 32 return nil, fmt.Errorf("failed to initialize database: %w", err) 33 } 34 35 config, err := store.LoadConfig() 36 if err != nil { 37 return nil, fmt.Errorf("failed to load configuration: %w", err) 38 } 39 40 repos := repo.NewRepositories(db.DB) 41 42 return &NoteHandler{ 43 db: db, 44 config: config, 45 repos: repos, 46 }, nil 47} 48 49// Close cleans up resources 50func (h *NoteHandler) Close() error { 51 if h.db != nil { 52 return h.db.Close() 53 } 54 return nil 55} 56 57// Create handles note creation with optional title, content, and file path 58func (h *NoteHandler) Create(ctx context.Context, title string, content string, filePath string, interactive bool) error { 59 return h.CreateWithOptions(ctx, title, content, filePath, interactive, false) 60} 61 62// CreateWithOptions handles note creation with additional options 63func (h *NoteHandler) CreateWithOptions(ctx context.Context, title string, content string, filePath string, interactive bool, promptEditor bool) error { 64 if interactive || (title == "" && content == "" && filePath == "") { 65 return h.createInteractive(ctx) 66 } 67 68 if filePath != "" { 69 return h.createFromFile(ctx, filePath) 70 } 71 72 return h.createFromArgsWithOptions(ctx, title, content, promptEditor) 73} 74 75func (h *NoteHandler) createInteractive(ctx context.Context) error { 76 logger := utils.GetLogger() 77 78 tempFile, err := os.CreateTemp("", "noteleaf-note-*.md") 79 if err != nil { 80 return fmt.Errorf("failed to create temporary file: %w", err) 81 } 82 defer os.Remove(tempFile.Name()) 83 84 template := `# New Note 85 86Enter your note content here... 87 88<!-- Tags: personal, work --> 89` 90 if _, err := tempFile.WriteString(template); err != nil { 91 return fmt.Errorf("failed to write template: %w", err) 92 } 93 tempFile.Close() 94 95 editor := h.getEditor() 96 if editor == "" { 97 return fmt.Errorf("no editor configured. Set EDITOR environment variable or configure editor in settings") 98 } 99 100 logger.Info("Opening editor", "editor", editor, "file", tempFile.Name()) 101 if err := h.openInEditor(editor, tempFile.Name()); err != nil { 102 return fmt.Errorf("failed to open editor: %w", err) 103 } 104 105 content, err := os.ReadFile(tempFile.Name()) 106 if err != nil { 107 return fmt.Errorf("failed to read edited content: %w", err) 108 } 109 110 contentStr := string(content) 111 if strings.TrimSpace(contentStr) == strings.TrimSpace(template) { 112 fmt.Println("Note creation cancelled (no changes made)") 113 return nil 114 } 115 116 title, noteContent, tags := h.parseNoteContent(contentStr) 117 if title == "" { 118 title = "Untitled Note" 119 } 120 121 note := &models.Note{ 122 Title: title, 123 Content: noteContent, 124 Tags: tags, 125 } 126 127 id, err := h.repos.Notes.Create(ctx, note) 128 if err != nil { 129 return fmt.Errorf("failed to create note: %w", err) 130 } 131 132 fmt.Printf("Created note: %s (ID: %d)\n", title, id) 133 if len(tags) > 0 { 134 fmt.Printf("Tags: %s\n", strings.Join(tags, ", ")) 135 } 136 137 return nil 138} 139 140func (h *NoteHandler) createFromFile(ctx context.Context, filePath string) error { 141 if _, err := os.Stat(filePath); os.IsNotExist(err) { 142 return fmt.Errorf("file does not exist: %s", filePath) 143 } 144 145 content, err := os.ReadFile(filePath) 146 if err != nil { 147 return fmt.Errorf("failed to read file: %w", err) 148 } 149 150 contentStr := string(content) 151 if strings.TrimSpace(contentStr) == "" { 152 return fmt.Errorf("file is empty: %s", filePath) 153 } 154 155 title, noteContent, tags := h.parseNoteContent(contentStr) 156 if title == "" { 157 title = strings.TrimSuffix(filepath.Base(filePath), filepath.Ext(filePath)) 158 } 159 160 note := &models.Note{ 161 Title: title, 162 Content: noteContent, 163 Tags: tags, 164 FilePath: filePath, 165 } 166 167 id, err := h.repos.Notes.Create(ctx, note) 168 if err != nil { 169 return fmt.Errorf("failed to create note: %w", err) 170 } 171 172 fmt.Printf("Created note from file: %s\n", filePath) 173 fmt.Printf("Note: %s (ID: %d)\n", title, id) 174 if len(tags) > 0 { 175 fmt.Printf("Tags: %s\n", strings.Join(tags, ", ")) 176 } 177 178 return nil 179} 180 181func (h *NoteHandler) createFromArgsWithOptions(ctx context.Context, title, content string, promptEditor bool) error { 182 note := &models.Note{ 183 Title: title, 184 Content: content, 185 } 186 187 id, err := h.repos.Notes.Create(ctx, note) 188 if err != nil { 189 return fmt.Errorf("failed to create note: %w", err) 190 } 191 192 fmt.Printf("Created note: %s (ID: %d)\n", title, id) 193 194 if promptEditor { 195 editor := h.getEditor() 196 if editor != "" { 197 fmt.Print("Open in editor? [y/N]: ") 198 var response string 199 fmt.Scanln(&response) 200 if strings.ToLower(response) == "y" || strings.ToLower(response) == "yes" { 201 return h.Edit(ctx, id) 202 } 203 } 204 } 205 206 return nil 207} 208 209// Edit handles note editing by ID 210func (h *NoteHandler) Edit(ctx context.Context, id int64) error { 211 note, err := h.repos.Notes.Get(ctx, id) 212 if err != nil { 213 return fmt.Errorf("failed to get note: %w", err) 214 } 215 216 tempFile, err := os.CreateTemp("", fmt.Sprintf("noteleaf-note-%d-*.md", id)) 217 if err != nil { 218 return fmt.Errorf("failed to create temporary file: %w", err) 219 } 220 defer os.Remove(tempFile.Name()) 221 222 fullContent := h.formatNoteForEdit(note) 223 if _, err := tempFile.WriteString(fullContent); err != nil { 224 return fmt.Errorf("failed to write note content: %w", err) 225 } 226 tempFile.Close() 227 228 editor := h.getEditor() 229 if err := h.openInEditor(editor, tempFile.Name()); err != nil { 230 return fmt.Errorf("failed to open editor: %w", err) 231 } 232 233 editedContent, err := os.ReadFile(tempFile.Name()) 234 if err != nil { 235 return fmt.Errorf("failed to read edited content: %w", err) 236 } 237 238 editedStr := string(editedContent) 239 if editedStr == fullContent { 240 fmt.Println("No changes made") 241 return nil 242 } 243 244 title, content, tags := h.parseNoteContent(editedStr) 245 if title == "" { 246 title = note.Title 247 } 248 note.Title = title 249 note.Content = content 250 note.Tags = tags 251 252 if err := h.repos.Notes.Update(ctx, note); err != nil { 253 return fmt.Errorf("failed to update note: %w", err) 254 } 255 256 fmt.Printf("Updated note: %s (ID: %d)\n", title, id) 257 return nil 258} 259 260func (h *NoteHandler) getEditor() string { 261 // Check config first 262 if h.config.Editor != "" { 263 return h.config.Editor 264 } 265 266 // Fall back to EDITOR environment variable 267 if editor := os.Getenv("EDITOR"); editor != "" { 268 return editor 269 } 270 271 // Try common editors 272 editors := []string{"vim", "nano", "code", "emacs"} 273 for _, editor := range editors { 274 if _, err := exec.LookPath(editor); err == nil { 275 return editor 276 } 277 } 278 279 return "" 280} 281 282func (h *NoteHandler) openInEditor(editor, filePath string) error { 283 if h.openInEditorFunc != nil { 284 return h.openInEditorFunc(editor, filePath) 285 } 286 return openInDefaultEditor(editor, filePath) 287} 288 289func (h *NoteHandler) parseNoteContent(content string) (title, noteContent string, tags []string) { 290 lines := strings.Split(content, "\n") 291 292 for _, line := range lines { 293 line = strings.TrimSpace(line) 294 if strings.HasPrefix(line, "# ") { 295 title = strings.TrimPrefix(line, "# ") 296 break 297 } 298 } 299 300 for _, line := range lines { 301 line = strings.TrimSpace(line) 302 if strings.HasPrefix(line, "<!-- Tags:") && strings.HasSuffix(line, "-->") { 303 tagStr := strings.TrimPrefix(line, "<!-- Tags:") 304 tagStr = strings.TrimSuffix(tagStr, "-->") 305 tagStr = strings.TrimSpace(tagStr) 306 307 if tagStr != "" { 308 for _, tag := range strings.Split(tagStr, ",") { 309 tag = strings.TrimSpace(tag) 310 if tag != "" { 311 tags = append(tags, tag) 312 } 313 } 314 } 315 } 316 } 317 318 noteContent = content 319 320 return title, noteContent, tags 321} 322 323// View displays a note with formatted markdown content 324func (h *NoteHandler) View(ctx context.Context, id int64) error { 325 note, err := h.repos.Notes.Get(ctx, id) 326 if err != nil { 327 return fmt.Errorf("failed to get note: %w", err) 328 } 329 330 content := h.formatNoteForView(note) 331 if rendered, err := renderMarkdown(content); err != nil { 332 return err 333 } else { 334 fmt.Print(rendered) 335 return nil 336 } 337} 338 339// List opens either an interactive TUI browser for navigating and viewing notes or a static list 340func (h *NoteHandler) List(ctx context.Context, static, showArchived bool, tags []string) error { 341 noteList := ui.NewNoteListFromList(h.repos.Notes, os.Stdout, os.Stdin, static, showArchived, tags) 342 return noteList.Browse(ctx) 343} 344 345// Delete permanently removes a note and its metadata 346func (h *NoteHandler) Delete(ctx context.Context, id int64) error { 347 note, err := h.repos.Notes.Get(ctx, id) 348 if err != nil { 349 return fmt.Errorf("failed to find note: %w", err) 350 } 351 352 if note.FilePath != "" { 353 if err := os.Remove(note.FilePath); err != nil && !os.IsNotExist(err) { 354 return fmt.Errorf("failed to remove note file %s: %w", note.FilePath, err) 355 } 356 } 357 358 if err := h.repos.Notes.Delete(ctx, id); err != nil { 359 return fmt.Errorf("failed to delete note from database: %w", err) 360 } 361 362 fmt.Printf("Note deleted (ID: %d): %s\n", note.ID, note.Title) 363 if note.FilePath != "" { 364 fmt.Printf("File removed: %s\n", note.FilePath) 365 } 366 return nil 367} 368 369func (h *NoteHandler) formatNoteForView(note *models.Note) string { 370 var content strings.Builder 371 372 content.WriteString("# " + note.Title + "\n\n") 373 374 if len(note.Tags) > 0 { 375 content.WriteString("**Tags:** ") 376 for i, tag := range note.Tags { 377 if i > 0 { 378 content.WriteString(", ") 379 } 380 content.WriteString("`" + tag + "`") 381 } 382 content.WriteString("\n\n") 383 } 384 385 content.WriteString("**Created:** " + note.Created.Format("2006-01-02 15:04") + "\n") 386 content.WriteString("**Modified:** " + note.Modified.Format("2006-01-02 15:04") + "\n\n") 387 content.WriteString("---\n\n") 388 389 noteContent := strings.TrimSpace(note.Content) 390 if !strings.HasPrefix(noteContent, "# ") { 391 content.WriteString(noteContent) 392 } else { 393 lines := strings.Split(noteContent, "\n") 394 if len(lines) > 1 { 395 content.WriteString(strings.Join(lines[1:], "\n")) 396 } 397 } 398 399 return content.String() 400} 401 402func (h *NoteHandler) formatNoteForEdit(note *models.Note) string { 403 var content strings.Builder 404 405 if !strings.Contains(note.Content, "# "+note.Title) { 406 content.WriteString("# " + note.Title + "\n\n") 407 } 408 409 content.WriteString(note.Content) 410 411 if len(note.Tags) > 0 { 412 if !strings.HasSuffix(note.Content, "\n") { 413 content.WriteString("\n") 414 } 415 content.WriteString("\n<!-- Tags: " + strings.Join(note.Tags, ", ") + " -->\n") 416 } 417 418 return content.String() 419} 420 421func openInDefaultEditor(editor, filePath string) error { 422 cmd := exec.Command(editor, filePath) 423 cmd.Stdin = os.Stdin 424 cmd.Stdout = os.Stdout 425 cmd.Stderr = os.Stderr 426 return cmd.Run() 427}