- 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
248 lines
6.4 KiB
Go
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()
|
|
}
|