bluesky viewer in the terminal
at main 7.7 kB view raw
1package main 2 3import ( 4 "context" 5 "fmt" 6 "strings" 7 "time" 8 9 "github.com/stormlightlabs/skypanel/cli/internal/export" 10 "github.com/stormlightlabs/skypanel/cli/internal/registry" 11 "github.com/stormlightlabs/skypanel/cli/internal/setup" 12 "github.com/stormlightlabs/skypanel/cli/internal/ui" 13 "github.com/urfave/cli/v3" 14) 15 16// ExportFeedAction exports posts from a feed to file 17func ExportFeedAction(ctx context.Context, cmd *cli.Command) error { 18 if err := setup.EnsurePersistenceReady(ctx); err != nil { 19 return fmt.Errorf("persistence layer not ready: %w", err) 20 } 21 22 reg := registry.Get() 23 24 if cmd.Args().Len() == 0 { 25 return fmt.Errorf("feed ID required") 26 } 27 28 feedID := cmd.Args().First() 29 format := strings.ToLower(cmd.String("format")) 30 size := cmd.Int("size") 31 32 if format != "json" && format != "csv" && format != "txt" { 33 return fmt.Errorf("invalid format: %s (must be json, csv, or txt)", format) 34 } 35 36 feedRepo, err := reg.GetFeedRepo() 37 if err != nil { 38 return fmt.Errorf("failed to get feed repository: %w", err) 39 } 40 41 postRepo, err := reg.GetPostRepo() 42 if err != nil { 43 return fmt.Errorf("failed to get post repository: %w", err) 44 } 45 46 _, err = feedRepo.Get(ctx, feedID) 47 if err != nil { 48 return fmt.Errorf("feed not found: %w", err) 49 } 50 51 posts, err := postRepo.QueryByFeedID(ctx, feedID, size, 0) 52 if err != nil { 53 logger.Error("Failed to query posts", "error", err) 54 return err 55 } 56 57 if len(posts) == 0 { 58 ui.Warningln("No posts found for this feed.") 59 return nil 60 } 61 62 filename := fmt.Sprintf("feed_%s_%s.%s", feedID, time.Now().Format("2006-01-02"), format) 63 64 switch format { 65 case "json": 66 err = export.ToJSON(filename, posts) 67 case "csv": 68 err = export.ToCSV(filename, posts) 69 case "txt": 70 err = export.ToTXT(filename, posts) 71 } 72 73 if err != nil { 74 logger.Error("Failed to export", "error", err) 75 return err 76 } 77 78 ui.Successln("Exported %d post(s) to %s", len(posts), filename) 79 return nil 80} 81 82// ExportProfileAction exports an actor profile to file 83func ExportProfileAction(ctx context.Context, cmd *cli.Command) error { 84 if err := setup.EnsurePersistenceReady(ctx); err != nil { 85 return fmt.Errorf("persistence layer not ready: %w", err) 86 } 87 88 reg := registry.Get() 89 90 if cmd.Args().Len() == 0 { 91 return fmt.Errorf("actor handle or DID required") 92 } 93 94 actor := cmd.Args().First() 95 format := strings.ToLower(cmd.String("format")) 96 97 if format != "json" && format != "txt" { 98 return fmt.Errorf("invalid format for profile: %s (must be json or txt)", format) 99 } 100 101 service, err := reg.GetService() 102 if err != nil { 103 return fmt.Errorf("failed to get service: %w", err) 104 } 105 106 if !service.Authenticated() { 107 return fmt.Errorf("not authenticated: run 'skycli login' first") 108 } 109 110 logger.Debug("Fetching profile for export", "actor", actor) 111 112 profile, err := service.GetProfile(ctx, actor) 113 if err != nil { 114 return fmt.Errorf("failed to fetch profile: %w", err) 115 } 116 117 filename := fmt.Sprintf("profile_%s_%s.%s", profile.Handle, time.Now().Format("2006-01-02"), format) 118 119 switch format { 120 case "json": 121 err = export.ProfileToJSON(filename, profile) 122 case "txt": 123 err = export.ProfileToTXT(filename, profile) 124 } 125 126 if err != nil { 127 logger.Error("Failed to export", "error", err) 128 return err 129 } 130 131 ui.Successln("Exported profile @%s to %s", profile.Handle, filename) 132 return nil 133} 134 135// ExportPostAction exports a single post to file 136func ExportPostAction(ctx context.Context, cmd *cli.Command) error { 137 if err := setup.EnsurePersistenceReady(ctx); err != nil { 138 return fmt.Errorf("persistence layer not ready: %w", err) 139 } 140 141 reg := registry.Get() 142 143 if cmd.Args().Len() == 0 { 144 return fmt.Errorf("post URI or URL required") 145 } 146 147 postIdentifier := cmd.Args().First() 148 format := strings.ToLower(cmd.String("format")) 149 150 if format != "json" && format != "txt" { 151 return fmt.Errorf("invalid format for post: %s (must be json or txt)", format) 152 } 153 154 service, err := reg.GetService() 155 if err != nil { 156 return fmt.Errorf("failed to get service: %w", err) 157 } 158 159 if !service.Authenticated() { 160 return fmt.Errorf("not authenticated: run 'skycli login' first") 161 } 162 163 postURI, err := parsePostURI(postIdentifier) 164 if err != nil { 165 return fmt.Errorf("failed to parse post identifier: %w", err) 166 } 167 168 logger.Debug("Fetching post for export", "uri", postURI) 169 170 response, err := service.GetPosts(ctx, []string{postURI}) 171 if err != nil { 172 return fmt.Errorf("failed to fetch post: %w", err) 173 } 174 175 if len(response.Posts) == 0 { 176 return fmt.Errorf("post not found: %s", postURI) 177 } 178 179 post := &response.Posts[0] 180 181 filename := fmt.Sprintf("post_%s_%s.%s", extractRkey(postURI), time.Now().Format("2006-01-02"), format) 182 183 switch format { 184 case "json": 185 err = export.FeedViewPostToJSON(filename, post) 186 case "txt": 187 err = export.FeedViewPostToTXT(filename, post) 188 } 189 190 if err != nil { 191 logger.Error("Failed to export", "error", err) 192 return err 193 } 194 195 ui.Successln("Exported post to %s", filename) 196 return nil 197} 198 199// ExportCommand returns the export command with subcommands for feed, profile, and post 200func ExportCommand() *cli.Command { 201 return &cli.Command{ 202 Name: "export", 203 Usage: "Export feeds, profiles, or posts to file", 204 Commands: []*cli.Command{ 205 { 206 Name: "feed", 207 Usage: "Export posts from a feed", 208 ArgsUsage: "<feed-id>", 209 Flags: []cli.Flag{ 210 &cli.StringFlag{ 211 Name: "format", 212 Aliases: []string{"f"}, 213 Usage: "Export format: json, csv, or txt", 214 Value: "json", 215 }, 216 &cli.IntFlag{ 217 Name: "size", 218 Aliases: []string{"s"}, 219 Usage: "Number of posts to export", 220 Value: 25, 221 }, 222 }, 223 Action: ExportFeedAction, 224 }, 225 { 226 Name: "profile", 227 Usage: "Export an actor profile", 228 ArgsUsage: "<actor-handle-or-did>", 229 Flags: []cli.Flag{ 230 &cli.StringFlag{ 231 Name: "format", 232 Aliases: []string{"f"}, 233 Usage: "Export format: json or txt", 234 Value: "json", 235 }, 236 }, 237 Action: ExportProfileAction, 238 }, 239 { 240 Name: "post", 241 Usage: "Export a single post", 242 ArgsUsage: "<post-uri-or-url>", 243 Flags: []cli.Flag{ 244 &cli.StringFlag{ 245 Name: "format", 246 Aliases: []string{"f"}, 247 Usage: "Export format: json or txt", 248 Value: "json", 249 }, 250 }, 251 Action: ExportPostAction, 252 }, 253 }, 254 Action: func(ctx context.Context, cmd *cli.Command) error { 255 if cmd.Args().Len() == 0 { 256 return fmt.Errorf("please use: export feed|profile|post <identifier>") 257 } 258 return ExportFeedAction(ctx, cmd) 259 }, 260 Flags: []cli.Flag{ 261 &cli.StringFlag{ 262 Name: "format", 263 Aliases: []string{"f"}, 264 Usage: "Export format: json, csv, or txt", 265 Value: "json", 266 }, 267 &cli.IntFlag{ 268 Name: "size", 269 Aliases: []string{"s"}, 270 Usage: "Number of posts to export", 271 Value: 25, 272 }, 273 }, 274 } 275} 276 277// parsePostURI converts a bsky.app URL or AT URI to an AT URI 278func parsePostURI(identifier string) (string, error) { 279 if strings.HasPrefix(identifier, "at://") { 280 return identifier, nil 281 } 282 283 if strings.HasPrefix(identifier, "https://bsky.app/profile/") || 284 strings.HasPrefix(identifier, "http://bsky.app/profile/") { 285 parts := strings.Split(identifier, "/") 286 if len(parts) < 7 || parts[5] != "post" { 287 return "", fmt.Errorf("invalid bsky.app URL format") 288 } 289 handle := parts[4] 290 rkey := parts[6] 291 return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", handle, rkey), nil 292 } 293 294 return "", fmt.Errorf("identifier must be an AT URI (at://...) or bsky.app URL") 295} 296 297// extractRkey extracts the record key from an AT URI 298func extractRkey(uri string) string { 299 parts := strings.Split(uri, "/") 300 if len(parts) > 0 { 301 return parts[len(parts)-1] 302 } 303 return "unknown" 304}