Files
lazyrun/internal/icon/online.go
T
oxpoe 7f7cb97a65 Ikon otomatis dari internet + cursor mengikuti tema host
- Cari ikon online (Iconify) dari dialog install, plus tombol sync ikon per shortcut
- Auto ikon: file terdekat -> favicon situs (termasuk ikon manifest PWA) -> ikon brand
  yang namanya cocok -> ikon default; sumber ikon disimpan di metadata
- Query dibersihkan (web.whatsapp.com -> whatsapp) dan kata <4 huruf tidak dipakai
  supaya aplikasi tanpa ikon resmi tidak dapat ikon acak
- Unduhan thumbnail dibatasi 4 bersamaan + retry sekali (rate limit Iconify)
- Cursor: XCURSOR_THEME/XCURSOR_SIZE diambil dari config KDE/GTK/~/.icons default,
  dan AppImage tidak lagi membundel libX*/libwayland*/libxkbcommon (dipakai dari host)
- README: bagian ikon + integrasi desktop
2026-10-13 08:00:12 +07:00

175 lines
4.5 KiB
Go

package icon
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
"unicode"
)
var iconifyBase = "https://api.iconify.design"
// SetSearchEndpoint mengarahkan pencarian ikon ke endpoint lain (dipakai tes).
func SetSearchEndpoint(base string) { iconifyBase = base }
// Result adalah satu ikon dari pencarian online.
type Result struct {
Name string // contoh: "logos:whatsapp-icon"
URL string // URL SVG siap diunduh
}
// ShortName mengembalikan nama ikon tanpa prefix koleksi.
func (r Result) ShortName() string {
_, name, ok := strings.Cut(r.Name, ":")
if !ok {
return r.Name
}
return name
}
// Search mencari ikon di Iconify berdasarkan kata kunci.
func Search(query string, limit int) ([]Result, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, errors.New("search query is empty")
}
if limit <= 0 {
limit = 32
}
endpoint := fmt.Sprintf("%s/search?query=%s&limit=%d", iconifyBase, url.QueryEscape(query), limit)
data, err := get(endpoint)
if err != nil {
return nil, err
}
var payload struct {
Icons []string `json:"icons"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, err
}
results := make([]Result, 0, len(payload.Icons))
for _, name := range payload.Icons {
prefix, icon, ok := strings.Cut(name, ":")
if !ok {
continue
}
// height=256 supaya SVG-nya punya ukuran eksplisit (bukan "1em")
results = append(results, Result{
Name: name,
URL: fmt.Sprintf("%s/%s/%s.svg?height=256", iconifyBase, url.PathEscape(prefix), url.PathEscape(icon)),
})
}
if len(results) == 0 {
return nil, errors.New("no icon found for " + query)
}
return results, nil
}
// SearchBest mencoba beberapa variasi kata kunci supaya nama seperti
// "web.whatsapp.com" atau "WhatsApp Web" tetap menemukan ikon.
func SearchBest(query string, limit int) ([]Result, error) {
for _, candidate := range queryVariants(query) {
if results, err := Search(candidate, limit); err == nil {
return results, nil
}
}
return nil, errors.New("no icon found for " + strings.TrimSpace(query))
}
// Find mengambil hasil pertama yang namanya cocok dengan kata kunci,
// supaya ikon acak tidak dipasang ke aplikasi yang namanya tidak dikenal.
func Find(query string) (Result, bool) {
for _, candidate := range queryVariants(query) {
results, err := Search(candidate, 32)
if err != nil {
continue
}
want := slug(candidate)
for _, r := range results {
name := slug(r.ShortName())
if name == want || strings.Contains(name, want) || strings.Contains(want, name) {
return r, true
}
}
}
return Result{}, false
}
// QueryHint mengembalikan kata kunci paling bermakna dari sebuah nama,
// dipakai untuk mengisi kolom pencarian ikon.
func QueryHint(query string) string {
variants := queryVariants(query)
if len(variants) > 1 {
return variants[1]
}
return strings.TrimSpace(query)
}
// queryVariants menyusun kandidat kata kunci: teks asli dulu, lalu kata-kata
// yang lebih sederhana (kata terpanjang didahulukan). Kata < 4 huruf dibuang
// supaya pencarian tidak jatuh ke ikon acak.
func queryVariants(query string) []string {
query = strings.TrimSpace(query)
if query == "" {
return nil
}
out := []string{query}
base := query
// host seperti "web.whatsapp.com" → buang TLD-nya
if !strings.Contains(base, " ") && strings.Contains(base, ".") {
parts := strings.Split(base, ".")
if len(parts) > 1 {
base = strings.Join(parts[:len(parts)-1], " ")
}
}
words := strings.FieldsFunc(base, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
sort.SliceStable(words, func(i, j int) bool { return len(words[i]) > len(words[j]) })
for _, word := range words {
if len(word) < 4 || strings.EqualFold(word, query) {
continue
}
out = append(out, word)
}
return out
}
// FromURL mengunduh ikon dari URL dan menebak ekstensinya dari isi file.
func FromURL(rawURL string) ([]byte, string, error) {
data, err := get(rawURL)
if err != nil {
// sekali coba lagi: API ikon publik kadang membatasi permintaan beruntun
time.Sleep(600 * time.Millisecond)
data, err = get(rawURL)
}
if err != nil {
return nil, "", err
}
ext := sniffExt(data)
if ext == "" {
return nil, "", fmt.Errorf("unsupported icon format at %s", rawURL)
}
return data, ext, nil
}
// slug menyamakan nama untuk perbandingan (huruf kecil, tanpa pemisah).
func slug(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}