bluesky viewer in the terminal
1package main
2
3import (
4 "context"
5 "fmt"
6 "strings"
7
8 "github.com/stormlightlabs/skypanel/cli/internal/registry"
9 "github.com/stormlightlabs/skypanel/cli/internal/setup"
10 "github.com/stormlightlabs/skypanel/cli/internal/store"
11 "github.com/stormlightlabs/skypanel/cli/internal/ui"
12 "github.com/urfave/cli/v3"
13)
14
15// SearchUsersAction searches for users (actors) by query string
16func SearchUsersAction(ctx context.Context, cmd *cli.Command) error {
17 if err := setup.EnsurePersistenceReady(ctx); err != nil {
18 return fmt.Errorf("persistence layer not ready: %w", err)
19 }
20
21 reg := registry.Get()
22
23 if cmd.Args().Len() == 0 {
24 return fmt.Errorf("search query required")
25 }
26
27 query := cmd.Args().First()
28 limit := cmd.Int("limit")
29 cursor := cmd.String("cursor")
30 asJSON := cmd.Bool("json")
31
32 service, err := reg.GetService()
33 if err != nil {
34 return fmt.Errorf("failed to get service: %w", err)
35 }
36
37 if !service.Authenticated() {
38 return fmt.Errorf("not authenticated: run 'skycli login' first")
39 }
40
41 logger.Debug("Searching users", "query", query, "limit", limit, "cursor", cursor)
42
43 result, err := service.SearchActors(ctx, query, limit, cursor)
44 if err != nil {
45 return fmt.Errorf("failed to search users: %w", err)
46 }
47
48 if asJSON {
49 return ui.DisplayJSON(result)
50 }
51
52 if len(result.Actors) == 0 {
53 ui.Infoln("No users found matching query: %s", query)
54 return nil
55 }
56
57 ui.Titleln("Search Results: %s", query)
58 fmt.Println()
59
60 for i, actor := range result.Actors {
61 ui.Subtitleln("[%d] @%s", i+1, actor.Handle)
62 if actor.DisplayName != "" {
63 ui.Infoln(" Name: %s", actor.DisplayName)
64 }
65 ui.Infoln(" DID: %s", actor.Did)
66 if actor.Description != "" {
67 desc := actor.Description
68 if len(desc) > 100 {
69 desc = desc[:100] + "..."
70 }
71 ui.Infoln(" Bio: %s", desc)
72 }
73 ui.Infoln(" Followers: %d | Following: %d | Posts: %d",
74 actor.FollowersCount, actor.FollowsCount, actor.PostsCount)
75 fmt.Println()
76 }
77
78 ui.Successln("Found %d user(s)", len(result.Actors))
79 if result.Cursor != "" {
80 ui.Infoln("Next cursor: %s", result.Cursor)
81 }
82
83 return nil
84}
85
86// SearchPostsAction searches for posts by query string
87func SearchPostsAction(ctx context.Context, cmd *cli.Command) error {
88 if err := setup.EnsurePersistenceReady(ctx); err != nil {
89 return fmt.Errorf("persistence layer not ready: %w", err)
90 }
91
92 reg := registry.Get()
93
94 if cmd.Args().Len() == 0 {
95 return fmt.Errorf("search query required")
96 }
97
98 query := cmd.Args().First()
99 limit := cmd.Int("limit")
100 cursor := cmd.String("cursor")
101 asJSON := cmd.Bool("json")
102
103 service, err := reg.GetService()
104 if err != nil {
105 return fmt.Errorf("failed to get service: %w", err)
106 }
107
108 if !service.Authenticated() {
109 return fmt.Errorf("not authenticated: run 'skycli login' first")
110 }
111
112 logger.Debug("Searching posts", "query", query, "limit", limit, "cursor", cursor)
113
114 result, err := service.SearchPosts(ctx, query, limit, cursor)
115 if err != nil {
116 return fmt.Errorf("failed to search posts: %w", err)
117 }
118
119 if asJSON {
120 return ui.DisplayJSON(result)
121 }
122
123 if len(result.Posts) == 0 {
124 ui.Infoln("No posts found matching query: %s", query)
125 return nil
126 }
127
128 ui.Titleln("Search Results: %s", query)
129 ui.DisplayFeed(result.Posts, result.Cursor)
130
131 return nil
132}
133
134// SearchFeedsAction searches for feeds in the local database by name or source
135func SearchFeedsAction(ctx context.Context, cmd *cli.Command) error {
136 if err := setup.EnsurePersistenceReady(ctx); err != nil {
137 return fmt.Errorf("persistence layer not ready: %w", err)
138 }
139
140 reg := registry.Get()
141
142 if cmd.Args().Len() == 0 {
143 return fmt.Errorf("search query required")
144 }
145
146 query := cmd.Args().First()
147 asJSON := cmd.Bool("json")
148
149 feedRepo, err := reg.GetFeedRepo()
150 if err != nil {
151 return fmt.Errorf("failed to get feed repository: %w", err)
152 }
153
154 logger.Debug("Searching local feeds", "query", query)
155
156 allFeeds, err := feedRepo.List(ctx)
157 if err != nil {
158 logger.Error("Failed to list feeds", "error", err)
159 return err
160 }
161
162 var matchingFeeds []store.Model
163 queryLower := strings.ToLower(query)
164
165 for _, model := range allFeeds {
166 if feed, ok := model.(*store.FeedModel); ok {
167 nameLower := strings.ToLower(feed.Name)
168 sourceLower := strings.ToLower(feed.Source)
169 if strings.Contains(nameLower, queryLower) || strings.Contains(sourceLower, queryLower) {
170 matchingFeeds = append(matchingFeeds, feed)
171 }
172 }
173 }
174
175 if len(matchingFeeds) == 0 {
176 ui.Infoln("No feeds found matching query: %s", query)
177 return nil
178 }
179
180 if asJSON {
181 return ui.DisplayJSON(matchingFeeds)
182 }
183
184 ui.Titleln("Search Results: %s", query)
185 fmt.Println()
186
187 for i, model := range matchingFeeds {
188 if feed, ok := model.(*store.FeedModel); ok {
189 ui.Subtitleln("[%d] %s", i+1, feed.Name)
190 ui.Infoln(" ID: %s", feed.ID())
191 ui.Infoln(" Source: %s", feed.Source)
192 ui.Infoln(" Local: %t", feed.IsLocal)
193 fmt.Println()
194 }
195 }
196
197 ui.Successln("Found %d feed(s)", len(matchingFeeds))
198 return nil
199}
200
201// SearchCommand returns the search command with subcommands for users, posts, and feeds
202func SearchCommand() *cli.Command {
203 commonFlags := []cli.Flag{
204 &cli.IntFlag{
205 Name: "limit",
206 Aliases: []string{"l"},
207 Usage: "Maximum number of results to return",
208 Value: 25,
209 },
210 &cli.StringFlag{
211 Name: "cursor",
212 Aliases: []string{"c"},
213 Usage: "Pagination cursor for fetching additional results",
214 },
215 &cli.BoolFlag{
216 Name: "json",
217 Aliases: []string{"j"},
218 Usage: "Output raw JSON response",
219 },
220 }
221
222 feedFlags := []cli.Flag{
223 &cli.BoolFlag{
224 Name: "json",
225 Aliases: []string{"j"},
226 Usage: "Output raw JSON response",
227 },
228 }
229
230 return &cli.Command{
231 Name: "search",
232 Usage: "Search for users, posts, or feeds",
233 Commands: []*cli.Command{
234 {
235 Name: "users",
236 Usage: "Search for users by handle or name",
237 ArgsUsage: "<query>",
238 Flags: commonFlags,
239 Action: SearchUsersAction,
240 },
241 {
242 Name: "posts",
243 Usage: "Search for posts by text content",
244 ArgsUsage: "<query>",
245 Flags: commonFlags,
246 Action: SearchPostsAction,
247 },
248 {
249 Name: "feeds",
250 Usage: "Search local feeds by name or source (local search only)",
251 ArgsUsage: "<query>",
252 Flags: feedFlags,
253 Action: SearchFeedsAction,
254 },
255 },
256 }
257}