Files
lazyrun/internal/icon/online.go
T
oxpoe cad41caaee Hapus tombol kosong bawaan Fyne di modal + semua format ikon dikonversi ke PNG
- 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
2026-10-13 08:00:12 +07:00

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()
}