- dialog.NewCustom selalu menambah tombol dismiss walau labelnya kosong; modal kita pakai baris tombol sendiri, jadi pindah ke NewCustomWithoutButtons (ditambah tes yang menolak pemakaian dialog.NewCustom lagi) - Ikon: SVG/webp/gif/bmp/ico/jpeg semuanya dikonversi ke PNG saat dipasang (desktop environment tidak konsisten membaca format lain), gambar besar diperkecil ke maksimal 512px - Filter file picker ikon menambah webp/gif/bmp - Perbaiki data race pada endpoint pencarian ikon (iconifyBase) yang ditemukan oleh go test -race; tes UI menunggu goroutine pencarian selesai - go test -race -count=1 ./... hijau untuk semua paket
192 lines
4.8 KiB
Go
192 lines
4.8 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 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()
|
|
}
|