cli + tui to publish to leaflet (wip) & manage tasks, notes & watch/read lists 馃崈
charm
leaflet
readability
golang
1package main
2
3import (
4 "fmt"
5 "strconv"
6
7 "github.com/spf13/cobra"
8 "github.com/stormlightlabs/noteleaf/internal/handlers"
9 "github.com/stormlightlabs/noteleaf/internal/ui"
10)
11
12// PublicationCommand implements [CommandGroup] for leaflet publication commands
13type PublicationCommand struct {
14 handler *handlers.PublicationHandler
15}
16
17// NewPublicationCommand creates a new [PublicationCommand] with the given handler
18func NewPublicationCommand(handler *handlers.PublicationHandler) *PublicationCommand {
19 return &PublicationCommand{handler: handler}
20}
21
22func (c *PublicationCommand) Create() *cobra.Command {
23 root := &cobra.Command{
24 Use: "pub",
25 Short: "Manage leaflet publication sync",
26 Long: `Sync notes with leaflet.pub (AT Protocol publishing platform).
27
28Authenticate with your BlueSky account to pull drafts and published documents
29from leaflet.pub into your local notes. Track publication status and manage
30your writing workflow across platforms.
31
32Authentication uses AT Protocol (the same system as BlueSky). You'll need:
33- BlueSky handle (e.g., username.bsky.social)
34- App password (generated at bsky.app/settings/app-passwords)
35
36Getting Started:
37 1. Authenticate: noteleaf pub auth <handle>
38 2. Pull documents: noteleaf pub pull
39 3. List publications: noteleaf pub list`,
40 }
41
42 authCmd := &cobra.Command{
43 Use: "auth [handle]",
44 Short: "Authenticate with BlueSky/leaflet",
45 Long: `Authenticate with AT Protocol (BlueSky) for leaflet access.
46
47Your handle is typically: username.bsky.social
48
49For the password, use an app password (not your main password):
501. Go to bsky.app/settings/app-passwords
512. Create a new app password named "noteleaf"
523. Use that password here
53
54If credentials are not provided via flags, use the interactive input.`,
55 Args: cobra.MaximumNArgs(1),
56 RunE: func(cmd *cobra.Command, args []string) error {
57 var handle string
58 if len(args) > 0 {
59 handle = args[0]
60 }
61
62 // Auto-fill with last authenticated handle if available
63 if handle == "" {
64 lastHandle := c.handler.GetLastAuthenticatedHandle()
65 if lastHandle != "" {
66 handle = lastHandle
67 }
68 }
69
70 password, _ := cmd.Flags().GetString("password")
71
72 if handle != "" && password != "" {
73 defer c.handler.Close()
74 return c.handler.Auth(cmd.Context(), handle, password)
75 }
76
77 form := ui.NewAuthForm(handle, ui.AuthFormOptions{})
78 result, err := form.Run()
79 if err != nil {
80 return fmt.Errorf("failed to display auth form: %w", err)
81 }
82
83 if result.Canceled {
84 return fmt.Errorf("authentication canceled")
85 }
86
87 defer c.handler.Close()
88 return c.handler.Auth(cmd.Context(), result.Handle, result.Password)
89 },
90 }
91 authCmd.Flags().StringP("password", "p", "", "App password (will prompt if not provided)")
92 root.AddCommand(authCmd)
93
94 pullCmd := &cobra.Command{
95 Use: "pull",
96 Short: "Pull documents from leaflet",
97 Long: `Fetch all drafts and published documents from leaflet.pub.
98
99This will:
100- Connect to your BlueSky/leaflet account
101- Fetch all documents in your repository
102- Create new notes for documents not yet synced
103- Update existing notes that have changed
104
105Notes are matched by their leaflet record key (rkey) stored in the database.`,
106 RunE: func(cmd *cobra.Command, args []string) error {
107 defer c.handler.Close()
108 return c.handler.Pull(cmd.Context())
109 },
110 }
111 root.AddCommand(pullCmd)
112
113 listCmd := &cobra.Command{
114 Use: "list [--published|--draft|--all] [--interactive]",
115 Short: "List notes synced with leaflet",
116 Aliases: []string{"ls"},
117 Long: `Display notes that have been pulled from or pushed to leaflet.
118
119Shows publication metadata including:
120- Publication status (draft vs published)
121- Published date
122- Leaflet record key (rkey)
123- Content identifier (cid) for change tracking
124
125Use filters to show specific subsets:
126 --published Show only published documents
127 --draft Show only drafts
128 --all Show all leaflet documents (default)
129 --interactive Open interactive TUI browser with search and preview`,
130 RunE: func(cmd *cobra.Command, args []string) error {
131 published, _ := cmd.Flags().GetBool("published")
132 draft, _ := cmd.Flags().GetBool("draft")
133 all, _ := cmd.Flags().GetBool("all")
134 interactive, _ := cmd.Flags().GetBool("interactive")
135
136 filter := "all"
137 if published {
138 filter = "published"
139 } else if draft {
140 filter = "draft"
141 } else if all {
142 filter = "all"
143 }
144
145 defer c.handler.Close()
146
147 if interactive {
148 return c.handler.Browse(cmd.Context(), filter)
149 }
150
151 return c.handler.List(cmd.Context(), filter)
152 },
153 }
154 listCmd.Flags().Bool("published", false, "Show only published documents")
155 listCmd.Flags().Bool("draft", false, "Show only drafts")
156 listCmd.Flags().Bool("all", false, "Show all leaflet documents")
157 listCmd.Flags().BoolP("interactive", "i", false, "Open interactive TUI browser")
158 root.AddCommand(listCmd)
159
160 readCmd := &cobra.Command{
161 Use: "read [identifier]",
162 Short: "Read a publication",
163 Long: `Display a publication's content with formatted markdown rendering.
164
165The identifier can be:
166- Omitted: Display the newest publication
167- Database ID: Display publication by note ID (e.g., 42)
168- AT Protocol rkey: Display publication by leaflet rkey
169
170Examples:
171 noteleaf pub read # Show newest publication
172 noteleaf pub read 123 # Show publication with note ID 123
173 noteleaf pub read 3jxx... # Show publication by rkey`,
174 Args: cobra.MaximumNArgs(1),
175 RunE: func(cmd *cobra.Command, args []string) error {
176 identifier := ""
177 if len(args) > 0 {
178 identifier = args[0]
179 }
180
181 defer c.handler.Close()
182 return c.handler.Read(cmd.Context(), identifier)
183 },
184 }
185 root.AddCommand(readCmd)
186
187 statusCmd := &cobra.Command{
188 Use: "status",
189 Short: "Show leaflet authentication status",
190 Long: "Display current authentication status and session information.",
191 RunE: func(cmd *cobra.Command, args []string) error {
192 defer c.handler.Close()
193 status := c.handler.GetAuthStatus()
194 fmt.Println("Leaflet Status:")
195 fmt.Printf(" %s\n", status)
196 return nil
197 },
198 }
199 root.AddCommand(statusCmd)
200
201 postCmd := &cobra.Command{
202 Use: "post [note-id]",
203 Short: "Create a new document on leaflet",
204 Long: `Publish a local note to leaflet.pub as a new document.
205
206This command converts your markdown note to leaflet's block format and creates
207a new document on the platform. The note will be linked to the leaflet document
208for future updates via the patch command.
209
210Examples:
211 noteleaf pub post 123 # Publish note 123
212 noteleaf pub post 123 --draft # Create as draft
213 noteleaf pub post 123 --preview # Preview without posting
214 noteleaf pub post 123 --validate # Validate conversion only`,
215 Args: cobra.ExactArgs(1),
216 RunE: func(cmd *cobra.Command, args []string) error {
217 noteID, err := parseNoteID(args[0])
218 if err != nil {
219 return err
220 }
221
222 isDraft, _ := cmd.Flags().GetBool("draft")
223 preview, _ := cmd.Flags().GetBool("preview")
224 validate, _ := cmd.Flags().GetBool("validate")
225 output, _ := cmd.Flags().GetString("output")
226 plaintext, _ := cmd.Flags().GetBool("plaintext")
227 txt, _ := cmd.Flags().GetBool("txt")
228
229 if txt {
230 plaintext = true
231 }
232
233 defer c.handler.Close()
234
235 if preview {
236 return c.handler.PostPreview(cmd.Context(), noteID, isDraft, output, plaintext)
237 }
238
239 if validate {
240 return c.handler.PostValidate(cmd.Context(), noteID, isDraft, output, plaintext)
241 }
242
243 return c.handler.Post(cmd.Context(), noteID, isDraft)
244 },
245 }
246 postCmd.Flags().Bool("draft", false, "Create as draft instead of publishing")
247 postCmd.Flags().Bool("preview", false, "Show what would be posted without actually posting")
248 postCmd.Flags().Bool("validate", false, "Validate markdown conversion without posting")
249 postCmd.Flags().StringP("output", "o", "", "Write document to file (defaults to JSON format)")
250 postCmd.Flags().Bool("plaintext", false, "Use plaintext format for output file")
251 postCmd.Flags().Bool("txt", false, "Alias for --plaintext")
252 root.AddCommand(postCmd)
253
254 patchCmd := &cobra.Command{
255 Use: "patch [note-id]",
256 Short: "Update an existing document on leaflet",
257 Long: `Update an existing leaflet document from a local note.
258
259This command converts your markdown note to leaflet's block format and updates
260the existing document on the platform. The note must have been previously posted
261or pulled from leaflet (it needs a leaflet record key).
262
263The document's draft/published status is preserved from the note's current state.
264
265Examples:
266 noteleaf pub patch 123 # Update existing document
267 noteleaf pub patch 123 --preview # Preview without updating
268 noteleaf pub patch 123 --validate # Validate conversion only`,
269 Args: cobra.ExactArgs(1),
270 RunE: func(cmd *cobra.Command, args []string) error {
271 noteID, err := parseNoteID(args[0])
272 if err != nil {
273 return err
274 }
275
276 preview, _ := cmd.Flags().GetBool("preview")
277 validate, _ := cmd.Flags().GetBool("validate")
278 output, _ := cmd.Flags().GetString("output")
279 plaintext, _ := cmd.Flags().GetBool("plaintext")
280 txt, _ := cmd.Flags().GetBool("txt")
281
282 if txt {
283 plaintext = true
284 }
285
286 defer c.handler.Close()
287
288 if preview {
289 return c.handler.PatchPreview(cmd.Context(), noteID, output, plaintext)
290 }
291
292 if validate {
293 return c.handler.PatchValidate(cmd.Context(), noteID, output, plaintext)
294 }
295
296 return c.handler.Patch(cmd.Context(), noteID)
297 },
298 }
299 patchCmd.Flags().Bool("preview", false, "Show what would be updated without actually patching")
300 patchCmd.Flags().Bool("validate", false, "Validate markdown conversion without patching")
301 patchCmd.Flags().StringP("output", "o", "", "Write document to file (defaults to JSON format)")
302 patchCmd.Flags().Bool("plaintext", false, "Use plaintext format for output file")
303 patchCmd.Flags().Bool("txt", false, "Alias for --plaintext")
304 root.AddCommand(patchCmd)
305
306 pushCmd := &cobra.Command{
307 Use: "push [note-ids...] [--file files...]",
308 Short: "Create or update multiple documents on leaflet",
309 Long: `Batch publish or update multiple local notes to leaflet.pub.
310
311For each note:
312- If the note has never been published, creates a new document (like post)
313- If the note has been published before, updates the existing document (like patch)
314
315This is useful for bulk operations and continuous publishing workflows.
316
317Examples:
318 noteleaf pub push 1 2 3 # Publish/update notes 1, 2, and 3
319 noteleaf pub push 42 99 --draft # Create/update as drafts
320 noteleaf pub push --file article.md # Create note from file and push
321 noteleaf pub push --file a.md b.md --draft # Create notes from multiple files
322 noteleaf pub push 1 2 --dry-run # Validate without pushing
323 noteleaf pub push --file article.md --dry-run # Create note but don't push`,
324 RunE: func(cmd *cobra.Command, args []string) error {
325 isDraft, _ := cmd.Flags().GetBool("draft")
326 dryRun, _ := cmd.Flags().GetBool("dry-run")
327 files, _ := cmd.Flags().GetStringSlice("file")
328
329 defer c.handler.Close()
330
331 if len(files) > 0 {
332 return c.handler.PushFromFiles(cmd.Context(), files, isDraft, dryRun)
333 }
334
335 if len(args) == 0 {
336 return fmt.Errorf("no note IDs or files provided")
337 }
338
339 noteIDs := make([]int64, len(args))
340 for i, arg := range args {
341 id, err := parseNoteID(arg)
342 if err != nil {
343 return err
344 }
345 noteIDs[i] = id
346 }
347
348 return c.handler.Push(cmd.Context(), noteIDs, isDraft, dryRun)
349 },
350 }
351 pushCmd.Flags().Bool("draft", false, "Create/update as drafts instead of publishing")
352 pushCmd.Flags().Bool("dry-run", false, "Create note records but skip leaflet push")
353 pushCmd.Flags().StringSliceP("file", "f", []string{}, "Create notes from markdown files before pushing")
354 root.AddCommand(pushCmd)
355 return root
356}
357
358func parseNoteID(arg string) (int64, error) {
359 noteID, err := strconv.ParseInt(arg, 10, 64)
360 if err != nil {
361 return 0, fmt.Errorf("invalid note ID '%s': must be a number", arg)
362 }
363 return noteID, nil
364}