bluesky viewer in the terminal
at main 6.8 kB view raw
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}