···29time tracking. Tasks can be filtered by status, priority, project, or context.`,
30 }
3100000032 for _, init := range []func(*handlers.TaskHandler) *cobra.Command{
33- addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd,
34- deleteTaskCmd, taskProjectsCmd, taskTagsCmd, taskContextsCmd,
35- taskCompleteCmd, taskStartCmd, taskStopCmd, timesheetViewCmd,
36- taskRecurCmd, taskDependCmd,
37 } {
38 cmd := init(c.handler)
0000000000000000039 root.AddCommand(cmd)
40 }
41···57Examples:
58 noteleaf todo add "Write documentation" --priority high --project docs
59 noteleaf todo add "Weekly review" --recur "FREQ=WEEKLY" --due 2024-01-15`,
60- Args: cobra.MinimumNArgs(1),
61 RunE: func(c *cobra.Command, args []string) error {
62 description := strings.Join(args, " ")
63 priority, _ := c.Flags().GetString("priority")
···125Shows all task attributes including description, status, priority, project,
126context, tags, due date, creation time, and modification history. Use --json
127for machine-readable output or --no-metadata to show only the description.`,
128- Args: cobra.ExactArgs(1),
129 RunE: func(cmd *cobra.Command, args []string) error {
130 format, _ := cmd.Flags().GetString("format")
131 jsonOutput, _ := cmd.Flags().GetBool("json")
···153Examples:
154 noteleaf todo update 123 --priority urgent --due tomorrow
155 noteleaf todo update 456 --add-tag urgent --project website`,
156- Args: cobra.ExactArgs(1),
157 RunE: func(cmd *cobra.Command, args []string) error {
158 taskID := args[0]
159 description, _ := cmd.Flags().GetString("description")
···239240Records the start time for a work session. Only one task can be actively
241tracked at a time. Use --note to add a description of what you're working on.`,
242- Args: cobra.ExactArgs(1),
243 RunE: func(c *cobra.Command, args []string) error {
244 taskID := args[0]
245 description, _ := c.Flags().GetString("note")
···260261Records the end time and calculates duration for the current work session.
262Duration is added to the task's total time tracked.`,
263- Args: cobra.ExactArgs(1),
264 RunE: func(c *cobra.Command, args []string) error {
265 taskID := args[0]
266 defer h.Close()
···300301Provides a user-friendly interface with status picker and priority toggle.
302Easier than using multiple command-line flags for complex updates.`,
303- Args: cobra.ExactArgs(1),
304 RunE: func(c *cobra.Command, args []string) error {
305 taskID := args[0]
306 defer h.Close()
···317318This operation cannot be undone. Consider updating the task status to
319'deleted' instead if you want to preserve the record for historical purposes.`,
320- Args: cobra.ExactArgs(1),
321 RunE: func(c *cobra.Command, args []string) error {
322 defer h.Close()
323 return h.Delete(c.Context(), args)
···357358Sets the task status to 'completed' and records the completion time. For
359recurring tasks, generates the next instance based on the recurrence rule.`,
360- Args: cobra.ExactArgs(1),
361 RunE: func(c *cobra.Command, args []string) error {
362 defer h.Close()
363 return h.Done(c.Context(), args)
···388Examples:
389 noteleaf todo recur set 123 --rule "FREQ=DAILY"
390 noteleaf todo recur set 456 --rule "FREQ=WEEKLY;BYDAY=MO" --until 2024-12-31`,
391- Args: cobra.ExactArgs(1),
392 RunE: func(c *cobra.Command, args []string) error {
393 rule, _ := c.Flags().GetString("rule")
394 until, _ := c.Flags().GetString("until")
···406407Converts a recurring task to a one-time task. Existing future instances are not
408affected.`,
409- Args: cobra.ExactArgs(1),
410 RunE: func(c *cobra.Command, args []string) error {
411 defer h.Close()
412 return h.ClearRecur(c.Context(), args[0])
···420421Shows the RRULE pattern, next occurrence date, and recurrence end date if
422configured.`,
423- Args: cobra.ExactArgs(1),
424 RunE: func(c *cobra.Command, args []string) error {
425 defer h.Close()
426 return h.ShowRecur(c.Context(), args[0])
···449450The first task cannot be started until the second task is completed. Use task
451UUIDs to specify dependencies.`,
452- Args: cobra.ExactArgs(2),
453 RunE: func(c *cobra.Command, args []string) error {
454 defer h.Close()
455 return h.AddDep(c.Context(), args[0], args[1])
···29time tracking. Tasks can be filtered by status, priority, project, or context.`,
30 }
3132+ root.AddGroup(
33+ &cobra.Group{ID: "task-ops", Title: "Basic Operations"},
34+ &cobra.Group{ID: "task-meta", Title: "Metadata"},
35+ &cobra.Group{ID: "task-tracking", Title: "Tracking"},
36+ )
37+38 for _, init := range []func(*handlers.TaskHandler) *cobra.Command{
39+ addTaskCmd, listTaskCmd, viewTaskCmd, updateTaskCmd, editTaskCmd, deleteTaskCmd,
00040 } {
41 cmd := init(c.handler)
42+ cmd.GroupID = "task-ops"
43+ root.AddCommand(cmd)
44+ }
45+46+ for _, init := range []func(*handlers.TaskHandler) *cobra.Command{
47+ taskProjectsCmd, taskTagsCmd, taskContextsCmd,
48+ } {
49+ cmd := init(c.handler)
50+ cmd.GroupID = "task-meta"
51+ root.AddCommand(cmd)
52+ }
53+54+ for _, init := range []func(*handlers.TaskHandler) *cobra.Command{
55+ timesheetViewCmd, taskStartCmd, taskStopCmd, taskCompleteCmd, taskRecurCmd, taskDependCmd,
56+ } {
57+ cmd := init(c.handler)
58+ cmd.GroupID = "task-tracking"
59 root.AddCommand(cmd)
60 }
61···77Examples:
78 noteleaf todo add "Write documentation" --priority high --project docs
79 noteleaf todo add "Weekly review" --recur "FREQ=WEEKLY" --due 2024-01-15`,
80+ Args: cobra.MinimumNArgs(1),
81 RunE: func(c *cobra.Command, args []string) error {
82 description := strings.Join(args, " ")
83 priority, _ := c.Flags().GetString("priority")
···145Shows all task attributes including description, status, priority, project,
146context, tags, due date, creation time, and modification history. Use --json
147for machine-readable output or --no-metadata to show only the description.`,
148+ Args: cobra.ExactArgs(1),
149 RunE: func(cmd *cobra.Command, args []string) error {
150 format, _ := cmd.Flags().GetString("format")
151 jsonOutput, _ := cmd.Flags().GetBool("json")
···173Examples:
174 noteleaf todo update 123 --priority urgent --due tomorrow
175 noteleaf todo update 456 --add-tag urgent --project website`,
176+ Args: cobra.ExactArgs(1),
177 RunE: func(cmd *cobra.Command, args []string) error {
178 taskID := args[0]
179 description, _ := cmd.Flags().GetString("description")
···259260Records the start time for a work session. Only one task can be actively
261tracked at a time. Use --note to add a description of what you're working on.`,
262+ Args: cobra.ExactArgs(1),
263 RunE: func(c *cobra.Command, args []string) error {
264 taskID := args[0]
265 description, _ := c.Flags().GetString("note")
···280281Records the end time and calculates duration for the current work session.
282Duration is added to the task's total time tracked.`,
283+ Args: cobra.ExactArgs(1),
284 RunE: func(c *cobra.Command, args []string) error {
285 taskID := args[0]
286 defer h.Close()
···320321Provides a user-friendly interface with status picker and priority toggle.
322Easier than using multiple command-line flags for complex updates.`,
323+ Args: cobra.ExactArgs(1),
324 RunE: func(c *cobra.Command, args []string) error {
325 taskID := args[0]
326 defer h.Close()
···337338This operation cannot be undone. Consider updating the task status to
339'deleted' instead if you want to preserve the record for historical purposes.`,
340+ Args: cobra.ExactArgs(1),
341 RunE: func(c *cobra.Command, args []string) error {
342 defer h.Close()
343 return h.Delete(c.Context(), args)
···377378Sets the task status to 'completed' and records the completion time. For
379recurring tasks, generates the next instance based on the recurrence rule.`,
380+ Args: cobra.ExactArgs(1),
381 RunE: func(c *cobra.Command, args []string) error {
382 defer h.Close()
383 return h.Done(c.Context(), args)
···408Examples:
409 noteleaf todo recur set 123 --rule "FREQ=DAILY"
410 noteleaf todo recur set 456 --rule "FREQ=WEEKLY;BYDAY=MO" --until 2024-12-31`,
411+ Args: cobra.ExactArgs(1),
412 RunE: func(c *cobra.Command, args []string) error {
413 rule, _ := c.Flags().GetString("rule")
414 until, _ := c.Flags().GetString("until")
···426427Converts a recurring task to a one-time task. Existing future instances are not
428affected.`,
429+ Args: cobra.ExactArgs(1),
430 RunE: func(c *cobra.Command, args []string) error {
431 defer h.Close()
432 return h.ClearRecur(c.Context(), args[0])
···440441Shows the RRULE pattern, next occurrence date, and recurrence end date if
442configured.`,
443+ Args: cobra.ExactArgs(1),
444 RunE: func(c *cobra.Command, args []string) error {
445 defer h.Close()
446 return h.ShowRecur(c.Context(), args[0])
···469470The first task cannot be started until the second task is completed. Use task
471UUIDs to specify dependencies.`,
472+ Args: cobra.ExactArgs(2),
473 RunE: func(c *cobra.Command, args []string) error {
474 defer h.Close()
475 return h.AddDep(c.Context(), args[0], args[1])
+174-18
internal/handlers/publication.go
···1-// TODO: Implement document processing
2-// For each document:
3-// 1. Check if note with this leaflet_rkey exists
4-// 2. If exists: Update note content, title, metadata
5-// 3. If new: Create new note with leaflet metadata
6-// 4. Convert document blocks to markdown
7-// 5. Save to database
8-//
9-// TODO: Implement list functionality
10-// 1. Query notes where leaflet_rkey IS NOT NULL
11-// 2. Apply filter (published vs draft) - "all", "published", "draft", or empty (default: all)
12-// 3. Use prior art from package ui and other handlers to render
13//
14-// TODO: Implmenent pull command
15// 1. Authenticates with AT Protocol
16// 2. Fetches all pub.leaflet.document records
17// 3. Creates new notes for documents not seen before
18// 4. Updates existing notes (matched by leaflet_rkey)
19// 5. Shows summary of pulled documents
000000020package handlers
2122import (
···24 "fmt"
25 "time"
260027 "github.com/stormlightlabs/noteleaf/internal/repo"
28 "github.com/stormlightlabs/noteleaf/internal/services"
29 "github.com/stormlightlabs/noteleaf/internal/store"
030)
3132// PublicationHandler handles leaflet publication commands
···90 return fmt.Errorf("password is required")
91 }
9293- fmt.Printf("Authenticating as %s...\n", handle)
9495 if err := h.atproto.Authenticate(ctx, handle, password); err != nil {
96 return fmt.Errorf("authentication failed: %w", err)
···112 return fmt.Errorf("authentication successful but failed to save credentials: %w", err)
113 }
114115- fmt.Println("โ Authentication successful!")
116- fmt.Println("โ Credentials saved")
117 return nil
00000000000000000118}
119120// Pull fetches all documents from leaflet and creates/updates local notes
121func (h *PublicationHandler) Pull(ctx context.Context) error {
122- fmt.Println("TODO: Implement document conversion and note creation")
000000000000000000000000000000000000000000000000000000000000000000000000000000000123 return nil
124}
125000000000000000000000126// List displays notes with leaflet publication metadata, showing all notes that have been pulled from or pushed to leaflet
127func (h *PublicationHandler) List(ctx context.Context, filter string) error {
128- fmt.Println("TODO: Implement leaflet document listing")
00000000000000000000000000000000000000129 return nil
130}
131
···1+// Package handlers provides command handlers for leaflet publication operations.
000000000002//
3+// Pull command:
4// 1. Authenticates with AT Protocol
5// 2. Fetches all pub.leaflet.document records
6// 3. Creates new notes for documents not seen before
7// 4. Updates existing notes (matched by leaflet_rkey)
8// 5. Shows summary of pulled documents
9+//
10+// List command:
11+// 1. Query notes where leaflet_rkey IS NOT NULL
12+// 2. Apply filter (published vs draft) - "all", "published", "draft", or empty (default: all)
13+// 3. Static output (TUI viewing marked as TODO)
14+//
15+// TODO: Add TUI viewing for document details
16package handlers
1718import (
···20 "fmt"
21 "time"
2223+ "github.com/stormlightlabs/noteleaf/internal/models"
24+ "github.com/stormlightlabs/noteleaf/internal/public"
25 "github.com/stormlightlabs/noteleaf/internal/repo"
26 "github.com/stormlightlabs/noteleaf/internal/services"
27 "github.com/stormlightlabs/noteleaf/internal/store"
28+ "github.com/stormlightlabs/noteleaf/internal/ui"
29)
3031// PublicationHandler handles leaflet publication commands
···89 return fmt.Errorf("password is required")
90 }
9192+ ui.Infoln("Authenticating as %s...", handle)
9394 if err := h.atproto.Authenticate(ctx, handle, password); err != nil {
95 return fmt.Errorf("authentication failed: %w", err)
···111 return fmt.Errorf("authentication successful but failed to save credentials: %w", err)
112 }
113114+ ui.Successln("Authentication successful!")
115+ ui.Successln("Credentials saved")
116 return nil
117+}
118+119+// documentToMarkdown converts a leaflet Document to markdown content
120+func documentToMarkdown(doc services.DocumentWithMeta) (string, error) {
121+ converter := public.NewMarkdownConverter()
122+ var allBlocks []public.BlockWrap
123+124+ for _, page := range doc.Document.Pages {
125+ allBlocks = append(allBlocks, page.Blocks...)
126+ }
127+128+ content, err := converter.FromLeaflet(allBlocks)
129+ if err != nil {
130+ return "", fmt.Errorf("failed to convert document to markdown: %w", err)
131+ }
132+133+ return content, nil
134}
135136// Pull fetches all documents from leaflet and creates/updates local notes
137func (h *PublicationHandler) Pull(ctx context.Context) error {
138+ if !h.atproto.IsAuthenticated() {
139+ return fmt.Errorf("not authenticated - run 'noteleaf pub auth' first")
140+ }
141+142+ ui.Infoln("Fetching documents from leaflet...")
143+144+ docs, err := h.atproto.PullDocuments(ctx)
145+ if err != nil {
146+ return fmt.Errorf("failed to fetch documents: %w", err)
147+ }
148+149+ if len(docs) == 0 {
150+ ui.Infoln("No documents found in leaflet.")
151+ return nil
152+ }
153+154+ ui.Infoln("Found %d document(s). Syncing...\n", len(docs))
155+156+ var created, updated int
157+158+ for _, doc := range docs {
159+ existing, err := h.repos.Notes.GetByLeafletRKey(ctx, doc.Meta.RKey)
160+ if err == nil && existing != nil {
161+ content, err := documentToMarkdown(doc)
162+ if err != nil {
163+ ui.Warningln("โ Skipping document %s: %v", doc.Document.Title, err)
164+ continue
165+ }
166+167+ existing.Title = doc.Document.Title
168+ existing.Content = content
169+ existing.LeafletCID = &doc.Meta.CID
170+ existing.IsDraft = doc.Meta.IsDraft
171+172+ if doc.Document.PublishedAt != "" {
173+ publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt)
174+ if err == nil {
175+ existing.PublishedAt = &publishedAt
176+ }
177+ }
178+179+ if err := h.repos.Notes.Update(ctx, existing); err != nil {
180+ ui.Warningln("โ Failed to update note for document %s: %v", doc.Document.Title, err)
181+ continue
182+ }
183+184+ updated++
185+ ui.Infoln(" Updated: %s", doc.Document.Title)
186+ } else {
187+ content, err := documentToMarkdown(doc)
188+ if err != nil {
189+ ui.Warningln("โ Skipping document %s: %v", doc.Document.Title, err)
190+ continue
191+ }
192+193+ note := &models.Note{
194+ Title: doc.Document.Title,
195+ Content: content,
196+ LeafletRKey: &doc.Meta.RKey,
197+ LeafletCID: &doc.Meta.CID,
198+ IsDraft: doc.Meta.IsDraft,
199+ }
200+201+ if doc.Document.PublishedAt != "" {
202+ publishedAt, err := time.Parse(time.RFC3339, doc.Document.PublishedAt)
203+ if err == nil {
204+ note.PublishedAt = &publishedAt
205+ }
206+ }
207+208+ _, err = h.repos.Notes.Create(ctx, note)
209+ if err != nil {
210+ ui.Warningln("โ Failed to create note for document %s: %v", doc.Document.Title, err)
211+ continue
212+ }
213+214+ created++
215+ ui.Infoln(" Created: %s", doc.Document.Title)
216+ }
217+ }
218+219+ ui.Successln("Sync complete: %d created, %d updated", created, updated)
220 return nil
221}
222223+// printPublication prints a single publication note in static format
224+func printPublication(note *models.Note) {
225+ status := "published"
226+ if note.IsDraft {
227+ status = "draft"
228+ }
229+230+ ui.Infoln("[%d] %s (%s)", note.ID, note.Title, status)
231+232+ if note.LeafletRKey != nil {
233+ ui.Infoln(" rkey: %s", *note.LeafletRKey)
234+ }
235+236+ if note.PublishedAt != nil {
237+ ui.Infoln(" published: %s", note.PublishedAt.Format("2006-01-02 15:04:05"))
238+ }
239+240+ ui.Infoln(" modified: %s", note.Modified.Format("2006-01-02 15:04:05"))
241+ ui.Newline()
242+}
243+244// List displays notes with leaflet publication metadata, showing all notes that have been pulled from or pushed to leaflet
245func (h *PublicationHandler) List(ctx context.Context, filter string) error {
246+ if filter == "" {
247+ filter = "all"
248+ }
249+250+ var notes []*models.Note
251+ var err error
252+253+ switch filter {
254+ case "all":
255+ notes, err = h.repos.Notes.GetLeafletNotes(ctx)
256+ if err != nil {
257+ return fmt.Errorf("failed to fetch leaflet notes: %w", err)
258+ }
259+ case "published":
260+ notes, err = h.repos.Notes.ListPublished(ctx)
261+ if err != nil {
262+ return fmt.Errorf("failed to fetch published notes: %w", err)
263+ }
264+ case "draft":
265+ notes, err = h.repos.Notes.ListDrafts(ctx)
266+ if err != nil {
267+ return fmt.Errorf("failed to fetch draft notes: %w", err)
268+ }
269+ default:
270+ return fmt.Errorf("invalid filter: %s (must be 'all', 'published', or 'draft')", filter)
271+ }
272+273+ if len(notes) == 0 {
274+ ui.Infoln("No %s documents found.", filter)
275+ return nil
276+ }
277+278+ ui.Infoln("Found %d %s document(s):", len(notes), filter)
279+ ui.Newline()
280+281+ for _, note := range notes {
282+ printPublication(note)
283+ }
284+285 return nil
286}
287