bluesky viewer in the terminal
1package main
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "time"
8
9 "github.com/google/uuid"
10 "github.com/stormlightlabs/skypanel/cli/internal/registry"
11 "github.com/stormlightlabs/skypanel/cli/internal/setup"
12 "github.com/stormlightlabs/skypanel/cli/internal/store"
13 "github.com/stormlightlabs/skypanel/cli/internal/ui"
14 "github.com/urfave/cli/v3"
15)
16
17// FetchTimelineAction fetches and displays the authenticated user's home timeline
18func FetchTimelineAction(ctx context.Context, cmd *cli.Command) error {
19 if err := setup.EnsurePersistenceReady(ctx); err != nil {
20 return fmt.Errorf("persistence layer not ready: %w", err)
21 }
22
23 reg := registry.Get()
24
25 service, err := reg.GetService()
26 if err != nil {
27 return fmt.Errorf("failed to get service: %w", err)
28 }
29
30 if !service.Authenticated() {
31 return fmt.Errorf("not authenticated: run 'skycli login' first")
32 }
33
34 limit := cmd.Int("limit")
35 cursor := cmd.String("cursor")
36 asJSON := cmd.Bool("json")
37
38 logger.Debug("Fetching timeline", "limit", limit, "cursor", cursor)
39
40 response, err := service.GetTimeline(ctx, limit, cursor)
41 if err != nil {
42 return fmt.Errorf("failed to fetch timeline: %w", err)
43 }
44
45 if asJSON {
46 return ui.DisplayJSON(response)
47 }
48
49 ui.DisplayFeed(response.Feed, response.Cursor)
50 return nil
51}
52
53// FetchFeedAction fetches and displays posts from a specific feed
54func FetchFeedAction(ctx context.Context, cmd *cli.Command) error {
55 if err := setup.EnsurePersistenceReady(ctx); err != nil {
56 return fmt.Errorf("persistence layer not ready: %w", err)
57 }
58
59 reg := registry.Get()
60
61 if cmd.Args().Len() == 0 {
62 return fmt.Errorf("feed URI or local feed ID required")
63 }
64
65 feedIdentifier := cmd.Args().First()
66 limit := cmd.Int("limit")
67 cursor := cmd.String("cursor")
68 asJSON := cmd.Bool("json")
69
70 service, err := reg.GetService()
71 if err != nil {
72 return fmt.Errorf("failed to get service: %w", err)
73 }
74
75 if !service.Authenticated() {
76 return fmt.Errorf("not authenticated: run 'skycli login' first")
77 }
78
79 feedRepo, err := reg.GetFeedRepo()
80 if err != nil {
81 return fmt.Errorf("failed to get feed repository: %w", err)
82 }
83
84 var feedURI string
85
86 if _, err := uuid.Parse(feedIdentifier); err == nil {
87 feed, err := feedRepo.Get(ctx, feedIdentifier)
88 if err != nil {
89 return fmt.Errorf("failed to get local feed: %w", err)
90 }
91 if feedModel, ok := feed.(*store.FeedModel); ok {
92 feedURI = feedModel.Source
93 logger.Debug("Resolved local feed ID to URI", "id", feedIdentifier, "uri", feedURI)
94 }
95 } else {
96 feedURI = feedIdentifier
97 }
98
99 logger.Debug("Fetching feed", "uri", feedURI, "limit", limit, "cursor", cursor)
100
101 response, err := service.GetAuthorFeed(ctx, feedURI, limit, cursor)
102 if err != nil {
103 return fmt.Errorf("failed to fetch feed: %w", err)
104 }
105
106 if asJSON {
107 return ui.DisplayJSON(response)
108 }
109
110 ui.Titleln("Feed: %s", feedURI)
111 ui.DisplayFeed(response.Feed, response.Cursor)
112 return nil
113}
114
115// FetchAuthorAction fetches and displays posts from a specific author with profile caching
116func FetchAuthorAction(ctx context.Context, cmd *cli.Command) error {
117 if err := setup.EnsurePersistenceReady(ctx); err != nil {
118 return fmt.Errorf("persistence layer not ready: %w", err)
119 }
120
121 reg := registry.Get()
122
123 if cmd.Args().Len() == 0 {
124 return fmt.Errorf("actor handle or DID required")
125 }
126
127 actor := cmd.Args().First()
128 limit := cmd.Int("limit")
129 cursor := cmd.String("cursor")
130 asJSON := cmd.Bool("json")
131
132 service, err := reg.GetService()
133 if err != nil {
134 return fmt.Errorf("failed to get service: %w", err)
135 }
136
137 if !service.Authenticated() {
138 return fmt.Errorf("not authenticated: run 'skycli login' first")
139 }
140
141 profileRepo, err := reg.GetProfileRepo()
142 if err != nil {
143 return fmt.Errorf("failed to get profile repository: %w", err)
144 }
145
146 cachedProfile, err := profileRepo.GetByDid(ctx, actor)
147 if err != nil {
148 logger.Warn("Failed to check profile cache", "error", err)
149 }
150
151 var profile *store.ActorProfile
152 if cachedProfile != nil && cachedProfile.IsFresh(time.Hour) {
153 logger.Debug("Using cached profile", "did", actor)
154 if err := json.Unmarshal([]byte(cachedProfile.DataJSON), &profile); err != nil {
155 logger.Warn("Failed to unmarshal cached profile", "error", err)
156 cachedProfile = nil
157 }
158 }
159
160 if cachedProfile == nil || !cachedProfile.IsFresh(time.Hour) {
161 logger.Debug("Fetching profile from API", "actor", actor)
162 profile, err = service.GetProfile(ctx, actor)
163 if err != nil {
164 return fmt.Errorf("failed to fetch profile: %w", err)
165 }
166
167 profileJSON, err := json.Marshal(profile)
168 if err != nil {
169 logger.Warn("Failed to marshal profile for caching", "error", err)
170 } else {
171 profileModel := &store.ProfileModel{
172 Did: profile.Did,
173 Handle: profile.Handle,
174 DataJSON: string(profileJSON),
175 FetchedAt: time.Now(),
176 }
177 if err := profileRepo.Save(ctx, profileModel); err != nil {
178 logger.Warn("Failed to cache profile", "error", err)
179 } else {
180 logger.Debug("Cached profile", "did", profile.Did)
181 }
182 }
183 }
184
185 logger.Debug("Fetching author feed", "actor", actor, "limit", limit, "cursor", cursor)
186
187 response, err := service.GetAuthorFeed(ctx, actor, limit, cursor)
188 if err != nil {
189 return fmt.Errorf("failed to fetch author feed: %w", err)
190 }
191
192 if asJSON {
193 return ui.DisplayJSON(response)
194 }
195
196 if profile != nil {
197 ui.DisplayProfileHeader(profile)
198 }
199
200 ui.DisplayFeed(response.Feed, response.Cursor)
201 return nil
202}
203
204// FetchCommand returns the fetch command with subcommands for timeline, feed, and author
205func FetchCommand() *cli.Command {
206 commonFlags := []cli.Flag{
207 &cli.IntFlag{
208 Name: "limit",
209 Aliases: []string{"l"},
210 Usage: "Maximum number of posts to fetch",
211 Value: 25,
212 },
213 &cli.StringFlag{
214 Name: "cursor",
215 Aliases: []string{"c"},
216 Usage: "Pagination cursor for fetching additional results",
217 },
218 &cli.BoolFlag{
219 Name: "json",
220 Aliases: []string{"j"},
221 Usage: "Output raw JSON response",
222 },
223 }
224
225 return &cli.Command{
226 Name: "fetch",
227 Usage: "Fetch posts from timeline, feeds, or authors",
228 Commands: []*cli.Command{
229 {
230 Name: "timeline",
231 Usage: "Fetch authenticated user's home timeline",
232 ArgsUsage: " ",
233 Flags: commonFlags,
234 Action: FetchTimelineAction,
235 },
236 {
237 Name: "feed",
238 Usage: "Fetch posts from a specific feed by URI or local feed ID",
239 ArgsUsage: "<feed-uri-or-id>",
240 Flags: commonFlags,
241 Action: FetchFeedAction,
242 },
243 {
244 Name: "author",
245 Usage: "Fetch posts from a specific author (with profile caching)",
246 ArgsUsage: "<actor-handle-or-did>",
247 Flags: commonFlags,
248 Action: FetchAuthorAction,
249 },
250 },
251 Action: func(ctx context.Context, cmd *cli.Command) error {
252 return FetchTimelineAction(ctx, cmd)
253 },
254 Flags: commonFlags,
255 }
256}