cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
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}