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.

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}