bluesky viewer in the terminal
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}