Files
lazyrun/internal/icon/online.go
T
oxpoe 59145910b5 Ikon: cari per domain/URL gambar + semua format dikonversi ke PNG
- Dialog ikon: nama aplikasi -> Iconify; domain (mikrotik.com) -> ikon resmi situs
  (manifest 512, apple-touch-icon, favicon) + layanan favicon; URL gambar -> langsung
- Contoh kasus: 'winbox' cuma punya 1 ikon tidak nyambung di Iconify, tapi
  'mikrotik.com' memberi ikon resminya (5 pilihan, semuanya PNG)
- Decoder ICO: entri BMP 24/32-bit (favicon.ico lama) ikut didukung, bukan cuma PNG
- FromURL/Fetch selalu mengembalikan PNG; hasil raster/konversi seragam 512px
- Tes: deteksi URL gambar/domain, daftar ikon situs, decode ICO BMP, konversi format
2026-10-13 08:00:12 +07:00

248 lines
6.4 KiB
Go

package icon
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"sync"
"time"
"unicode"
)
var (
iconifyMu sync.RWMutex
iconifyBase = "https://api.iconify.design"
)
// SetSearchEndpoint mengarahkan pencarian ikon ke endpoint lain (dipakai tes).
func SetSearchEndpoint(base string) {
iconifyMu.Lock()
defer iconifyMu.Unlock()
iconifyBase = base
}
// searchEndpoint dibaca setiap kali mencari, jadi harus lewat lock
// (tes bisa mengganti endpoint sementara goroutine lain mencari).
func searchEndpoint() string {
iconifyMu.RLock()
defer iconifyMu.RUnlock()
return iconifyBase
}
// 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
}
base := searchEndpoint()
endpoint := fmt.Sprintf("%s/search?query=%s&limit=%d", base, 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", base, 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. Hasilnya selalu PNG: format apa pun
// (svg, webp, gif, ico, jpeg) dikonversi dulu supaya desktop environment
// tidak perlu mendukung banyak format.
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
}
converted, err := ToPNG(data)
if err != nil {
return nil, "", fmt.Errorf("%s: %w", rawURL, err)
}
return converted, ".png", nil
}
// imageExts adalah ekstensi gambar yang bisa dipakai langsung sebagai ikon.
var imageExts = []string{".png", ".svg", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".ico", ".xpm"}
// IsImageURL benar kalau teks yang diketik user berupa URL gambar.
func IsImageURL(text string) bool {
text = strings.TrimSpace(text)
if !strings.Contains(text, "://") {
return false
}
lower := strings.ToLower(text)
for _, ext := range imageExts {
if strings.HasSuffix(strings.SplitN(lower, "?", 2)[0], ext) {
return true
}
}
return false
}
// Domain mengembalikan domain kalau user mengetik alamat situs (mis. "mikrotik.com"),
// bukan nama aplikasi biasa.
func Domain(text string) string {
text = strings.TrimSpace(text)
if text == "" || strings.ContainsAny(text, " \t") || IsImageURL(text) {
return ""
}
// nama file gambar (mis. "lazyrun.png") bukan domain
lower := strings.ToLower(strings.SplitN(text, "?", 2)[0])
for _, ext := range imageExts {
if strings.HasSuffix(lower, ext) {
return ""
}
}
host := text
if strings.Contains(text, "://") {
if u, err := url.Parse(text); err == nil {
host = u.Hostname()
}
}
parts := strings.Split(strings.Trim(host, "."), ".")
if len(parts) < 2 {
return ""
}
tld := parts[len(parts)-1]
if len(tld) < 2 || len(tld) > 24 {
return ""
}
for _, r := range strings.Join(parts, "") {
if !(r == '-' || r == '.' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') {
return ""
}
}
return host
}
// 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()
}