Skip to content

Commit 8a5f368

Browse files
committed
feat(icons): add cloud icon search and show popular by default
- Add icons search command to query 2000+ icons from LaMetric cloud - Default `lametric icons` now shows popular icons instead of static list - Fix incorrect icon aliases (rocket, mail, star, heart) - Keep aliases for use with --icon flag in notify command
1 parent 11f500e commit 8a5f368

4 files changed

Lines changed: 207 additions & 32 deletions

File tree

internal/api/icons.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
"time"
12+
)
13+
14+
const cloudIconsURL = "https://developer.lametric.com/api/v2/icons"
15+
16+
// CloudIcon represents an icon from the LaMetric cloud icon library.
17+
type CloudIcon struct {
18+
ID int `json:"id"`
19+
Title string `json:"title"`
20+
Code string `json:"code"`
21+
Type string `json:"type"` // "picture" or "movie"
22+
}
23+
24+
// IconsResponse is the API response for the icons endpoint.
25+
type IconsResponse struct {
26+
Meta struct {
27+
TotalIconCount int `json:"total_icon_count"`
28+
Page int `json:"page"`
29+
PageSize int `json:"page_size"`
30+
PageCount int `json:"page_count"`
31+
} `json:"meta"`
32+
Data []CloudIcon `json:"data"`
33+
}
34+
35+
// GetPopularIcons returns popular icons from the LaMetric cloud library.
36+
func GetPopularIcons(ctx context.Context, limit int) ([]CloudIcon, error) {
37+
client := &http.Client{Timeout: 15 * time.Second}
38+
icons, _, err := fetchIconPage(ctx, client, 0, limit)
39+
return icons, err
40+
}
41+
42+
// SearchIcons searches the LaMetric cloud icon library.
43+
// It fetches icons and filters by title containing the query (case-insensitive).
44+
func SearchIcons(ctx context.Context, query string, limit int) ([]CloudIcon, error) {
45+
client := &http.Client{Timeout: 15 * time.Second}
46+
47+
// Fetch pages until we have enough matches or run out of pages.
48+
var matches []CloudIcon
49+
queryLower := strings.ToLower(query)
50+
pageSize := 200
51+
maxPages := 10 // Don't fetch more than 2000 icons
52+
53+
for page := 0; page < maxPages && len(matches) < limit; page++ {
54+
icons, hasMore, err := fetchIconPage(ctx, client, page, pageSize)
55+
if err != nil {
56+
return nil, err
57+
}
58+
59+
for _, icon := range icons {
60+
if strings.Contains(strings.ToLower(icon.Title), queryLower) {
61+
matches = append(matches, icon)
62+
if len(matches) >= limit {
63+
break
64+
}
65+
}
66+
}
67+
68+
if !hasMore {
69+
break
70+
}
71+
}
72+
73+
return matches, nil
74+
}
75+
76+
// fetchIconPage fetches a single page of icons from the cloud API.
77+
func fetchIconPage(ctx context.Context, client *http.Client, page, pageSize int) ([]CloudIcon, bool, error) {
78+
u, _ := url.Parse(cloudIconsURL)
79+
q := u.Query()
80+
q.Set("page", fmt.Sprintf("%d", page))
81+
q.Set("page_size", fmt.Sprintf("%d", pageSize))
82+
q.Set("order", "popular")
83+
u.RawQuery = q.Encode()
84+
85+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
86+
if err != nil {
87+
return nil, false, fmt.Errorf("create request: %w", err)
88+
}
89+
90+
resp, err := client.Do(req)
91+
if err != nil {
92+
return nil, false, fmt.Errorf("fetch icons: %w", err)
93+
}
94+
defer func() { _ = resp.Body.Close() }()
95+
96+
if resp.StatusCode != http.StatusOK {
97+
return nil, false, fmt.Errorf("icons API returned %d", resp.StatusCode)
98+
}
99+
100+
body, err := io.ReadAll(resp.Body)
101+
if err != nil {
102+
return nil, false, fmt.Errorf("read response: %w", err)
103+
}
104+
105+
var result IconsResponse
106+
if err := json.Unmarshal(body, &result); err != nil {
107+
return nil, false, fmt.Errorf("parse response: %w", err)
108+
}
109+
110+
hasMore := page < result.Meta.PageCount-1
111+
return result.Data, hasMore, nil
112+
}

internal/cmd/icons.go

Lines changed: 90 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,31 @@
11
package cmd
22

33
import (
4+
"context"
5+
"fmt"
46
"os"
5-
"sort"
67

8+
"github.com/dedene/lametric-cli/internal/api"
79
"github.com/dedene/lametric-cli/internal/output"
810
)
911

1012
// IconAliases maps friendly names to LaMetric icon IDs.
13+
// Use "lametric icons search <query>" to find more icons.
1114
var IconAliases = map[string]string{
12-
"rocket": "i120",
1315
"checkmark": "i2867",
1416
"cross": "a2868",
15-
"heart": "i339",
16-
"star": "i2283",
17+
"heart": "a230",
18+
"star": "i635",
1719
"fire": "a2735",
1820
"warning": "i555",
1921
"info": "i65",
20-
"clock": "i82",
21-
"calendar": "i227",
22-
"mail": "i65",
22+
"clock": "a82",
23+
"calendar": "i66",
24+
"mail": "i43",
25+
"email": "i20907",
2326
"download": "i120",
2427
"upload": "i121",
28+
"rocket": "a26304",
2529
"github": "i32320",
2630
"slack": "i36682",
2731
"thumbsup": "i14180",
@@ -31,14 +35,20 @@ var IconAliases = map[string]string{
3135
"snow": "i2289",
3236
"cloud": "i2283",
3337
"music": "i612",
34-
"beer": "i915",
38+
"beer": "i3253",
3539
"coffee": "i16041",
3640
"pizza": "i8779",
3741
"dollar": "i73",
3842
"bitcoin": "i12949",
3943
"eye": "i14092",
4044
"lock": "i242",
4145
"unlock": "i1370",
46+
"bell": "a1370",
47+
"phone": "i124",
48+
"home": "i96",
49+
"error": "i555",
50+
"success": "i2867",
51+
"fail": "a2868",
4252
}
4353

4454
// ResolveIcon returns the icon ID for a name or alias.
@@ -57,31 +67,84 @@ func ResolveIcon(nameOrID string) string {
5767
return nameOrID
5868
}
5969

60-
// IconsCmd lists available icon aliases.
61-
type IconsCmd struct{}
70+
// IconsCmd manages icon search.
71+
type IconsCmd struct {
72+
Popular IconsPopularCmd `cmd:"" default:"1" help:"Show popular icons"`
73+
Search IconsSearchCmd `cmd:"" help:"Search icons by name from LaMetric cloud"`
74+
}
75+
76+
// IconsPopularCmd shows popular icons from the cloud.
77+
type IconsPopularCmd struct {
78+
Limit int `help:"Maximum results to show" default:"30" short:"n"`
79+
}
80+
81+
// Run fetches and displays popular icons.
82+
func (c *IconsPopularCmd) Run(flags *RootFlags) error {
83+
f := output.NewFormatter(os.Stdout, flags.JSON, flags.Plain, flags.NoColor)
84+
85+
spinner := output.NewSpinner("Fetching popular icons...")
86+
spinner.Start()
87+
88+
icons, err := api.GetPopularIcons(context.Background(), c.Limit)
89+
spinner.Stop()
90+
91+
if err != nil {
92+
return fmt.Errorf("fetch icons: %w", err)
93+
}
94+
95+
type iconResult struct {
96+
Code string `json:"code"`
97+
Title string `json:"title"`
98+
Type string `json:"type"`
99+
}
62100

63-
// Run prints the icon alias table.
64-
func (c *IconsCmd) Run(flags *RootFlags) error {
101+
results := make([]iconResult, len(icons))
102+
rows := make([][]string, len(icons))
103+
for i, icon := range icons {
104+
results[i] = iconResult{Code: icon.Code, Title: icon.Title, Type: icon.Type}
105+
rows[i] = []string{icon.Code, icon.Title, icon.Type}
106+
}
107+
108+
return f.Output(results, []string{"CODE", "TITLE", "TYPE"}, rows)
109+
}
110+
111+
// IconsSearchCmd searches icons in the LaMetric cloud library.
112+
type IconsSearchCmd struct {
113+
Query string `arg:"" help:"Search query (e.g., 'rocket', 'heart')"`
114+
Limit int `help:"Maximum results to show" default:"20" short:"n"`
115+
}
116+
117+
// Run searches for icons matching the query.
118+
func (c *IconsSearchCmd) Run(flags *RootFlags) error {
65119
f := output.NewFormatter(os.Stdout, flags.JSON, flags.Plain, flags.NoColor)
66120

67-
type iconEntry struct {
68-
Alias string `json:"alias"`
69-
ID string `json:"id"`
121+
spinner := output.NewSpinner("Searching icons...")
122+
spinner.Start()
123+
124+
icons, err := api.SearchIcons(context.Background(), c.Query, c.Limit)
125+
spinner.Stop()
126+
127+
if err != nil {
128+
return fmt.Errorf("search icons: %w", err)
70129
}
71130

72-
names := make([]string, 0, len(IconAliases))
73-
for name := range IconAliases {
74-
names = append(names, name)
131+
if len(icons) == 0 {
132+
fmt.Printf("No icons found matching %q\n", c.Query)
133+
return nil
75134
}
76-
sort.Strings(names)
77-
78-
entries := make([]iconEntry, 0, len(names))
79-
rows := make([][]string, 0, len(names))
80-
for _, name := range names {
81-
id := IconAliases[name]
82-
entries = append(entries, iconEntry{Alias: name, ID: id})
83-
rows = append(rows, []string{name, id})
135+
136+
type iconResult struct {
137+
Code string `json:"code"`
138+
Title string `json:"title"`
139+
Type string `json:"type"`
140+
}
141+
142+
results := make([]iconResult, len(icons))
143+
rows := make([][]string, len(icons))
144+
for i, icon := range icons {
145+
results[i] = iconResult{Code: icon.Code, Title: icon.Title, Type: icon.Type}
146+
rows[i] = []string{icon.Code, icon.Title, icon.Type}
84147
}
85148

86-
return f.Output(entries, []string{"ALIAS", "ID"}, rows)
149+
return f.Output(results, []string{"CODE", "TITLE", "TYPE"}, rows)
87150
}

internal/cmd/notify_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ func TestBuildFrames_WithIcon(t *testing.T) {
3333
if len(frames) != 1 {
3434
t.Fatalf("expected 1 frame, got %d", len(frames))
3535
}
36-
// "rocket" alias resolves to "i120"
37-
if frames[0].Icon != "i120" {
38-
t.Errorf("icon = %q, want %q", frames[0].Icon, "i120")
36+
// "rocket" alias resolves to animated rocket icon
37+
if frames[0].Icon != "a26304" {
38+
t.Errorf("icon = %q, want %q", frames[0].Icon, "a26304")
3939
}
4040
}
4141

internal/cmd/resolve_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ func TestResolveIcon(t *testing.T) {
1313
want string
1414
}{
1515
{"empty", "", ""},
16-
{"alias rocket", "rocket", "i120"},
17-
{"alias heart", "heart", "i339"},
16+
{"alias rocket", "rocket", "a26304"},
17+
{"alias heart", "heart", "a230"},
1818
{"icon id i-prefix", "i9999", "i9999"},
1919
{"icon id a-prefix", "a1234", "a1234"},
2020
{"unknown passthrough", "custom", "custom"},

0 commit comments

Comments
 (0)