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