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